Compare commits

..

6 Commits

Author SHA1 Message Date
Kyle McCormick
08e11b6f27 Fix logout URL 2020-04-09 12:32:43 -04:00
Kyle McCormick
a6522f5983 Prefix a bunch of routes with /learning 2020-04-09 12:32:32 -04:00
Kyle McCormick
a21be5d83c Update .env.development for devstack 2020-04-08 16:43:46 -04:00
Kyle McCormick
521483b836 Temporary: Prefix React routes with /learning 2020-04-08 15:53:44 -04:00
Kyle McCormick
6a5e906cbe Switch to kdmccormick/devstack-frontends branch of frontend-build
Required doing some reformatting in InsturctorToolbar.jsx
to make the linter happy.

Also, add dev-build command.
2020-04-07 12:01:13 -04:00
Kyle McCormick
9b76cc4d97 Make React routes relative 2020-04-07 11:32:44 -04:00
212 changed files with 9748 additions and 15865 deletions

3
.env
View File

@@ -4,7 +4,6 @@ BASE_URL=null
CREDENTIALS_BASE_URL=null
CSRF_TOKEN_API_PATH=null
ECOMMERCE_BASE_URL=null
INSIGHTS_BASE_URL=
LANGUAGE_PREFERENCE_COOKIE_NAME=null
LMS_BASE_URL=null
LOGIN_URL=null
@@ -14,6 +13,4 @@ ORDER_HISTORY_URL=null
REFRESH_ACCESS_TOKEN_ENDPOINT=null
SEGMENT_KEY=null
SITE_NAME=null
TWITTER_URL=null
STUDIO_BASE_URL=
USER_INFO_COOKIE_NAME=null

View File

@@ -1,6 +1,7 @@
NODE_ENV='development'
PORT=2000
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
BASE_URL='localhost:2000'
BASE_URL='localhost:19000/learning'
CREDENTIALS_BASE_URL='http://localhost:18150'
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
ECOMMERCE_BASE_URL='http://localhost:18130'
@@ -9,11 +10,8 @@ LMS_BASE_URL='http://localhost:18000'
LOGIN_URL='http://localhost:18000/login'
LOGOUT_URL='http://localhost:18000/logout'
MARKETING_SITE_BASE_URL='http://localhost:18000'
ORDER_HISTORY_URL='localhost:1996/orders'
PORT=2000
ORDER_HISTORY_URL='localhost:19000/orders'
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
SEGMENT_KEY=null
SITE_NAME='edX'
TWITTER_URL='https://twitter.com/edXOnline'
STUDIO_BASE_URL='http://localhost:18010'
USER_INFO_COOKIE_NAME='edx-user-info'

View File

@@ -1,19 +1,15 @@
NODE_ENV='test'
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
BASE_URL='localhost:2000'
BASE_URL='localhost:1995'
CREDENTIALS_BASE_URL='http://localhost:18150'
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
ECOMMERCE_BASE_URL='http://localhost:18130'
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'
LOGOUT_URL='http://localhost:18000/login'
MARKETING_SITE_BASE_URL='http://localhost:18000'
ORDER_HISTORY_URL='localhost:1996/orders'
PORT=2000
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
SEGMENT_KEY=null
SITE_NAME='edX'
TWITTER_URL='https://twitter.com/edXOnline'
STUDIO_BASE_URL='http://localhost:18010'
USER_INFO_COOKIE_NAME='edx-user-info'

View File

@@ -1,11 +1,3 @@
const { createConfig } = require('@edx/frontend-build');
module.exports = createConfig('eslint', {
overrides: [{
files: ["**/__tests__/**/*.[jt]s?(x)", "**/?(*.)+(spec|test).[jt]s?(x)", "setupTest.js"],
rules: {
'import/named': 'off',
'import/no-extraneous-dependencies': 'off',
},
}],
});
module.exports = createConfig('eslint');

0
LICENSE Normal file → Executable file
View File

0
Makefile Normal file → Executable file
View File

View File

@@ -20,26 +20,3 @@ React app for edX learning.
:target: @edx/frontend-app-learning
.. |license| image:: https://img.shields.io/npm/l/@edx/frontend-app-learning.svg
:target: @edx/frontend-app-learning
Development
-----------
Start Devstack
^^^^^^^^^^^^^^
To use this application `devstack <https://github.com/edx/devstack>`__ must be running and you must be logged into it.
- Start devstack
- Log in (http://localhost:18000/login)
Start the development server
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
In this project, install requirements and start the development server by running:
.. code:: bash
npm install
npm start # The server will run on port 1995
Once the dev server is up, visit http://localhost:2000/course/course-v1:edX+DemoX+Demo_Course to view the demo course. You can replace ``course-v1:edX+DemoX+Demo_Course`` with a different course key.

View File

@@ -4,6 +4,4 @@ Because we have a variety of models in this app (course, section, sequence, unit
https://redux.js.org/faq/organizing-state#how-do-i-organize-nested-or-duplicate-data-in-my-state
Different modules of the application maintain individual/lists of IDs that reference data stored in the model store. These are akin to indices in a database, in that they allow you to quickly extract data from the model store without iteration or filtering.
A common pattern when loading data from an API endpoint is to use the model-store's redux actions (addModel, updateModel, etc.) to load the "models" themselves into the model store by ID, and then dispatch another action to save references elsewhere in the redux store to the data that was just added. When adding courses, sequences, etc., to model-store, we also save the courseId and sequenceId in the 'courseware' part of redux. This means the courseware React Components can extract the data from the model-store quickly by using the courseId as a key: `state.models.courses[state.activeCourse.courseId]`. For an array, it iterates once over the ID list in order to extract the models from model-store. This iteration is done when React components' re-render, and can be done less often through memoization as necessary.
(As an additional data point, djoy has stored data in this format in multiple projects over the years and found it to be very effective)

View File

@@ -1,24 +0,0 @@
# Naming API functions and redux thunks
Because API functions and redux thunks are two parts of a larger process, we've informally settled on some naming conventions for them to help differentiate the type of code we're looking at.
## API Functions
This micro-frontend follows a pattern of naming API functions with a prefix for their HTTP verb.
Examples:
`getCourseBlocks` - The GET request we make to load course blocks data.
`postSequencePosition` - The POST request for saving sequence position.
## Redux Thunks
Meanwhile, we use a different set of verbs for redux thunks to differentiate them from the API functions. For instance, we use the `fetch` prefix for loading data (primarily via GET requests), and `save` for sending data back to the server (primarily via POST or PATCH requests)
Examples:
`fetchCourse` - The thunk for getting course data across several APIs.
`fetchSequence` - The thunk for the process of retrieving sequence data.
`saveSequencePosition` - Wraps the POST request for sending sequence position back to the server.
The verb prefixes for thunks aren't perfect - but they're a little more 'friendly' and semantically meaningful than the HTTP verbs used for APIs. So far we have `fetch`, `save`, `check`, `reset`, etc.

View File

@@ -1,7 +1,7 @@
const { createConfig } = require('@edx/frontend-build');
module.exports = createConfig('jest', {
setupFilesAfterEnv: [
setupFiles: [
'<rootDir>/src/setupTest.js',
],
coveragePathIgnorePatterns: [

View File

@@ -3,8 +3,3 @@
oeps: {}
owner: edx/platform-core-tnl
openedx-release:
# The openedx-release key is described in OEP-10:
# https://open-edx-proposals.readthedocs.io/en/latest/oep-0010-proc-openedx-releases.html
# The FAQ might also be helpful: https://openedx.atlassian.net/wiki/spaces/COMM/pages/1331268879/Open+edX+Release+FAQ
ref: master

15463
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,7 @@
],
"scripts": {
"build": "fedx-scripts webpack",
"dev-build": "fedx-scripts webpack-dev",
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null",
"is-es5": "es-check es5 ./dist/*.js",
"lint": "fedx-scripts eslint --ext .js --ext .jsx .",
@@ -34,44 +35,34 @@
"url": "https://github.com/edx/frontend-app-learning/issues"
},
"dependencies": {
"@edx/frontend-component-footer": "10.0.11",
"@edx/frontend-component-header": "2.0.5",
"@edx/frontend-platform": "1.5.2",
"@edx/paragon": "^9.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.11",
"@reduxjs/toolkit": "1.3.6",
"classnames": "2.2.6",
"core-js": "3.6.5",
"prop-types": "15.7.2",
"react": "16.13.1",
"react-break": "1.3.2",
"react-dom": "16.13.1",
"react-helmet": "6.0.0",
"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"
"@edx/frontend-component-footer": "^10.0.6",
"@edx/frontend-component-header": "^2.0.3",
"@edx/frontend-platform": "^1.3.1",
"@edx/paragon": "^7.2.1",
"@fortawesome/fontawesome-svg-core": "^1.2.26",
"@fortawesome/free-brands-svg-icons": "^5.12.0",
"@fortawesome/free-regular-svg-icons": "^5.12.0",
"@fortawesome/free-solid-svg-icons": "^5.12.0",
"@fortawesome/react-fontawesome": "^0.1.8",
"@reduxjs/toolkit": "^1.2.3",
"classnames": "^2.2.6",
"core-js": "^3.6.2",
"prop-types": "^15.7.2",
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-redux": "^7.1.3",
"react-router": "^5.1.2",
"react-router-dom": "^5.1.2",
"redux": "^4.0.5",
"regenerator-runtime": "^0.13.3"
},
"devDependencies": {
"@edx/frontend-build": "5.0.6",
"@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.0",
"glob": "7.1.6",
"husky": "3.1.0",
"jest": "24.9.0",
"reactifex": "1.1.1",
"rosie": "2.0.1"
"@edx/frontend-build": "github:edx/frontend-build#kdmccormick/devstack-frontends",
"codecov": "^3.6.1",
"es-check": "^5.1.0",
"glob": "^7.1.6",
"husky": "^3.1.0",
"jest": "^24.9.0",
"reactifex": "^1.1.1"
}
}

View File

@@ -1,7 +1,7 @@
<!doctype html>
<html lang="en-us">
<head>
<title>Course | <%= process.env.SITE_NAME %></title>
<title>Course | edX</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" />

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { Switch, Route, useRouteMatch } from 'react-router';
import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import PageLoading from '../generic/PageLoading';
import PageLoading from './PageLoading';
export default () => {
const { path } = useRouteMatch();
@@ -24,12 +24,6 @@ export default () => {
global.location.assign(`${getConfig().LMS_BASE_URL}/courses/${match.params.courseId}/course/`);
}}
/>
<Route
path={`${path}/dashboard`}
render={({ location }) => {
global.location.assign(`${getConfig().LMS_BASE_URL}/dashboard${location.search}`);
}}
/>
</Switch>
</div>
);

View File

@@ -1,8 +0,0 @@
/* eslint-disable import/prefer-default-export */
import { createSelector } from 'reselect';
export const activeCourseSelector = createSelector(
(state) => state.models.courses || {},
(state) => state.activeCourse.courseId,
(coursesById, courseId) => (coursesById[courseId] ? coursesById[courseId] : null),
);

View File

@@ -1,44 +0,0 @@
/* eslint-disable no-param-reassign */
import { createSlice } from '@reduxjs/toolkit';
export const COURSE_LOADING = 'loading';
export const COURSE_LOADED = 'loaded';
export const COURSE_FAILED = 'failed';
export const COURSE_DENIED = 'denied';
const slice = createSlice({
name: 'activeCourse',
initialState: {
courseStatus: COURSE_LOADING,
courseId: null,
},
reducers: {
fetchCourseRequest: (state, { payload }) => {
state.courseId = payload.courseId;
state.courseStatus = COURSE_LOADING;
},
fetchCourseSuccess: (state, { payload }) => {
state.courseId = payload.courseId;
state.courseStatus = COURSE_LOADED;
},
fetchCourseFailure: (state, { payload }) => {
state.courseId = payload.courseId;
state.courseStatus = COURSE_FAILED;
},
fetchCourseDenied: (state, { payload }) => {
state.courseId = payload.courseId;
state.courseStatus = COURSE_DENIED;
},
},
});
export const {
fetchCourseRequest,
fetchCourseSuccess,
fetchCourseFailure,
fetchCourseDenied,
} = slice.actions;
export const {
reducer,
} = slice;

View File

@@ -1,12 +0,0 @@
export { activeCourseSelector } from './data/selectors';
export {
reducer,
COURSE_LOADING,
COURSE_LOADED,
COURSE_FAILED,
COURSE_DENIED,
fetchCourseRequest,
fetchCourseSuccess,
fetchCourseFailure,
fetchCourseDenied,
} from './data/slice';

View File

@@ -1,24 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Alert, ALERT_TYPES } from '../../generic/user-messages';
function AccessExpirationAlert({ payload }) {
const {
rawHtml,
} = payload;
return rawHtml && (
<Alert type={ALERT_TYPES.INFO}>
{/* eslint-disable-next-line react/no-danger */}
<div dangerouslySetInnerHTML={{ __html: rawHtml }} />
</Alert>
);
}
AccessExpirationAlert.propTypes = {
payload: PropTypes.shape({
rawHtml: PropTypes.string.isRequired,
}).isRequired,
};
export default AccessExpirationAlert;

View File

@@ -1,21 +0,0 @@
import React, { useMemo } from 'react';
import { useAlert } from '../../generic/user-messages';
const AccessExpirationAlert = React.lazy(() => import('./AccessExpirationAlert'));
function useAccessExpirationAlert(courseExpiredMessage, topic) {
const rawHtml = courseExpiredMessage || null;
const isVisible = !!rawHtml; // If it exists, show it.
const payload = useMemo(() => ({ rawHtml }), [rawHtml]);
useAlert(isVisible, {
code: 'clientAccessExpirationAlert',
payload,
topic,
});
return { clientAccessExpirationAlert: AccessExpirationAlert };
}
export default useAccessExpirationAlert;

View File

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

View File

@@ -1,62 +0,0 @@
import React from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import { Button } from '@edx/paragon';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSpinner } from '@fortawesome/free-solid-svg-icons';
import { Alert, ALERT_TYPES } from '../../generic/user-messages';
import messages from './messages';
import { useEnrollClickHandler } from './hooks';
function EnrollmentAlert({ intl, payload }) {
const {
canEnroll,
courseId,
extraText,
isStaff,
} = payload;
const { enrollClickHandler, loading } = useEnrollClickHandler(
courseId,
intl.formatMessage(messages.success),
);
let text = intl.formatMessage(messages.alert);
let type = ALERT_TYPES.ERROR;
if (isStaff) {
text = intl.formatMessage(messages.staffAlert);
type = ALERT_TYPES.INFO;
} else if (extraText) {
text = `${text} ${extraText}`;
}
const button = canEnroll && (
<Button disabled={loading} className="btn-link p-0 border-0 align-top" onClick={enrollClickHandler}>
{intl.formatMessage(messages.enroll)}
</Button>
);
return (
<Alert type={type}>
{text}
{' '}
{button}
{' '}
{loading && <FontAwesomeIcon icon={faSpinner} spin />}
</Alert>
);
}
EnrollmentAlert.propTypes = {
intl: intlShape.isRequired,
payload: PropTypes.shape({
canEnroll: PropTypes.bool,
courseId: PropTypes.string,
extraText: PropTypes.string,
isStaff: PropTypes.bool,
}).isRequired,
};
export default injectIntl(EnrollmentAlert);

View File

@@ -1,9 +0,0 @@
/* eslint-disable import/prefer-default-export */
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { getConfig } from '@edx/frontend-platform';
export async function postCourseEnrollment(courseId) {
const url = `${getConfig().LMS_BASE_URL}/api/enrollment/v1/enrollment`;
const { data } = await getAuthenticatedHttpClient().post(url, { course_details: { course_id: courseId } });
return data;
}

View File

@@ -1,51 +0,0 @@
/* eslint-disable import/prefer-default-export */
import React, {
useContext, useState, useCallback,
} from 'react';
import { UserMessagesContext, ALERT_TYPES, useAlert } from '../../generic/user-messages';
import { useModel } from '../../generic/model-store';
import { postCourseEnrollment } from './data/api';
const EnrollmentAlert = React.lazy(() => import('./EnrollmentAlert'));
export function useEnrollmentAlert(courseId) {
const course = useModel('courses', courseId);
const outline = useModel('outline', courseId);
const isVisible = course && course.isEnrolled !== undefined && !course.isEnrolled;
useAlert(isVisible, {
code: 'clientEnrollmentAlert',
payload: {
canEnroll: outline.enrollAlert.canEnroll,
courseId,
extraText: outline.enrollAlert.extraText,
isStaff: course.isStaff,
},
topic: 'outline',
});
return { clientEnrollmentAlert: EnrollmentAlert };
}
export function useEnrollClickHandler(courseId, successText) {
const [loading, setLoading] = useState(false);
const { addFlash } = useContext(UserMessagesContext);
const enrollClickHandler = useCallback(() => {
setLoading(true);
postCourseEnrollment(courseId).then(() => {
addFlash({
dismissible: true,
flash: true,
text: successText,
type: ALERT_TYPES.SUCCESS,
topic: 'course',
});
setLoading(false);
global.location.reload();
});
}, [courseId]);
return { enrollClickHandler, loading };
}

View File

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

View File

@@ -1,20 +0,0 @@
/* eslint-disable import/prefer-default-export */
import React, { useContext } from 'react';
import { AppContext } from '@edx/frontend-platform/react';
import { ALERT_TYPES, useAlert } from '../../generic/user-messages';
const LogistrationAlert = React.lazy(() => import('./LogistrationAlert'));
export function useLogistrationAlert() {
const { authenticatedUser } = useContext(AppContext);
const isVisible = authenticatedUser === null;
useAlert(isVisible, {
code: 'clientLogistrationAlert',
topic: 'outline',
dismissible: false,
type: ALERT_TYPES.ERROR,
});
return { clientLogistrationAlert: LogistrationAlert };
}

View File

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

View File

@@ -1,24 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Alert, ALERT_TYPES } from '../../generic/user-messages';
function OfferAlert({ payload }) {
const {
rawHtml,
} = payload;
return rawHtml && (
<Alert type={ALERT_TYPES.INFO}>
{/* eslint-disable-next-line react/no-danger */}
<div dangerouslySetInnerHTML={{ __html: rawHtml }} />
</Alert>
);
}
OfferAlert.propTypes = {
payload: PropTypes.shape({
rawHtml: PropTypes.string.isRequired,
}).isRequired,
};
export default OfferAlert;

