feat: use react-toastify for notifications

This commit is contained in:
Elliot DeNolf
2020-11-19 10:00:26 -05:00
parent 0d333ce7da
commit 131dd51c39
16 changed files with 97 additions and 287 deletions

View File

@@ -1,5 +1,6 @@
import React, { useState, useCallback } from 'react';
import PropTypes from 'prop-types';
import { toast } from 'react-toastify';
import { useHistory } from 'react-router-dom';
import { Modal, useModal } from '@faceless-ui/modal';
import { useConfig } from '../../providers/Config';
@@ -8,7 +9,6 @@ import MinimalTemplate from '../../templates/Minimal';
import { useForm } from '../../forms/Form/context';
import useTitle from '../../../hooks/useTitle';
import { requests } from '../../../api';
import { useStatusList } from '../Status';
import './index.scss';
@@ -30,7 +30,6 @@ const DeleteDocument = (props) => {
} = props;
const { serverURL, routes: { api, admin } } = useConfig();
const { replaceStatus } = useStatusList();
const { setModified } = useForm();
const [deleting, setDeleting] = useState(false);
const { closeAll, toggle } = useModal();
@@ -41,11 +40,8 @@ const DeleteDocument = (props) => {
const modalSlug = `delete-${id}`;
const addDefaultError = useCallback(() => {
replaceStatus([{
message: `There was an error while deleting ${title}. Please check your connection and try again.`,
type: 'error',
}]);
}, [replaceStatus, title]);
toast.error(`There was an error while deleting ${title}. Please check your connection and try again.`);
}, [title]);
const handleDelete = useCallback(() => {
setDeleting(true);
@@ -72,7 +68,7 @@ const DeleteDocument = (props) => {
closeAll();
if (json.errors) {
replaceStatus(json.errors);
toast.error(json.errors);
}
addDefaultError();
return false;
@@ -80,7 +76,7 @@ const DeleteDocument = (props) => {
return addDefaultError();
}
});
}, [addDefaultError, closeAll, history, id, replaceStatus, singular, slug, title, admin, api, serverURL, setModified]);
}, [addDefaultError, closeAll, history, id, singular, slug, title, admin, api, serverURL, setModified]);
if (id) {
return (

View File

@@ -1,9 +1,9 @@
import React from 'react';
import PropTypes from 'prop-types';
import { toast } from 'react-toastify';
import { Modal, useModal } from '@faceless-ui/modal';
import Button from '../Button';
import MinimalTemplate from '../../templates/Minimal';
import { useStatusList } from '../Status';
import './index.scss';
@@ -16,17 +16,13 @@ const GenerateConfirmation = (props) => {
} = props;
const { toggle } = useModal();
const { replaceStatus } = useStatusList();
const modalSlug = 'generate-confirmation';
const handleGenerate = () => {
setKey();
toggle(modalSlug);
replaceStatus([{
message: 'New API Key Generated.',
type: 'success',
}]);
toast.success('New API Key Generated.', { autoClose: 3000 });
highlightField(true);
};

View File

@@ -1,101 +0,0 @@
import React, {
useReducer, createContext, useContext, useEffect, useCallback,
} from 'react';
import { useLocation } from 'react-router-dom';
import PropTypes from 'prop-types';
import X from '../../icons/X';
import reducer from './reducer';
import './index.scss';
const baseClass = 'status-list';
const Context = createContext({});
const useStatusList = () => useContext(Context);
const StatusListProvider = ({ children }) => {
const [statusList, dispatchStatus] = useReducer(reducer, []);
const { pathname, state } = useLocation();
const removeStatus = useCallback((i) => dispatchStatus({ type: 'REMOVE', payload: i }), []);
const addStatus = useCallback((status) => dispatchStatus({ type: 'ADD', payload: status }), []);
const clearStatus = useCallback(() => dispatchStatus({ type: 'CLEAR' }), []);
const replaceStatus = useCallback((status) => dispatchStatus({ type: 'REPLACE', payload: status }), []);
useEffect(() => {
if (state && state.status) {
if (Array.isArray(state.status)) {
replaceStatus(state.status);
} else {
replaceStatus([state.status]);
}
} else {
clearStatus();
}
}, [addStatus, replaceStatus, clearStatus, state, pathname]);
return (
<Context.Provider value={{
statusList,
removeStatus,
addStatus,
clearStatus,
replaceStatus,
}}
>
{children}
</Context.Provider>
);
};
StatusListProvider.propTypes = {
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node,
]).isRequired,
};
const StatusList = () => {
const { statusList, removeStatus } = useStatusList();
if (statusList.length > 0) {
return (
<ul className={baseClass}>
{statusList.map((status, i) => {
const classes = [
`${baseClass}__status`,
`${baseClass}__status--${status.type}`,
].join(' ');
return (
<li
className={classes}
key={i}
>
{status.message}
<button
type="button"
className="close"
onClick={(e) => {
e.preventDefault();
removeStatus(i);
}}
>
<X />
</button>
</li>
);
})}
</ul>
);
}
return null;
};
export {
StatusListProvider,
useStatusList,
};
export default StatusList;

