Compare commits

...

41 Commits

Author SHA1 Message Date
Michael Terry
e101b41c08 Whoops, add one-liner missing from github in last commit (#72) 2020-05-21 12:03:09 -04:00
Michael Terry
2f01e8a646 Refactor containers to share more code (#61)
Specifically, make sure that the header, footer, and tabs are all
shared code so that they look the same and don't need to be
redefined as we add more tab pages.
2020-05-21 11:56:49 -04:00
stvn
589db9356e Merge PR #65 add/staff-links
* Commits:
  Remove explanatory paragraph
  Add new Studio/insights links to InstructorToolbar
  Add courseId to InstructorToolbar props
  Create new config values for Insights/Studio URLs
  Fix missing definition of unitId in InstructorToolbar.props
  Set NODE_ENV in the test environment
  Fix mismatched test BASE_URL
  Cleanup PORT config
2020-05-15 14:25:10 -07:00
stvn
e2f37ff20e Remove explanatory paragraph 2020-05-15 12:36:25 -07:00
stvn
ab544b5d2b Add new Studio/insights links to InstructorToolbar 2020-05-15 12:36:25 -07:00
stvn
7bef14c329 Add courseId to InstructorToolbar props 2020-05-15 12:36:25 -07:00
stvn
5cb11189a7 Create new config values for Insights/Studio URLs 2020-05-15 12:36:23 -07:00
stvn
fd951fb18a Fix missing definition of unitId in InstructorToolbar.props 2020-05-15 12:05:08 -07:00
stvn
b2fa93af13 Set NODE_ENV in the test environment 2020-05-15 12:04:34 -07:00
stvn
93ccdf829b Fix mismatched test BASE_URL 2020-05-15 12:04:34 -07:00
stvn
65ab77bed3 Cleanup PORT config 2020-05-15 12:04:34 -07:00
stvn
efba1c1f5a Merge PR #67 toggle/course-sock
* Commits:
  Show course-sock only when the API says so
2020-05-15 09:37:16 -07:00
Ned Batchelder
3c53c4af4e Mark this repo for inclusion in Open edX release tagging (#68) 2020-05-15 10:08:07 -04:00
Dave St.Germain
2b27f0774d Resume from last completed unit (#66) 2020-05-14 11:17:35 -04:00
stvn
97f335be62 Show course-sock only when the API says so 2020-05-12 12:20:29 -07:00
stvn
6c7af3817b Merge PR #64 fix/banner/expiration
* Commits:
  Make the two rawHtml alerts look near-identical
2020-05-07 12:07:00 -07:00
stvn
a7932ed730 Make the two rawHtml alerts look near-identical 2020-05-07 11:57:35 -07:00
David Joy
29b234e2f0 Scroll to top when the sequenceId or unitId changes (#63)
* Scroll to top when the sequenceId or unitId changes

* Add a spinner to the unit.
2020-05-06 12:59:17 -04:00
David Joy
a718c67f36 Show message when there are no units in a sequence. (#60)
TNL-7191 - We didn’t fully protect against sequences with no units. The next/previous buttons now check whether there is a unit ID and construct a URL without if one doesn’t exist.  When we load a sequence without units, we now show a message to the user so the page doesn’t look broken.
2020-05-05 09:46:18 -04:00
stvn
cc5e5ecc00 Merge PR #55 refactor-iframe-messages
* Commits:
  Refactor iframe message handler
2020-05-04 21:30:03 -07:00
stvn
7df95378d6 Refactor iframe message handler
TNL-7187
2020-05-04 14:17:39 -07:00
Adam Butterworth
18426dd313 make unitNavigationHandler hook depend on unitId (#59)
This should fix intermittent bugs in checking block completions. Prior we were checking the completion only for the first unit loaded in a given sequence no matter the current unit.
2020-05-04 16:55:16 -04:00
Adam Butterworth
a1eee2d662 Fix IE11 layout issue by setting header flex-basis to auto (#58) 2020-05-04 16:14:20 -04:00
Adam Butterworth
7dfb01a397 Mobile fixes: content tools and verified certificate details (#57)
* Prevent wrapping of show notes button

* text overflow

* Update layout for course sock
2020-05-04 16:06:19 -04:00
David Joy
d58a81bf19 Use layout effect to avoid iframe pausing React lifecycle (#56)
Fixes TNL-7187 - Adds a no-op useLayoutEffect hook to Unit.jsx to prevent the unit iframe from pausing React’s rendering lifecycle.  Very strange bug - see comments in that file for more detail.
2020-05-04 12:37:23 -04:00
stvn
bd0ab5b6c9 Merge PR #54 debug/iframe
* Commits:
  Add temporary logging to iframe message handler
2020-04-30 11:46:04 -07:00
stvn
5185f986df Add temporary logging to iframe message handler 2020-04-30 11:43:57 -07:00
David Joy
d3b22bc879 TNL-7164, Enroll Now button fix, flash messages, and custom message props (#53)
* Adding an index.js file for user-messages.

Importing from the module, not its contents.

* Allowing customProps to be passed though AlertList to Alerts.

* UserMessagesProvider can create flash messages.

A flash message is one that will be displayed on the next reload of the page.  UserMessagesProvider now provides a “addFlash” function.  These messages are stored in localStorage and displayed the next time UserMessagesProvider is mounted, which is generally going to be on the next page refresh.

Once displayed, flash messages are cleared out of localStorage.

* Hooking up Enroll Now button and adding “success” alert.

Success alert is shown as a flash message on next page reload.

* Using ALERT_TYPES constants.
2020-04-30 10:22:44 -04:00
stvn
36526def67 Merge PR #52 upgrade offer banner
* Commits:
  Use new upgrade offer banner
  Add new upgrade offer alert
2020-04-23 10:48:58 -07:00
stvn
c510fe1c1d Use new upgrade offer banner 2020-04-23 10:28:03 -07:00
stvn
ca8afb3294 Add new upgrade offer alert 2020-04-23 10:28:00 -07:00
stvn
1f4e2cd6f5 Merge PR #50 banner/lock-access
* Commits:
  Add audit access locked banner
2020-04-21 12:48:56 -07:00
stvn
6d60584596 Add audit access locked banner
when Content Type Gating, aka Feature Based Enrollment is enabled.
2020-04-21 12:46:11 -07:00
stvn
b20a4ed304 Merge PR #49 banner/access-expiration
* Commits:
  Add warning banner for audit access expiration
2020-04-21 12:43:09 -07:00
stvn
44f535ba1e Add warning banner for audit access expiration
to inform users of deadline and prompt them to upgrade to the verified
access track.
2020-04-21 12:38:11 -07:00
David Joy
5f0774b66d Fixing logout URL in dev and test. 2020-04-21 11:26:10 -04:00
Adam Butterworth
04a8638d00 Reduce min-width of unit buttons in nav (#51)
Makes space for more units before swapping the display to a dropdown.
2020-04-16 10:37:29 -04:00
Adam Butterworth
1cc7dc266b Redirect users when they cannot access content (#48)
TNL-7171, TNL-7172, TNL-7173, TNL-7174: When a user is denied access to load courseware, redirect them to the appropriate location based upon the error code returned. If the error code is unknown they will be redirected to course home.
2020-04-15 12:56:51 -04:00
Adam Butterworth
a852182a00 Support can_load_courseware as legacy boolean and future object (#47) 2020-04-09 16:10:55 -04:00
Dave St.Germain
15c3053e87 Adds notes visibility toggle (#44)
* added notes

* moved around components

* Addressed feedback
2020-04-09 14:46:33 -04:00
David Joy
e2399e30d4 fix: Pull lms_base_url out of vertical data from the blocks API (#46)
TNL-7170

lms_base_url becomes lmsBaseUrl in the app and is then used by the InstructorToolbar to link the user back to the LMS.  If it isn’t present, the toolbar hides itself.  This puts it back.
2020-04-09 11:26:02 -04:00
61 changed files with 1288 additions and 486 deletions

2
.env
View File

@@ -4,6 +4,7 @@ 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
@@ -13,4 +14,5 @@ ORDER_HISTORY_URL=null
REFRESH_ACCESS_TOKEN_ENDPOINT=null
SEGMENT_KEY=null
SITE_NAME=null
STUDIO_BASE_URL=
USER_INFO_COOKIE_NAME=null

View File

@@ -1,5 +1,4 @@
NODE_ENV='development'
PORT=2000
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
BASE_URL='localhost:2000'
CREDENTIALS_BASE_URL='http://localhost:18150'
@@ -8,10 +7,12 @@ 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/login'
LOGOUT_URL='http://localhost:18000/logout'
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'
STUDIO_BASE_URL='http://localhost:18010'
USER_INFO_COOKIE_NAME='edx-user-info'

View File

@@ -1,15 +1,18 @@
NODE_ENV='test'
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
BASE_URL='localhost:1995'
BASE_URL='localhost:2000'
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/login'
LOGOUT_URL='http://localhost:18000/logout'
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'
STUDIO_BASE_URL='http://localhost:18010'
USER_INFO_COOKIE_NAME='edx-user-info'

0
LICENSE Executable file → Normal file
View File

0
Makefile Executable file → Normal file
View File

View File

@@ -3,3 +3,8 @@
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

View File

@@ -24,6 +24,12 @@ 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

@@ -0,0 +1,21 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Alert } from '../user-messages';
function AccessExpirationAlert(props) {
const {
rawHtml,
} = props;
return rawHtml && (
<Alert type="info">
<div dangerouslySetInnerHTML={{ __html: rawHtml }} />
</Alert>
);
}
AccessExpirationAlert.propTypes = {
rawHtml: PropTypes.string.isRequired,
};
export default AccessExpirationAlert;

View File

@@ -0,0 +1,28 @@
/* eslint-disable import/prefer-default-export */
import { useContext, useState, useEffect } from 'react';
import { UserMessagesContext } from '../user-messages';
import { useModel } from '../model-store';
export function useAccessExpirationAlert(courseId) {
const course = useModel('courses', courseId);
const { add, remove } = useContext(UserMessagesContext);
const [alertId, setAlertId] = useState(null);
const rawHtml = (course && course.courseExpiredMessage) || null;
useEffect(() => {
if (rawHtml && alertId === null) {
setAlertId(add({
code: 'clientAccessExpirationAlert',
topic: 'course',
rawHtml,
}));
} else if (!rawHtml && alertId !== null) {
remove(alertId);
setAlertId(null);
}
return () => {
if (alertId !== null) {
remove(alertId);
}
};
}, [alertId, courseId, rawHtml]);
}

View File

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

View File

@@ -1,17 +1,16 @@
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 '../tabs/Tabs';
function CourseTabsNavigation({
activeTabSlug, tabs, intl,
activeTabSlug, className, tabs, intl,
}) {
return (
<div className="course-tabs-navigation">
<div className={classNames('course-tabs-navigation', className)}>
<div className="container-fluid">
<Tabs
className="nav-underline-tabs"
@@ -21,7 +20,7 @@ function CourseTabsNavigation({
<a
key={slug}
className={classNames('nav-item flex-shrink-0 nav-link', { active: slug === activeTabSlug })}
href={`${getConfig().LMS_BASE_URL}${url}`}
href={url}
>
{title}
</a>
@@ -34,6 +33,7 @@ function CourseTabsNavigation({
CourseTabsNavigation.propTypes = {
activeTabSlug: PropTypes.string,
className: PropTypes.string,
tabs: PropTypes.arrayOf(PropTypes.shape({
title: PropTypes.string.isRequired,
priority: PropTypes.number.isRequired,
@@ -45,6 +45,7 @@ CourseTabsNavigation.propTypes = {
CourseTabsNavigation.defaultProps = {
activeTabSlug: undefined,
className: null,
};
export default injectIntl(CourseTabsNavigation);

View File

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

View File

@@ -1,11 +1,8 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
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 { AlertList } from '../user-messages';
import CourseDates from './CourseDates';
import Section from './Section';
@@ -18,15 +15,12 @@ import { useModel } from '../model-store';
const { EnrollmentAlert, StaffEnrollmentAlert } = React.lazy(() => import('../enrollment-alert'));
const LogistrationAlert = React.lazy(() => import('../logistration-alert'));
export default function CourseHome({
courseId,
}) {
useLogistrationAlert();
useEnrollmentAlert(courseId);
export default function CourseHome() {
const {
courseId,
} = useSelector(state => state.courseware);
const {
org,
number,
title,
start,
end,
@@ -34,64 +28,45 @@ export default function CourseHome({
enrollmentEnd,
enrollmentMode,
isEnrolled,
tabs,
sectionIds,
} = useModel('courses', courseId);
return (
<>
<Header
courseOrg={org}
courseNumber={number}
courseTitle={title}
<AlertList
topic="outline"
className="mb-3"
customAlerts={{
clientEnrollmentAlert: EnrollmentAlert,
clientStaffEnrollmentAlert: StaffEnrollmentAlert,
clientLogistrationAlert: LogistrationAlert,
}}
/>
<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 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 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>
</div>
</>
);
}
CourseHome.propTypes = {
courseId: PropTypes.string.isRequired,
};

View File

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

@@ -1 +1 @@
export { default } from './CourseHomeContainer';
export { default } from './CourseHome';

View File

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

@@ -2,20 +2,22 @@ import React, { useEffect, useCallback } from 'react';
import PropTypes from 'prop-types';
import { useSelector, useDispatch } from 'react-redux';
import { history } from '@edx/frontend-platform';
import { getLocale } from '@edx/frontend-platform/i18n';
import { useRouteMatch, Redirect } from 'react-router';
import {
fetchCourse,
fetchSequence,
getResumeBlock,
} from '../data';
import {
checkBlockCompletion,
saveSequencePosition,
} from './data/thunks';
import { useModel } from '../model-store';
import { TabPage } from '../tab-page';
import Course from './course';
import { sequenceIdsSelector, firstSequenceIdSelector } from './data/selectors';
function useUnitNavigationHandler(courseId, sequenceId, unitId) {
@@ -23,7 +25,7 @@ function useUnitNavigationHandler(courseId, sequenceId, unitId) {
return useCallback((nextUnitId) => {
dispatch(checkBlockCompletion(courseId, sequenceId, unitId));
history.push(`/course/${courseId}/${sequenceId}/${nextUnitId}`);
}, [courseId, sequenceId]);
}, [courseId, sequenceId, unitId]);
}
function usePreviousSequence(sequenceId) {
@@ -55,8 +57,13 @@ function useNextSequenceHandler(courseId, sequenceId) {
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
return useCallback(() => {
if (nextSequence !== null) {
const nextUnitId = nextSequence.unitIds[0];
history.push(`/course/${courseId}/${nextSequence.id}/${nextUnitId}`);
if (nextSequence.unitIds.length > 0) {
const nextUnitId = nextSequence.unitIds[0];
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}`);
}
}
}, [courseStatus, sequenceStatus, sequenceId]);
}
@@ -67,8 +74,13 @@ function usePreviousSequenceHandler(courseId, sequenceId) {
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
return useCallback(() => {
if (previousSequence !== null) {
const previousUnitId = previousSequence.unitIds[previousSequence.unitIds.length - 1];
history.push(`/course/${courseId}/${previousSequence.id}/${previousUnitId}`);
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}`);
}
}
}, [courseStatus, sequenceStatus, sequenceId]);
}
@@ -90,8 +102,14 @@ function useContentRedirect(courseStatus, sequenceStatus) {
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(`/course/${courseId}/${firstSequenceId}`);
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 {
history.replace(`/course/${courseId}/${firstSequenceId}`);
}
});
}
}, [courseStatus, sequenceId]);
@@ -162,12 +180,32 @@ export default function CoursewareContainer() {
useExamRedirect(sequenceId);
useSavedSequencePosition(courseId, sequenceId, routeUnitId);
const course = useModel('courses', courseId);
if (courseStatus === 'denied') {
return <Redirect to={`/redirect/course-home/${courseId}`} />;
switch (course.canLoadCourseware.errorCode) {
case 'audit_expired':
return <Redirect to={`/redirect/dashboard?access_response_error=${course.canLoadCourseware.additionalContextUserMessage}`} />;
case 'course_not_started':
// eslint-disable-next-line no-case-declarations
const startDate = (new Intl.DateTimeFormat(getLocale())).format(new Date(course.start));
return <Redirect to={`/redirect/dashboard?notlive=${startDate}`} />;
case 'survey_required': // TODO: Redirect to the course survey
case 'unfulfilled_milestones':
return <Redirect to="/redirect/dashboard" />;
case 'authentication_required':
case 'enrollment_required':
default:
return <Redirect to={`/redirect/course-home/${courseId}`} />;
}
}
return (
<main className="flex-grow-1 d-flex flex-column">
<TabPage
activeTabSlug="courseware"
courseId={courseId}
unitId={routeUnitId}
>
<Course
courseId={courseId}
sequenceId={sequenceId}
@@ -176,7 +214,7 @@ export default function CoursewareContainer() {
previousSequenceHandler={previousSequenceHandler}
unitNavigationHandler={unitNavigationHandler}
/>
</main>
</TabPage>
);
}

View File

@@ -1,30 +1,26 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
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 { AlertList } from '../../user-messages';
import { useAccessExpirationAlert } from '../../access-expiration-alert';
import { useOfferAlert } from '../../offer-alert';
import InstructorToolbar from './InstructorToolbar';
import Sequence from './sequence';
import CourseBreadcrumbs from './CourseBreadcrumbs';
import { Header, CourseTabsNavigation } from '../../course-header';
import CourseSock from './course-sock';
import Calculator from './calculator';
import messages from './messages';
import ContentTools from './tools/ContentTools';
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 AccessExpirationAlert = React.lazy(() => import('../../access-expiration-alert/AccessExpirationAlert'));
const EnrollmentAlert = React.lazy(() => import('../../enrollment-alert/EnrollmentAlert'));
const StaffEnrollmentAlert = React.lazy(() => import('../../enrollment-alert/StaffEnrollmentAlert'));
const LogistrationAlert = React.lazy(() => import('../../logistration-alert'));
const OfferAlert = React.lazy(() => import('../../offer-alert/OfferAlert'));
function Course({
courseId,
@@ -33,80 +29,53 @@ function Course({
nextSequenceHandler,
previousSequenceHandler,
unitNavigationHandler,
intl,
}) {
const course = useModel('courses', courseId);
const sequence = useModel('sequences', sequenceId);
const section = useModel('sections', sequence ? sequence.sectionId : null);
useLogistrationAlert();
useEnrollmentAlert(courseId);
useOfferAlert(courseId);
useAccessExpirationAlert(courseId);
const courseStatus = useSelector(state => state.courseware.courseStatus);
const {
canShowUpgradeSock,
verifiedMode,
} = course;
if (courseStatus === 'loading') {
return (
<PageLoading
srMessage={intl.formatMessage(messages['learn.loading.learning.sequence'])}
/>
);
}
if (courseStatus === 'loaded') {
const {
org, number, title, isStaff, tabs, verifiedMode, showCalculator,
} = course;
return (
<>
<Header
courseOrg={org}
courseNumber={number}
courseTitle={title}
/>
{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>
<>
<AlertList
className="my-3"
topic="course"
customAlerts={{
clientEnrollmentAlert: EnrollmentAlert,
clientStaffEnrollmentAlert: StaffEnrollmentAlert,
clientLogistrationAlert: LogistrationAlert,
clientAccessExpirationAlert: AccessExpirationAlert,
clientOfferAlert: OfferAlert,
}}
// courseId is provided because EnrollmentAlert and StaffEnrollmentAlert require it.
customProps={{
courseId,
}}
/>
<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}
/>
{canShowUpgradeSock && verifiedMode && <CourseSock verifiedMode={verifiedMode} />}
<ContentTools course={course} />
</>
);
}
@@ -117,7 +86,6 @@ Course.propTypes = {
nextSequenceHandler: PropTypes.func.isRequired,
previousSequenceHandler: PropTypes.func.isRequired,
unitNavigationHandler: PropTypes.func.isRequired,
intl: intlShape.isRequired,
};
Course.defaultProps = {
@@ -126,4 +94,4 @@ Course.defaultProps = {
unitId: null,
};
export default injectIntl(Course);
export default Course;

View File

@@ -1,38 +1,68 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { Collapsible } from '@edx/paragon';
import { getConfig } from '@edx/frontend-platform';
function getInsightsUrl(courseId) {
const urlBase = getConfig().INSIGHTS_BASE_URL;
let urlFull;
if (urlBase) {
urlFull = `${urlBase}/courses`;
// This shouldn't actually be missing, at present,
// but we're providing a reasonable fallback,
// in case of either error or extension.
if (courseId) {
urlFull += `/${courseId}`;
}
}
return urlFull;
}
function getStudioUrl(courseId, unitId) {
const urlBase = getConfig().STUDIO_BASE_URL;
let urlFull;
if (urlBase) {
if (unitId) {
urlFull = `${urlBase}/container/${unitId}`;
} else if (courseId) {
urlFull = `{$urlBase}/course/${courseId}`;
}
}
return urlFull;
}
function InstructorToolbar(props) {
// TODO: Only render this toolbar if the user is course staff
if (!props.activeUnitLmsWebUrl) {
return null;
}
const {
courseId,
unitId,
} = props;
const urlInsights = getInsightsUrl(courseId);
const urlLms = props.activeUnitLmsWebUrl;
const urlStudio = getStudioUrl(courseId, unitId);
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>
&nbsp;
</div>
{urlLms && (
<div className="flex-shrink-0">
<a className="btn d-block btn-outline-light" href={urlLms}>View in the existing experience</a>
</div>
)}
&nbsp;
{urlStudio && (
<div className="flex-shrink-0">
<a className="btn d-block btn-outline-light" href={urlStudio}>View in Studio</a>
</div>
)}
&nbsp;
{urlInsights && (
<div className="flex-shrink-0">
<a className="btn d-block btn-outline-light" href={urlInsights}>View in Insights</a>
</div>
)}
</div>
</div>
);
@@ -40,10 +70,14 @@ function InstructorToolbar(props) {
InstructorToolbar.propTypes = {
activeUnitLmsWebUrl: PropTypes.string,
courseId: PropTypes.string,
unitId: PropTypes.string,
};
InstructorToolbar.defaultProps = {
activeUnitLmsWebUrl: undefined,
courseId: undefined,
unitId: undefined,
};
const mapStateToProps = (state, props) => {

View File

@@ -32,128 +32,152 @@ export default class CourseSock extends Component {
</button>
</div>
{this.state.showUpsell && (
<div className="d-flex justify-content-around">
<div className="mt-3">
<h2 className="font-weight-lighter">
<>
<h2 className="mt-3 mb-4">
<FormattedMessage
id="coursesock.upsell.verifiedcert"
defaultMessage="edX Verified Certificate"
/>
</h2>
<h3>
<FormattedMessage
id="coursesock.upsell.why"
defaultMessage="Why upgrade?"
/>
</h3>
<ul>
<li><FormattedMessage
id="coursesock.upsell.reason1"
defaultMessage="Official proof of completion"
/>
</li>
<li><FormattedMessage
id="coursesock.upsell.reason2"
defaultMessage="Easily shareable certificate"
/>
</li>
<li><FormattedMessage
id="coursesock.upsell.reason3"
defaultMessage="Proven motivator to complete the course"
/>
</li>
<li><FormattedMessage
id="coursesock.upsell.reason4"
defaultMessage="Certificate purchases help edX continue to offer free courses"
/>
</li>
</ul>
<h3><FormattedMessage
id="coursesock.upsell.howtitle"
defaultMessage="How it works"
/>
</h3>
<ul>
<li><FormattedMessage
id="coursesock.upsell.how1"
defaultMessage="Pay the Verified Certificate upgrade fee"
/>
</li>
<li><FormattedMessage
id="coursesock.upsell.how2"
defaultMessage="Verify your identity with a webcam and government-issued ID"
/>
</li>
<li><FormattedMessage
id="coursesock.upsell.how3"
defaultMessage="Study hard and pass the course"
/>
</li>
<li><FormattedMessage
id="coursesock.upsell.how4"
defaultMessage="Share your certificate with friends, employers, and others"
/>
</li>
</ul>
<h3><FormattedMessage
id="coursesock.upsell.storytitle"
defaultMessage="edX Learner Stories"
/>
</h3>
<div className="d-flex align-items-center my-4">
<img style={{ maxWidth: '4rem' }} alt="Christina Fong" src={LearnerQuote1} />
<div className="w-50 px-4">
<FormattedMessage
id="coursesock.upsell.story1"
defaultMessage="My certificate has helped me showcase my knowledge on my
resume - I feel like this certificate could really help me land
my dream job!"
/>
<br />
<strong>&mdash; <FormattedMessage
id="coursesock.upsell.learner"
description="Name of learner"
defaultMessage="{ name }, edX Learner"
values={{ name: 'Christina Fong' }}
/>
</strong>
<div className="row flex-row-reverse">
<div className="col-md-4 col-lg-6 d-flex flex-column">
<div>
<img alt="Example Certificate" src={VerifiedCert} className="d-block img-thumbnail mb-3 ml-md-auto" />
</div>
<div className="position-relative flex-grow-1 d-flex flex-column justify-content-end align-items-md-end">
<div style={{ position: 'sticky', bottom: '4rem' }}>
<a
href={this.verifiedMode.upgradeUrl}
className="btn btn-success btn-lg btn-upgrade focusable mb-3"
data-creative="original_sock"
data-position="sock"
>
<FormattedMessage
id="coursesock.upsell.upgrade"
defaultMessage="Upgrade ({symbol}{price} {currency})"
values={{
symbol: this.verifiedMode.currencySymbol,
price: this.verifiedMode.price,
currency: this.verifiedMode.currency,
}}
/>
</a>
</div>
</div>
</div>
<div className="col-md-8 col-lg-6">
<h3 className="h5">
<FormattedMessage
id="coursesock.upsell.why"
defaultMessage="Why upgrade?"
/>
</h3>
<ul>
<li>
<FormattedMessage
id="coursesock.upsell.reason1"
defaultMessage="Official proof of completion"
/>
</li>
<li>
<FormattedMessage
id="coursesock.upsell.reason2"
defaultMessage="Easily shareable certificate"
/>
</li>
<li>
<FormattedMessage
id="coursesock.upsell.reason3"
defaultMessage="Proven motivator to complete the course"
/>
</li>
<li>
<FormattedMessage
id="coursesock.upsell.reason4"
defaultMessage="Certificate purchases help edX continue to offer free courses"
/>
</li>
</ul>
<h3 className="h5">
<FormattedMessage
id="coursesock.upsell.howtitle"
defaultMessage="How it works"
/>
</h3>
<ul>
<li>
<FormattedMessage
id="coursesock.upsell.how1"
defaultMessage="Pay the Verified Certificate upgrade fee"
/>
</li>
<li>
<FormattedMessage
id="coursesock.upsell.how2"
defaultMessage="Verify your identity with a webcam and government-issued ID"
/>
</li>
<li>
<FormattedMessage
id="coursesock.upsell.how3"
defaultMessage="Study hard and pass the course"
/>
</li>
<li>
<FormattedMessage
id="coursesock.upsell.how4"
defaultMessage="Share your certificate with friends, employers, and others"
/>
</li>
</ul>
<h3 className="h5">
<FormattedMessage
id="coursesock.upsell.storytitle"
defaultMessage="edX Learner Stories"
/>
</h3>
<div className="media my-3">
<img className="mr-3" style={{ maxWidth: '4rem' }} alt="Christina Fong" src={LearnerQuote1} />
<div className="media-body">
<FormattedMessage
id="coursesock.upsell.story1"
defaultMessage="My certificate has helped me showcase my knowledge on my
resume - I feel like this certificate could really help me land
my dream job!"
/>
<p className="font-weight-bold">
&mdash; <FormattedMessage
id="coursesock.upsell.learner"
description="Name of learner"
defaultMessage="{ name }, edX Learner"
values={{ name: 'Christina Fong' }}
/>
</p>
</div>
</div>
<div className="media my-3">
<img className="mr-3" style={{ maxWidth: '4rem' }} alt="Chery Troell" src={LearnerQuote2} />
<div className="media-body">
<FormattedMessage
id="coursesock.upsell.story2"
defaultMessage="I wanted to include a verified certificate on my resume and my profile to
illustrate that I am working towards this goal I have and that I have
achieved something while I was unemployed."
/>
<p className="font-weight-bold">
&mdash; <FormattedMessage
id="coursesock.upsell.learner"
description="Name of learner"
defaultMessage="{ name }, edX Learner"
values={{ name: 'Cheryl Troell' }}
/>
</p>
</div>
</div>
</div>
</div>
<div className="d-flex align-items-center my-2">
<img style={{ maxWidth: '4rem' }} alt="Chery Troell" src={LearnerQuote2} />
<div className="w-50 px-4">
<FormattedMessage
id="coursesock.upsell.story2"
defaultMessage="I wanted to include a verified certificate on my resume and my profile to
illustrate that I am working towards this goal I have and that I have
achieved something while I was unemployed."
/>
<br />
<strong>&mdash; <FormattedMessage
id="coursesock.upsell.learner"
description="Name of learner"
defaultMessage="{ name }, edX Learner"
values={{ name: 'Cheryl Troell' }}
/>
</strong>
</div>
</div>
</div>
<div className="d-flex flex-column justify-content-between">
<img alt="Example Certificate" src={VerifiedCert} />
<a href={this.verifiedMode.upgradeUrl} className="btn btn-success btn-lg btn-upgrade focusable" data-creative="original_sock" data-position="sock">
<FormattedMessage
id="coursesock.upsell.upgrade"
defaultMessage="Upgrade ({symbol}{price} {currency})"
values={{
symbol: this.verifiedMode.currencySymbol,
price: this.verifiedMode.price,
currency: this.verifiedMode.currency,
}}
/>
</a>
</div>
</div>
</>
)}
</div>
);

View File

@@ -1,16 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'learn.loading.learning.sequence': {
id: 'learn.loading.learning.sequence',
defaultMessage: 'Loading learning sequence...',
description: 'Message when learning sequence is being loaded',
},
'learn.course.load.failure': {
id: 'learn.course.load.failure',
defaultMessage: 'There was an error loading this course.',
description: 'Message when a course fails to load',
},
});
export default messages;

View File

@@ -1,20 +1,19 @@
/* eslint-disable no-use-before-define */
import React, {
useEffect, useContext, Suspense, useState,
useEffect, useContext, useState,
} from 'react';
import PropTypes from 'prop-types';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useSelector } from 'react-redux';
import Unit from './Unit';
import { SequenceNavigation, UnitNavigation } from './sequence-navigation';
import PageLoading from '../../../PageLoading';
import messages from './messages';
import UserMessagesContext from '../../../user-messages/UserMessagesContext';
import { UserMessagesContext, ALERT_TYPES } from '../../../user-messages';
import { useModel } from '../../../model-store';
const ContentLock = React.lazy(() => import('./content-lock'));
import messages from './messages';
import { SequenceNavigation, UnitNavigation } from './sequence-navigation';
import SequenceContent from './SequenceContent';
function Sequence({
unitId,
@@ -78,7 +77,7 @@ function Sequence({
code: null,
dismissible: false,
text: sequence.bannerText,
type: 'info',
type: ALERT_TYPES.INFO,
topic: 'sequence',
});
}
@@ -132,29 +131,13 @@ function Sequence({
}}
/>
<div className="unit-container flex-grow-1">
{gated && (
<Suspense
fallback={(
<PageLoading
srMessage={intl.formatMessage(messages['learn.loading.content.lock'])}
/>
)}
>
<ContentLock
courseId={courseId}
sequenceTitle={sequence.title}
prereqSectionName={sequence.gatedContent.gatedSectionName}
prereqId={sequence.gatedContent.prereqId}
/>
</Suspense>
)}
{!gated && unitId !== null && (
<Unit
key={unitId}
id={unitId}
onLoaded={handleUnitLoaded}
/>
)}
<SequenceContent
courseId={courseId}
gated={gated}
sequenceId={sequenceId}
unitId={unitId}
unitLoadedHandler={handleUnitLoaded}
/>
{unitHasLoaded && (
<UnitNavigation
sequenceId={sequenceId}

View File

@@ -0,0 +1,72 @@
import React, { Suspense, useEffect } from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import PageLoading from '../../../PageLoading';
import { useModel } from '../../../model-store';
import messages from './messages';
import Unit from './Unit';
const ContentLock = React.lazy(() => import('./content-lock'));
function SequenceContent({
gated, intl, courseId, sequenceId, unitId, unitLoadedHandler,
}) {
const sequence = useModel('sequences', sequenceId);
// Go back to the top of the page whenever the unit or sequence changes.
useEffect(() => {
global.scrollTo(0, 0);
}, [sequenceId, unitId]);
if (gated) {
return (
<Suspense
fallback={(
<PageLoading
srMessage={intl.formatMessage(messages['learn.loading.content.lock'])}
/>
)}
>
<ContentLock
courseId={courseId}
sequenceTitle={sequence.title}
prereqSectionName={sequence.gatedContent.gatedSectionName}
prereqId={sequence.gatedContent.prereqId}
/>
</Suspense>
);
}
if (unitId === null) {
return (
<div>
{intl.formatMessage(messages['learn.sequence.no.content'])}
</div>
);
}
return (
<Unit
courseId={courseId}
key={unitId}
id={unitId}
onLoaded={unitLoadedHandler}
/>
);
}
SequenceContent.propTypes = {
gated: PropTypes.bool.isRequired,
courseId: PropTypes.string.isRequired,
sequenceId: PropTypes.string.isRequired,
unitId: PropTypes.string,
unitLoadedHandler: PropTypes.func.isRequired,
intl: intlShape.isRequired,
};
SequenceContent.defaultProps = {
unitId: null,
};
export default injectIntl(SequenceContent);

View File

@@ -1,25 +1,78 @@
import React, { useRef, useEffect, useState } from 'react';
import React, {
Suspense,
useEffect,
useRef,
useState,
useLayoutEffect,
} from 'react';
import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import messages from './messages';
import BookmarkButton from '../bookmark/BookmarkButton';
import { useModel } from '../../../model-store';
import PageLoading from '../../../PageLoading';
export default function Unit({
const LockPaywall = React.lazy(() => import('./lock-paywall'));
/**
* We discovered an error in Firefox where - upon iframe load - React would cease to call any
* useEffect hooks until the user interacts with the page again. This is particularly confusing
* when navigating between sequences, as the UI partially updates leaving the user in a nebulous
* state.
*
* We were able to solve this error by using a layout effect to update some component state, which
* executes synchronously on render. Somehow this forces React to continue it's lifecycle
* immediately, rather than waiting for user interaction. This layout effect could be anywhere in
* the parent tree, as far as we can tell - we chose to add a conspicuously 'load bearing' (that's
* a joke) one here so it wouldn't be accidentally removed elsewhere.
*
* If we remove this hook when one of these happens:
* 1. React figures out that there's an issue here and fixes a bug.
* 2. We cease to use an iframe for unit rendering.
* 3. Firefox figures out that there's an issue in their iframe loading and fixes a bug.
* 4. We stop supporting Firefox.
* 5. An enterprising engineer decides to create a repo that reproduces the problem, submits it to
* Firefox/React for review, and they kindly help us figure out what in the world is happening
* so we can fix it.
*
* This hook depends on the unit id just to make sure it re-evaluates whenever the ID changes. If
* we change whether or not the Unit component is re-mounted when the unit ID changes, this may
* become important, as this hook will otherwise only evaluate the useLayoutEffect once.
*/
function useLoadBearingHook(id) {
const setValue = useState(0)[1];
useLayoutEffect(() => {
setValue(currentValue => currentValue + 1);
}, [id]);
}
function Unit({
courseId,
onLoaded,
id,
intl,
}) {
const iframeRef = useRef(null);
const iframeUrl = `${getConfig().LMS_BASE_URL}/xblock/${id}?show_title=0&show_bookmark_button=0`;
const [iframeHeight, setIframeHeight] = useState(0);
const [hasLoaded, setHasLoaded] = useState(false);
const unit = useModel('units', id);
const course = useModel('courses', courseId);
const {
contentTypeGatingEnabled,
enrollmentMode,
} = course;
// Do not remove this hook. See function description.
useLoadBearingHook(id);
// We use this ref so that we can hold a reference to the currently active event listener.
const messageEventListenerRef = useRef(null);
useEffect(() => {
global.onmessage = (event) => {
function receiveMessage(event) {
const { type, payload } = event.data;
if (type === 'plugin.resize') {
setIframeHeight(payload.height);
if (!hasLoaded && iframeHeight === 0 && payload.height > 0) {
@@ -29,8 +82,19 @@ export default function Unit({
}
}
}
};
}, []);
}
// If we currently have an event listener, remove it.
if (messageEventListenerRef.current !== null) {
global.removeEventListener('message', messageEventListenerRef.current);
messageEventListenerRef.current = null;
}
// Now add our new receiveMessage handler as the event listener.
global.addEventListener('message', receiveMessage);
// And then save it to our ref for next time.
messageEventListenerRef.current = receiveMessage;
// When the component finally unmounts, use the ref to remove the correct handler.
return () => global.removeEventListener('message', messageEventListenerRef.current);
}, [id, setIframeHeight, hasLoaded, iframeHeight, setHasLoaded, onLoaded]);
return (
<div className="unit">
@@ -40,11 +104,28 @@ export default function Unit({
isBookmarked={unit.bookmarked}
isProcessing={unit.bookmarkedUpdateState === 'loading'}
/>
{ contentTypeGatingEnabled && unit.graded && enrollmentMode === 'audit' && (
<Suspense
fallback={(
<PageLoading
srMessage={intl.formatMessage(messages['learn.loading.content.lock'])}
/>
)}
>
<LockPaywall
courseId={courseId}
/>
</Suspense>
)}
{!hasLoaded && (
<PageLoading
srMessage={intl.formatMessage(messages['learn.loading.learning.sequence'])}
/>
)}
<div className="unit-iframe-wrapper">
<iframe
id="unit-iframe"
title={unit.title}
ref={iframeRef}
src={iframeUrl}
allowFullScreen
height={iframeHeight}
@@ -57,10 +138,14 @@ export default function Unit({
}
Unit.propTypes = {
courseId: PropTypes.string.isRequired,
id: PropTypes.string.isRequired,
intl: intlShape.isRequired,
onLoaded: PropTypes.func,
};
Unit.defaultProps = {
onLoaded: undefined,
};
export default injectIntl(Unit);

View File

@@ -0,0 +1,61 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faLock } from '@fortawesome/free-solid-svg-icons';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import messages from './messages';
import VerifiedCert from './assets/edx-verified-mini-cert.png';
import { useModel } from '../../../../model-store';
function LockPaywall({
intl,
courseId,
}) {
const course = useModel('courses', courseId);
const {
verifiedMode,
} = course;
if (!verifiedMode) {
return null;
}
const {
currencySymbol,
price,
upgradeUrl,
} = verifiedMode;
return (
<div className="border border-gray rounded d-flex justify-content-between mt-2 p-3">
<div>
<h4 className="font-weight-bold mb-2">
<FontAwesomeIcon icon={faLock} className="text-black mr-2 ml-1" style={{ fontSize: '2rem' }} />
<span>{intl.formatMessage(messages['learn.lockPaywall.title'])}</span>
</h4>
<p className="mb-0">
<span>{intl.formatMessage(messages['learn.lockPaywall.content'])}</span>
&nbsp;
<a href={upgradeUrl}>
{intl.formatMessage(messages['learn.lockPaywall.upgrade.link'], {
currencySymbol,
price,
})}
</a>
</p>
</div>
<div>
<img
alt={intl.formatMessage(messages['learn.lockPaywall.example.alt'])}
src={VerifiedCert}
className="border-0"
style={{ height: '70px' }}
/>
</div>
</div>
);
}
LockPaywall.propTypes = {
intl: intlShape.isRequired,
courseId: PropTypes.string.isRequired,
};
export default injectIntl(LockPaywall);

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

View File

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

View File

@@ -0,0 +1,26 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'learn.lockPaywall.title': {
id: 'learn.lockPaywall.title',
defaultMessage: 'Verified Track Access',
description: 'Heading for message shown to indicate that a piece of content is unavailable to audit track users.',
},
'learn.lockPaywall.content': {
id: 'learn.lockPaywall.content',
defaultMessage: 'Graded assessments are available to Verified Track learners.',
description: 'Message shown to indicate that a piece of content is unavailable to audit track users.',
},
'learn.lockPaywall.upgrade.link': {
id: 'learn.lockPaywall.upgrade.link',
defaultMessage: 'Upgrade to unlock ({currencySymbol}{price})',
description: 'A link users can click that navigates their browser to the upgrade payment page.',
},
'learn.lockPaywall.example.alt': {
id: 'learn.lockPaywall.example.alt',
defaultMessage: 'Example Certificate',
description: 'Alternate text displayed when the example certificate image cannot be displayed.',
},
});
export default messages;

View File

@@ -16,6 +16,11 @@ const messages = defineMessages({
defaultMessage: 'There was an error loading this course.',
description: 'Message when a course fails to load',
},
'learn.sequence.no.content': {
id: 'learn.sequence.no.content',
defaultMessage: 'There is no content here.',
description: 'Message shown when there is no content to show a user inside a learning sequence.',
},
});
export default messages;

View File

@@ -0,0 +1,33 @@
import React from 'react';
import PropTypes from 'prop-types';
import Calculator from './calculator';
import NotesVisibility from './notes/NotesVisibility';
import './tools.scss';
export default function ContentTools({
course,
}) {
return (
<div className="content-tools">
<div className="d-flex justify-content-end align-items-end m-0">
{course.showCalculator && (
<Calculator />
)}
{course.notes.enabled && (
<NotesVisibility course={course} />
)}
</div>
</div>
);
}
ContentTools.propTypes = {
course: PropTypes.shape({
notes: PropTypes.shape({
enabled: PropTypes.bool,
}),
showCalculator: PropTypes.bool,
}).isRequired,
};

View File

@@ -0,0 +1,7 @@
.calculator {
flex-grow: 1;
.calculator-content {
background-color: #f1f1f1;
box-shadow: 0 -1px 0 0 #ddd;
}
}

View File

@@ -41,8 +41,8 @@ class Calculator extends Component {
render() {
return (
<Collapsible.Advanced className="calculator">
<div className="container-fluid text-right">
<Collapsible.Trigger tag="a" className="calculator-trigger btn">
<div className="text-right">
<Collapsible.Trigger tag="a" className="trigger btn">
<Collapsible.Visible whenOpen>
<FontAwesomeIcon icon={faTimesCircle} aria-hidden="true" className="mr-2" />
</Collapsible.Visible>

View File

@@ -0,0 +1,6 @@
import { getConfig } from '@edx/frontend-platform';
export default function toggleNotes() {
const iframe = document.getElementById('unit-iframe');
iframe.contentWindow.postMessage('tools.toggleNotes', getConfig().LMS_BASE_URL);
}

View File

@@ -0,0 +1,65 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import {
injectIntl, intlShape,
} from '@edx/frontend-platform/i18n';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPencilAlt } from '@fortawesome/free-solid-svg-icons';
import toggleNotes from '../data/api';
import messages from './messages';
class NotesVisibility extends Component {
constructor(props) {
super(props);
this.state = {
visible: props.course.notes.visible,
};
this.visibilityUrl = `${getConfig().LMS_BASE_URL}/courses/${props.course.id}/edxnotes/visibility/`;
}
handleClick = () => {
const data = { visibility: this.state.visible };
getAuthenticatedHttpClient().put(
this.visibilityUrl,
data,
).then(() => {
this.setState((state) => ({ visible: !state.visible }));
toggleNotes();
});
}
render() {
const message = this.state.visible ? 'notes.button.hide' : 'notes.button.show';
return (
<button
className={`trigger btn ${this.state.visible ? 'text-secondary' : 'text-success'} mx-2 `}
role="switch"
type="button"
onClick={this.handleClick}
onKeyDown={this.handleClick}
tabIndex="-1"
aria-checked={this.state.visible ? 'true' : 'false'}
>
<FontAwesomeIcon icon={faPencilAlt} aria-hidden="true" className="mr-2" />
{this.props.intl.formatMessage(messages[message])}
</button>
);
}
}
NotesVisibility.propTypes = {
intl: intlShape.isRequired,
course: PropTypes.shape({
id: PropTypes.string,
notes: PropTypes.shape({
enabled: PropTypes.bool,
visible: PropTypes.bool,
}),
}).isRequired,
};
export default injectIntl(NotesVisibility);

View File

@@ -0,0 +1,16 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'notes.button.show': {
id: 'notes.button.show',
defaultMessage: 'Show Notes',
description: 'Message for toggling notes visibility',
},
'notes.button.hide': {
id: 'notes.button.hide',
defaultMessage: 'Hide Notes',
description: 'Message for toggling notes visibility',
},
});
export default messages;

View File

@@ -1,10 +1,10 @@
.calculator {
.content-tools {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 100;
.calculator-trigger {
.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;
}
}

View File

@@ -3,11 +3,30 @@ import { getConfig, camelCaseObject } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient, getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { logError } from '@edx/frontend-platform/logging';
function overrideTabUrls(id, tabs) {
// "LMS tab slug" to "MFE URL slug" for overridden tabs
const tabOverrides = {};
return tabs.map((tab) => {
let url;
if (tabOverrides[tab.slug]) {
url = `/course/${id}/${tabOverrides[tab.slug]}`;
} else {
url = `${getConfig().LMS_BASE_URL}${tab.url}`;
}
return { ...tab, url };
});
}
function normalizeMetadata(metadata) {
return {
canShowUpgradeSock: metadata.can_show_upgrade_sock,
contentTypeGatingEnabled: metadata.content_type_gating_enabled,
// TODO: TNL-7185: return course expired _date_, instead of _message_
courseExpiredMessage: metadata.course_expired_message,
id: metadata.id,
title: metadata.name,
number: metadata.number,
offerHtml: metadata.offer_html,
org: metadata.org,
enrollmentStart: metadata.enrollment_start,
enrollmentEnd: metadata.enrollment_end,
@@ -15,11 +34,12 @@ function normalizeMetadata(metadata) {
start: metadata.start,
enrollmentMode: metadata.enrollment.mode,
isEnrolled: metadata.enrollment.is_active,
canLoadCourseware: metadata.can_load_courseware,
canLoadCourseware: camelCaseObject(metadata.can_load_courseware),
isStaff: metadata.is_staff,
verifiedMode: camelCaseObject(metadata.verified_mode),
tabs: camelCaseObject(metadata.tabs),
tabs: overrideTabUrls(metadata.id, camelCaseObject(metadata.tabs)),
showCalculator: metadata.show_calculator,
notes: camelCaseObject(metadata.notes),
};
}
@@ -63,8 +83,10 @@ function normalizeBlocks(courseId, blocks) {
break;
case 'vertical':
models.units[block.id] = {
graded: block.graded,
id: block.id,
title: block.display_name,
lmsWebUrl: block.lms_web_url,
};
break;
default:
@@ -108,12 +130,13 @@ export async function getCourseBlocks(courseId) {
url.searchParams.append('course_id', courseId);
url.searchParams.append('username', username);
url.searchParams.append('depth', 3);
url.searchParams.append('requested_fields', 'children,show_gated_sections');
url.searchParams.append('requested_fields', 'children,show_gated_sections,graded');
const { data } = await getAuthenticatedHttpClient().get(url.href, {});
return normalizeBlocks(courseId, data.blocks);
}
function normalizeSequenceMetadata(sequence) {
return {
sequence: {
@@ -145,3 +168,9 @@ export async function getSequenceMetadata(sequenceId) {
return normalizeSequenceMetadata(data);
}
export async function getResumeBlock(courseId) {
const url = new URL(`${getConfig().LMS_BASE_URL}/api/courseware/resume/${courseId}`);
const { data } = await getAuthenticatedHttpClient().get(url.href, {});
return camelCaseObject(data);
}

View File

@@ -3,4 +3,5 @@ export {
fetchSequence,
} from './thunks';
export { getResumeBlock } from './api';
export { reducer } from './slice';

View File

@@ -68,7 +68,7 @@ export function fetchCourse(courseId) {
}
if (fetchedMetadata) {
if (courseMetadataResult.value.canLoadCourseware && fetchedBlocks) {
if (courseMetadataResult.value.canLoadCourseware.hasAccess && fetchedBlocks) {
// User has access
dispatch(fetchCourseSuccess({ courseId }));
return;

View File

@@ -1,24 +1,37 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
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 } from '../user-messages';
import Alert from '../user-messages/Alert';
import messages from './messages';
import { useEnrollClickHandler } from './hooks';
function EnrollmentAlert({ intl, courseId }) {
const { enrollClickHandler, loading } = useEnrollClickHandler(
courseId,
intl.formatMessage(messages['learning.enrollment.success']),
);
function EnrollmentAlert({ intl }) {
return (
<Alert type="error">
{intl.formatMessage(messages['learning.enrollment.alert'])}
{' '}
<a href={`${getConfig().LMS_BASE_URL}/api/enrollment/v1/enrollment`}>
<Button disabled={loading} className="btn-link p-0 border-0 align-top" onClick={enrollClickHandler}>
{intl.formatMessage(messages['learning.enrollment.enroll.now'])}
</a>
</Button>
{' '}
{loading && <FontAwesomeIcon icon={faSpinner} spin />}
</Alert>
);
}
EnrollmentAlert.propTypes = {
intl: intlShape.isRequired,
courseId: PropTypes.string.isRequired,
};
export default injectIntl(EnrollmentAlert);

View File

@@ -1,24 +1,37 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
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 } from '../user-messages';
import Alert from '../user-messages/Alert';
import messages from './messages';
import { useEnrollClickHandler } from './hooks';
function StaffEnrollmentAlert({ intl, courseId }) {
const { enrollClickHandler, loading } = useEnrollClickHandler(
courseId,
intl.formatMessage(messages['learning.enrollment.success']),
);
function StaffEnrollmentAlert({ intl }) {
return (
<Alert type="info" dismissible>
{intl.formatMessage(messages['learning.staff.enrollment.alert'])}
{' '}
<a href={`${getConfig().LMS_BASE_URL}/api/enrollment/v1/enrollment`}>
<Button disabled={loading} className="btn-link p-0 border-0 align-top" onClick={enrollClickHandler}>
{intl.formatMessage(messages['learning.enrollment.enroll.now'])}
</a>
</Button>
{' '}
{loading && <FontAwesomeIcon icon={faSpinner} spin />}
</Alert>
);
}
StaffEnrollmentAlert.propTypes = {
intl: intlShape.isRequired,
courseId: PropTypes.string.isRequired,
};
export default injectIntl(StaffEnrollmentAlert);

View File

@@ -0,0 +1,9 @@
/* 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,7 +1,10 @@
/* eslint-disable import/prefer-default-export */
import { useContext, useState, useEffect } from 'react';
import UserMessagesContext from '../user-messages/UserMessagesContext';
import {
useContext, useState, useEffect, useCallback,
} from 'react';
import { UserMessagesContext, ALERT_TYPES } from '../user-messages';
import { useModel } from '../model-store';
import { postCourseEnrollment } from './data/api';
export function useEnrollmentAlert(courseId) {
const course = useModel('courses', courseId);
@@ -11,17 +14,11 @@ export function useEnrollmentAlert(courseId) {
useEffect(() => {
if (course && course.isEnrolled !== undefined) {
if (!course.isEnrolled && alertId === null) {
if (course.isStaff) {
setAlertId(add({
code: 'clientStaffEnrollmentAlert',
topic: 'course',
}));
} else {
setAlertId(add({
code: 'clientEnrollmentAlert',
topic: 'course',
}));
}
const code = course.isStaff ? 'clientStaffEnrollmentAlert' : 'clientEnrollmentAlert';
setAlertId(add({
code,
topic: 'course',
}));
} else if (course.isEnrolled && alertId !== null) {
remove(alertId);
setAlertId(null);
@@ -34,3 +31,24 @@ export function useEnrollmentAlert(courseId) {
};
}, [course, isEnrolled]);
}
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

@@ -16,6 +16,11 @@ const messages = defineMessages({
defaultMessage: 'Enroll Now',
description: 'A link prompting the user to click on it to enroll in the currently viewed course.',
},
'learning.enrollment.success': {
id: 'learning.enrollment.success',
defaultMessage: "You've successfully enrolled in this course!",
description: 'A message telling the user that their course enrollment was successful.',
},
});
export default messages;

View File

@@ -3,6 +3,7 @@ import 'regenerator-runtime/runtime';
import {
APP_INIT_ERROR, APP_READY, subscribe, initialize,
mergeConfig,
} from '@edx/frontend-platform';
import { AppProvider, ErrorPage } from '@edx/frontend-platform/react';
import React from 'react';
@@ -13,13 +14,14 @@ import { messages as headerMessages } from '@edx/frontend-component-header';
import Footer, { messages as footerMessages } from '@edx/frontend-component-footer';
import appMessages from './i18n';
import UserMessagesProvider from './user-messages/UserMessagesProvider';
import { UserMessagesProvider } from './user-messages';
import './index.scss';
import './assets/favicon.ico';
import CourseHome from './course-home';
import CoursewareContainer from './courseware';
import CourseHomeContainer from './course-home';
import CoursewareRedirect from './CoursewareRedirect';
import { TabContainer } from './tab-page';
import store from './store';
@@ -29,7 +31,11 @@ subscribe(APP_READY, () => {
<UserMessagesProvider>
<Switch>
<Route path="/redirect" component={CoursewareRedirect} />
<Route path="/course/:courseId/home" component={CourseHomeContainer} />
<Route path="/course/:courseId/home">
<TabContainer tab="courseware">
<CourseHome />
</TabContainer>
</Route>
<Route
path={[
'/course/:courseId/:sequenceId/:unitId',
@@ -51,6 +57,14 @@ subscribe(APP_INIT_ERROR, (error) => {
});
initialize({
handlers: {
config: () => {
mergeConfig({
INSIGHTS_BASE_URL: process.env.INSIGHTS_BASE_URL || null,
STUDIO_BASE_URL: process.env.STUDIO_BASE_URL || null,
}, 'LearnerAppConfig');
},
},
// TODO: Remove this once the course blocks api supports unauthenticated
// access and we are prepared to support public courses in this app.
requireAuthenticatedUser: true,

View File

@@ -32,7 +32,7 @@ $primary: #1176B2;
flex-grow: 1;
}
header {
flex: 0;
flex: 0 0 auto;
.logo {
display: block;
@@ -108,7 +108,6 @@ $primary: #1176B2;
@media (min-width: map-get($grid-breakpoints, 'sm')) {
max-width: 1440px;
width: 100%;
padding: 0 $grid-gutter-width;
margin-right: auto;
margin-left: auto;
}
@@ -186,7 +185,7 @@ $primary: #1176B2;
.sequence-navigation-tabs {
.btn {
flex-basis: 100%;
min-width: 4rem;
min-width: 2rem;
}
}
.sequence-navigation-dropdown {

View File

@@ -3,7 +3,7 @@ import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
import { getLoginRedirectUrl } from '@edx/frontend-platform/auth';
import Alert from '../user-messages/Alert';
import { Alert } from '../user-messages';
import messages from './messages';
function LogistrationAlert({ intl }) {

View File

@@ -1,7 +1,7 @@
/* eslint-disable import/prefer-default-export */
import { useContext, useState, useEffect } from 'react';
import { AppContext } from '@edx/frontend-platform/react';
import UserMessagesContext from '../user-messages/UserMessagesContext';
import { UserMessagesContext, ALERT_TYPES } from '../user-messages';
export function useLogistrationAlert() {
const { authenticatedUser } = useContext(AppContext);
@@ -12,7 +12,7 @@ export function useLogistrationAlert() {
setAlertId(add({
code: 'clientLogistrationAlert',
dismissible: false,
type: 'error',
type: ALERT_TYPES.ERROR,
topic: 'course',
}));
} else if (authenticatedUser !== null && alertId !== null) {

View File

@@ -0,0 +1,21 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Alert } from '../user-messages';
function OfferAlert(props) {
const {
rawHtml,
} = props;
return rawHtml && (
<Alert type="info">
<div dangerouslySetInnerHTML={{ __html: rawHtml }} />
</Alert>
);
}
OfferAlert.propTypes = {
rawHtml: PropTypes.string.isRequired,
};
export default OfferAlert;

28
src/offer-alert/hooks.js Normal file
View File

@@ -0,0 +1,28 @@
/* eslint-disable import/prefer-default-export */
import { useContext, useState, useEffect } from 'react';
import { UserMessagesContext } from '../user-messages';
import { useModel } from '../model-store';
export function useOfferAlert(courseId) {
const course = useModel('courses', courseId);
const { add, remove } = useContext(UserMessagesContext);
const [alertId, setAlertId] = useState(null);
const rawHtml = (course && course.offerHtml) || null;
useEffect(() => {
if (rawHtml && alertId === null) {
setAlertId(add({
code: 'clientOfferAlert',
topic: 'course',
rawHtml,
}));
} else if (!rawHtml && alertId !== null) {
remove(alertId);
setAlertId(null);
}
return () => {
if (alertId !== null) {
remove(alertId);
}
};
}, [alertId, courseId, rawHtml]);
}

2
src/offer-alert/index.js Normal file
View File

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

View File

@@ -0,0 +1,60 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Header, CourseTabsNavigation } from '../course-header';
import { useModel } from '../model-store';
import { useEnrollmentAlert } from '../enrollment-alert';
import InstructorToolbar from '../courseware/course/InstructorToolbar';
function LoadedTabPage({
activeTabSlug,
children,
courseId,
unitId,
}) {
useEnrollmentAlert(courseId);
const {
isStaff,
number,
org,
tabs,
title,
} = useModel('courses', courseId);
return (
<>
<Header
courseOrg={org}
courseNumber={number}
courseTitle={title}
/>
{isStaff && (
<InstructorToolbar
courseId={courseId}
unitId={unitId}
/>
)}
<main className="d-flex flex-column flex-grow-1">
<CourseTabsNavigation tabs={tabs} className="mb-3" activeTabSlug={activeTabSlug} />
<div className="container-fluid">
{children}
</div>
</main>
</>
);
}
LoadedTabPage.propTypes = {
activeTabSlug: PropTypes.string.isRequired,
children: PropTypes.node,
courseId: PropTypes.string.isRequired,
unitId: PropTypes.string,
};
LoadedTabPage.defaultProps = {
children: null,
unitId: null,
};
export default LoadedTabPage;

View File

@@ -0,0 +1,42 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { useParams } from 'react-router-dom';
import { fetchCourse } from '../data';
import TabPage from './TabPage';
export default function TabContainer(props) {
const {
children,
tab,
} = props;
const { courseId: courseIdFromUrl } = useParams();
const dispatch = useDispatch();
useEffect(() => {
// The courseId from the URL is the course we WANT to load.
dispatch(fetchCourse(courseIdFromUrl));
}, [courseIdFromUrl]);
// 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,
} = useSelector(state => state.courseware);
return (
<TabPage
activeTabSlug={tab}
courseId={courseId}
>
{children}
</TabPage>
);
}
TabContainer.propTypes = {
children: PropTypes.node.isRequired,
tab: PropTypes.string.isRequired,
};

52
src/tab-page/TabPage.jsx Normal file
View File

@@ -0,0 +1,52 @@
import React from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useSelector } from 'react-redux';
import { Header } from '../course-header';
import { useLogistrationAlert } from '../logistration-alert';
import PageLoading from '../PageLoading';
import messages from './messages';
import LoadedTabPage from './LoadedTabPage';
function TabPage({
intl,
...passthroughProps
}) {
useLogistrationAlert();
const courseStatus = useSelector(state => state.courseware.courseStatus);
if (courseStatus === 'loading') {
return (
<>
<Header />
<PageLoading
srMessage={intl.formatMessage(messages['learn.loading'])}
/>
</>
);
}
if (courseStatus === 'loaded') {
return (
<LoadedTabPage {...passthroughProps} />
);
}
// courseStatus 'failed' and any other unexpected course status.
return (
<>
<Header />
<p className="text-center py-5 mx-auto" style={{ maxWidth: '30em' }}>
{intl.formatMessage(messages['learn.loading.failure'])}
</p>
</>
);
}
TabPage.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(TabPage);

2
src/tab-page/index.js Normal file
View File

@@ -0,0 +1,2 @@
export { default as TabContainer } from './TabContainer';
export { default as TabPage } from './TabPage';

16
src/tab-page/messages.js Normal file
View File

@@ -0,0 +1,16 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'learn.loading': {
id: 'learn.loading',
defaultMessage: 'Loading course page...',
description: 'Message when course page is being loaded',
},
'learn.loading.failure': {
id: 'learn.loading.failure',
defaultMessage: 'There was an error loading this course.',
description: 'Message when a course page fails to load',
},
});
export default messages;

View File

@@ -7,27 +7,29 @@ import {
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Button } from '@edx/paragon';
import { ALERT_TYPES } from './UserMessagesProvider';
function getAlertClass(type) {
if (type === 'error') {
if (type === ALERT_TYPES.ERROR) {
return 'alert-warning';
}
if (type === 'danger') {
if (type === ALERT_TYPES.DANGER) {
return 'alert-danger';
}
if (type === 'success') {
if (type === ALERT_TYPES.SUCCESS) {
return 'alert-success';
}
return 'alert-info';
}
function getAlertIcon(type) {
if (type === 'error') {
if (type === ALERT_TYPES.ERROR) {
return faExclamationTriangle;
}
if (type === 'danger') {
if (type === ALERT_TYPES.DANGER) {
return faMinusCircle;
}
if (type === 'success') {
if (type === ALERT_TYPES.SUCCESS) {
return faCheckCircle;
}
return faInfoCircle;
@@ -53,7 +55,7 @@ function Alert({
Alert.propTypes = {
type: PropTypes.oneOf(['error', 'danger', 'info', 'success']).isRequired,
type: PropTypes.oneOf([ALERT_TYPES.ERROR, ALERT_TYPES.DANGER, ALERT_TYPES.INFO, ALERT_TYPES.SUCCESS]).isRequired,
dismissible: PropTypes.bool,
children: PropTypes.node,
onDismiss: PropTypes.func,

View File

@@ -4,7 +4,9 @@ import PropTypes from 'prop-types';
import UserMessagesContext from './UserMessagesContext';
import Alert from './Alert';
export default function AlertList({ topic, className, customAlerts }) {
export default function AlertList({
topic, className, customAlerts, customProps,
}) {
const { remove, messages } = useContext(UserMessagesContext);
const getAlertComponent = useCallback(
(code) => (customAlerts[code] !== undefined ? customAlerts[code] : Alert),
@@ -26,6 +28,8 @@ export default function AlertList({ topic, className, customAlerts }) {
type={message.type}
dismissible={message.dismissible}
onDismiss={() => remove(message.id)}
rawHtml={message.rawHtml}
{...customProps}
>
{message.text}
</AlertComponent>
@@ -46,10 +50,13 @@ AlertList.propTypes = {
PropTypes.node,
]),
),
// eslint-disable-next-line react/forbid-prop-types
customProps: PropTypes.object,
};
AlertList.defaultProps = {
topic: null,
className: null,
customAlerts: {},
customProps: {},
};

View File

@@ -1,8 +1,61 @@
import React, { useState, useRef } from 'react';
import React, { useState, useRef, useEffect } from 'react';
import PropTypes from 'prop-types';
import UserMessagesContext from './UserMessagesContext';
export const ALERT_TYPES = {
ERROR: 'error',
DANGER: 'danger',
SUCCESS: 'success',
INFO: 'info',
};
// NOTE: This storage key is not namespaced. That means that it's shared for the current fully
// qualified domain. Namespacing could be added by adding an optional prop to UserMessagesProvider
// to set a namespace, but we'll cross that bridge when we need it.
const FLASH_MESSAGES_LOCAL_STORAGE_KEY = 'UserMessagesProvider.flashMessages';
function getFlashMessages() {
let flashMessages = [];
try {
if (global.localStorage) {
const rawItem = global.localStorage.getItem(FLASH_MESSAGES_LOCAL_STORAGE_KEY);
if (rawItem) {
// Only try to parse and set flashMessages from the raw item if it exists.
const parsed = JSON.parse(rawItem);
if (Array.isArray(parsed)) {
flashMessages = parsed;
}
}
}
} catch (e) {
// If this fails for some reason, just return the empty array.
}
return flashMessages;
}
function addFlashMessage(message) {
try {
if (global.localStorage) {
const flashMessages = getFlashMessages();
flashMessages.push(message);
global.localStorage.setItem(FLASH_MESSAGES_LOCAL_STORAGE_KEY, JSON.stringify(flashMessages));
}
} catch (e) {
// If this fails, just bail.
}
}
function clearFlashMessages() {
try {
if (global.localStorage) {
global.localStorage.removeItem(FLASH_MESSAGES_LOCAL_STORAGE_KEY);
}
} catch (e) {
// If this fails, just bail.
}
}
export default function UserMessagesProvider({ children }) {
// Note: The callbacks (add, remove, clear) below interact with useState in very subtle ways.
// When we call setMessages, we always do so with the function-based form of the handler, making
@@ -20,28 +73,47 @@ export default function UserMessagesProvider({ children }) {
// its very nature.
const refId = useRef(nextId);
const add = ({
code, dismissible, text, type, topic, ...others
}) => {
/**
* Flash messages are a special kind of message that appears once on page refresh.
*/
function addFlash(message) {
addFlashMessage(message);
}
function add(message) {
const {
code, dismissible, text, type, topic, ...others
} = message;
const id = refId.current;
setMessages(currentMessages => [...currentMessages, {
code, dismissible, text, type, topic, ...others, id,
}]);
refId.current += 1;
setNextId(refId.current);
return refId.current;
};
const remove = id => {
return id;
}
function remove(id) {
setMessages(currentMessages => currentMessages.filter(message => message.id !== id));
};
}
const clear = (topic = null) => {
function clear(topic = null) {
setMessages(currentMessages => (topic === null ? [] : currentMessages.filter(message => message.topic !== topic)));
};
}
useEffect(() => {
const flashMessages = getFlashMessages();
flashMessages.forEach(flashMessage => add(flashMessage));
// We only allow flash messages to persist through one refresh, then we clear them out.
// If we want persistent messages, then add a 'persist' key to the messages and handle that
// as a separate local storage item.
clearFlashMessages();
}, []);
const value = {
add,
addFlash,
remove,
clear,
messages,

View File

@@ -0,0 +1,4 @@
export { default as UserMessagesProvider, ALERT_TYPES } from './UserMessagesProvider';
export { default as UserMessagesContext } from './UserMessagesContext';
export { default as AlertList } from './AlertList';
export { default as Alert } from './Alert';