Custom alerts for anonymous and unenrolled users. (#17)

* build: bumping version of frontend-platform

We’re going to need to use the new getLoginRedirectUrl helper.

* Adding custom alerts for anonymous and unenrolled users.

- Anonymous users are prompted to sign in or register.
- Unenrolled users are prompted to enroll.

The alerts themselves are lazy-loaded as necessary, like the ContentLock component.

This PR also adds `customAlerts` to the AlertList, allowing an application to specify custom components to be shown as Alerts for a given alert code.

* refactor: Renaming enrollmentIsActive to isEnrolled

As per review feedback that the former wasn’t clear.
This commit is contained in:
David Joy
2020-03-05 10:23:47 -05:00
committed by GitHub
parent bda738c9d1
commit c3d0ac1417
14 changed files with 245 additions and 34 deletions

30
package-lock.json generated
View File

@@ -2529,9 +2529,9 @@
}
},
"@cospired/i18n-iso-languages": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@cospired/i18n-iso-languages/-/i18n-iso-languages-2.1.0.tgz",
"integrity": "sha512-r+bqdtVTkO8R07JiBxEc7ckbkXgeIP/X51MxqxODv9gZovc/MBDS6uJVCHg+mtJuldwvSD6L0XF5RzH6qyAacQ=="
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@cospired/i18n-iso-languages/-/i18n-iso-languages-2.1.1.tgz",
"integrity": "sha512-dmlPmVw51tQzCJkYvtWWXxuvsAkNvCmUnvjMWyo7Qd318YeEZalCUInUgdhKU985gk2wkITtdakPYqk5GrJ99A=="
},
"@edx/eslint-config": {
"version": "1.1.4",
@@ -2665,13 +2665,13 @@
}
},
"@edx/frontend-platform": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@edx/frontend-platform/-/frontend-platform-1.1.11.tgz",
"integrity": "sha512-Nx1nj/WSZ6ELUUOyjAWfHVTJIkWt7MUhN8cHKCwrnvNcXsAAqKkG3ZiLWErmdg+asfvXcn6E6Ipzej6E7t91PA==",
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@edx/frontend-platform/-/frontend-platform-1.3.1.tgz",
"integrity": "sha512-RAJ6IciIX+ZwXhlBfxOOB0sjNNpBQaN/eDLsgztL9MxEmuAvQtbvQBBJAum6qsVZpKzOZYhtRYFyWoHTuyzFZA==",
"requires": {
"@cospired/i18n-iso-languages": "2.1.0",
"@cospired/i18n-iso-languages": "2.1.1",
"axios": "0.18.1",
"form-urlencoded": "4.1.0",
"form-urlencoded": "4.1.3",
"glob": "7.1.6",
"history": "4.10.1",
"i18n-iso-countries": "4.3.1",
@@ -2682,7 +2682,7 @@
"lodash.snakecase": "4.1.1",
"pubsub-js": "1.7.0",
"react-intl": "2.9.0",
"universal-cookie": "4.0.2"
"universal-cookie": "4.0.3"
}
},
"@edx/paragon": {
@@ -8968,9 +8968,9 @@
}
},
"form-urlencoded": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/form-urlencoded/-/form-urlencoded-4.1.0.tgz",
"integrity": "sha512-1F/gR6Lx+KX1PtV2l0Gg+PcJ61h5NLNJBgIK4p+L1V958h5bmPi8GD8enlT1a7Sr32EVzea+qOzpO/gl3mOgZA=="
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/form-urlencoded/-/form-urlencoded-4.1.3.tgz",
"integrity": "sha512-z0YJtPuq0BSOrErlpj+o1KHTRaOH6LN16043ZVK2Wk5uUGpX308PJ5ZJDtU++ndoZkzASHVclUTD2mb1jHzqlA=="
},
"formidable": {
"version": "1.2.1",
@@ -18939,9 +18939,9 @@
}
},
"universal-cookie": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/universal-cookie/-/universal-cookie-4.0.2.tgz",
"integrity": "sha512-n14lhA//lQeYRweP9j9uXsshN9Cs4LunVSnvAGmnA69SofwsjpUU03geaCaPC9LlsH2rkBy99o3zxQyVOldGvA==",
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/universal-cookie/-/universal-cookie-4.0.3.tgz",
"integrity": "sha512-YbEHRs7bYOBTIWedTR9koVEe2mXrq+xdjTJZcoKJK/pQaE6ni28ak2AKXFpevb+X6w3iU5SXzWDiJkmpDRb9qw==",
"requires": {
"@types/cookie": "^0.3.3",
"@types/object-assign": "^4.0.30",

View File

@@ -36,7 +36,7 @@
"dependencies": {
"@edx/frontend-component-footer": "^10.0.6",
"@edx/frontend-component-header": "^2.0.3",
"@edx/frontend-platform": "^1.1.11",
"@edx/frontend-platform": "^1.3.1",
"@edx/paragon": "^7.2.0",
"@fortawesome/fontawesome-svg-core": "^1.2.26",
"@fortawesome/free-brands-svg-icons": "^5.12.0",

View File

@@ -66,6 +66,7 @@ function CourseContainer(props) {
unitId={unitId}
models={models}
tabs={props.metadata.tabs}
isEnrolled={props.metadata.isEnrolled}
verifiedMode={props.metadata.verifiedMode}
/>
);
@@ -90,6 +91,7 @@ CourseContainer.propTypes = {
type: PropTypes.string,
url: PropTypes.string,
})),
isEnrolled: PropTypes.bool,
verifiedMode: PropTypes.shape({
price: PropTypes.number.isRequired,
currency: PropTypes.string.isRequired,

View File

@@ -10,9 +10,24 @@ import CourseHeader from './CourseHeader';
import CourseSock from './course-sock';
import CourseTabsNavigation from './CourseTabsNavigation';
import InstructorToolbar from '../InstructorToolbar';
import { useLogistrationAlert, useEnrollmentAlert } from '../../hooks';
const EnrollmentAlert = React.lazy(() => import('../../enrollment-alert'));
const LogistrationAlert = React.lazy(() => import('../../logistration-alert'));
export default function Course({
courseOrg, courseNumber, courseName, courseUsageKey, courseId, sequenceId, unitId, models, tabs, verifiedMode,
courseId,
courseNumber,
courseName,
courseOrg,
courseUsageKey,
isEnrolled,
models,
sequenceId,
tabs,
unitId,
verifiedMode,
}) {
const nextSequenceHandler = useCallback(() => {
const sequenceIds = createSequenceIdList(models, courseId);
@@ -36,6 +51,9 @@ export default function Course({
}
});
useLogistrationAlert();
useEnrollmentAlert(isEnrolled);
return (
<>
<CourseHeader
@@ -52,7 +70,14 @@ export default function Course({
<main className="d-flex flex-column flex-grow-1">
<div className="container-fluid">
<CourseTabsNavigation tabs={tabs} className="mb-3" activeTabSlug="courseware" />
<AlertList topic="course" className="mb-3" />
<AlertList
topic="course"
className="mb-3"
customAlerts={{
clientEnrollmentAlert: EnrollmentAlert,
clientLogistrationAlert: LogistrationAlert,
}}
/>
<CourseBreadcrumbs
courseUsageKey={courseUsageKey}
courseId={courseId}
@@ -85,6 +110,7 @@ Course.propTypes = {
courseId: PropTypes.string.isRequired,
sequenceId: PropTypes.string.isRequired,
unitId: PropTypes.string,
isEnrolled: PropTypes.bool,
models: PropTypes.objectOf(PropTypes.shape({
id: PropTypes.string.isRequired,
displayName: PropTypes.string.isRequired,
@@ -109,5 +135,6 @@ Course.propTypes = {
Course.defaultProps = {
unitId: undefined,
isEnrolled: false,
verifiedMode: null,
};

View File

@@ -17,6 +17,7 @@ const courseMetaSlice = createSlice({
org: payload.org,
tabs: payload.tabs,
userHasAccess: payload.userHasAccess,
isEnrolled: payload.enrollment.isActive,
verifiedMode: payload.verifiedMode,
}),
fetchCourseMetadataFailure: (draftState) => {

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 EnrollmentAlert({ intl }) {
return (
<Alert type="error">
{intl.formatMessage(messages['learning.enrollment.alert'])}
{' '}
<a href={`${getConfig().LMS_BASE_URL}/api/enrollment/v1/enrollment`}>
{intl.formatMessage(messages['learning.enrollment.enroll.now'])}
</a>
</Alert>
);
}
EnrollmentAlert.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(EnrollmentAlert);

View File

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

View File

@@ -0,0 +1,16 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'learning.enrollment.alert': {
id: 'learning.enrollment.alert',
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.enrollment.enroll.now': {
id: 'learning.enrollment.enroll.now',
defaultMessage: 'Enroll Now',
description: 'A link prompting the user to click on it to enroll in the currently viewed course.',
},
});
export default messages;

50
src/hooks.js Normal file
View File

@@ -0,0 +1,50 @@
import { useContext, useState, useEffect } from 'react';
import { AppContext } from '@edx/frontend-platform/react';
import UserMessagesContext from './user-messages/UserMessagesContext';
export function useLogistrationAlert() {
const { authenticatedUser } = useContext(AppContext);
const { add, remove } = useContext(UserMessagesContext);
const [alertId, setAlertId] = useState(null);
useEffect(() => {
if (authenticatedUser === null) {
setAlertId(add({
code: 'clientLogistrationAlert',
dismissible: false,
type: 'error',
topic: 'course',
}));
} else if (alertId !== null) {
remove(alertId);
setAlertId(null);
}
return () => {
if (alertId !== null) {
remove(alertId);
}
};
}, [authenticatedUser]);
}
export function useEnrollmentAlert(isEnrolled) {
const { add, remove } = useContext(UserMessagesContext);
const [alertId, setAlertId] = useState(null);
useEffect(() => {
if (!isEnrolled) {
setAlertId(add({
code: 'clientEnrollmentAlert',
dismissible: false,
type: 'error',
topic: 'course',
}));
} else if (alertId !== null) {
remove(alertId);
setAlertId(null);
}
return () => {
if (alertId !== null) {
remove(alertId);
}
};
}, [isEnrolled]);
}

View File

@@ -0,0 +1,43 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
import { getLoginRedirectUrl } from '@edx/frontend-platform/auth';
import Alert from '../user-messages/Alert';
import messages from './messages';
function LogistrationAlert({ intl }) {
const signIn = (
<a href={`${getLoginRedirectUrl(global.location.href)}`}>
{intl.formatMessage(messages['learning.logistration.login'])}
</a>
);
// TODO: Pull this registration URL building out into a function, like the login one above.
// This is complicated by the fact that we don't have a REGISTER_URL env variable available.
const register = (
<a href={`${getConfig().LMS_BASE_URL}/register?next=${encodeURIComponent(global.location.href)}`}>
{intl.formatMessage(messages['learning.logistration.register'])}
</a>
);
return (
<Alert type="error">
<FormattedMessage
id="learning.logistration.alert"
description="Prompts the user to sign in or register to see course content."
defaultMessage="Please {signIn} or {register} to see course content."
values={{
signIn,
register,
}}
/>
</Alert>
);
}
LogistrationAlert.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(LogistrationAlert);

View File

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

View File

@@ -0,0 +1,16 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'learning.logistration.login': {
id: 'learning.logistration.login',
defaultMessage: 'sign in',
description: 'Text in a link, prompting the user to log in. Used in "learning.logistration.alert"',
},
'learning.logistration.register': {
id: 'learning.logistration.register',
defaultMessage: 'register',
description: 'Text in a link, prompting the user to create an account. Used in "learning.logistration.alert"',
},
});
export default messages;

View File

@@ -1,11 +1,15 @@
import React, { useContext } from 'react';
import React, { useContext, useCallback, Suspense } from 'react';
import PropTypes from 'prop-types';
import UserMessagesContext from './UserMessagesContext';
import Alert from './Alert';
export default function AlertList({ topic, className }) {
export default function AlertList({ topic, className, customAlerts }) {
const { remove, messages } = useContext(UserMessagesContext);
const getAlertComponent = useCallback(
(code) => (customAlerts[code] !== undefined ? customAlerts[code] : Alert),
[customAlerts],
);
const topicMessages = messages.filter(message => !topic || message.topic === topic);
if (topicMessages.length === 0) {
@@ -14,16 +18,20 @@ export default function AlertList({ topic, className }) {
return (
<div className={className}>
{topicMessages.map(message => (
<Alert
key={message.id}
type={message.type}
dismissible={message.dismissible}
onDismiss={() => remove(message.id)}
>
{message.text}
</Alert>
))}
{topicMessages.map(message => {
const AlertComponent = getAlertComponent(message.code);
return (
<Suspense key={message.id} fallback={null}>
<AlertComponent
type={message.type}
dismissible={message.dismissible}
onDismiss={() => remove(message.id)}
>
{message.text}
</AlertComponent>
</Suspense>
);
})}
</div>
);
}
@@ -31,9 +39,17 @@ export default function AlertList({ topic, className }) {
AlertList.propTypes = {
className: PropTypes.string,
topic: PropTypes.string,
customAlerts: PropTypes.objectOf(
PropTypes.oneOfType([
PropTypes.object,
PropTypes.func,
PropTypes.node,
]),
),
};
AlertList.defaultProps = {
topic: null,
className: null,
customAlerts: {},
};

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useRef } from 'react';
import PropTypes from 'prop-types';
import UserMessagesContext from './UserMessagesContext';
@@ -7,21 +7,35 @@ export default function UserMessagesProvider({ children }) {
const [messages, setMessages] = useState([]);
const [nextId, setNextId] = useState(1);
const refMessages = useRef(messages);
const add = ({
code, dismissible, text, type, topic, ...others
}) => {
const id = nextId;
setMessages([...messages, {
refMessages.current = [...refMessages.current, {
code, dismissible, text, type, topic, ...others, id,
}]);
}];
setMessages(refMessages.current);
setNextId(nextId + 1);
return id;
};
const remove = id => setMessages(messages.filter(message => message.id !== id));
const remove = id => {
refMessages.current = refMessages.current.filter(message => message.id !== id);
setMessages(refMessages.current);
};
const clear = (topic = null) => {
refMessages.current = topic === null ? [] : refMessages.current.filter(message => message.topic !== topic);
setMessages(refMessages.current);
};
const value = {
add,
remove,
clear,
messages,
};