Compare commits

...

12 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
David Joy
a10e6c2826 Switching the MFE to use the new permissions fields (#43)
- “userHasAccess” becomes “canLoadCourseware”, and is loaded from “can_load_courseware”
- “isStaff” is now loaded from “is_staff” instead of “user_has_staff_access”
2020-04-06 15:32:50 -04:00
David Joy
b4fbd1cf83 fix: “current.” was left over from when the implementation used refs (#42) 2020-04-02 15:58:58 -04:00
Adam Butterworth
37610ab181 Improve access control behavior (#39)
Fixes TNL-7175: Redirect to course home if a user is not unenrolled and the course is private.

- Require authentication to use the app while course blocks api requires it
- Gracefully handle course blocks api request failures allowing app to proceed to it redirection logic

Notable changes:

- selectors related to sequences are more resilient to missing models. In the case the course blocks api returns successfully but empty (in this case of enrolled but course not yet started).
- `fetchCourse` thunk handles failures for fetchCourseMeta and fetchCourseBlocks separately using `Promise.allSettled` instead of `Promise.all`
- `denied` is a new `courseStatus`
- Access denied redirect is done using a component at a new route `redirect/course-home/:courseId`

Now handles cases

- User is unauthenticated > redirect to login
- User is authenticated but not enrolled > redirects to lms course home
- When an enrolled user attempts to access courseware before the course start date they will load the sequence (but unable to load the vertical block). This behavior should be fixed in an update to edx-platform
2020-04-02 15:12:07 -04:00
David Joy
70428228a5 fix: Fix UserMessagesProvider state references (#38)
See details in code, but this causes UserMessagesProvider to always use the most “recent” version of its messages and nextId state when its callbacks are called.
2020-04-01 16:07:58 -04:00
David Joy
1dc069dbbf Adding a separate StaffEnrollmentAlert (#41)
This is a separate component because we have no mechanism for passing context/state into these alerts right now, and I’m not sure it’s worth building.  Easier to just use different codes for different situations.
2020-04-01 15:59:05 -04:00
David Joy
9b72380dea Bumping paragon and fixing an i18n build issue (#40) 2020-04-01 15:42:10 -04:00
24 changed files with 6246 additions and 2381 deletions

View File

@@ -1,16 +1,16 @@
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'
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'
ORDER_HISTORY_URL='localhost:19000/orders'
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
SEGMENT_KEY=null
SITE_NAME='edX'

8294
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 .",
@@ -37,7 +38,7 @@
"@edx/frontend-component-footer": "^10.0.6",
"@edx/frontend-component-header": "^2.0.3",
"@edx/frontend-platform": "^1.3.1",
"@edx/paragon": "^7.2.0",
"@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",
@@ -56,7 +57,7 @@
"regenerator-runtime": "^0.13.3"
},
"devDependencies": {
"@edx/frontend-build": "^3.0.0",
"@edx/frontend-build": "github:edx/frontend-build#kdmccormick/devstack-frontends",
"codecov": "^3.6.1",
"es-check": "^5.1.0",
"glob": "^7.1.6",

View File

@@ -0,0 +1,30 @@
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 './PageLoading';
export default () => {
const { path } = useRouteMatch();
return (
<div className="flex-grow-1">
<PageLoading srMessage={(
<FormattedMessage
id="learn.redirect.interstitial.message"
description="The screen-reader message when a page is about to redirect"
defaultMessage="Redirecting..."
/>
)}
/>
<Switch>
<Route
path={`${path}/course-home/:courseId`}
render={({ match }) => {
global.location.assign(`${getConfig().LMS_BASE_URL}/courses/${match.params.courseId}/course/`);
}}
/>
</Switch>
</div>
);
};

View File

@@ -33,5 +33,5 @@ export default class PageLoading extends Component {
}
PageLoading.propTypes = {
srMessage: PropTypes.string.isRequired,
srMessage: PropTypes.node.isRequired,
};

View File

@@ -11,7 +11,11 @@ import CourseDates from './CourseDates';
import Section from './Section';
import { useModel } from '../model-store';
const EnrollmentAlert = React.lazy(() => import('../enrollment-alert'));
// 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({
@@ -49,6 +53,7 @@ export default function CourseHome({
className="mb-3"
customAlerts={{
clientEnrollmentAlert: EnrollmentAlert,
clientStaffEnrollmentAlert: StaffEnrollmentAlert,
clientLogistrationAlert: LogistrationAlert,
}}
/>

View File

@@ -7,7 +7,7 @@ export default function SequenceLink({ id, courseId }) {
const sequence = useModel('sequences', id);
return (
<div className="ml-4">
<Link to={`/course/${courseId}/${id}`}>{sequence.title}</Link>
<Link to={`/learning/course/${courseId}/${id}`}>{sequence.title}</Link>
</div>
);
}

View File

@@ -1,9 +1,9 @@
import React, { useEffect, useCallback } from 'react';
import PropTypes from 'prop-types';
import { useSelector, useDispatch } from 'react-redux';
import { history, getConfig } from '@edx/frontend-platform';
import { history } from '@edx/frontend-platform';
import { useRouteMatch } from 'react-router';
import { useRouteMatch, Redirect } from 'react-router';
import {
fetchCourse,
fetchSequence,
@@ -22,7 +22,7 @@ function useUnitNavigationHandler(courseId, sequenceId, unitId) {
const dispatch = useDispatch();
return useCallback((nextUnitId) => {
dispatch(checkBlockCompletion(courseId, sequenceId, unitId));
history.push(`/course/${courseId}/${sequenceId}/${nextUnitId}`);
history.push(`/learning/course/${courseId}/${sequenceId}/${nextUnitId}`);
}, [courseId, sequenceId]);
}
@@ -56,7 +56,7 @@ function useNextSequenceHandler(courseId, sequenceId) {
return useCallback(() => {
if (nextSequence !== null) {
const nextUnitId = nextSequence.unitIds[0];
history.push(`/course/${courseId}/${nextSequence.id}/${nextUnitId}`);
history.push(`/learning/course/${courseId}/${nextSequence.id}/${nextUnitId}`);
}
}, [courseStatus, sequenceStatus, sequenceId]);
}
@@ -68,7 +68,7 @@ function usePreviousSequenceHandler(courseId, sequenceId) {
return useCallback(() => {
if (previousSequence !== null) {
const previousUnitId = previousSequence.unitIds[previousSequence.unitIds.length - 1];
history.push(`/course/${courseId}/${previousSequence.id}/${previousUnitId}`);
history.push(`/learning/course/${courseId}/${previousSequence.id}/${previousUnitId}`);
}
}, [courseStatus, sequenceStatus, sequenceId]);
}
@@ -91,7 +91,7 @@ function useContentRedirect(courseStatus, sequenceStatus) {
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}`);
history.replace(`/learning/course/${courseId}/${firstSequenceId}`);
}
}, [courseStatus, sequenceId]);
@@ -102,7 +102,7 @@ function useContentRedirect(courseStatus, sequenceStatus) {
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(`/course/${courseId}/${sequence.id}/${nextUnitId}`);
history.replace(`/learning/course/${courseId}/${sequence.id}/${nextUnitId}`);
}
}
}, [sequenceStatus, sequenceId, unitId]);
@@ -120,21 +120,6 @@ function useSavedSequencePosition(courseId, sequenceId, unitId) {
}, [unitId]);
}
/**
* Redirects the user away from the app if they don't have access to view this course.
*
* @param {*} courseStatus
* @param {*} course
*/
function useAccessDeniedRedirect(courseStatus, courseId) {
const course = useModel('courses', courseId);
useEffect(() => {
if (courseStatus === 'loaded' && !course.userHasAccess && !course.isStaff) {
global.location.assign(`${getConfig().LMS_BASE_URL}/courses/${course.id}/course/`);
}
}, [courseStatus, course]);
}
export default function CoursewareContainer() {
const { params } = useRouteMatch();
const {
@@ -173,11 +158,14 @@ export default function CoursewareContainer() {
const previousSequenceHandler = usePreviousSequenceHandler(courseId, sequenceId);
const unitNavigationHandler = useUnitNavigationHandler(courseId, sequenceId, routeUnitId);
useAccessDeniedRedirect(courseStatus, courseId);
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

View File

@@ -18,7 +18,12 @@ import Calculator from './calculator';
import messages from './messages';
import { useModel } from '../../model-store';
const EnrollmentAlert = React.lazy(() => import('../../enrollment-alert'));
// 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({
@@ -70,6 +75,7 @@ function Course({
topic="course"
customAlerts={{
clientEnrollmentAlert: EnrollmentAlert,
clientStaffEnrollmentAlert: StaffEnrollmentAlert,
clientLogistrationAlert: LogistrationAlert,
}}
/>

View File

@@ -45,7 +45,7 @@ export default function CourseBreadcrumbs({
const links = useMemo(() => {
if (courseStatus === 'loaded' && sequenceStatus === 'loaded') {
return [section, sequence].map((node) => ({
return [section, sequence].filter(node => !!node).map((node) => ({
id: node.id,
label: node.title,
url: `${getConfig().LMS_BASE_URL}/courses/${course.id}/course/#${node.id}`,

View File

@@ -14,14 +14,14 @@ function InstructorToolbar(props) {
<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.
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>.
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;

View File

@@ -345,7 +345,7 @@ class Calculator extends Component {
</th>
<td dir="auto">
<FormattedMessage
id="calculator.instruction.table.to.use.scientific.notation.type"
id="calculator.instruction.table.to.use.scientific.notation.type1"
defaultMessage="{exponentSyntax} and the exponent"
values={{
exponentSyntax: '10^',
@@ -357,7 +357,7 @@ class Calculator extends Component {
<tr>
<th scope="row">
<FormattedMessage
id="calculator.instruction.table.to.use.scientific.notation.type"
id="calculator.instruction.table.to.use.scientific.notation.type2"
defaultMessage="{notationSyntax} notation"
values={{
notationSyntax: 'e',
@@ -366,7 +366,7 @@ class Calculator extends Component {
</th>
<td dir="auto">
<FormattedMessage
id="calculator.instruction.table.to.use.scientific.notation.type"
id="calculator.instruction.table.to.use.scientific.notation.type3"
defaultMessage="{notationSyntax} and the exponent"
values={{
notationSyntax: '1e',

View File

@@ -12,7 +12,7 @@ function ContentLock({
intl, courseId, prereqSectionName, prereqId, sequenceTitle,
}) {
const handleClick = useCallback(() => {
history.push(`/course/${courseId}/${prereqId}`);
history.push(`/learning/course/${courseId}/${prereqId}`);
});
return (

View File

@@ -3,11 +3,11 @@ export function sequenceIdsSelector(state) {
if (state.courseware.courseStatus !== 'loaded') {
return [];
}
const { sectionIds } = state.models.courses[state.courseware.courseId];
let sequenceIds = [];
sectionIds.forEach(sectionId => {
sequenceIds = [...sequenceIds, ...state.models.sections[sectionId].sequenceIds];
});
const { sectionIds = [] } = state.models.courses[state.courseware.courseId];
const sequenceIds = sectionIds
.flatMap(sectionId => state.models.sections[sectionId].sequenceIds);
return sequenceIds;
}
@@ -15,6 +15,11 @@ export function firstSequenceIdSelector(state) {
if (state.courseware.courseStatus !== 'loaded') {
return null;
}
const sectionId = state.models.courses[state.courseware.courseId].sectionIds[0];
return state.models.sections[sectionId].sequenceIds[0];
const { sectionIds = [] } = state.models.courses[state.courseware.courseId];
if (sectionIds.length === 0) {
return null;
}
return state.models.sections[sectionIds[0]].sequenceIds[0];
}

View File

@@ -15,8 +15,8 @@ function normalizeMetadata(metadata) {
start: metadata.start,
enrollmentMode: metadata.enrollment.mode,
isEnrolled: metadata.enrollment.is_active,
userHasAccess: metadata.user_has_access,
isStaff: metadata.user_has_staff_access,
canLoadCourseware: metadata.can_load_courseware,
isStaff: metadata.is_staff,
verifiedMode: camelCaseObject(metadata.verified_mode),
tabs: camelCaseObject(metadata.tabs),
showCalculator: metadata.show_calculator,

View File

@@ -4,6 +4,7 @@ import { createSlice } from '@reduxjs/toolkit';
export const LOADING = 'loading';
export const LOADED = 'loaded';
export const FAILED = 'failed';
export const DENIED = 'denied';
const slice = createSlice({
name: 'courseware',
@@ -26,6 +27,10 @@ const slice = createSlice({
state.courseId = payload.courseId;
state.courseStatus = FAILED;
},
fetchCourseDenied: (state, { payload }) => {
state.courseId = payload.courseId;
state.courseStatus = DENIED;
},
fetchSequenceRequest: (state, { payload }) => {
state.sequenceId = payload.sequenceId;
state.sequenceStatus = LOADING;
@@ -45,6 +50,7 @@ export const {
fetchCourseRequest,
fetchCourseSuccess,
fetchCourseFailure,
fetchCourseDenied,
fetchSequenceRequest,
fetchSequenceSuccess,
fetchSequenceFailure,

View File

@@ -5,12 +5,13 @@ import {
getSequenceMetadata,
} from './api';
import {
addModelsMap, updateModel, updateModels, updateModelsMap,
addModelsMap, updateModel, updateModels, updateModelsMap, addModel,
} from '../model-store';
import {
fetchCourseRequest,
fetchCourseSuccess,
fetchCourseFailure,
fetchCourseDenied,
fetchSequenceRequest,
fetchSequenceSuccess,
fetchSequenceFailure,
@@ -19,39 +20,66 @@ import {
export function fetchCourse(courseId) {
return async (dispatch) => {
dispatch(fetchCourseRequest({ courseId }));
Promise.all([
getCourseBlocks(courseId),
Promise.allSettled([
getCourseMetadata(courseId),
]).then(([
{
courses, sections, sequences, units,
},
course,
]) => {
dispatch(addModelsMap({
modelType: 'courses',
modelsMap: courses,
}));
dispatch(updateModel({
modelType: 'courses',
model: course,
}));
dispatch(addModelsMap({
modelType: 'sections',
modelsMap: sections,
}));
// We update for sequences and units because the sequence metadata may have come back first.
dispatch(updateModelsMap({
modelType: 'sequences',
modelsMap: sequences,
}));
dispatch(updateModelsMap({
modelType: 'units',
modelsMap: units,
}));
dispatch(fetchCourseSuccess({ courseId }));
}).catch((error) => {
logError(error);
getCourseBlocks(courseId),
]).then(([courseMetadataResult, courseBlocksResult]) => {
if (courseMetadataResult.status === 'fulfilled') {
dispatch(addModel({
modelType: 'courses',
model: courseMetadataResult.value,
}));
}
if (courseBlocksResult.status === 'fulfilled') {
const {
courses, sections, sequences, units,
} = courseBlocksResult.value;
dispatch(updateModelsMap({
modelType: 'courses',
modelsMap: courses,
}));
dispatch(addModelsMap({
modelType: 'sections',
modelsMap: sections,
}));
// We update for sequences and units because the sequence metadata may have come back first.
dispatch(updateModelsMap({
modelType: 'sequences',
modelsMap: sequences,
}));
dispatch(updateModelsMap({
modelType: 'units',
modelsMap: units,
}));
}
const fetchedMetadata = courseMetadataResult.status === 'fulfilled';
const fetchedBlocks = courseBlocksResult.status === 'fulfilled';
// Log errors for each request if needed. Course block failures may occur
// even if the course metadata request is successful
if (!fetchedBlocks) {
logError(courseBlocksResult.reason);
}
if (!fetchedMetadata) {
logError(courseMetadataResult.reason);
}
if (fetchedMetadata) {
if (courseMetadataResult.value.canLoadCourseware && fetchedBlocks) {
// User has access
dispatch(fetchCourseSuccess({ courseId }));
return;
}
// User either doesn't have access or only has partial access
// (can't access course blocks)
dispatch(fetchCourseDenied({ courseId }));
return;
}
// Definitely an error happening
dispatch(fetchCourseFailure({ courseId }));
});
};

View File

@@ -0,0 +1,24 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import Alert from '../user-messages/Alert';
import messages from './messages';
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`}>
{intl.formatMessage(messages['learning.enrollment.enroll.now'])}
</a>
</Alert>
);
}
StaffEnrollmentAlert.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(StaffEnrollmentAlert);

View File

@@ -10,14 +10,19 @@ export function useEnrollmentAlert(courseId) {
const isEnrolled = course && course.isEnrolled;
useEffect(() => {
if (course && course.isEnrolled !== undefined) {
if (!course.isEnrolled) {
setAlertId(add({
code: 'clientEnrollmentAlert',
dismissible: false,
type: 'error',
topic: 'course',
}));
} else if (alertId !== null) {
if (!course.isEnrolled && alertId === null) {
if (course.isStaff) {
setAlertId(add({
code: 'clientStaffEnrollmentAlert',
topic: 'course',
}));
} else {
setAlertId(add({
code: 'clientEnrollmentAlert',
topic: 'course',
}));
}
} else if (course.isEnrolled && alertId !== null) {
remove(alertId);
setAlertId(null);
}

View File

@@ -1,2 +1,3 @@
export { default } from './EnrollmentAlert';
export { default as EnrollmentAlert } from './EnrollmentAlert';
export { default as StaffEnrollmentAlert } from './StaffEnrollmentAlert';
export { useEnrollmentAlert } from './hooks';

View File

@@ -6,6 +6,11 @@ const messages = defineMessages({
defaultMessage: 'You must be enrolled in the course to see course content.',
description: 'Message shown to indicate that a user needs to enroll in a course prior to viewing the course content. Shown as part of an alert, along with a link to enroll.',
},
'learning.staff.enrollment.alert': {
id: 'learning.staff.enrollment.alert',
defaultMessage: 'You are viewing this course as staff, and are not enrolled.',
description: 'Message shown to indicate that a user is not enrolled, but is able to view a course anyway because they are staff. Shown as part of an alert, along with a link to enroll.',
},
'learning.enrollment.enroll.now': {
id: 'learning.enrollment.enroll.now',
defaultMessage: 'Enroll Now',

View File

@@ -19,6 +19,7 @@ import './index.scss';
import './assets/favicon.ico';
import CoursewareContainer from './courseware';
import CourseHomeContainer from './course-home';
import CoursewareRedirect from './CoursewareRedirect';
import store from './store';
@@ -27,12 +28,13 @@ subscribe(APP_READY, () => {
<AppProvider store={store}>
<UserMessagesProvider>
<Switch>
<Route path="/course/:courseId/home" component={CourseHomeContainer} />
<Route path="/learning/redirect" component={CoursewareRedirect} />
<Route path="/learning/course/:courseId/home" component={CourseHomeContainer} />
<Route
path={[
'/course/:courseId/:sequenceId/:unitId',
'/course/:courseId/:sequenceId',
'/course/:courseId',
'/learning/course/:courseId/:sequenceId/:unitId',
'/learning/course/:courseId/:sequenceId',
'/learning/course/:courseId',
]}
component={CoursewareContainer}
/>
@@ -49,6 +51,9 @@ subscribe(APP_INIT_ERROR, (error) => {
});
initialize({
// TODO: Remove this once the course blocks api supports unauthenticated
// access and we are prepared to support public courses in this app.
requireAuthenticatedUser: true,
messages: [
appMessages,
headerMessages,

View File

@@ -8,14 +8,14 @@ export function useLogistrationAlert() {
const { add, remove } = useContext(UserMessagesContext);
const [alertId, setAlertId] = useState(null);
useEffect(() => {
if (authenticatedUser === null) {
if (authenticatedUser === null && alertId === null) {
setAlertId(add({
code: 'clientLogistrationAlert',
dismissible: false,
type: 'error',
topic: 'course',
}));
} else if (alertId !== null) {
} else if (authenticatedUser !== null && alertId !== null) {
remove(alertId);
setAlertId(null);
}

View File

@@ -4,32 +4,40 @@ import PropTypes from 'prop-types';
import UserMessagesContext from './UserMessagesContext';
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
// use of the "current" state and not relying on lexical scoping to access the state exposed
// above with useState. This is very important and allows us to call multiple "add", "remove",
// or "clear" functions in a single render. Without it, each call to one of the callbacks
// references back to the -original- value of messages instead of the most recent, causing them
// all to override each other. Last one in would win.
const [messages, setMessages] = useState([]);
const [nextId, setNextId] = useState(1);
const refMessages = useRef(messages);
// Because the add, remove, and clear handlers also need to access nextId, we have to do
// something a bit different. There's no way to wait for the "currentNextId" in a setMessages
// handler. The alternative is to update a ref, which will always point to the current value by
// its very nature.
const refId = useRef(nextId);
const add = ({
code, dismissible, text, type, topic, ...others
}) => {
const id = nextId;
refMessages.current = [...refMessages.current, {
const id = refId.current;
setMessages(currentMessages => [...currentMessages, {
code, dismissible, text, type, topic, ...others, id,
}];
setMessages(refMessages.current);
setNextId(nextId + 1);
return id;
}]);
refId.current += 1;
setNextId(refId.current);
return refId.current;
};
const remove = id => {
refMessages.current = refMessages.current.filter(message => message.id !== id);
setMessages(refMessages.current);
setMessages(currentMessages => currentMessages.filter(message => message.id !== id));
};
const clear = (topic = null) => {
refMessages.current = topic === null ? [] : refMessages.current.filter(message => message.topic !== topic);
setMessages(refMessages.current);
setMessages(currentMessages => (topic === null ? [] : currentMessages.filter(message => message.topic !== topic)));
};
const value = {