View File

@@ -1,47 +0,0 @@
@import '../../../scss/styles.scss';
.status-list {
position: relative;
z-index: $z-status;
list-style: none;
padding: 0;
margin: 0;
li {
background: $color-green;
color: $color-dark-gray;
padding: base(.5) $baseline;
margin-bottom: 1px;
display: flex;
justify-content: space-between;
}
button {
@extend %btn-reset;
cursor: pointer;
svg {
width: base(1);
height: base(1);
}
&:hover {
opacity: .5;
}
&:active,
&:focus {
outline: none;
border: 0;
}
}
li.status-list__status--error {
background: $color-red;
color: white;
button svg {
@include color-svg(white);
}
}
}

View File

@@ -1,32 +0,0 @@
const statusReducer = (state, action) => {
switch (action.type) {
case 'ADD': {
const newState = [
...state,
action.payload,
];
return newState;
}
case 'REMOVE': {
const statusList = [...state];
statusList.splice(action.payload, 1);
return statusList;
}
case 'CLEAR': {
return [];
}
case 'REPLACE': {
return action.payload;
}
default:
return state;
}
};
export default statusReducer;

View File

@@ -4,8 +4,8 @@ import React, {
import { objectToFormData } from 'object-to-formdata';
import { useHistory } from 'react-router-dom';
import PropTypes from 'prop-types';
import { toast } from 'react-toastify';
import { useLocale } from '../../utilities/Locale';
import { useStatusList } from '../../elements/Status';
import { requests } from '../../../api';
import useThrottledEffect from '../../../hooks/useThrottledEffect';
import { useAuth } from '../../providers/Authentication';
@@ -38,14 +38,12 @@ const Form = (props) => {
disableSuccessStatus,
initialState, // fully formed initial field state
initialData, // values only, paths are required as key - form should build initial state as convenience
disableScrollOnSuccess,
waitForAutocomplete,
log,
} = props;
const history = useHistory();
const locale = useLocale();
const { replaceStatus, addStatus, clearStatus } = useStatusList();
const { refreshCookie } = useAuth();
const [modified, setModified] = useState(false);
@@ -111,17 +109,7 @@ const Form = (props) => {
// If not valid, prevent submission
if (!isValid) {
addStatus({
message: 'Please correct the fields below.',
type: 'error',
});
if (!disableScrollOnSuccess) {
window.scrollTo({
top: 0,
behavior: 'smooth',
});
}
toast.error('Please correct invalid fields.');
return false;
}
@@ -131,13 +119,6 @@ const Form = (props) => {
return onSubmit(fields, reduceFieldsToValues(fields));
}
if (!disableScrollOnSuccess) {
window.scrollTo({
top: 0,
behavior: 'smooth',
});
}
const formData = contextRef.current.createFormData();
try {
@@ -151,7 +132,6 @@ const Form = (props) => {
setProcessing(false);
clearStatus();
const contentType = res.headers.get('content-type');
const isJSON = contentType && contentType.indexOf('application/json') !== -1;
@@ -182,20 +162,13 @@ const Form = (props) => {
history.push(destination);
} else if (!disableSuccessStatus) {
replaceStatus([{
message: json.message || 'Submission successful.',
type: 'success',
disappear: 3000,
}]);
toast.success(json.message || 'Submission successful.', { autoClose: 3000 });
}
} else {
contextRef.current = { ...contextRef.current }; // triggers rerender of all components that subscribe to form
if (json.message) {
addStatus({
message: json.message,
type: 'error',
});
toast.error(json.message);
return json;
}
@@ -218,10 +191,7 @@ const Form = (props) => {
});
nonFieldErrors.forEach((err) => {
addStatus({
message: err.message || 'An unknown error occurred.',
type: 'error',
});
toast.error(err.message || 'An unknown error occurred.');
});
return json;
@@ -229,25 +199,17 @@ const Form = (props) => {
const message = errorMessages[res.status] || 'An unknown error occurrred.';
addStatus({
message,
type: 'error',
});
toast.error(message);
}
return json;
} catch (err) {
setProcessing(false);
return addStatus({
message: err,
type: 'error',
});
toast.error(err);
}
}, [
action,
addStatus,
clearStatus,
disableSuccessStatus,
disabled,
fields,
@@ -257,8 +219,6 @@ const Form = (props) => {
onSubmit,
onSuccess,
redirect,
replaceStatus,
disableScrollOnSuccess,
waitForAutocomplete,
]);
@@ -366,7 +326,6 @@ Form.defaultProps = {
disableSuccessStatus: false,
disabled: false,
initialState: undefined,
disableScrollOnSuccess: false,
waitForAutocomplete: false,
initialData: undefined,
log: false,
@@ -387,7 +346,6 @@ Form.propTypes = {
redirect: PropTypes.string,
disabled: PropTypes.bool,
initialState: PropTypes.shape({}),
disableScrollOnSuccess: PropTypes.bool,
waitForAutocomplete: PropTypes.bool,
initialData: PropTypes.shape({}),
log: PropTypes.bool,

View File

@@ -45,7 +45,6 @@ const AddUploadModal = (props) => {
action={`${serverURL}${api}/${collection.slug}`}
onSuccess={onSuccess}
disableSuccessStatus
disableScrollOnSuccess
>
<header className={`${baseClass}__header`}>
<h1>

View File

@@ -1,8 +1,8 @@
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { toast } from 'react-toastify';
import { useConfig } from '../../providers/Config';
import MinimalTemplate from '../../templates/Minimal';
import StatusList, { useStatusList } from '../../elements/Status';
import Form from '../../forms/Form';
import Email from '../../forms/field-types/Email';
import FormSubmit from '../../forms/Submit';
@@ -15,7 +15,6 @@ import './index.scss';
const baseClass = 'forgot-password';
const ForgotPassword = () => {
const { addStatus } = useStatusList();
const [hasSubmitted, setHasSubmitted] = useState(false);
const { user } = useAuth();
const {
@@ -32,10 +31,7 @@ const ForgotPassword = () => {
.then(() => {
setHasSubmitted(true);
}, () => {
addStatus({
type: 'error',
message: 'The email provided is not valid.',
});
toast.error('The email provided is not valid.');
});
};
@@ -81,7 +77,6 @@ const ForgotPassword = () => {
return (
<MinimalTemplate className={baseClass}>
<StatusList />
<Form
novalidate
handleResponse={handleResponse}

View File

@@ -1,5 +1,6 @@
import React, { useState, useEffect, useCallback } from 'react';
import PropTypes from 'prop-types';
import { toast } from 'react-toastify';
import Email from '../../../../forms/field-types/Email';
import Password from '../../../../forms/field-types/Password';
import Checkbox from '../../../../forms/field-types/Checkbox';
@@ -7,7 +8,6 @@ import Button from '../../../../elements/Button';
import ConfirmPassword from '../../../../forms/field-types/ConfirmPassword';
import { useFormFields, useFormModified } from '../../../../forms/Form/context';
import { useConfig } from '../../../../providers/Config';
import { useStatusList } from '../../../../elements/Status';
import APIKey from './APIKey';
@@ -20,7 +20,6 @@ const Auth = (props) => {
const [changingPassword, setChangingPassword] = useState(requirePassword);
const { getField } = useFormFields();
const modified = useFormModified();
const { replaceStatus } = useStatusList();
const enableAPIKey = getField('enableAPIKey');
@@ -50,15 +49,9 @@ const Auth = (props) => {
});
if (response.status === 200) {
replaceStatus([{
message: 'Successfully unlocked',
type: 'success',
}]);
toast.success('Successfully unlocked', { autoClose: 3000 });
} else {
replaceStatus([{
message: 'Unable to unlock',
type: 'error',
}]);
toast.error('Successfully unlocked');
}
}, [replaceStatus, serverURL, api, slug, email]);

View File

@@ -46,7 +46,6 @@ const DefaultEditView = (props) => {
admin: {
useAsTitle,
disableDuplicate,
disableScrollOnSuccess,
},
timestamps,
preview,
@@ -68,7 +67,6 @@ const DefaultEditView = (props) => {
onSuccess={onSave}
disabled={!hasSavePermission}
initialState={initialState}
disableScrollOnSuccess={disableScrollOnSuccess}
>
<div className={`${baseClass}__main`}>
<Meta
@@ -232,7 +230,6 @@ DefaultEditView.propTypes = {
admin: PropTypes.shape({
useAsTitle: PropTypes.string,
disableDuplicate: PropTypes.bool,
disableScrollOnSuccess: PropTypes.bool,
}),
fields: PropTypes.arrayOf(PropTypes.shape({})),
preview: PropTypes.func,

View File

@@ -4,9 +4,9 @@ import { BrowserRouter as Router } from 'react-router-dom';
import { ScrollInfoProvider } from '@faceless-ui/scroll-info';
import { WindowInfoProvider } from '@faceless-ui/window-info';
import { ModalProvider, ModalContainer } from '@faceless-ui/modal';
import { ToastContainer, Slide } from 'react-toastify';
import { SearchParamsProvider } from './components/utilities/SearchParams';
import { LocaleProvider } from './components/utilities/Locale';
import StatusList, { StatusListProvider } from './components/elements/Status';
import { AuthenticationProvider } from './components/providers/Authentication';
import Routes from './components/Routes';
import getCSSVariable from '../utilities/getCSSVariable';
@@ -25,30 +25,34 @@ const Index = () => {
};
return (
<ConfigProvider>
<WindowInfoProvider {...windowInfoProps}>
<ScrollInfoProvider>
<Router>
<ModalProvider
classPrefix="payload"
zIndex={parseInt(getCSSVariable('z-modal'), 10)}
>
<AuthenticationProvider>
<StatusListProvider>
<React.Fragment>
<ConfigProvider>
<WindowInfoProvider {...windowInfoProps}>
<ScrollInfoProvider>
<Router>
<ModalProvider
classPrefix="payload"
zIndex={parseInt(getCSSVariable('z-modal'), 10)}
>
<AuthenticationProvider>
<SearchParamsProvider>
<LocaleProvider>
<StatusList />
<Routes />
</LocaleProvider>
</SearchParamsProvider>
</StatusListProvider>
<ModalContainer />
</AuthenticationProvider>
</ModalProvider>
</Router>
</ScrollInfoProvider>
</WindowInfoProvider>
</ConfigProvider>
<ModalContainer />
</AuthenticationProvider>
</ModalProvider>
</Router>
</ScrollInfoProvider>
</WindowInfoProvider>
</ConfigProvider>
<ToastContainer
position="bottom-center"
transition={Slide}
/>
</React.Fragment>
);
};

View File

@@ -1,5 +1,6 @@
@import 'fonts';
@import 'styles';
@import './toastify.scss';
:root {
--breakpoint-xs-width : #{$breakpoint-xs-width};

View File

@@ -0,0 +1,37 @@
@import 'vars';
@import '~react-toastify/dist/ReactToastify.css';
.Toastify {
.Toastify__toast-container {
left: base(5);
transform: none;
right: base(5);
width: auto;
}
.Toastify__toast {
padding: base(.5);
border-radius: $style-radius-m;
font-weight: 600;
}
.Toastify__close-button {
align-self: center;
}
.Toastify__toast--success {
color: $color-dark-gray;
}
.Toastify__close-button--success {
color: $color-dark-gray;
}
.Toastify__toast--success {
background: $color-green;
}
.Toastify__toast--error {
background: $color-red;
}
}