View File

@@ -1,19 +0,0 @@
import React from 'react';
import { useAlert } from '../../generic/user-messages';
const OfferAlert = React.lazy(() => import('./OfferAlert'));
export function useOfferAlert(offerHtml, topic) {
const rawHtml = offerHtml || null;
const isVisible = !!rawHtml; // if it exists, show it.
useAlert(isVisible, {
code: 'clientOfferAlert',
topic,
payload: { rawHtml },
});
return { clientOfferAlert: OfferAlert };
}
export default useOfferAlert;

View File

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

View File

@@ -1,16 +1,17 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
import classNames from 'classnames';
import messages from './messages';
import Tabs from '../generic/tabs/Tabs';
import Tabs from '../tabs/Tabs';
function CourseTabsNavigation({
activeTabSlug, className, tabs, intl,
activeTabSlug, tabs, intl,
}) {
return (
<div className={classNames('course-tabs-navigation', className)}>
<div className="course-tabs-navigation">
<div className="container-fluid">
<Tabs
className="nav-underline-tabs"
@@ -20,7 +21,7 @@ function CourseTabsNavigation({
<a
key={slug}
className={classNames('nav-item flex-shrink-0 nav-link', { active: slug === activeTabSlug })}
href={url}
href={`${getConfig().LMS_BASE_URL}${url}`}
>
{title}
</a>
@@ -33,9 +34,9 @@ function CourseTabsNavigation({
CourseTabsNavigation.propTypes = {
activeTabSlug: PropTypes.string,
className: PropTypes.string,
tabs: PropTypes.arrayOf(PropTypes.shape({
title: PropTypes.string.isRequired,
priority: PropTypes.number.isRequired,
slug: PropTypes.string.isRequired,
url: PropTypes.string.isRequired,
})).isRequired,
@@ -44,7 +45,6 @@ CourseTabsNavigation.propTypes = {
CourseTabsNavigation.defaultProps = {
activeTabSlug: undefined,
className: null,
};
export default injectIntl(CourseTabsNavigation);

View File

@@ -68,13 +68,7 @@ export default function Header({
}
Header.propTypes = {
courseOrg: PropTypes.string,
courseNumber: PropTypes.string,
courseTitle: PropTypes.string,
};
Header.defaultProps = {
courseOrg: null,
courseNumber: null,
courseTitle: null,
courseOrg: PropTypes.string.isRequired,
courseNumber: PropTypes.string.isRequired,
courseTitle: PropTypes.string.isRequired,
};

View File

@@ -0,0 +1,41 @@
import React from 'react';
import PropTypes from 'prop-types';
export default function CourseDates({
start,
end,
enrollmentStart,
enrollmentEnd,
enrollmentMode,
isEnrolled,
}) {
return (
<section>
<h4>Upcoming Dates</h4>
<div><strong>Course Start:</strong><br />{start}</div>
<div><strong>Course End:</strong><br />{end}</div>
<div><strong>Enrollment Start:</strong><br />{enrollmentStart}</div>
<div><strong>Enrollment End:</strong><br />{enrollmentEnd}</div>
<div><strong>Mode:</strong><br />{enrollmentMode}</div>
<div>{isEnrolled ? 'Active Enrollment' : 'Inactive Enrollment'}</div>
</section>
);
}
CourseDates.propTypes = {
start: PropTypes.string,
end: PropTypes.string,
enrollmentStart: PropTypes.string,
enrollmentEnd: PropTypes.string,
enrollmentMode: PropTypes.string,
isEnrolled: PropTypes.bool,
};
CourseDates.defaultProps = {
start: null,
end: null,
enrollmentStart: null,
enrollmentEnd: null,
enrollmentMode: null,
isEnrolled: false,
};

View File

@@ -0,0 +1,97 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Button } from '@edx/paragon';
import AlertList from '../user-messages/AlertList';
import { Header, CourseTabsNavigation } from '../course-header';
import { useLogistrationAlert } from '../logistration-alert';
import { useEnrollmentAlert } from '../enrollment-alert';
import CourseDates from './CourseDates';
import Section from './Section';
import { useModel } from '../model-store';
// Note that we import from the component files themselves in the enrollment-alert package.
// This is because Reacy.lazy() requires that we import() from a file with a Component as it's
// default export.
// See React.lazy docs here: https://reactjs.org/docs/code-splitting.html#reactlazy
const { EnrollmentAlert, StaffEnrollmentAlert } = React.lazy(() => import('../enrollment-alert'));
const LogistrationAlert = React.lazy(() => import('../logistration-alert'));
export default function CourseHome({
courseId,
}) {
useLogistrationAlert();
useEnrollmentAlert(courseId);
const {
org,
number,
title,
start,
end,
enrollmentStart,
enrollmentEnd,
enrollmentMode,
isEnrolled,
tabs,
sectionIds,
} = useModel('courses', courseId);
return (
<>
<Header
courseOrg={org}
courseNumber={number}
courseTitle={title}
/>
<main className="d-flex flex-column flex-grow-1">
<div className="container-fluid">
<CourseTabsNavigation tabs={tabs} className="mb-3" activeTabSlug="courseware" />
<AlertList
topic="outline"
className="mb-3"
customAlerts={{
clientEnrollmentAlert: EnrollmentAlert,
clientStaffEnrollmentAlert: StaffEnrollmentAlert,
clientLogistrationAlert: LogistrationAlert,
}}
/>
</div>
<div className="flex-grow-1">
<div className="container-fluid">
<div className="d-flex justify-content-between mb-3">
<h2>{title}</h2>
<Button className="btn-primary" type="button">Resume Course</Button>
</div>
<div className="row">
<div className="col col-8">
{sectionIds.map((sectionId) => (
<Section
key={sectionId}
id={sectionId}
courseId={courseId}
/>
))}
</div>
<div className="col col-4">
<CourseDates
start={start}
end={end}
enrollmentStart={enrollmentStart}
enrollmentEnd={enrollmentEnd}
enrollmentMode={enrollmentMode}
isEnrolled={isEnrolled}
/>
</div>
</div>
</div>
</div>
</main>
</>
);
}
CourseHome.propTypes = {
courseId: PropTypes.string.isRequired,
};

View File

@@ -0,0 +1,54 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import messages from './messages';
import PageLoading from '../PageLoading';
import CourseHome from './CourseHome';
import { fetchCourse } from '../data';
function CourseHomeContainer(props) {
const {
intl,
match,
} = props;
const dispatch = useDispatch();
useEffect(() => {
// The courseId from the URL is the course we WANT to load.
dispatch(fetchCourse(match.params.courseId));
}, [match.params.courseId]);
// The courseId from the store is the course we HAVE loaded. If the URL changes,
// we don't want the application to adjust to it until it has actually loaded the new data.
const {
courseId,
courseStatus,
} = useSelector(state => state.courseware);
return (
<>
{courseStatus === 'loaded' ? (
<CourseHome
courseId={courseId}
/>
) : (
<PageLoading
srMessage={intl.formatMessage(messages['learn.loading.outline'])}
/>
)}
</>
);
}
CourseHomeContainer.propTypes = {
intl: intlShape.isRequired,
match: PropTypes.shape({
params: PropTypes.shape({
courseId: PropTypes.string.isRequired,
}).isRequired,
}).isRequired,
};
export default injectIntl(CourseHomeContainer);

View File

@@ -4,15 +4,11 @@ import { Collapsible } from '@edx/paragon';
import { faChevronRight, faChevronDown } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import SequenceLink from './SequenceLink';
import { useModel } from '../../generic/model-store';
export default function Section({ courseId, title, sequenceIds }) {
const {
courseBlocks: {
sequences,
},
} = useModel('outline', courseId);
import { useModel } from '../model-store';
export default function Section({ id, courseId }) {
const section = useModel('sections', id);
const { title, sequenceIds } = section;
return (
<Collapsible.Advanced className="collapsible-card mb-2">
<Collapsible.Trigger className="collapsible-trigger d-flex align-items-start">
@@ -35,7 +31,6 @@ export default function Section({ courseId, title, sequenceIds }) {
key={sequenceId}
id={sequenceId}
courseId={courseId}
title={sequences[sequenceId].title}
/>
))}
</Collapsible.Body>
@@ -44,7 +39,6 @@ export default function Section({ courseId, title, sequenceIds }) {
}
Section.propTypes = {
id: PropTypes.string.isRequired,
courseId: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
sequenceIds: PropTypes.arrayOf(PropTypes.string).isRequired,
};

View File

@@ -1,11 +1,13 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
import { useModel } from '../model-store';
export default function SequenceLink({ id, courseId, title }) {
export default function SequenceLink({ id, courseId }) {
const sequence = useModel('sequences', id);
return (
<div className="ml-4">
<Link to={`/course/${courseId}/${id}`}>{title}</Link>
<Link to={`/learning/course/${courseId}/${id}`}>{sequence.title}</Link>
</div>
);
}
@@ -13,5 +15,4 @@ export default function SequenceLink({ id, courseId, title }) {
SequenceLink.propTypes = {
id: PropTypes.string.isRequired,
courseId: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
};

View File

@@ -1,24 +0,0 @@
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
Factory.define('courseHomeMetadata')
.sequence(
'course_id',
(courseId) => `course-v1:edX+DemoX+Demo_Course_${courseId}`,
)
.option('courseTabs', [])
.option('host', 'http://localhost:18000')
.attrs({
is_staff: false,
original_user_is_staff: false,
number: 'DemoX',
org: 'edX',
title: 'Demonstration Course',
is_self_paced: false,
})
.attr('tabs', ['courseTabs', 'host'], (courseTabs, host) => courseTabs.map(
tab => ({
tab_id: tab.slug,
title: tab.title,
url: `${host}${tab.url}`,
}),
));

View File

@@ -1,27 +0,0 @@
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
Factory.define('datesTabData')
.attrs({
dates_banner_info: {
content_type_gating_enabled: false,
missed_gated_content: false,
missed_deadlines: false,
},
course_date_blocks: [
{
assigment_type: 'Homework',
date: '2013-02-05T05:00:00Z',
date_type: 'course-start-date',
description: '',
learner_has_access: true,
link: '',
title: 'Course Starts',
extraInfo: '',
},
],
missed_deadlines: false,
missed_gated_content: false,
learner_is_full_access: true,
user_timezone: null,
verified_upgrade_link: 'http://localhost:18130/basket/add/?sku=8CF08E5',
});

View File

@@ -1,3 +0,0 @@
import './courseHomeMetadata.factory';
import './datesTabData.factory';
import './outlineTabData.factory';

View File

@@ -1,25 +0,0 @@
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
import buildSimpleCourseBlocks from '../../../courseware/data/__factories__/courseBlocks.factory';
Factory.define('outlineTabData')
.option('courseId', 'course-v1:edX+DemoX+Demo_Course')
.option('host', 'http://localhost:18000')
.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('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>');

View File

@@ -1,105 +0,0 @@
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
// TODO: Pull this normalization function up so we're not reaching into courseware
import { normalizeBlocks } from '../../courseware/data/api';
function normalizeCourseHomeCourseMetadata(metadata) {
const data = camelCaseObject(metadata);
return {
...data,
tabs: data.tabs.map(tab => ({
slug: tab.tabId,
title: tab.title,
url: tab.url,
})),
};
}
export async function getCourseHomeCourseMetadata(courseId) {
const url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`;
const { data } = await getAuthenticatedHttpClient().get(url);
return normalizeCourseHomeCourseMetadata(data);
}
export async function getDatesTabData(courseId) {
const url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`;
try {
const { data } = await getAuthenticatedHttpClient().get(url);
return camelCaseObject(data);
} catch (error) {
const { httpErrorStatus } = error && error.customAttributes;
if (httpErrorStatus === 404) {
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/dates`);
return {};
}
throw error;
}
}
export async function getProgressTabData(courseId) {
const url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/progress/${courseId}`;
try {
const { data } = await getAuthenticatedHttpClient().get(url);
return camelCaseObject(data);
} catch (error) {
const { httpErrorStatus } = error && error.customAttributes;
if (httpErrorStatus === 404) {
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/progress`);
return {};
}
throw error;
}
}
export async function getOutlineTabData(courseId) {
const url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/outline/${courseId}`;
let { tabData } = {};
try {
tabData = await getAuthenticatedHttpClient().get(url);
} catch (error) {
const { httpErrorStatus } = error && error.customAttributes;
if (httpErrorStatus === 404) {
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/course/`);
return {};
}
throw error;
}
const {
data,
} = tabData;
const courseBlocks = normalizeBlocks(courseId, data.course_blocks.blocks);
const courseExpiredHtml = data.course_expired_html;
const courseTools = camelCaseObject(data.course_tools);
const datesWidget = camelCaseObject(data.dates_widget);
const enrollAlert = camelCaseObject(data.enroll_alert);
const handoutsHtml = data.handouts_html;
const offerHtml = data.offer_html;
const welcomeMessageHtml = data.welcome_message_html;
return {
courseBlocks,
courseExpiredHtml,
courseTools,
datesWidget,
enrollAlert,
handoutsHtml,
offerHtml,
welcomeMessageHtml,
};
}
export async function postCourseDeadlines(courseId) {
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_experience/v1/reset_course_deadlines`);
await getAuthenticatedHttpClient().post(url.href, { course_key: courseId });
}
export async function postDismissWelcomeMessage(courseId) {
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_home/v1/dismiss_welcome_message`);
await getAuthenticatedHttpClient().post(url.href, { course_id: courseId });
}
export async function postRequestCert(courseId) {
const url = new URL(`${getConfig().LMS_BASE_URL}/courses/${courseId}/generate_user_cert`);
await getAuthenticatedHttpClient().post(url.href);
}

View File

@@ -1,8 +0,0 @@
export {
fetchDatesTab,
fetchOutlineTab,
fetchProgressTab,
resetDeadlines,
} from './thunks';
export { reducer } from './slice';

View File

@@ -1,183 +0,0 @@
import { Factory } from 'rosie';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { getConfig } from '@edx/frontend-platform';
import { COURSE_LOADED, COURSE_LOADING, COURSE_FAILED } from '../../active-course';
import initializeMockApp from '../../setupTest';
import initializeStore from '../../store';
import executeThunk from '../../utils';
import * as thunks from './thunks';
const { loggingService } = initializeMockApp();
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
describe('Data layer integration tests', () => {
const courseMetadata = Factory.build('courseMetadata');
const courseHomeMetadata = Factory.build(
'courseHomeMetadata', {
course_id: courseMetadata.id,
},
{ courseTabs: courseMetadata.tabs },
);
const courseId = courseMetadata.id;
const courseBaseUrl = `${getConfig().LMS_BASE_URL}/api/courseware/course`;
const courseMetadataBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata`;
const courseUrl = `${courseBaseUrl}/${courseId}`;
const courseMetadataUrl = `${courseMetadataBaseUrl}/${courseId}`;
let store;
beforeEach(() => {
axiosMock.reset();
loggingService.logError.mockReset();
store = initializeStore();
});
it('Should initialize store', () => {
expect(store.getState().activeCourse.courseId).toBeNull();
expect(store.getState().activeCourse.courseStatus).toEqual(COURSE_LOADING);
});
describe('Test fetchDatesTab', () => {
const datesBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/dates`;
it('Should fail to fetch if error occurs', async () => {
axiosMock.onGet(courseUrl).networkError();
axiosMock.onGet(courseMetadataUrl).networkError();
axiosMock.onGet(`${datesBaseUrl}/${courseId}`).networkError();
await executeThunk(thunks.fetchDatesTab(courseId), store.dispatch);
expect(loggingService.logError).toHaveBeenCalled();
expect(store.getState().activeCourse.courseStatus).toEqual(COURSE_FAILED);
});
it('Should fetch, normalize, and save metadata', async () => {
const datesTabData = Factory.build('datesTabData');
const datesUrl = `${datesBaseUrl}/${courseId}`;
axiosMock.onGet(courseUrl).reply(200, courseMetadata);
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeMetadata);
axiosMock.onGet(datesUrl).reply(200, datesTabData);
await executeThunk(thunks.fetchDatesTab(courseId), store.dispatch);
const state = store.getState();
expect(state.activeCourse.courseStatus).toEqual(COURSE_LOADED);
expect(state.activeCourse.courseId).toEqual(courseId);
expect(state.courseHome.displayResetDatesToast).toBe(false);
// Validate course
const course = state.models.courses[courseId];
const expectedFieldCount = Object.keys(course).length;
// If this breaks, you should consider adding assertions below for the new data. If it's not
// an "interesting" addition, just bump the number anyway.
expect(expectedFieldCount).toBe(9);
expect(course.title).toEqual(courseHomeMetadata.title);
// Representative sample of data that proves data normalization and ingestion happened.
expect(course.id).toEqual(courseId);
expect(course.isStaff).toBe(courseHomeMetadata.is_staff);
expect(course.number).toEqual(courseHomeMetadata.number);
expect(Array.isArray(course.tabs)).toBe(true);
expect(course.tabs.length).toBe(5); // Weak assertion, but proves the array made it through.
// This proves the tab type came through as a modelType. We don't need to assert much else
// here because the shape of this data is not passed through any sort of normalization scheme,
// it just gets camelCased.
const dates = state.models.dates[courseId];
expect(dates.id).toEqual(courseId);
expect(dates.verifiedUpgradeLink).toBe(datesTabData.verified_upgrade_link);
});
});
describe('Test fetchOutlineTab', () => {
const outlineBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/outline`;
it('Should result in fetch failure if error occurs', async () => {
axiosMock.onGet(courseUrl).networkError();
axiosMock.onGet(courseMetadataUrl).networkError();
axiosMock.onGet(`${outlineBaseUrl}/${courseId}`).networkError();
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
expect(loggingService.logError).toHaveBeenCalled();
expect(store.getState().activeCourse.courseStatus).toEqual(COURSE_FAILED);
});
it('Should fetch, normalize, and save metadata', async () => {
const outlineTabData = Factory.build('outlineTabData', { courseId });
const outlineUrl = `${outlineBaseUrl}/${courseId}`;
axiosMock.onGet(courseUrl).reply(200, courseMetadata);
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeMetadata);
axiosMock.onGet(outlineUrl).reply(200, outlineTabData);
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
const state = store.getState();
expect(state.activeCourse.courseStatus).toEqual(COURSE_LOADED);
expect(state.courseHome.displayResetDatesToast).toBe(false);
// Validate course
const course = state.models.courses[courseId];
const expectedFieldCount = Object.keys(course).length;
// If this breaks, you should consider adding assertions below for the new data. If it's not
// an "interesting" addition, just bump the number anyway.
expect(expectedFieldCount).toBe(9);
expect(course.title).toEqual(courseHomeMetadata.title);
// Representative sample of data that proves data normalization and ingestion happened.
expect(course.id).toEqual(courseId);
expect(course.isStaff).toBe(courseHomeMetadata.is_staff);
expect(course.number).toEqual(courseHomeMetadata.number);
expect(Array.isArray(course.tabs)).toBe(true);
expect(course.tabs.length).toBe(5); // Weak assertion, but proves the array made it through.
// This proves the tab type came through as a modelType. We don't need to assert much else
// here because the shape of this data is not passed through any sort of normalization scheme,
// it just gets camelCased.
const outline = state.models.outline[courseId];
expect(outline.id).toEqual(courseId);
expect(outline.handoutsHtml).toBe(outlineTabData.handouts_html);
});
});
describe('Test resetDeadlines', () => {
it('Should reset course deadlines', async () => {
const resetUrl = `${getConfig().LMS_BASE_URL}/api/course_experience/v1/reset_course_deadlines`;
axiosMock.onPost(resetUrl).reply(201);
const getTabDataMock = jest.fn(() => ({
type: 'MOCK_ACTION',
}));
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}"}`);
expect(getTabDataMock).toHaveBeenCalledWith(courseId);
});
});
describe('Test dismissWelcomeMessage', () => {
it('Should dismiss welcome message', async () => {
const dismissUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/dismiss_welcome_message`;
axiosMock.onPost(dismissUrl).reply(201);
await executeThunk(thunks.dismissWelcomeMessage(courseId), store.dispatch);
expect(axiosMock.history.post[0].url).toEqual(dismissUrl);
expect(axiosMock.history.post[0].data).toEqual(`{"course_id":"${courseId}"}`);
});
});
});

View File

@@ -1,22 +0,0 @@
/* eslint-disable no-param-reassign */
import { createSlice } from '@reduxjs/toolkit';
const slice = createSlice({
name: 'course-home',
initialState: {
displayResetDatesToast: false,
},
reducers: {
toggleResetDatesToast: (state, { payload }) => {
state.displayResetDatesToast = payload.displayResetDatesToast;
},
},
});
export const {
toggleResetDatesToast,
} = slice.actions;
export const {
reducer,
} = slice;

View File

@@ -1,97 +0,0 @@
import { logError } from '@edx/frontend-platform/logging';
import {
getCourseHomeCourseMetadata,
getDatesTabData,
getOutlineTabData,
getProgressTabData,
postCourseDeadlines,
postDismissWelcomeMessage,
postRequestCert,
} from './api';
import {
addModel,
} from '../../generic/model-store';
import {
fetchCourseRequest,
fetchCourseSuccess,
fetchCourseFailure,
} from '../../active-course';
import {
toggleResetDatesToast,
} from './slice';
export function fetchTab(courseId, tab, getTabData) {
return async (dispatch) => {
dispatch(fetchCourseRequest({ courseId }));
Promise.allSettled([
getCourseHomeCourseMetadata(courseId),
getTabData(courseId),
]).then(([courseHomeCourseMetadataResult, tabDataResult]) => {
const fetchedCourseHomeCourseMetadata = courseHomeCourseMetadataResult.status === 'fulfilled';
const fetchedTabData = tabDataResult.status === 'fulfilled';
if (fetchedCourseHomeCourseMetadata) {
dispatch(addModel({
modelType: 'courses',
model: {
id: courseId,
...courseHomeCourseMetadataResult.value,
},
}));
} else {
logError(courseHomeCourseMetadataResult.reason);
}
if (fetchedTabData) {
dispatch(addModel({
modelType: tab,
model: {
id: courseId,
...tabDataResult.value,
},
}));
} else {
logError(tabDataResult.reason);
}
if (fetchedCourseHomeCourseMetadata && fetchedTabData) {
dispatch(fetchCourseSuccess({ courseId }));
} else {
dispatch(fetchCourseFailure({ courseId }));
}
});
};
}
export function fetchDatesTab(courseId) {
return fetchTab(courseId, 'dates', getDatesTabData);
}
export function fetchProgressTab(courseId) {
return fetchTab(courseId, 'progress', getProgressTabData);
}
export function fetchOutlineTab(courseId) {
return fetchTab(courseId, 'outline', getOutlineTabData);
}
export function resetDeadlines(courseId, getTabData) {
return async (dispatch) => {
postCourseDeadlines(courseId).then(() => {
dispatch(getTabData(courseId));
dispatch(toggleResetDatesToast({ displayResetDatesToast: true }));
});
};
}
export function dismissWelcomeMessage(courseId) {
return async () => postDismissWelcomeMessage(courseId);
}
export function requestCert(courseId) {
return async () => postRequestCert(courseId);
}

View File

@@ -1,45 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import messages from './messages';
function DatesBanner(props) {
const {
intl,
name,
bannerClickHandler,
} = 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-md-9'}>
<strong>
{intl.formatMessage(messages[`datesBanner.${name}.header`])}
</strong>
{intl.formatMessage(messages[`datesBanner.${name}.body`])}
</div>
{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>
</div>
)}
</div>
</div>
);
}
DatesBanner.propTypes = {
intl: intlShape.isRequired,
name: PropTypes.string.isRequired,
bannerClickHandler: PropTypes.func,
};
DatesBanner.defaultProps = {
bannerClickHandler: null,
};
export default injectIntl(DatesBanner);

View File

@@ -1,80 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { useModel } from '../../generic/model-store';
import DatesBanner from './DatesBanner';
import { fetchDatesTab, resetDeadlines } from '../data/thunks';
function DatesBannerContainer(props) {
const {
model,
} = props;
const {
courseId,
} = useSelector(state => state.activeCourse);
const {
courseDateBlocks,
datesBannerInfo,
hasEnded,
} = useModel(model, courseId);
const {
contentTypeGatingEnabled,
missedDeadlines,
missedGatedContent,
verifiedUpgradeLink,
} = datesBannerInfo;
const {
isSelfPaced,
} = useModel('courses', courseId);
const dispatch = useDispatch();
const hasDeadlines = courseDateBlocks.some(x => x.dateType === 'assignment-due-date');
const upgradeToCompleteGraded = model === 'dates' && contentTypeGatingEnabled && !missedDeadlines;
const upgradeToReset = !upgradeToCompleteGraded && missedDeadlines && missedGatedContent;
const resetDates = !upgradeToCompleteGraded && missedDeadlines && !missedGatedContent;
const datesBanners = [
{
name: 'datesTabInfoBanner',
shouldDisplay: model === 'dates' && hasDeadlines && !missedDeadlines && isSelfPaced,
},
{
name: 'upgradeToCompleteGradedBanner',
shouldDisplay: upgradeToCompleteGraded,
clickHandler: () => window.location.replace(verifiedUpgradeLink),
},
{
name: 'upgradeToResetBanner',
shouldDisplay: upgradeToReset,
clickHandler: () => window.location.replace(verifiedUpgradeLink),
},
{
name: 'resetDatesBanner',
shouldDisplay: resetDates,
clickHandler: () => dispatch(resetDeadlines(courseId, fetchDatesTab)),
},
];
return (
<>
{!hasEnded && datesBanners.map((banner) => banner.shouldDisplay && (
<DatesBanner
name={banner.name}
bannerClickHandler={banner.clickHandler}
key={banner.name}
/>
))}
</>
);
}
DatesBannerContainer.propTypes = {
model: PropTypes.string.isRequired,
};
export default DatesBannerContainer;

View File

@@ -1,3 +0,0 @@
import DatesBannerContainer from './DatesBannerContainer';
export default DatesBannerContainer;

View File

@@ -1,66 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'datesBanner.datesTabInfoBanner.header': {
id: 'datesBanner.datesTabInfoBanner.header',
defaultMessage: "We've built a suggested schedule to help you stay on track. ",
description: 'Strong text in Dates Tab Info Banner',
},
'datesBanner.datesTabInfoBanner.body': {
id: 'datesBanner.datesTabInfoBanner.body',
defaultMessage: `But don't worry—it's flexible so you can learn at your own pace. If you happen to fall behind on
our suggested dates, you'll be able to adjust them to keep yourself on track.`,
description: 'Body in Dates Tab Info Banner',
},
'datesBanner.upgradeToCompleteGradedBanner.header': {
id: 'datesBanner.upgradeToCompleteGradedBanner.header',
defaultMessage: 'You are auditing this course, ',
description: 'Strong text in Upgrade To Complete Graded Banner',
},
'datesBanner.upgradeToCompleteGradedBanner.body': {
id: 'datesBanner.upgradeToCompleteGradedBanner.body',
defaultMessage: `which means that you are unable to participate in graded assignments. To complete graded
assignments as part of this course, you can upgrade today.`,
description: 'Body in Upgrade To Complete Graded Banner',
},
'datesBanner.upgradeToCompleteGradedBanner.button': {
id: 'datesBanner.upgradeToCompleteGradedBanner.button',
defaultMessage: 'Upgrade now',
description: 'Button in Upgrade To Complete Graded Banner',
},
'datesBanner.upgradeToResetBanner.header': {
id: 'datesBanner.upgradeToResetBanner.header',
defaultMessage: 'You are auditing this course, ',
description: 'Strong text in Upgrade To Reset Banner',
},
'datesBanner.upgradeToResetBanner.body': {
id: 'datesBanner.upgradeToResetBanner.body',
defaultMessage: `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.`,
description: 'Body in Upgrade To Reset Banner',
},
'datesBanner.upgradeToResetBanner.button': {
id: 'datesBanner.upgradeToResetBanner.button',
defaultMessage: 'Upgrade to shift due dates',
description: 'Button in Upgrade To Reset Banner',
},
'datesBanner.resetDatesBanner.header': {
id: 'datesBanner.resetDatesBanner.header',
defaultMessage: 'It looks like you missed some important deadlines based on our suggested schedule. ',
description: 'Strong text in Reset Dates Banner',
},
'datesBanner.resetDatesBanner.body': {
id: 'datesBanner.resetDatesBanner.body',
defaultMessage: `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.`,
description: 'Body in Reset Dates Banner',
},
'datesBanner.resetDatesBanner.button': {
id: 'datesBanner.resetDatesBanner.button',
defaultMessage: 'Shift due dates',
description: 'Button in Reset Dates Banner',
},
});
export default messages;

View File

@@ -1,21 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
export default function Badge({ children, className }) {
return (
<span className={classNames('dates-badge badge align-text-bottom font-italic ml-2 px-2 py-1', className)}>
{children}
</span>
);
}
Badge.propTypes = {
children: PropTypes.node,
className: PropTypes.string,
};
Badge.defaultProps = {
children: null,
className: null,
};

View File

@@ -1,3 +0,0 @@
.dates-badge {
font-size: 0.75rem;
}

View File

@@ -1,24 +0,0 @@
import React from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import messages from './messages';
import Timeline from './Timeline';
import DatesBannerContainer from '../dates-banner/DatesBannerContainer';
function DatesTab({ intl }) {
return (
<>
<div role="heading" aria-level="1" className="h4 my-3">
{intl.formatMessage(messages.title)}
</div>
<DatesBannerContainer model="dates" />
<Timeline />
</>
);
}
DatesTab.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(DatesTab);

View File

@@ -1,97 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useSelector } from 'react-redux';
import { FormattedDate, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useModel } from '../../generic/model-store';
import { getBadgeListAndColor } from './badgelist';
import { isLearnerAssignment } from './utils';
function Day({
date, first, intl, items, last,
}) {
const {
courseId,
} = useSelector(state => state.activeCourse);
const {
userTimezone,
} = useModel('dates', courseId);
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
const { color, badges } = getBadgeListAndColor(date, intl, null, items);
return (
<li className="dates-day pb-4">
{/* Top Line */}
{!first && <div className="dates-line-top border-1 border-left border-gray-900 bg-gray-900" />}
{/* Dot */}
<div className={classNames(color, 'dates-dot border border-gray-900')} />
{/* Bottom Line */}
{!last && <div className="dates-line-bottom border-1 border-left border-gray-900 bg-gray-900" />}
{/* Content */}
<div className="d-inline-block ml-3 pl-2">
<div className="mb-1">
<p className="d-inline text-dark-500 font-weight-bold">
<FormattedDate
value={date}
day="numeric"
month="short"
weekday="short"
year="numeric"
{...timezoneFormatArgs}
/>
</p>
{badges}
</div>
{items.map((item) => {
const { badges: itemBadges } = getBadgeListAndColor(date, intl, item, items);
const showLink = item.link && isLearnerAssignment(item);
const title = showLink ? (<u><a href={item.link} className="text-reset">{item.title}</a></u>) : item.title;
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}>
<div>
<span className="font-weight-bold small mt-1">
{item.assignmentType && `${item.assignmentType}: `}{title}
</span>
{itemBadges}
</div>
{item.description && <div className="small mb-2">{item.description}</div>}
{item.extraInfo && <div className="small mb-2">{item.extraInfo}</div>}
</div>
);
})}
</div>
</li>
);
}
Day.propTypes = {
date: PropTypes.objectOf(Date).isRequired,
first: PropTypes.bool,
intl: intlShape.isRequired,
items: PropTypes.arrayOf(PropTypes.shape({
date: PropTypes.string,
dateType: PropTypes.string,
description: PropTypes.string,
dueNext: PropTypes.bool,
learnerHasAccess: PropTypes.bool,
link: PropTypes.string,
title: PropTypes.string,
})).isRequired,
last: PropTypes.bool,
};
Day.defaultProps = {
first: false,
last: false,
};
export default injectIntl(Day);

View File

@@ -1,47 +0,0 @@
$dot-radius: 0.3rem;
$dot-size: $dot-radius * 2;
$offset: $dot-radius * 1.5;
.dates-day {
position: relative;
}
.dates-line-top {
display: inline-block;
position: absolute;
left: $offset;
top: 0;
height: $offset;
z-index: 0;
}
.dates-dot {
display: inline-block;
position: absolute;
border-radius: 50%;
left: $dot-radius * 0.5; // save room for today's larger size
top: $offset;
height: $dot-size;
width: $dot-size;
z-index: 1;
&.dates-bg-today {
left: 0;
top: $offset - $dot-radius;
height: $dot-size * 1.5;
width: $dot-size * 1.5;
}
}
.dates-line-bottom {
display: inline-block;
position: absolute;
top: $offset + $dot-size;
bottom: 0;
left: $offset;
z-index: 0;
}
.dates-bg-today {
background: #ffdb87;
}

View File

@@ -1,70 +0,0 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { useModel } from '../../generic/model-store';
import Day from './Day';
import { daycmp, isLearnerAssignment } from './utils';
export default function Timeline() {
const {
courseId,
} = useSelector(state => state.activeCourse);
const {
courseDateBlocks,
} = useModel('dates', courseId);
// Group date items by day (assuming they are sorted in first place) and add some metadata
const groupedDates = [];
const now = new Date();
let foundNextDue = false;
let foundToday = false;
courseDateBlocks.forEach(courseDateBlock => {
const dateInfo = { ...courseDateBlock };
const parsedDate = new Date(dateInfo.date);
if (!foundNextDue && parsedDate >= now && isLearnerAssignment(dateInfo) && !dateInfo.complete) {
foundNextDue = true;
dateInfo.dueNext = true;
}
if (!foundToday) {
const compared = daycmp(parsedDate, now);
if (compared === 0) {
foundToday = true;
} else if (compared > 0) {
foundToday = true;
groupedDates.push({
date: now,
items: [],
});
}
}
if (groupedDates.length === 0 || daycmp(groupedDates[groupedDates.length - 1].date, parsedDate) !== 0) {
// Add new grouped date
groupedDates.push({
date: parsedDate,
items: [dateInfo],
first: groupedDates.length === 0,
});
} else {
groupedDates[groupedDates.length - 1].items.push(dateInfo);
}
});
if (!foundToday) {
groupedDates.push({ date: now, items: [] });
}
if (groupedDates.length) {
groupedDates[groupedDates.length - 1].last = true;
}
return (
<ul className="list-unstyled m-0">
{groupedDates.map((groupedDate) => (
<Day key={groupedDate.date} {...groupedDate} />
))}
</ul>
);
}

View File

@@ -1,115 +0,0 @@
import React from 'react';
import classNames from 'classnames';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faLock } from '@fortawesome/free-solid-svg-icons';
import Badge from './Badge';
import messages from './messages';
import { daycmp, isLearnerAssignment } from './utils';
function hasAccess(item) {
return item.learnerHasAccess;
}
function isComplete(assignment) {
return assignment.complete;
}
function isPastDue(assignment) {
return !isComplete(assignment) && (new Date(assignment.date) < new Date());
}
function isUnreleased(assignment) {
return !assignment.link;
}
// Pass a null item if you want to get a whole day's badge list, not just one item's list.
// Returns an object with 'color' and 'badges' properties.
function getBadgeListAndColor(date, intl, item, items) {
const now = new Date();
const assignments = items.filter(isLearnerAssignment);
const isToday = daycmp(date, now) === 0;
const isInFuture = daycmp(date, now) > 0;
// This badge info list is in order of priority (they will appear left to right in this order and the first badge
// sets the color of the dot in the timeline).
const badgesInfo = [
{
message: messages.today,
shownForDay: isToday,
bg: 'dates-bg-today',
},
{
message: messages.completed,
shownForDay: assignments.length && assignments.every(isComplete),
shownForItem: x => isLearnerAssignment(x) && isComplete(x),
bg: 'bg-dark-100',
},
{
message: messages.pastDue,
shownForDay: assignments.length && assignments.every(isPastDue),
shownForItem: x => isLearnerAssignment(x) && isPastDue(x),
bg: 'bg-dark-200',
},
{
message: messages.dueNext,
shownForDay: !isToday && assignments.some(x => x.dueNext),
shownForItem: x => x.dueNext,
bg: 'bg-gray-500',
className: 'text-white',
},
{
message: messages.unreleased,
shownForDay: assignments.length && assignments.every(isUnreleased),
shownForItem: x => isLearnerAssignment(x) && isUnreleased(x),
className: 'border border-dark-200 text-gray-500 align-top',
},
{
message: messages.verifiedOnly,
shownForDay: items.length && items.every(x => !hasAccess(x)),
shownForItem: x => !hasAccess(x),
icon: faLock,
bg: 'bg-dark-500',
className: 'text-white',
},
];
let color = null; // first color of any badge
const badges = (
<>
{badgesInfo.map(b => {
let shown = b.shownForDay;
if (item) {
if (b.shownForDay) {
shown = false; // don't double up, if the day already has this badge
} else {
shown = b.shownForItem && b.shownForItem(item);
}
}
if (!shown) {
return null;
}
if (!color && !isInFuture) {
color = b.bg;
}
return (
<Badge key={b.message.id} className={classNames(b.bg, b.className)}>
{b.icon && <FontAwesomeIcon icon={b.icon} className="mr-1" />}
{intl.formatMessage(b.message)}
</Badge>
);
})}
</>
);
if (!color && isInFuture) {
color = 'bg-gray-900';
}
return {
color,
badges,
};
}
// eslint-disable-next-line import/prefer-default-export
export { getBadgeListAndColor };

View File

@@ -1,228 +0,0 @@
// 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

@@ -1,3 +0,0 @@
import DatesTab from './DatesTab';
export default DatesTab;

View File

@@ -1,34 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
completed: {
id: 'learning.dates.badge.completed',
defaultMessage: 'Completed',
},
dueNext: {
id: 'learning.dates.badge.dueNext',
defaultMessage: 'Due Next',
},
pastDue: {
id: 'learning.dates.badge.pastDue',
defaultMessage: 'Past Due',
},
title: {
id: 'learning.dates.title',
defaultMessage: 'Important Dates',
},
today: {
id: 'learning.dates.badge.today',
defaultMessage: 'Today',
},
unreleased: {
id: 'learning.dates.badge.unreleased',
defaultMessage: 'Not Yet Released',
},
verifiedOnly: {
id: 'learning.dates.badge.verifiedOnly',
defaultMessage: 'Verified Only',
},
});
export default messages;

View File

@@ -1,16 +0,0 @@
function daycmp(a, b) {
if (a.getFullYear() < b.getFullYear()) { return -1; }
if (a.getFullYear() > b.getFullYear()) { return 1; }
if (a.getMonth() < b.getMonth()) { return -1; }
if (a.getMonth() > b.getMonth()) { return 1; }
if (a.getDate() < b.getDate()) { return -1; }
if (a.getDate() > b.getDate()) { return 1; }
return 0;
}
// item is a date block returned from the API
function isLearnerAssignment(item) {
return item.learnerHasAccess && item.dateType === 'assignment-due-date';
}
export { daycmp, isLearnerAssignment };

View File

@@ -1,2 +1 @@
/* eslint-disable import/prefer-default-export */
export { reducer } from './data';
export { default } from './CourseHomeContainer';

View File

@@ -0,0 +1,11 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'learn.loading.outline': {
id: 'learn.loading.learning.sequence',
defaultMessage: 'Loading learning sequence...',
description: 'Message when learning sequence is being loaded',
},
});
export default messages;

View File

@@ -1,62 +0,0 @@
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCalendarAlt } from '@fortawesome/free-regular-svg-icons';
import { FormattedDate } from '@edx/frontend-platform/i18n';
import React from 'react';
import PropTypes from 'prop-types';
import { isLearnerAssignment } from '../dates-tab/utils';
import './DateSummary.scss';
export default function DateSummary({
dateBlock,
userTimezone,
}) {
const linkedTitle = dateBlock.link && isLearnerAssignment(dateBlock);
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
return (
<section className="container p-0 mb-3">
<div className="row">
<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"
month="short"
weekday="short"
year="numeric"
{...timezoneFormatArgs}
/>
</div>
</div>
<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>}
{!linkedTitle
&& <div className="font-weight-bold mt-2">{dateBlock.title}</div>}
</div>
{dateBlock.description
&& <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>
</section>
);
}
DateSummary.propTypes = {
dateBlock: PropTypes.shape({
date: PropTypes.string.isRequired,
dateType: PropTypes.string,
description: PropTypes.string,
link: PropTypes.string,
linkText: PropTypes.string,
title: PropTypes.string.isRequired,
learnerHasAccess: PropTypes.bool,
}).isRequired,
userTimezone: PropTypes.string,
};
DateSummary.defaultProps = {
userTimezone: null,
};

View File

@@ -1,8 +0,0 @@
.date-summary-text {
margin-left: 2px;
flex-basis: 100%;
}
.description-link {
margin-left: 1px;
}

View File

@@ -1,39 +0,0 @@
import React, { useRef } from 'react';
import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
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">
</head>
<body>${html}</body>
</html>
`;
const iframe = useRef(null);
function handleLoad() {
iframe.current.height = iframe.current.contentWindow.document.body.scrollHeight;
}
return (
<iframe
className="w-100 border-0"
onLoad={handleLoad}
ref={iframe}
referrerPolicy="origin"
scrolling="no"
srcDoc={wholePage}
title={title}
{...rest}
/>
);
}
LmsHtmlFragment.propTypes = {
html: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
};

View File

@@ -1,124 +0,0 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { Button } from '@edx/paragon';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { AlertList } from '../../generic/user-messages';
import CourseDates from './widgets/CourseDates';
import CourseHandouts from './widgets/CourseHandouts';
import CourseTools from './widgets/CourseTools';
import messages from './messages';
import Section from './Section';
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 { useModel } from '../../generic/model-store';
import WelcomeMessage from './widgets/WelcomeMessage';
function OutlineTab({ intl }) {
const {
courseId,
} = useSelector(state => state.activeCourse);
const {
title,
start,
end,
enrollmentStart,
enrollmentEnd,
enrollmentMode,
isEnrolled,
} = useModel('courses', courseId);
const {
courseBlocks: {
courses,
sections,
},
courseExpiredHtml,
offerHtml,
} = useModel('outline', courseId);
// 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(offerHtml, 'outline-course-alerts');
const accessExpirationAlert = useAccessExpirationAlert(courseExpiredHtml, 'outline-course-alerts');
const courseStartAlert = useCourseStartAlert(courseId);
const courseEndAlert = useCourseEndAlert(courseId);
const certificateAvailableAlert = useCertificateAvailableAlert(courseId);
const rootCourseId = Object.keys(courses)[0];
const { sectionIds } = courses[rootCourseId];
return (
<>
<AlertList
topic="outline"
className="mb-3"
customAlerts={{
...enrollmentAlert,
...logistrationAlert,
}}
/>
<div className="d-flex justify-content-between mb-3">
<h2>{title}</h2>
<Button className="btn-primary" type="button">{intl.formatMessage(messages.resume)}</Button>
</div>
<div className="row">
<div className="col col-8">
<WelcomeMessage courseId={courseId} />
<AlertList
topic="outline-course-alerts"
className="mb-3"
customAlerts={{
...accessExpirationAlert,
...certificateAvailableAlert,
...courseEndAlert,
...courseStartAlert,
...offerAlert,
}}
/>
{sectionIds.map((sectionId) => (
<Section
key={sectionId}
courseId={courseId}
title={sections[sectionId].title}
sequenceIds={sections[sectionId].sequenceIds}
/>
))}
</div>
<div className="col col-4">
<CourseTools
courseId={courseId}
/>
<CourseDates
start={start}
end={end}
enrollmentStart={enrollmentStart}
enrollmentEnd={enrollmentEnd}
enrollmentMode={enrollmentMode}
isEnrolled={isEnrolled}
courseId={courseId}
/>
<CourseHandouts
courseId={courseId}
/>
</div>
</div>
</>
);
}
OutlineTab.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(OutlineTab);

View File

@@ -1,62 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage, FormattedRelative } from '@edx/frontend-platform/i18n';
import { Hyperlink } from '@edx/paragon';
import { Alert, ALERT_TYPES } from '../../../../generic/user-messages';
function CertificateAvailableAlert({ payload }) {
const {
certDate,
username,
userTimezone,
} = payload;
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
return (
<Alert type={ALERT_TYPES.INFO}>
<strong>
<FormattedMessage
id="learning.outline.alert.cert.title"
defaultMessage="We are working on generating course certificates."
/>
</strong>
<br />
<FormattedMessage
id="learning.outline.alert.cert.when"
defaultMessage="If you have earned a certificate, you will be able to access it {timeRemaining}. You will also be able to view your certificates on your {profileLink}."
values={{
profileLink: (
<Hyperlink
destination={`${getConfig().LMS_BASE_URL}/u/${username}`}
>
<FormattedMessage
id="learning.outline.alert.cert.profile"
defaultMessage="Learner Profile"
/>
</Hyperlink>
),
timeRemaining: (
<FormattedRelative
key="timeRemaining"
value={certDate}
{...timezoneFormatArgs}
/>
),
}}
/>
</Alert>
);
}
CertificateAvailableAlert.propTypes = {
payload: PropTypes.shape({
certDate: PropTypes.string,
username: PropTypes.string,
userTimezone: PropTypes.string,
}).isRequired,
};
export default CertificateAvailableAlert;

View File

@@ -1,42 +0,0 @@
import React from 'react';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { useAlert } from '../../../../generic/user-messages';
import { useModel } from '../../../../generic/model-store';
const CertificateAvailableAlert = React.lazy(() => import('./CertificateAvailableAlert'));
function useCertificateAvailableAlert(courseId) {
const {
isEnrolled,
} = useModel('courses', courseId);
const {
datesWidget: {
courseDateBlocks,
userTimezone,
},
} = useModel('outline', courseId);
const { username } = getAuthenticatedUser();
const certBlock = courseDateBlocks.find(b => b.dateType === 'certificate-available-date');
const endBlock = courseDateBlocks.find(b => b.dateType === 'course-end-date');
const endDate = endBlock ? new Date(endBlock.date) : null;
const hasEnded = endBlock ? endDate < new Date() : false;
const isVisible = isEnrolled && certBlock && hasEnded; // only show if we're between end and cert dates
useAlert(isVisible, {
code: 'clientCertificateAvailableAlert',
payload: {
certDate: certBlock && certBlock.date,
username,
userTimezone,
},
topic: 'outline-course-alerts',
});
return {
clientCertificateAvailableAlert: CertificateAvailableAlert,
};
}
export default useCertificateAvailableAlert;

View File

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

View File

@@ -1,98 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
FormattedDate,
FormattedMessage,
FormattedRelative,
FormattedTime,
} from '@edx/frontend-platform/i18n';
import { Alert, ALERT_TYPES } from '../../../../generic/user-messages';
const DAY_MS = 24 * 60 * 60 * 1000; // in ms
function CourseEndAlert({ payload }) {
const {
delta,
description,
endDate,
userTimezone,
} = payload;
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
const timeRemaining = (
<FormattedRelative
key="timeRemaining"
value={endDate}
{...timezoneFormatArgs}
/>
);
let msg;
if (delta < DAY_MS) {
const courseEndTime = (
<FormattedTime
key="courseEndTime"
day="numeric"
month="short"
year="numeric"
hour12={false}
timeZoneName="short"
value={endDate}
{...timezoneFormatArgs}
/>
);
msg = (
<FormattedMessage
id="learning.outline.alert.end.short"
defaultMessage="This course is ending {timeRemaining} at {courseEndTime}."
description="Used when the time remaining is less than a day away."
values={{
courseEndTime,
timeRemaining,
}}
/>
);
} else {
const courseEndDate = (
<FormattedDate
key="courseEndDate"
day="numeric"
month="short"
year="numeric"
value={endDate}
{...timezoneFormatArgs}
/>
);
msg = (
<FormattedMessage
id="learning.outline.alert.end.long"
defaultMessage="This course is ending {timeRemaining} on {courseEndDate}."
description="Used when the time remaining is more than a day away."
values={{
courseEndDate,
timeRemaining,
}}
/>
);
}
return (
<Alert type={ALERT_TYPES.INFO}>
<strong>{msg}</strong><br />
{description}
</Alert>
);
}
CourseEndAlert.propTypes = {
payload: PropTypes.shape({
delta: PropTypes.number,
description: PropTypes.string,
endDate: PropTypes.string,
userTimezone: PropTypes.string,
}).isRequired,
};
export default CourseEndAlert;

View File

@@ -1,41 +0,0 @@
/* eslint-disable import/prefer-default-export */
import React from 'react';
import { useAlert } from '../../../../generic/user-messages';
import { useModel } from '../../../../generic/model-store';
const CourseEndAlert = React.lazy(() => import('./CourseEndAlert'));
// period of time (in ms) before end of course during which we alert
const WARNING_PERIOD_MS = 14 * 24 * 60 * 60 * 1000; // 14 days
export function useCourseEndAlert(courseId) {
const {
isEnrolled,
} = useModel('courses', courseId);
const {
datesWidget: {
courseDateBlocks,
userTimezone,
},
} = useModel('outline', courseId);
const endBlock = courseDateBlocks.find(b => b.dateType === 'course-end-date');
const endDate = endBlock ? new Date(endBlock.date) : null;
const delta = endBlock ? endDate - new Date() : 0;
const isVisible = isEnrolled && endBlock && delta > 0 && delta < WARNING_PERIOD_MS;
useAlert(isVisible, {
code: 'clientCourseEndAlert',
payload: {
delta,
description: endBlock && endBlock.description,
endDate: endBlock && endBlock.date,
userTimezone,
},
topic: 'outline-course-alerts',
});
return {
clientCourseEndAlert: CourseEndAlert,
};
}

View File

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

View File

@@ -1,97 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
FormattedDate,
FormattedMessage,
FormattedRelative,
FormattedTime,
} from '@edx/frontend-platform/i18n';
import { Alert, ALERT_TYPES } from '../../../../generic/user-messages';
const DAY_MS = 24 * 60 * 60 * 1000; // in ms
function CourseStartAlert({ payload }) {
const {
delta,
startDate,
userTimezone,
} = payload;
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
const timeRemaining = (
<FormattedRelative
key="timeRemaining"
value={startDate}
{...timezoneFormatArgs}
/>
);
if (delta < DAY_MS) {
return (
<Alert type={ALERT_TYPES.INFO}>
<FormattedMessage
id="learning.outline.alert.start.short"
defaultMessage="Course starts {timeRemaining} at {courseStartTime}."
description="Used when the time remaining is less than a day away."
values={{
courseStartTime: (
<FormattedTime
key="courseStartTime"
day="numeric"
month="short"
year="numeric"
hour12={false}
timeZoneName="short"
value={startDate}
{...timezoneFormatArgs}
/>
),
timeRemaining,
}}
/>
</Alert>
);
}
return (
<Alert type={ALERT_TYPES.INFO}>
<strong>
<FormattedMessage
id="learning.outline.alert.end.long"
defaultMessage="Course starts {timeRemaining} on {courseStartDate}."
description="Used when the time remaining is more than a day away."
values={{
courseStartDate: (
<FormattedDate
key="courseStartDate"
day="numeric"
month="short"
year="numeric"
value={startDate}
{...timezoneFormatArgs}
/>
),
timeRemaining,
}}
/>
</strong>
<br />
<FormattedMessage
id="learning.outline.alert.end.calendar"
defaultMessage="Dont forget to add a calendar reminder!"
/>
</Alert>
);
}
CourseStartAlert.propTypes = {
payload: PropTypes.shape({
delta: PropTypes.number,
startDate: PropTypes.string,
userTimezone: PropTypes.string,
}).isRequired,
};
export default CourseStartAlert;

View File

@@ -1,37 +0,0 @@
import React from 'react';
import { useAlert } from '../../../../generic/user-messages';
import { useModel } from '../../../../generic/model-store';
const CourseStartAlert = React.lazy(() => import('./CourseStartAlert'));
function useCourseStartAlert(courseId) {
const {
isEnrolled,
} = useModel('courses', courseId);
const {
datesWidget: {
courseDateBlocks,
userTimezone,
},
} = useModel('outline', 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;
useAlert(isVisible, {
code: 'clientCourseStartAlert',
payload: {
delta,
startDate: startBlock && startBlock.date,
userTimezone,
},
topic: 'outline-course-alerts',
});
return {
clientCourseStartAlert: CourseStartAlert,
};
}
export default useCourseStartAlert;

View File

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

View File

@@ -1 +0,0 @@
export { default } from './OutlineTab';

View File

@@ -1,38 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
allDates: {
id: 'learning.outline.dates.all',
defaultMessage: 'View all course dates',
},
dates: {
id: 'learning.outline.dates',
defaultMessage: 'Upcoming Dates',
},
handouts: {
id: 'learning.outline.handouts',
defaultMessage: 'Course Handouts',
},
resume: {
id: 'learning.outline.resume',
defaultMessage: 'Resume Course',
},
tools: {
id: 'learning.outline.tools',
defaultMessage: 'Course Tools',
},
welcomeMessage: {
id: 'learning.outline.welcomeMessage',
defaultMessage: 'Welcome Message',
},
welcomeMessageShowMoreButton: {
id: 'learning.outline.welcomeMessageShowMoreButton',
defaultMessage: 'Show More',
},
welcomeMessageShowLessButton: {
id: 'learning.outline.welcomeMessageShowLessButton',
defaultMessage: 'Show Less',
},
});
export default messages;

View File

@@ -1,41 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import DateSummary from '../DateSummary';
import messages from '../messages';
import { useModel } from '../../../generic/model-store';
function CourseDates({ courseId, intl }) {
const {
datesWidget,
} = useModel('outline', courseId);
return (
<section className="mb-3">
<h4>{intl.formatMessage(messages.dates)}</h4>
{datesWidget.courseDateBlocks.map((courseDateBlock) => (
<DateSummary
key={courseDateBlock.title + courseDateBlock.date}
dateBlock={courseDateBlock}
userTimezone={datesWidget.userTimezone}
/>
))}
<a className="font-weight-bold" href={datesWidget.datesTabLink}>
{intl.formatMessage(messages.allDates)}
</a>
</section>
);
}
CourseDates.propTypes = {
courseId: PropTypes.string,
intl: intlShape.isRequired,
};
CourseDates.defaultProps = {
courseId: null,
};
export default injectIntl(CourseDates);

View File

@@ -1,35 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import LmsHtmlFragment from '../LmsHtmlFragment';
import messages from '../messages';
import { useModel } from '../../../generic/model-store';
function CourseHandouts({ courseId, intl }) {
const {
handoutsHtml,
} = useModel('outline', courseId);
if (!handoutsHtml) {
return null;
}
return (
<section className="mb-3">
<h4>{intl.formatMessage(messages.handouts)}</h4>
<LmsHtmlFragment
html={handoutsHtml}
title={intl.formatMessage(messages.handouts)}
/>
</section>
);
}
CourseHandouts.propTypes = {
courseId: PropTypes.string.isRequired,
intl: intlShape.isRequired,
};
export default injectIntl(CourseHandouts);

View File

@@ -1,73 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
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';
import {
faBookmark, faCertificate, faInfo, faCalendar, faStar,
} from '@fortawesome/free-solid-svg-icons';
import { faNewspaper } from '@fortawesome/free-regular-svg-icons';
import messages from '../messages';
import { useModel } from '../../../generic/model-store';
function CourseTools({ courseId, intl }) {
const {
courseTools,
} = useModel('outline', courseId);
const logClick = (analyticsId) => {
const { administrator } = getAuthenticatedUser();
sendTrackEvent('edx.course.tool.accessed', {
course_id: courseId,
is_staff: administrator,
tool_name: analyticsId,
});
};
const renderIcon = (iconClasses) => {
switch (iconClasses) {
case 'edx.bookmarks':
return faBookmark;
case 'edx.tool.verified_upgrade':
return faCertificate;
case 'edx.tool.financial_assistance':
return faInfo;
case 'edx.calendar-sync':
return faCalendar;
case 'edx.updates':
return faNewspaper;
case 'edx.reviews':
return faStar;
default:
return null;
}
};
return (
<section className="mb-3">
<h4>{intl.formatMessage(messages.tools)}</h4>
{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>
);
}
CourseTools.propTypes = {
courseId: PropTypes.string,
intl: intlShape.isRequired,
};
CourseTools.defaultProps = {
courseId: null,
};
export default injectIntl(CourseTools);

View File

@@ -1,68 +0,0 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useDispatch } from 'react-redux';
import LmsHtmlFragment from '../LmsHtmlFragment';
import messages from '../messages';
import { useModel } from '../../../generic/model-store';
import { Alert } from '../../../generic/user-messages';
import { dismissWelcomeMessage } from '../../data/thunks';
function WelcomeMessage({ courseId, intl }) {
const {
welcomeMessageHtml,
} = useModel('outline', courseId);
if (!welcomeMessageHtml) {
return null;
}
const [display, setDisplay] = useState(true);
const shortWelcomeMessageHtml = welcomeMessageHtml.length > 200 && `${welcomeMessageHtml.substring(0, 199)}...`;
const [showShortMessage, setShowShortMessage] = useState(!!shortWelcomeMessageHtml);
const dispatch = useDispatch();
return (
display && (
<Alert
type="welcome"
dismissible
onDismiss={() => {
setDisplay(false);
dispatch(dismissWelcomeMessage(courseId));
}}
>
<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)}
>
{showShortMessage ? intl.formatMessage(messages.welcomeMessageShowMoreButton)
: intl.formatMessage(messages.welcomeMessageShowLessButton)}
</button>
</div>
)
}
</Alert>
)
);
}
WelcomeMessage.propTypes = {
courseId: PropTypes.string.isRequired,
intl: intlShape.isRequired,
};
export default injectIntl(WelcomeMessage);

View File

@@ -1,73 +0,0 @@
import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { requestCert } from '../data/thunks';
import { useModel } from '../../generic/model-store';
import messages from './messages';
import VerifiedCert from '../../courseware/course/sequence/lock-paywall/assets/edx-verified-mini-cert.png';
function CertificateBanner({ intl }) {
const {
courseId,
} = useSelector(state => state.courseHome);
const {
certificateData,
enrollmentMode,
} = useModel('progress', courseId);
if (certificateData === null || enrollmentMode === 'audit') { return null; }
const { certUrl, certDownloadUrl } = certificateData;
const dispatch = useDispatch();
function requestHandler() {
dispatch(requestCert(courseId));
}
return (
<section className="banner rounded my-4 p-4 container-fluid border border-primary-200 bg-info-100 row">
<div className="col-12 col-sm-9">
<div>
<div className="font-weight-bold">{certificateData.title}</div>
<div className="mt-1">{certificateData.msg}</div>
</div>
{certUrl && (
<div>
<a className="btn btn-primary my-3" href={certUrl} rel="noopener noreferrer" target="_blank">
{intl.formatMessage(messages.viewCert)}
<span className="sr-only">{intl.formatMessage(messages.opensNewWindow)}</span>
</a>
</div>
)}
{!certUrl && certificateData.isDownloadable && (
<div>
<a className="btn btn-primary my-3" href={certDownloadUrl} rel="noopener noreferrer" target="_blank">
{intl.formatMessage(messages.downloadCert)}
<span className="sr-only">{intl.formatMessage(messages.opensNewWindow)}</span>
</a>
</div>
)}
{!certUrl && !certificateData.isDownloadable && certificateData.isRequestable && (
<div className="my-3">
<button className="btn btn-primary" type="button" onClick={requestHandler}>
{intl.formatMessage(messages.requestCert)}
</button>
</div>
)}
</div>
<div className="col-0 col-sm-3 d-none d-sm-block">
<img
alt={intl.formatMessage(messages.certAlt)}
src={VerifiedCert}
className="float-right"
style={{ height: '120px' }}
/>
</div>
</section>
);
}
CertificateBanner.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(CertificateBanner);

View File

@@ -1,36 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import Subsection from './Subsection';
export default function Chapter({
chapter,
}) {
if (chapter.displayName === 'hidden') { return null; }
const { subsections } = chapter;
return (
<section className="border-top border-light-500">
<div className="row">
<div className="lead font-weight-normal col-12 col-sm-3 my-3 border-right border-light-500">
{chapter.displayName}
</div>
<div className="col-12 col-sm-9">
{subsections.map((subsection) => (
<Subsection
key={subsection.url}
subsection={subsection}
/>
))}
</div>
</div>
</section>
);
}
Chapter.propTypes = {
chapter: PropTypes.shape({
displayName: PropTypes.string,
subsections: PropTypes.arrayOf(PropTypes.shape({
url: PropTypes.string,
})),
}).isRequired,
};

View File

@@ -1,36 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import { FormattedDate, FormattedTime } from '@edx/frontend-platform/i18n';
import { useModel } from '../../generic/model-store';
export default function DueDateTime({
due,
}) {
const {
courseId,
} = useSelector(state => state.activeCourse);
const {
userTimezone,
} = useModel('progress', courseId);
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
return (
<em className="ml-0">
due <FormattedDate
value={due}
day="numeric"
month="short"
weekday="short"
year="numeric"
{...timezoneFormatArgs}
/> <FormattedTime
value={due}
/>
</em>
);
}
DueDateTime.propTypes = {
due: PropTypes.string.isRequired,
};

View File

@@ -1,37 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import messages from './messages';
function ProblemScores({
intl,
scoreName,
problemScores,
}) {
return (
<div className="row mt-1">
<dl className="d-flex flex-wrap small text-gray-500">
<dt className="mr-3">{intl.formatMessage(messages[`${scoreName}`])}</dt>
{problemScores.map((problem, index) => {
const key = scoreName + index;
return (
<dd className="mr-3" key={key}>{problem.earned}/{problem.possible}</dd>
);
})}
</dl>
</div>
);
}
ProblemScores.propTypes = {
intl: intlShape.isRequired,
scoreName: PropTypes.string.isRequired,
problemScores: PropTypes.arrayOf(PropTypes.shape({
possible: PropTypes.number,
earned: PropTypes.number,
id: PropTypes.string,
})).isRequired,
};
export default injectIntl(ProblemScores);

View File

@@ -1,46 +0,0 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useModel } from '../../generic/model-store';
import Chapter from './Chapter';
import CertificateBanner from './CertificateBanner';
import messages from './messages';
function ProgressTab({ intl }) {
const {
courseId,
} = useSelector(state => state.activeCourse);
const { administrator } = getAuthenticatedUser();
const {
coursewareSummary,
studioUrl,
} = useModel('progress', courseId);
return (
<section>
{administrator && studioUrl && (
<div className="row mb-3 mr-3 justify-content-end">
<a className="btn-sm border border-info" href={studioUrl}>
{intl.formatMessage(messages.studioLink)}
</a>
</div>
)}
<CertificateBanner />
{coursewareSummary.map((chapter) => (
<Chapter
key={chapter.displayName}
chapter={chapter}
/>
))}
</section>
);
}
ProgressTab.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(ProgressTab);

View File

@@ -1,76 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import messages from './messages';
import DueDateTime from './DueDateTime';
import ProblemScores from './ProblemScores';
function Subsection({
intl,
subsection,
}) {
const scoreName = subsection.graded ? 'problem' : 'practice';
const { earned, possible } = subsection.gradedTotal;
const showTotalScore = ((possible > 0) || (earned > 0)) && subsection.showGrades;
// screen reader information
const totalScoreSr = intl.formatMessage(messages.pointsEarned, { earned, total: possible });
return (
<section className="my-3 ml-3">
<div className="row">
<a className="h6" href={subsection.url}>
<div dangerouslySetInnerHTML={{ __html: subsection.displayName }} />
{showTotalScore && <span className="sr-only">{totalScoreSr}</span>}
</a>
{showTotalScore && <span className="small ml-1 mb-2">({earned}/{possible}) {subsection.percentGraded}%</span>}
</div>
<div className="row small">
{subsection.format && <div className="mr-1">{subsection.format}</div>}
{subsection.due !== null && <DueDateTime due={subsection.due} />}
</div>
{subsection.problemScores.length > 0 && subsection.showGrades && (
<ProblemScores scoreName={scoreName} problemScores={subsection.problemScores} />
)}
{subsection.problemScores.length > 0 && !subsection.showGrades && subsection.showCorrectness === 'past_due' && (
<div className="row small">{intl.formatMessage(messages[`${scoreName}HiddenUntil`])}</div>
)}
{subsection.problemScores.length > 0 && !subsection.showGrades && !(subsection.showCorrectness === 'past_due')
&& <div className="row small">{intl.formatMessage(messages[`${scoreName}Hidden`])}</div>}
{(subsection.problemScores.length === 0) && (
<div className="row small">{intl.formatMessage(messages.noScores)}</div>
)}
</section>
);
}
Subsection.propTypes = {
intl: intlShape.isRequired,
subsection: PropTypes.shape({
graded: PropTypes.bool.isRequired,
url: PropTypes.string.isRequired,
showGrades: PropTypes.bool.isRequired,
gradedTotal: PropTypes.shape({
possible: PropTypes.number,
earned: PropTypes.number,
graded: PropTypes.bool,
}).isRequired,
showCorrectness: PropTypes.string.isRequired,
due: PropTypes.string,
problemScores: PropTypes.arrayOf(PropTypes.shape({
possible: PropTypes.number,
earned: PropTypes.number,
id: PropTypes.string,
})).isRequired,
format: PropTypes.string,
// override: PropTypes.object,
displayName: PropTypes.string.isRequired,
percentGraded: PropTypes.number.isRequired,
}).isRequired,
};
export default injectIntl(Subsection);

View File

@@ -1,63 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
problem: {
id: 'learning.progress.badge.problem',
defaultMessage: 'Problem Scores: ',
},
practice: {
id: 'learning.progress.badge.practice',
defaultMessage: 'Practice Scores: ',
},
problemHiddenUntil: {
id: 'learning.progress.badge.problemHiddenUntil',
defaultMessage: 'Problem scores are hidden until the due date.',
},
practiceHiddenUntil: {
id: 'learning.progress.badge.practiceHiddenUntil',
defaultMessage: 'Practice scores are hidden until the due date.',
},
problemHidden: {
id: 'learning.progress.badge.probHidden',
defaultMessage: 'problemlem scores are hidden.',
},
practiceHidden: {
id: 'learning.progress.badge.practiceHidden',
defaultMessage: 'Practice scores are hidden.',
},
noScores: {
id: 'learning.progress.badge.noScores',
defaultMessage: 'No problem scores in this section.',
},
pointsEarned: {
id: 'learning.progress.badge.scoreEarned',
defaultMessage: '{earned} of {total} possible points',
},
viewCert: {
id: 'learning.progress.badge.viewCert',
defaultMessage: 'View Certificate',
},
downloadCert: {
id: 'learning.progress.badge.downloadCert',
defaultMessage: 'Download Your Certificate',
},
requestCert: {
id: 'learning.progress.badge.requestCert',
defaultMessage: 'Request Certificate',
},
opensNewWindow: {
id: 'learning.progress.badge.opensNewWindow',
defaultMessage: 'Opens in a new browser window',
},
certAlt: {
id: 'learning.progress.badge.certAlt',
defaultMessage: 'Example Certificate',
description: 'Alternate text displayed when the example certificate image cannot be displayed.',
},
studioLink: {
id: 'learning.progress.badge.studioLink',
defaultMessage: 'View grading in studio',
},
});
export default messages;

View File

@@ -1,255 +1,184 @@
import React, { Component } from 'react';
import React, { useEffect, useCallback } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { useSelector, useDispatch } from 'react-redux';
import { history } from '@edx/frontend-platform';
import { getLocale } from '@edx/frontend-platform/i18n';
import { Redirect } from 'react-router';
import { createSelector } from '@reduxjs/toolkit';
import { defaultMemoize as memoize } from 'reselect';
import { useRouteMatch, Redirect } from 'react-router';
import {
checkBlockCompletion,
fetchCourse,
fetchSequence,
getResumeBlock,
saveSequencePosition,
SEQUENCE_LOADED,
SEQUENCE_LOADING,
SEQUENCE_FAILED,
} from './data';
import { TabPage } from '../tab-page';
} from '../data';
import {
activeCourseSelector, COURSE_LOADED, COURSE_LOADING, COURSE_FAILED, COURSE_DENIED,
} from '../active-course';
checkBlockCompletion,
saveSequencePosition,
} from './data/thunks';
import { useModel } from '../model-store';
import Course from './course';
import { handleNextSectionCelebration } from './course/celebration';
const checkExamRedirect = memoize((sequenceStatus, sequence) => {
if (sequenceStatus === SEQUENCE_LOADED) {
if (sequence.isTimeLimited && sequence.lmsWebUrl !== undefined) {
global.location.assign(sequence.lmsWebUrl);
}
}
});
import { sequenceIdsSelector, firstSequenceIdSelector } from './data/selectors';
const checkResumeRedirect = memoize((courseStatus, courseId, sequenceId, firstSequenceId) => {
if (courseStatus === COURSE_LOADED && !sequenceId) {
// Note that getResumeBlock is just an API call, not a redux thunk.
getResumeBlock(courseId).then((data) => {
// This is a replace because we don't want this change saved in the browser's history.
if (data.sectionId && data.unitId) {
history.replace(`/course/${courseId}/${data.sectionId}/${data.unitId}`);
} else if (firstSequenceId) {
history.replace(`/course/${courseId}/${firstSequenceId}`);
}
});
}
});
const checkContentRedirect = memoize((courseId, sequenceStatus, sequenceId, sequence, unitId) => {
if (sequenceStatus === SEQUENCE_LOADED && sequenceId && !unitId) {
if (sequence.unitIds !== undefined && sequence.unitIds.length > 0) {
const nextUnitId = sequence.unitIds[sequence.activeUnitIndex];
// This is a replace because we don't want this change saved in the browser's history.
history.replace(`/course/${courseId}/${sequence.id}/${nextUnitId}`);
}
}
});
class CoursewareContainer extends Component {
checkSaveSequencePosition = memoize((unitId) => {
const {
courseId,
sequenceId,
sequenceStatus,
sequence,
} = this.props;
if (sequenceStatus === SEQUENCE_LOADED && sequence.saveUnitPosition && unitId) {
const activeUnitIndex = sequence.unitIds.indexOf(unitId);
this.props.saveSequencePosition(courseId, sequenceId, activeUnitIndex);
}
});
checkFetchCourse = memoize((courseId) => {
this.props.fetchCourse(courseId);
});
checkFetchSequence = memoize((sequenceId) => {
if (sequenceId) {
this.props.fetchSequence(sequenceId);
}
});
componentDidMount() {
const {
match: {
params: {
courseId: routeCourseId,
sequenceId: routeSequenceId,
},
},
} = this.props;
// Load data whenever the course or sequence ID changes.
this.checkFetchCourse(routeCourseId);
this.checkFetchSequence(routeSequenceId);
}
componentDidUpdate() {
const {
courseId,
sequenceId,
courseStatus,
sequenceStatus,
sequence,
firstSequenceId,
match: {
params: {
courseId: routeCourseId,
sequenceId: routeSequenceId,
unitId: routeUnitId,
},
},
} = this.props;
// Load data whenever the course or sequence ID changes.
this.checkFetchCourse(routeCourseId);
this.checkFetchSequence(routeSequenceId);
// Redirect to the legacy experience for exams.
checkExamRedirect(sequenceStatus, sequence);
// Determine if we need to redirect because our URL is incomplete.
checkContentRedirect(courseId, sequenceStatus, sequenceId, sequence, routeUnitId);
// Determine if we can resume where we left off.
checkResumeRedirect(courseStatus, courseId, sequenceId, firstSequenceId);
// Check if we should save our sequence position. Only do this when the route unit ID changes.
this.checkSaveSequencePosition(routeUnitId);
}
handleUnitNavigationClick = (nextUnitId) => {
const {
courseId, sequenceId, unitId,
} = this.props;
this.props.checkBlockCompletion(courseId, sequenceId, unitId);
history.push(`/course/${courseId}/${sequenceId}/${nextUnitId}`);
}
handleNextSequenceClick = () => {
const {
course,
courseId,
nextSequence,
sequence,
sequenceId,
} = this.props;
if (nextSequence !== null) {
let nextUnitId = null;
if (nextSequence.unitIds.length > 0) {
[nextUnitId] = nextSequence.unitIds;
history.push(`/course/${courseId}/${nextSequence.id}/${nextUnitId}`);
} else {
// Some sequences have no units. This will show a blank page with prev/next buttons.
history.push(`/course/${courseId}/${nextSequence.id}`);
}
const celebrateFirstSection = course && course.celebrations && course.celebrations.firstSection;
if (celebrateFirstSection && sequence.sectionId !== nextSequence.sectionId) {
handleNextSectionCelebration(sequenceId, nextSequence.id, nextUnitId);
}
}
}
handlePreviousSequenceClick = () => {
const { previousSequence, courseId } = this.props;
if (previousSequence !== null) {
if (previousSequence.unitIds.length > 0) {
const previousUnitId = previousSequence.unitIds[previousSequence.unitIds.length - 1];
history.push(`/course/${courseId}/${previousSequence.id}/${previousUnitId}`);
} else {
// Some sequences have no units. This will show a blank page with prev/next buttons.
history.push(`/course/${courseId}/${previousSequence.id}`);
}
}
}
renderDenied() {
const { courseId, course } = this.props;
let url = `/redirect/course-home/${courseId}`;
switch (course.canLoadCourseware.errorCode) {
case 'audit_expired':
url = `/redirect/dashboard?access_response_error=${course.canLoadCourseware.additionalContextUserMessage}`;
break;
case 'course_not_started':
// eslint-disable-next-line no-case-declarations
const startDate = (new Intl.DateTimeFormat(getLocale())).format(new Date(course.start));
url = `/redirect/dashboard?notlive=${startDate}`;
break;
case 'survey_required': // TODO: Redirect to the course survey
case 'unfulfilled_milestones':
url = '/redirect/dashboard';
break;
case 'authentication_required':
case 'enrollment_required':
default:
}
return (
<Redirect to={url} />
);
}
render() {
const {
courseStatus,
courseId,
sequenceId,
match: {
params: {
unitId: routeUnitId,
},
},
} = this.props;
if (courseStatus === COURSE_DENIED) {
return this.renderDenied();
}
return (
<TabPage
activeTabSlug="courseware"
courseId={courseId}
unitId={routeUnitId}
courseStatus={courseStatus}
>
<Course
courseId={courseId}
sequenceId={sequenceId}
unitId={routeUnitId}
nextSequenceHandler={this.handleNextSequenceClick}
previousSequenceHandler={this.handlePreviousSequenceClick}
unitNavigationHandler={this.handleUnitNavigationClick}
/>
</TabPage>
);
}
function useUnitNavigationHandler(courseId, sequenceId, unitId) {
const dispatch = useDispatch();
return useCallback((nextUnitId) => {
dispatch(checkBlockCompletion(courseId, sequenceId, unitId));
history.push(`/learning/course/${courseId}/${sequenceId}/${nextUnitId}`);
}, [courseId, sequenceId]);
}
const sequenceShape = PropTypes.shape({
id: PropTypes.string.isRequired,
unitIds: PropTypes.arrayOf(PropTypes.string).isRequired,
isTimeLimited: PropTypes.bool,
lmsWebUrl: PropTypes.string,
});
function usePreviousSequence(sequenceId) {
const sequenceIds = useSelector(sequenceIdsSelector);
const sequences = useSelector(state => state.models.sequences);
if (!sequenceId || sequenceIds.length === 0) {
return null;
}
const sequenceIndex = sequenceIds.indexOf(sequenceId);
const previousSequenceId = sequenceIndex > 0 ? sequenceIds[sequenceIndex - 1] : null;
return previousSequenceId !== null ? sequences[previousSequenceId] : null;
}
const courseShape = PropTypes.shape({
canLoadCourseware: PropTypes.shape({
errorCode: PropTypes.string,
additionalContextUserMessage: PropTypes.string,
}).isRequired,
});
function useNextSequence(sequenceId) {
const sequenceIds = useSelector(sequenceIdsSelector);
const sequences = useSelector(state => state.models.sequences);
if (!sequenceId || sequenceIds.length === 0) {
return null;
}
const sequenceIndex = sequenceIds.indexOf(sequenceId);
const nextSequenceId = sequenceIndex < sequenceIds.length - 1 ? sequenceIds[sequenceIndex + 1] : null;
return nextSequenceId !== null ? sequences[nextSequenceId] : null;
}
function useNextSequenceHandler(courseId, sequenceId) {
const nextSequence = useNextSequence(sequenceId);
const courseStatus = useSelector(state => state.courseware.courseStatus);
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
return useCallback(() => {
if (nextSequence !== null) {
const nextUnitId = nextSequence.unitIds[0];
history.push(`/learning/course/${courseId}/${nextSequence.id}/${nextUnitId}`);
}
}, [courseStatus, sequenceStatus, sequenceId]);
}
function usePreviousSequenceHandler(courseId, sequenceId) {
const previousSequence = usePreviousSequence(sequenceId);
const courseStatus = useSelector(state => state.courseware.courseStatus);
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
return useCallback(() => {
if (previousSequence !== null) {
const previousUnitId = previousSequence.unitIds[previousSequence.unitIds.length - 1];
history.push(`/learning/course/${courseId}/${previousSequence.id}/${previousUnitId}`);
}
}, [courseStatus, sequenceStatus, sequenceId]);
}
function useExamRedirect(sequenceId) {
const sequence = useModel('sequences', sequenceId);
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
useEffect(() => {
if (sequenceStatus === 'loaded' && sequence.isTimeLimited) {
global.location.assign(sequence.lmsWebUrl);
}
}, [sequenceStatus, sequence]);
}
function useContentRedirect(courseStatus, sequenceStatus) {
const match = useRouteMatch();
const { courseId, sequenceId, unitId } = match.params;
const sequence = useModel('sequences', sequenceId);
const firstSequenceId = useSelector(firstSequenceIdSelector);
useEffect(() => {
if (courseStatus === 'loaded' && !sequenceId) {
// This is a replace because we don't want this change saved in the browser's history.
history.replace(`/learning/course/${courseId}/${firstSequenceId}`);
}
}, [courseStatus, sequenceId]);
useEffect(() => {
if (sequenceStatus === 'loaded' && sequenceId && !unitId) {
// The position may be null, in which case we'll just assume 0.
if (sequence.unitIds !== undefined && sequence.unitIds.length > 0) {
const unitIndex = sequence.position || 0;
const nextUnitId = sequence.unitIds[unitIndex];
// This is a replace because we don't want this change saved in the browser's history.
history.replace(`/learning/course/${courseId}/${sequence.id}/${nextUnitId}`);
}
}
}, [sequenceStatus, sequenceId, unitId]);
}
function useSavedSequencePosition(courseId, sequenceId, unitId) {
const dispatch = useDispatch();
const sequence = useModel('sequences', sequenceId);
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
useEffect(() => {
if (sequenceStatus === 'loaded' && sequence.savePosition) {
const activeUnitIndex = sequence.unitIds.indexOf(unitId);
dispatch(saveSequencePosition(courseId, sequenceId, activeUnitIndex));
}
}, [unitId]);
}
export default function CoursewareContainer() {
const { params } = useRouteMatch();
const {
courseId: routeCourseUsageKey,
sequenceId: routeSequenceId,
unitId: routeUnitId,
} = params;
const dispatch = useDispatch();
useEffect(() => {
dispatch(fetchCourse(routeCourseUsageKey));
}, [routeCourseUsageKey]);
useEffect(() => {
if (routeSequenceId) {
dispatch(fetchSequence(routeSequenceId));
}
}, [routeSequenceId]);
// The courseId and sequenceId in the store are the entities we currently have loaded.
// We get these two IDs from the store because until fetchCourse and fetchSequence below have
// finished their work, the IDs in the URL are not representative of what we should actually show.
// This is important particularly when switching sequences. Until a new sequence is fully loaded,
// there's information that we don't have yet - if we use the URL's sequence ID to tell the app
// which sequence is loaded, we'll instantly try to pull it out of the store and use it, before
// the sequenceStatus flag has even switched back to "loading", which will put our app into an
// invalid state.
const {
courseId,
sequenceId,
courseStatus,
sequenceStatus,
} = useSelector(state => state.courseware);
const nextSequenceHandler = useNextSequenceHandler(courseId, sequenceId);
const previousSequenceHandler = usePreviousSequenceHandler(courseId, sequenceId);
const unitNavigationHandler = useUnitNavigationHandler(courseId, sequenceId, routeUnitId);
useContentRedirect(courseStatus, sequenceStatus);
useExamRedirect(sequenceId);
useSavedSequencePosition(courseId, sequenceId, routeUnitId);
if (courseStatus === 'denied') {
return <Redirect to={`/learning/redirect/course-home/${courseId}`} />;
}
return (
<main className="flex-grow-1 d-flex flex-column">
<Course
courseId={courseId}
sequenceId={sequenceId}
unitId={routeUnitId}
nextSequenceHandler={nextSequenceHandler}
previousSequenceHandler={previousSequenceHandler}
unitNavigationHandler={unitNavigationHandler}
/>
</main>
);
}
CoursewareContainer.propTypes = {
match: PropTypes.shape({
@@ -259,123 +188,4 @@ CoursewareContainer.propTypes = {
unitId: PropTypes.string,
}).isRequired,
}).isRequired,
courseId: PropTypes.string,
sequenceId: PropTypes.string,
firstSequenceId: PropTypes.string,
unitId: PropTypes.string,
courseStatus: PropTypes.oneOf([COURSE_LOADED, COURSE_LOADING, COURSE_FAILED, COURSE_DENIED]).isRequired,
sequenceStatus: PropTypes.oneOf([SEQUENCE_LOADED, SEQUENCE_LOADING, SEQUENCE_FAILED]).isRequired,
nextSequence: sequenceShape,
previousSequence: sequenceShape,
course: courseShape,
sequence: sequenceShape,
saveSequencePosition: PropTypes.func.isRequired,
checkBlockCompletion: PropTypes.func.isRequired,
fetchCourse: PropTypes.func.isRequired,
fetchSequence: PropTypes.func.isRequired,
};
CoursewareContainer.defaultProps = {
courseId: null,
sequenceId: null,
firstSequenceId: null,
unitId: null,
nextSequence: null,
previousSequence: null,
course: null,
sequence: null,
};
const currentSequenceSelector = createSelector(
(state) => state.models.sequences || {},
(state) => state.courseware.sequenceId,
(sequencesById, sequenceId) => (sequencesById[sequenceId] ? sequencesById[sequenceId] : null),
);
const sequenceIdsSelector = createSelector(
(state) => state.activeCourse.courseStatus,
activeCourseSelector,
(state) => state.models.sections,
(courseStatus, course, sectionsById) => {
if (courseStatus !== COURSE_LOADED) {
return [];
}
const { sectionIds = [] } = course;
return sectionIds.flatMap(sectionId => sectionsById[sectionId].sequenceIds);
},
);
const previousSequenceSelector = createSelector(
sequenceIdsSelector,
(state) => state.models.sequences || {},
(state) => state.courseware.sequenceId,
(sequenceIds, sequencesById, sequenceId) => {
if (!sequenceId || sequenceIds.length === 0) {
return null;
}
const sequenceIndex = sequenceIds.indexOf(sequenceId);
const previousSequenceId = sequenceIndex > 0 ? sequenceIds[sequenceIndex - 1] : null;
return previousSequenceId !== null ? sequencesById[previousSequenceId] : null;
},
);
const nextSequenceSelector = createSelector(
sequenceIdsSelector,
(state) => state.models.sequences || {},
(state) => state.courseware.sequenceId,
(sequenceIds, sequencesById, sequenceId) => {
if (!sequenceId || sequenceIds.length === 0) {
return null;
}
const sequenceIndex = sequenceIds.indexOf(sequenceId);
const nextSequenceId = sequenceIndex < sequenceIds.length - 1 ? sequenceIds[sequenceIndex + 1] : null;
return nextSequenceId !== null ? sequencesById[nextSequenceId] : null;
},
);
const firstSequenceIdSelector = createSelector(
(state) => state.activeCourse.courseStatus,
activeCourseSelector,
(state) => state.models.sections || {},
(courseStatus, course, sectionsById) => {
if (courseStatus !== COURSE_LOADED) {
return null;
}
const { sectionIds = [] } = course;
if (sectionIds.length === 0) {
return null;
}
return sectionsById[sectionIds[0]].sequenceIds[0];
},
);
const mapStateToProps = (state) => {
const {
sequenceId, sequenceStatus, unitId,
} = state.courseware;
const {
courseId, courseStatus,
} = state.activeCourse;
return {
courseId,
sequenceId,
unitId,
courseStatus,
sequenceStatus,
course: activeCourseSelector(state),
sequence: currentSequenceSelector(state),
previousSequence: previousSequenceSelector(state),
nextSequence: nextSequenceSelector(state),
firstSequenceId: firstSequenceIdSelector(state),
};
};
export default connect(mapStateToProps, {
checkBlockCompletion,
saveSequencePosition,
fetchCourse,
fetchSequence,
})(CoursewareContainer);

View File

@@ -1,378 +0,0 @@
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 '@testing-library/jest-dom/extend-expect';
import { render, screen } from '@testing-library/react';
import React from 'react';
import { Route, Switch } from 'react-router';
import { Factory } from 'rosie';
import MockAdapter from 'axios-mock-adapter';
import { UserMessagesProvider } from '../generic/user-messages';
import tabMessages from '../tab-page/messages';
import initializeMockApp from '../setupTest';
import CoursewareContainer from './CoursewareContainer';
import buildSimpleCourseBlocks from './data/__factories__/courseBlocks.factory';
import initializeStore from '../store';
// NOTE: Because the unit creates an iframe, we choose to mock it out as its rendering isn't
// pertinent to this test. Instead, we render a simple div that displays the properties we expect
// to have been passed into the component. Separate tests can handle unit rendering, but this
// proves that the component is rendered and receives the correct props. We probably COULD render
// Unit.jsx and its iframe in this test, but it's already complex enough.
function MockUnit({ courseId, id }) { // eslint-disable-line react/prop-types
return (
<div className="fake-unit">Unit Contents {courseId} {id}</div>
);
}
jest.mock(
'./course/sequence/Unit',
() => MockUnit,
);
initializeMockApp();
describe('CoursewareContainer', () => {
let store;
let component;
let axiosMock;
beforeEach(() => {
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
store = initializeStore();
component = (
<AppProvider store={store}>
<UserMessagesProvider>
<Switch>
<Route
path={[
'/course/:courseId/:sequenceId/:unitId',
'/course/:courseId/:sequenceId',
'/course/:courseId',
]}
component={CoursewareContainer}
/>
</Switch>
</UserMessagesProvider>
</AppProvider>
);
});
it('should initialize to show a spinner', () => {
history.push('/course/abc123');
render(component);
const spinner = screen.getByRole('status');
expect(spinner.firstChild).toContainHTML(
`<span class="sr-only">${tabMessages.loading.defaultMessage}</span>`,
);
});
describe('when receiving successful course data', () => {
let courseId;
let courseMetadata;
let courseBlocks;
let sequenceMetadata;
let sequenceBlock;
let unitBlocks;
function assertLoadedHeader(container) {
const courseHeader = container.querySelector('.course-header');
// Ensure the course number and org appear - this proves we loaded course metadata properly.
expect(courseHeader).toHaveTextContent(courseMetadata.number);
expect(courseHeader).toHaveTextContent(courseMetadata.org);
// Ensure the course title is showing up in the header. This means we loaded course blocks properly.
expect(courseHeader.querySelector('.course-title')).toHaveTextContent(courseMetadata.name);
}
function assertSequenceNavigation(container) {
// Ensure we had appropriate sequence navigation buttons. We should only have one unit.
const sequenceNavButtons = container.querySelectorAll('nav.sequence-navigation button');
expect(sequenceNavButtons).toHaveLength(5);
expect(sequenceNavButtons[0]).toHaveTextContent('Previous');
// Prove this button is rendering an SVG tasks icon, meaning it's a unit/vertical.
expect(sequenceNavButtons[1].querySelector('svg')).toHaveClass('fa-tasks');
expect(sequenceNavButtons[4]).toHaveTextContent('Next');
}
function setupMockRequests() {
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/courseware/course/${courseId}`).reply(200, courseMetadata);
axiosMock.onGet(new RegExp(`${getConfig().LMS_BASE_URL}/api/courses/v2/blocks/*`)).reply(200, courseBlocks);
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/courseware/sequence/${sequenceBlock.id}`).reply(200, sequenceMetadata);
}
beforeEach(async () => {
// On page load, SequenceContext attempts to scroll to the top of the page.
global.scrollTo = jest.fn();
courseMetadata = Factory.build('courseMetadata');
courseId = courseMetadata.id;
const customUnitBlocks = [
Factory.build(
'block',
{ type: 'vertical' },
{ courseId },
),
Factory.build(
'block',
{ type: 'vertical' },
{ courseId },
),
Factory.build(
'block',
{ type: 'vertical' },
{ courseId },
),
];
const result = buildSimpleCourseBlocks(courseId, courseMetadata.name, { unitBlocks: customUnitBlocks });
courseBlocks = result.courseBlocks;
unitBlocks = result.unitBlocks;
// eslint-disable-next-line prefer-destructuring
sequenceBlock = result.sequenceBlock[0];
sequenceMetadata = Factory.build(
'sequenceMetadata',
{},
{ courseId, unitBlocks, sequenceBlock },
);
setupMockRequests();
});
describe('when the URL only contains a course ID', () => {
it('should use the resume block repsonse to pick a unit if it contains one', async () => {
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/courseware/resume/${courseId}`).reply(200, {
sectionId: sequenceBlock.id,
unitId: unitBlocks[1].id,
});
history.push(`/course/${courseId}`);
const { container } = render(component);
// This is an important line that ensures the spinner has been removed - and thus our main
// content has been loaded - prior to proceeding with our expectations.
await waitForElementToBeRemoved(screen.getByRole('status'));
assertLoadedHeader(container);
assertSequenceNavigation(container);
expect(container.querySelector('.fake-unit')).toHaveTextContent('Unit Contents');
expect(container.querySelector('.fake-unit')).toHaveTextContent(courseId);
expect(container.querySelector('.fake-unit')).toHaveTextContent(unitBlocks[1].id);
});
it('should use the first sequence ID and activeUnitIndex if the resume block response is empty', async () => {
// OVERRIDE SEQUENCE METADATA:
// set the position to the third unit so we can prove activeUnitIndex is working
sequenceMetadata = Factory.build(
'sequenceMetadata',
{ position: 3 }, // position index is 1-based and is converted to 0-based for activeUnitIndex
{ courseId, unitBlocks, sequenceBlock },
);
// Re-call the mock setup now that sequenceMetadata is different.
setupMockRequests();
// Note how there is no sectionId/unitId returned in this mock response!
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/courseware/resume/${courseId}`).reply(200, {});
history.push(`/course/${courseId}`);
const { container } = render(component);
// This is an important line that ensures the spinner has been removed - and thus our main
// content has been loaded - prior to proceeding with our expectations.
await waitForElementToBeRemoved(screen.getByRole('status'));
assertLoadedHeader(container);
assertSequenceNavigation(container);
expect(container.querySelector('.fake-unit')).toHaveTextContent('Unit Contents');
expect(container.querySelector('.fake-unit')).toHaveTextContent(courseId);
expect(container.querySelector('.fake-unit')).toHaveTextContent(unitBlocks[2].id);
});
});
describe('when the URL contains a course ID and sequence ID', () => {
it('should pick the first unit if position was not defined (activeUnitIndex becomes 0)', async () => {
history.push(`/course/${courseId}/${sequenceBlock.id}`);
const { container } = render(component);
// This is an important line that ensures the spinner has been removed - and thus our main
// content has been loaded - prior to proceeding with our expectations.
await waitForElementToBeRemoved(screen.getByRole('status'));
assertLoadedHeader(container);
assertSequenceNavigation(container);
expect(container.querySelector('.fake-unit')).toHaveTextContent('Unit Contents');
expect(container.querySelector('.fake-unit')).toHaveTextContent(courseId);
expect(container.querySelector('.fake-unit')).toHaveTextContent(unitBlocks[0].id);
});
it('should use activeUnitIndex to pick a unit from the sequence', async () => {
// OVERRIDE SEQUENCE METADATA:
sequenceMetadata = Factory.build(
'sequenceMetadata',
{ position: 3 }, // position index is 1-based and is converted to 0-based for activeUnitIndex
{ courseId, unitBlocks, sequenceBlock },
);
// Re-call the mock setup now that sequenceMetadata is different.
setupMockRequests();
history.push(`/course/${courseId}/${sequenceBlock.id}`);
const { container } = render(component);
// This is an important line that ensures the spinner has been removed - and thus our main
// content has been loaded - prior to proceeding with our expectations.
await waitForElementToBeRemoved(screen.getByRole('status'));
assertLoadedHeader(container);
assertSequenceNavigation(container);
expect(container.querySelector('.fake-unit')).toHaveTextContent('Unit Contents');
expect(container.querySelector('.fake-unit')).toHaveTextContent(courseId);
expect(container.querySelector('.fake-unit')).toHaveTextContent(unitBlocks[2].id);
});
});
describe('when the URL contains a course, sequence, and unit ID', () => {
it('should load the specified unit', async () => {
history.push(`/course/${courseId}/${sequenceBlock.id}/${unitBlocks[2].id}`);
const { container } = render(component);
// This is an important line that ensures the spinner has been removed - and thus our main
// content has been loaded - prior to proceeding with our expectations.
await waitForElementToBeRemoved(screen.getByRole('status'));
assertLoadedHeader(container);
assertSequenceNavigation(container);
expect(container.querySelector('.fake-unit')).toHaveTextContent('Unit Contents');
expect(container.querySelector('.fake-unit')).toHaveTextContent(courseId);
expect(container.querySelector('.fake-unit')).toHaveTextContent(unitBlocks[2].id);
});
});
describe('when the current sequence is an exam', () => {
const { location } = window;
beforeEach(() => {
delete window.location;
window.location = {
assign: jest.fn(),
};
});
afterEach(() => {
window.location = location;
});
it('should redirect to the sequence lmsWebUrl', async () => {
// OVERRIDE SEQUENCE METADATA:
sequenceMetadata = Factory.build(
'sequenceMetadata',
{ is_time_limited: true }, // position index is 1-based and is converted to 0-based for activeUnitIndex
{ courseId, unitBlocks, sequenceBlock },
);
// Re-call the mock setup now that sequenceMetadata is different.
setupMockRequests();
history.push(`/course/${courseId}/${sequenceBlock.id}/${unitBlocks[2].id}`);
render(component);
// This is an important line that ensures the spinner has been removed - and thus our main
// content has been loaded - prior to proceeding with our expectations.
await waitForElementToBeRemoved(screen.getByRole('status'));
expect(global.location.assign).toHaveBeenCalledWith(sequenceBlock.lms_web_url);
});
});
});
describe('when receiving a can_load_courseware error_code', () => {
let courseMetadata;
function setupWithDeniedStatus(errorCode) {
courseMetadata = Factory.build('courseMetadata', {
can_load_courseware: {
has_access: false,
error_code: errorCode,
additional_context_user_message: 'uhoh oh no', // only used by audit_expired
},
});
const courseId = courseMetadata.id;
const { courseBlocks, unitBlocks, sequenceBlock } = buildSimpleCourseBlocks(courseId, courseMetadata.name);
const sequenceMetadata = Factory.build(
'sequenceMetadata',
{},
{ courseId, unitBlocks, sequenceBlock },
);
const forbiddenCourseUrl = `${getConfig().LMS_BASE_URL}/api/courseware/course/${courseId}`;
const courseBlocksUrlRegExp = new RegExp(`${getConfig().LMS_BASE_URL}/api/courses/v2/blocks/*`);
const sequenceMetadataUrl = `${getConfig().LMS_BASE_URL}/api/courseware/sequence/${sequenceBlock.id}`;
axiosMock.onGet(forbiddenCourseUrl).reply(200, courseMetadata);
axiosMock.onGet(courseBlocksUrlRegExp).reply(200, courseBlocks);
axiosMock.onGet(sequenceMetadataUrl).reply(200, sequenceMetadata);
history.push(`/course/${courseId}`);
}
it('should go to course home for an enrollment_required error code', async () => {
setupWithDeniedStatus('enrollment_required');
render(component);
await waitForElementToBeRemoved(screen.getByRole('status'));
expect(global.location.href).toEqual(`http://localhost/redirect/course-home/${courseMetadata.id}`);
});
it('should go to course home for an authentication_required error code', async () => {
setupWithDeniedStatus('authentication_required');
render(component);
await waitForElementToBeRemoved(screen.getByRole('status'));
expect(global.location.href).toEqual(`http://localhost/redirect/course-home/${courseMetadata.id}`);
});
it('should go to dashboard for an unfulfilled_milestones error code', async () => {
setupWithDeniedStatus('unfulfilled_milestones');
render(component);
await waitForElementToBeRemoved(screen.getByRole('status'));
expect(global.location.href).toEqual('http://localhost/redirect/dashboard');
});
it('should go to the dashboard with an attached access_response_error for an audit_expired error code', async () => {
setupWithDeniedStatus('audit_expired');
render(component);
await waitForElementToBeRemoved(screen.getByRole('status'));
expect(global.location.href).toEqual('http://localhost/redirect/dashboard?access_response_error=uhoh%20oh%20no');
});
it('should go to the dashboard with a notlive start date for a course_not_started error code', async () => {
setupWithDeniedStatus('course_not_started');
render(component);
await waitForElementToBeRemoved(screen.getByRole('status'));
const startDate = '2/5/2013'; // This date is based on our courseMetadata factory's sample data.
expect(global.location.href).toEqual(`http://localhost/redirect/dashboard?notlive=${startDate}`);
});
});
});

View File

@@ -1,20 +1,30 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Helmet } from 'react-helmet';
import { useDispatch } from 'react-redux';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { AlertList } from '../../generic/user-messages';
import useAccessExpirationAlert from '../../alerts/access-expiration-alert';
import useOfferAlert from '../../alerts/offer-alert';
import { useSelector } from 'react-redux';
import AlertList from '../../user-messages/AlertList';
import { useLogistrationAlert } from '../../logistration-alert';
import { useEnrollmentAlert } from '../../enrollment-alert';
import PageLoading from '../../PageLoading';
import InstructorToolbar from './InstructorToolbar';
import Sequence from './sequence';
import { CelebrationModal, shouldCelebrateOnSectionLoad } from './celebration';
import CourseBreadcrumbs from './CourseBreadcrumbs';
import { Header, CourseTabsNavigation } from '../../course-header';
import CourseSock from './course-sock';
import ContentTools from './content-tools';
import { useModel } from '../../generic/model-store';
import Calculator from './calculator';
import messages from './messages';
import { useModel } from '../../model-store';
// Note that we import from the component files themselves in the enrollment-alert package.
// This is because Reacy.lazy() requires that we import() from a file with a Component as it's
// default export.
// See React.lazy docs here: https://reactjs.org/docs/code-splitting.html#reactlazy
const EnrollmentAlert = React.lazy(() => import('../../enrollment-alert/EnrollmentAlert'));
const StaffEnrollmentAlert = React.lazy(() => import('../../enrollment-alert/StaffEnrollmentAlert'));
const LogistrationAlert = React.lazy(() => import('../../logistration-alert'));
function Course({
courseId,
@@ -23,69 +33,80 @@ function Course({
nextSequenceHandler,
previousSequenceHandler,
unitNavigationHandler,
intl,
}) {
const course = useModel('courses', courseId);
const sequence = useModel('sequences', sequenceId);
const section = useModel('sections', sequence ? sequence.sectionId : null);
const pageTitleBreadCrumbs = [
sequence,
section,
course,
].filter(element => element != null).map(element => element.title);
useLogistrationAlert();
useEnrollmentAlert(courseId);
const {
canShowUpgradeSock,
celebrations,
courseExpiredMessage,
offerHtml,
verifiedMode,
} = course;
const courseStatus = useSelector(state => state.courseware.courseStatus);
// Below the tabs, above the breadcrumbs alerts (appearing in the order listed here)
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);
return (
<>
<Helmet>
<title>{`${pageTitleBreadCrumbs.join(' | ')} | ${getConfig().SITE_NAME}`}</title>
</Helmet>
<AlertList
className="my-3"
topic="course"
customAlerts={{
...accessExpirationAlert,
...offerAlert,
}}
if (courseStatus === 'loading') {
return (
<PageLoading
srMessage={intl.formatMessage(messages['learn.loading.learning.sequence'])}
/>
<CourseBreadcrumbs
courseId={courseId}
sectionId={section ? section.id : null}
sequenceId={sequenceId}
/>
<AlertList topic="sequence" />
<Sequence
unitId={unitId}
sequenceId={sequenceId}
courseId={courseId}
unitNavigationHandler={unitNavigationHandler}
nextSequenceHandler={nextSequenceHandler}
previousSequenceHandler={previousSequenceHandler}
/>
{celebrationOpen && (
<CelebrationModal
courseId={courseId}
open
);
}
if (courseStatus === 'loaded') {
const {
org, number, title, isStaff, tabs, verifiedMode, showCalculator,
} = course;
return (
<>
<Header
courseOrg={org}
courseNumber={number}
courseTitle={title}
/>
)}
{canShowUpgradeSock && verifiedMode && <CourseSock verifiedMode={verifiedMode} />}
<ContentTools course={course} />
</>
{isStaff && (
<InstructorToolbar
unitId={unitId}
/>
)}
<CourseTabsNavigation tabs={tabs} activeTabSlug="courseware" />
<div className="container-fluid">
<AlertList
className="my-3"
topic="course"
customAlerts={{
clientEnrollmentAlert: EnrollmentAlert,
clientStaffEnrollmentAlert: StaffEnrollmentAlert,
clientLogistrationAlert: LogistrationAlert,
}}
/>
<CourseBreadcrumbs
courseId={courseId}
sectionId={section ? section.id : null}
sequenceId={sequenceId}
/>
<AlertList topic="sequence" />
</div>
<div className="flex-grow-1 d-flex flex-column">
<Sequence
unitId={unitId}
sequenceId={sequenceId}
courseId={courseId}
unitNavigationHandler={unitNavigationHandler}
nextSequenceHandler={nextSequenceHandler}
previousSequenceHandler={previousSequenceHandler}
/>
{verifiedMode && <CourseSock verifiedMode={verifiedMode} />}
{showCalculator && <Calculator />}
</div>
</>
);
}
// courseStatus 'failed' and any other unexpected course status.
return (
<p className="text-center py-5 mx-auto" style={{ maxWidth: '30em' }}>
{intl.formatMessage(messages['learn.course.load.failure'])}
</p>
);
}
@@ -96,6 +117,7 @@ Course.propTypes = {
nextSequenceHandler: PropTypes.func.isRequired,
previousSequenceHandler: PropTypes.func.isRequired,
unitNavigationHandler: PropTypes.func.isRequired,
intl: intlShape.isRequired,
};
Course.defaultProps = {
@@ -104,4 +126,4 @@ Course.defaultProps = {
unitId: null,
};
export default Course;
export default injectIntl(Course);

View File

@@ -5,9 +5,7 @@ import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faHome } from '@fortawesome/free-solid-svg-icons';
import { useSelector } from 'react-redux';
import { useModel } from '../../generic/model-store';
import { COURSE_LOADED } from '../../active-course';
import { SEQUENCE_LOADED } from '../data';
import { useModel } from '../../model-store';
function CourseBreadcrumb({
url, children, withSeparator, ...attrs
@@ -42,11 +40,11 @@ export default function CourseBreadcrumbs({
const course = useModel('courses', courseId);
const sequence = useModel('sequences', sequenceId);
const section = useModel('sections', sectionId);
const courseStatus = useSelector(state => state.activeCourse.courseStatus);
const courseStatus = useSelector(state => state.courseware.courseStatus);
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
const links = useMemo(() => {
if (courseStatus === COURSE_LOADED && sequenceStatus === SEQUENCE_LOADED) {
if (courseStatus === 'loaded' && sequenceStatus === 'loaded') {
return [section, sequence].filter(node => !!node).map((node) => ({
id: node.id,
label: node.title,

View File

@@ -0,0 +1,60 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { Collapsible } from '@edx/paragon';
function InstructorToolbar(props) {
// TODO: Only render this toolbar if the user is course staff
if (!props.activeUnitLmsWebUrl) {
return null;
}
return (
<div className="bg-primary text-light">
<div className="container-fluid py-3 d-md-flex justify-content-end align-items-center">
<div className="flex-grow-1">
<Collapsible.Advanced className="mr-5 mb-md-0">
You are currently previewing the new learning sequence experience.
<Collapsible.Trigger className="d-inline-block ml-2" style={{ cursor: 'pointer' }}>
<Collapsible.Visible whenClosed>
<span style={{ borderBottom: 'solid 1px white' }}>More info</span> &rarr;
</Collapsible.Visible>
</Collapsible.Trigger>
<Collapsible.Body>
This preview is to allow for early content testing, especially for custom content blocks, with the goal of ensuring it renders as expected in the next experience. You can learn more through the following <a className="text-white" style={{ textDecoration: 'underline' }} href="https://partners.edx.org/announcements/author-preview-learning-sequence-experience-update" target="blank" rel="noopener">Partner Portal post</a>. Please report any issues or provide <a className="text-white" style={{ textDecoration: 'underline' }} target="blank" rel="noopener" href="https://forms.gle/R6jMYJNTCj1vgC1D6">feedback using the linked form</a>.
<Collapsible.Trigger className="d-inline-block ml-2" style={{ cursor: 'pointer' }}>
<Collapsible.Visible whenOpen>
<span style={{ borderBottom: 'solid 1px white' }}>Close</span> &times;
</Collapsible.Visible>
</Collapsible.Trigger>
</Collapsible.Body>
</Collapsible.Advanced>
</div>
<div className="flex-shrink-0">
<a className="btn d-block btn-outline-light" href={props.activeUnitLmsWebUrl}>View unit in the existing experience</a>
</div>
</div>
</div>
);
}
InstructorToolbar.propTypes = {
activeUnitLmsWebUrl: PropTypes.string,
};
InstructorToolbar.defaultProps = {
activeUnitLmsWebUrl: undefined,
};
const mapStateToProps = (state, props) => {
if (!props.unitId) {
return {};
}
const activeUnit = state.models.units[props.unitId];
return {
activeUnitLmsWebUrl: activeUnit ? activeUnit.lmsWebUrl : undefined,
};
};
export default connect(mapStateToProps)(InstructorToolbar);

View File

@@ -1,87 +0,0 @@
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient, getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { getConfig } from '@edx/frontend-platform';
import executeThunk from '../../../../utils';
import initializeMockApp from '../../../../setupTest';
import initializeStore from '../../../../store';
import {
addBookmark,
removeBookmark,
BOOKMARK_FAILED,
BOOKMARK_LOADED,
} from './thunks';
const { loggingService } = initializeMockApp();
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
describe('Data layer integration tests', () => {
const unitId = 'unitId';
let store;
beforeEach(() => {
axiosMock.reset();
loggingService.logError.mockReset();
store = initializeStore();
});
describe('Test addBookmark', () => {
const createBookmarkURL = `${getConfig().LMS_BASE_URL}/api/bookmarks/v1/bookmarks/`;
it('Should fail to create bookmark in case of error', async () => {
axiosMock.onPost(createBookmarkURL).networkError();
await executeThunk(addBookmark(unitId), store.dispatch);
expect(loggingService.logError).toHaveBeenCalled();
expect(axiosMock.history.post[0].url).toEqual(createBookmarkURL);
expect(store.getState().models.units[unitId]).toEqual(expect.objectContaining({
bookmarked: false,
bookmarkedUpdateState: BOOKMARK_FAILED,
}));
});
it('Should create bookmark and update model state', async () => {
axiosMock.onPost(createBookmarkURL).reply(201);
await executeThunk(addBookmark(unitId), store.dispatch);
expect(store.getState().models.units[unitId]).toEqual(expect.objectContaining({
bookmarked: true,
bookmarkedUpdateState: BOOKMARK_LOADED,
}));
});
});
describe('Test removeBookmark', () => {
const deleteBookmarkURL = `${getConfig().LMS_BASE_URL}/api/bookmarks/v1/bookmarks/${getAuthenticatedUser().username},${unitId}/`;
it('Should fail to remove bookmark in case of error', async () => {
axiosMock.onDelete(deleteBookmarkURL).networkError();
await executeThunk(removeBookmark(unitId), store.dispatch);
expect(loggingService.logError).toHaveBeenCalled();
expect(axiosMock.history.delete[0].url).toEqual(deleteBookmarkURL);
expect(store.getState().models.units[unitId]).toEqual(expect.objectContaining({
bookmarked: true,
bookmarkedUpdateState: BOOKMARK_FAILED,
}));
});
it('Should delete bookmark and update model state', async () => {
axiosMock.onDelete(deleteBookmarkURL).reply(201);
await executeThunk(removeBookmark(unitId), store.dispatch);
expect(store.getState().models.units[unitId]).toEqual(expect.objectContaining({
bookmarked: false,
bookmarkedUpdateState: BOOKMARK_LOADED,
}));
});
});
});

View File

@@ -1,13 +1,10 @@
import { logError } from '@edx/frontend-platform/logging';
import {
createBookmark,
deleteBookmark,
} from './api';
import { updateModel } from '../../../../generic/model-store';
export const BOOKMARK_LOADING = 'loading';
export const BOOKMARK_LOADED = 'loaded';
export const BOOKMARK_FAILED = 'failed';
import { updateModel } from '../../../../model-store';
export function addBookmark(unitId) {
return async (dispatch) => {
@@ -17,7 +14,7 @@ export function addBookmark(unitId) {
model: {
id: unitId,
bookmarked: true,
bookmarkedUpdateState: BOOKMARK_LOADING,
bookmarkedUpdateState: 'loading',
},
}));
@@ -28,7 +25,7 @@ export function addBookmark(unitId) {
model: {
id: unitId,
bookmarked: true,
bookmarkedUpdateState: BOOKMARK_LOADED,
bookmarkedUpdateState: 'loaded',
},
}));
} catch (error) {
@@ -38,7 +35,7 @@ export function addBookmark(unitId) {
model: {
id: unitId,
bookmarked: false,
bookmarkedUpdateState: BOOKMARK_FAILED,
bookmarkedUpdateState: 'failed',
},
}));
}
@@ -53,7 +50,7 @@ export function removeBookmark(unitId) {
model: {
id: unitId,
bookmarked: false,
bookmarkedUpdateState: BOOKMARK_LOADING,
bookmarkedUpdateState: 'loading',
},
}));
try {
@@ -63,7 +60,7 @@ export function removeBookmark(unitId) {
model: {
id: unitId,
bookmarked: false,
bookmarkedUpdateState: BOOKMARK_LOADED,
bookmarkedUpdateState: 'loaded',
},
}));
} catch (error) {
@@ -73,7 +70,7 @@ export function removeBookmark(unitId) {
model: {
id: unitId,
bookmarked: true,
bookmarkedUpdateState: BOOKMARK_FAILED,
bookmarkedUpdateState: 'failed',
},
}));
}

View File

@@ -1,9 +1,3 @@
export { default as BookmarkButton } from './BookmarkButton';
export { default as BookmarkFilledIcon } from './BookmarkFilledIcon';
export { default as BookmarkOutlineIcon } from './BookmarkFilledIcon';
export {
BOOKMARK_LOADING,
BOOKMARK_LOADED,
BOOKMARK_FAILED,
} from './data/thunks';

View File

@@ -1,10 +1,10 @@
.content-tools {
.calculator {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 100;
.trigger {
.calculator-trigger {
cursor: pointer;
display: inline-block;
position: relative;
@@ -16,12 +16,12 @@
border-top-right-radius: .3rem;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
text-overflow: ellipsis;
overflow: hidden;
padding-left: .75rem;
white-space: nowrap;
&:before {
border-radius: .5rem;
}
}
.calculator-content {
background-color: #f1f1f1;
box-shadow: 0 -1px 0 0 #ddd;
}
}

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