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.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import Alert from '../user-messages/Alert';
|
||||
import { Alert } from '../user-messages';
|
||||
|
||||
function AccessExpirationAlert(props) {
|
||||
const {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { useContext, useState, useEffect } from 'react';
|
||||
import UserMessagesContext from '../user-messages/UserMessagesContext';
|
||||
import { UserMessagesContext } from '../user-messages';
|
||||
import { useModel } from '../model-store';
|
||||
|
||||
export function useAccessExpirationAlert(courseId) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Button } from '@edx/paragon';
|
||||
|
||||
import AlertList from '../user-messages/AlertList';
|
||||
import { AlertList } from '../user-messages';
|
||||
import { Header, CourseTabsNavigation } from '../course-header';
|
||||
import { useLogistrationAlert } from '../logistration-alert';
|
||||
import { useEnrollmentAlert } from '../enrollment-alert';
|
||||
|
||||
@@ -3,7 +3,7 @@ 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 { AlertList } from '../../user-messages';
|
||||
import { useAccessExpirationAlert } from '../../access-expiration-alert';
|
||||
import { useLogistrationAlert } from '../../logistration-alert';
|
||||
import { useEnrollmentAlert } from '../../enrollment-alert';
|
||||
@@ -86,6 +86,10 @@ function Course({
|
||||
clientAccessExpirationAlert: AccessExpirationAlert,
|
||||
clientOfferAlert: OfferAlert,
|
||||
}}
|
||||
// courseId is provided because EnrollmentAlert and StaffEnrollmentAlert require it.
|
||||
customProps={{
|
||||
courseId,
|
||||
}}
|
||||
/>
|
||||
<CourseBreadcrumbs
|
||||
courseId={courseId}
|
||||
|
||||
@@ -11,7 +11,7 @@ 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'));
|
||||
@@ -78,7 +78,7 @@ function Sequence({
|
||||
code: null,
|
||||
dismissible: false,
|
||||
text: sequence.bannerText,
|
||||
type: 'info',
|
||||
type: ALERT_TYPES.INFO,
|
||||
topic: 'sequence',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
9
src/enrollment-alert/data/api.js
Normal file
9
src/enrollment-alert/data/api.js
Normal 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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -13,7 +13,7 @@ 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';
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import Alert from '../user-messages/Alert';
|
||||
import { Alert } from '../user-messages';
|
||||
|
||||
function OfferAlert(props) {
|
||||
const {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { useContext, useState, useEffect } from 'react';
|
||||
import UserMessagesContext from '../user-messages/UserMessagesContext';
|
||||
import { UserMessagesContext } from '../user-messages';
|
||||
import { useModel } from '../model-store';
|
||||
|
||||
export function useOfferAlert(courseId) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
@@ -27,6 +29,7 @@ export default function AlertList({ topic, className, customAlerts }) {
|
||||
dismissible={message.dismissible}
|
||||
onDismiss={() => remove(message.id)}
|
||||
rawHtml={message.rawHtml}
|
||||
{...customProps}
|
||||
>
|
||||
{message.text}
|
||||
</AlertComponent>
|
||||
@@ -47,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: {},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
4
src/user-messages/index.js
Normal file
4
src/user-messages/index.js
Normal 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';
|
||||
Reference in New Issue
Block a user