Merge branch 'master' of github.com:trouble/payload

This commit is contained in:
Jarrod Flesch
2020-11-19 16:52:40 -05:00
33 changed files with 327 additions and 464 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

@@ -29,6 +29,7 @@ const DefaultAccount = (props) => {
apiURL,
initialState,
isLoading,
action,
} = props;
const {
@@ -42,7 +43,7 @@ const DefaultAccount = (props) => {
auth,
} = collection;
const { serverURL, routes: { api, admin } } = useConfig();
const { routes: { admin } } = useConfig();
const classes = [
baseClass,
@@ -53,7 +54,7 @@ const DefaultAccount = (props) => {
<Form
className={`${baseClass}__form`}
method="put"
action={`${serverURL}${api}/${slug}/${data?.id}`}
action={action}
initialState={initialState}
disabled={!hasSavePermission}
>
@@ -170,6 +171,7 @@ DefaultAccount.defaultProps = {
DefaultAccount.propTypes = {
hasSavePermission: PropTypes.bool.isRequired,
apiURL: PropTypes.string.isRequired,
action: PropTypes.string.isRequired,
collection: PropTypes.shape({
labels: PropTypes.shape({
plural: PropTypes.string,

View File

@@ -4,6 +4,7 @@ import { useConfig } from '../../providers/Config';
import { useStepNav } from '../../elements/StepNav';
import { useAuth } from '../../providers/Authentication';
import usePayloadAPI from '../../../hooks/usePayloadAPI';
import { useLocale } from '../../utilities/Locale';
import DefaultAccount from './Default';
import buildStateFromSchema from '../../forms/Form/buildStateFromSchema';
import RenderCustomComponent from '../../utilities/RenderCustomComponent';
@@ -11,6 +12,7 @@ import { NegativeFieldGutterProvider } from '../../forms/FieldTypeGutter/context
const AccountView = () => {
const { state: locationState } = useLocation();
const locale = useLocale();
const { setStepNav } = useStepNav();
const { user, permissions } = useAuth();
const [initialState, setInitialState] = useState({});
@@ -40,6 +42,8 @@ const AccountView = () => {
const dataToRender = locationState?.data || data;
const apiURL = `${serverURL}${api}/${user.collection}/${data?.id}`;
const action = `${serverURL}${api}/${user.collection}/${data?.id}?locale=${locale}&depth=0`;
useEffect(() => {
const nav = [{
label: 'Account',
@@ -63,6 +67,7 @@ const AccountView = () => {
DefaultComponent={DefaultAccount}
CustomComponent={CustomAccount}
componentProps={{
action,
data,
collection,
permissions: collectionPermissions,

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

@@ -21,7 +21,6 @@ const DefaultGlobalView = (props) => {
} = props;
const {
slug,
fields,
preview,
label,
@@ -122,7 +121,6 @@ DefaultGlobalView.defaultProps = {
DefaultGlobalView.propTypes = {
global: PropTypes.shape({
label: PropTypes.string.isRequired,
slug: PropTypes.string,
fields: PropTypes.arrayOf(PropTypes.shape({})),
preview: PropTypes.func,
}).isRequired,

View File

@@ -91,7 +91,7 @@ const GlobalView = (props) => {
global,
onSave,
apiURL: `${serverURL}${api}/globals/${slug}?depth=0`,
action: `${serverURL}${api}/globals/${slug}?locale=${locale}`,
action: `${serverURL}${api}/globals/${slug}?locale=${locale}&depth=0`,
}}
/>
</NegativeFieldGutterProvider>

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

@@ -10,6 +10,7 @@ import RenderCustomComponent from '../../../utilities/RenderCustomComponent';
import DefaultEdit from './Default';
import buildStateFromSchema from '../../../forms/Form/buildStateFromSchema';
import { NegativeFieldGutterProvider } from '../../../forms/FieldTypeGutter/context';
import { useLocale } from '../../../utilities/Locale';
const EditView = (props) => {
const { collection, isEditing } = props;
@@ -30,6 +31,7 @@ const EditView = (props) => {
fields,
} = collection;
const locale = useLocale();
const { serverURL, routes: { admin, api } } = useConfig();
const { params: { id } = {} } = useRouteMatch();
const { state: locationState } = useLocation();
@@ -92,11 +94,9 @@ const EditView = (props) => {
const collectionPermissions = permissions?.[slug];
const apiURL = `${serverURL}${api}/${slug}/${id}`;
let action = `${serverURL}${api}/${slug}${isEditing ? `/${id}` : ''}`;
const action = `${serverURL}${api}/${slug}${isEditing ? `/${id}` : ''}?locale=${locale}&depth=0`;
const hasSavePermission = (isEditing && collectionPermissions?.update?.permission) || (!isEditing && collectionPermissions?.create?.permission);
action += '?depth=0';
return (
<NegativeFieldGutterProvider allow>
<RenderCustomComponent

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;
}
}

View File

@@ -5,8 +5,13 @@ const mockHandler = require('./mockHandler');
async function buildEmail() {
if (!this.config.email.transport || this.config.email.transport === 'mock') {
logger.info('E-mail configured with mock configuration');
// TODO: Log mock e-mail credentials here as well?
const mockAccount = await mockHandler(this.config.email);
if (this.config.email.transport === 'mock') {
const { account: { web, user, pass } } = mockAccount;
logger.info(`Log into mock email provider at ${web}`);
logger.info(`Mock email account username: ${user}`);
logger.info(`Mock email account password: ${pass}`);
}
return mockAccount;
}

View File

@@ -0,0 +1,10 @@
const httpStatus = require('http-status');
const APIError = require('./APIError');
class InvalidConfiguration extends APIError {
constructor(message, results) {
super(message, httpStatus.INTERNAL_SERVER_ERROR, results);
}
}
module.exports = InvalidConfiguration;

View File

@@ -7,6 +7,7 @@ const errorHandler = require('../express/middleware/errorHandler');
const FileUploadError = require('./FileUploadError');
const Forbidden = require('./Forbidden');
const LockedAuth = require('./LockedAuth');
const InvalidConfiguration = require('./InvalidConfiguration');
const InvalidFieldRelationship = require('./InvalidFieldRelationship');
const MissingCollectionLabel = require('./MissingCollectionLabel');
const MissingFieldInputOptions = require('./MissingFieldInputOptions');
@@ -26,6 +27,7 @@ module.exports = {
FileUploadError,
Forbidden,
LockedAuth,
InvalidConfiguration,
InvalidFieldRelationship,
MissingCollectionLabel,
MissingFieldInputOptions,

View File

@@ -16,10 +16,19 @@ const accessPromise = async ({
}) => {
const resultingData = data;
if (field.access && field.access[operation]) {
const result = overrideAccess ? true : await field.access[operation]({ req, id });
let accessOperation;
if (!result && operation === 'update' && originalDoc[field.name] !== undefined) {
if (hook === 'afterRead') {
accessOperation = 'read';
} else if (hook === 'beforeValidate') {
if (operation === 'update') accessOperation = 'update';
if (operation === 'create') accessOperation = 'create';
}
if (field.access && field.access[accessOperation]) {
const result = overrideAccess ? true : await field.access[accessOperation]({ req, id });
if (!result && accessOperation === 'update' && originalDoc[field.name] !== undefined) {
resultingData[field.name] = originalDoc[field.name];
} else if (!result) {
delete resultingData[field.name];

View File

@@ -23,7 +23,7 @@ const hookPromise = async ({
data: fullData,
operation,
req,
});
}) || data[field.name];
if (hookedValue !== undefined) {
resultingData[field.name] = hookedValue;

View File

@@ -6,7 +6,7 @@ const validationPromise = async ({
field,
path,
}) => {
if (hook === 'beforeValidate') return true;
if (hook !== 'beforeChange') return true;
const hasCondition = field.admin && field.admin.condition;
const shouldValidate = field.validate && !hasCondition;

View File

@@ -35,6 +35,11 @@ const sanitizeConfig = (config) => {
if (!sanitizedConfig.admin.user) {
sanitizedConfig.admin.user = 'users';
sanitizedConfig.collections.push(defaultUser);
} else if (!sanitizedConfig.collections
.filter((c) => c.auth !== undefined)
.map((c) => c.slug)
.includes(sanitizedConfig.admin.user)) {
throw new InvalidConfiguration(`${sanitizedConfig.admin.user} is not a valid admin user collection`);
}
sanitizedConfig.email = config.email || {};