Handle errors

This commit is contained in:
soupette 2019-10-30 18:46:19 +01:00 committed by Alexandre Bodin
parent de15224e75
commit 7c2633cc53
7 changed files with 391 additions and 61 deletions

View File

@ -7,16 +7,14 @@
},
"options": {
"increments": true,
"timestamps": [
"created_at",
"updated_at"
],
"timestamps": ["created_at", "updated_at"],
"comment": ""
},
"attributes": {
"name": {
"type": "string",
"required": true
"required": true,
"minLength": 3
},
"code": {
"type": "string",
@ -25,4 +23,4 @@
"minLength": 2
}
}
}
}

View File

@ -1,7 +1,7 @@
import React, { memo, useMemo } from 'react';
import PropTypes from 'prop-types';
import { get, omit } from 'lodash';
// import { InputsIndex } from 'strapi-helper-plugin';
import { get, isEmpty, omit, toLower } from 'lodash';
import { FormattedMessage } from 'react-intl';
import { InputFileWithErrors } from 'strapi-helper-plugin';
import { Inputs as InputsIndex } from '@buffetjs/custom';
@ -10,7 +10,7 @@ import InputJSONWithErrors from '../InputJSONWithErrors';
import WysiwygWithErrors from '../WysiwygWithErrors';
const getInputType = (type = '') => {
switch (type.toLowerCase()) {
switch (toLower(type)) {
case 'boolean':
return 'bool';
case 'biginteger':
@ -50,12 +50,12 @@ const getInputType = (type = '') => {
function Inputs({ autoFocus, keys, name, onBlur }) {
const {
didCheckErrors,
errors,
formErrors,
layout,
modifiedData,
onChange,
} = useDataManager();
console.log({ errors });
const attribute = useMemo(
() => get(layout, ['schema', 'attributes', name], {}),
[layout, name]
@ -85,37 +85,57 @@ function Inputs({ autoFocus, keys, name, onBlur }) {
if (visible === false) {
return null;
}
const temporaryErrorIdUntilBuffetjsSupportsFormattedMessage =
'app.utils.defaultMessage';
const errorId = get(
formErrors,
[keys, 'id'],
temporaryErrorIdUntilBuffetjsSupportsFormattedMessage
);
// const inputErrors = get(errors, keys, []);
// TODO add the option placeholder to buffetjs
// check https://github.com/strapi/strapi/issues/2471
// const withOptionPlaceholder = get(attribute, 'type', '') === 'enumeration';
// TODO format error for the JSON, the WYSIWYG and also the file inputs
// TODO check if the height for the textarea is 196px (not mandatory)
return (
<InputsIndex
{...metadatas}
autoFocus={autoFocus}
didCheckErrors={didCheckErrors}
disabled={disabled}
// errors={errors}
// errors={inputErrors}
// inputDescription={description}
description={description}
// inputStyle={inputStyle}
customInputs={{
media: InputFileWithErrors,
json: InputJSONWithErrors,
wysiwyg: WysiwygWithErrors,
<FormattedMessage id={errorId}>
{error => {
return (
<InputsIndex
{...metadatas}
autoFocus={autoFocus}
didCheckErrors={didCheckErrors}
disabled={disabled}
error={
isEmpty(error) ||
errorId === temporaryErrorIdUntilBuffetjsSupportsFormattedMessage
? null
: error
}
inputDescription={description}
description={description}
// inputStyle={inputStyle} used to set the height of the text area
customInputs={{
media: InputFileWithErrors,
json: InputJSONWithErrors,
wysiwyg: WysiwygWithErrors,
}}
multiple={get(attribute, 'multiple', false)}
name={name}
onBlur={onBlur}
onChange={onChange}
options={get(attribute, 'enum', [])}
type={getInputType(type)}
validations={validations}
value={value}
// withOptionPlaceholder={withOptionPlaceholder}
/>
);
}}
multiple={get(attribute, 'multiple', false)}
name={name}
onBlur={onBlur}
onChange={onChange}
options={get(attribute, 'enum', [])}
type={getInputType(type)}
// validations={null}
validations={validations}
value={value}
// withOptionPlaceholder={withOptionPlaceholder}
/>
</FormattedMessage>
);
}

View File

@ -14,11 +14,11 @@ const Header = () => {
const [showWarningDelete, setWarningDelete] = useState(false);
const { id } = useParams();
const { initialData, layout } = useDataManager();
const { initialData, layout, shouldShowLoadingState } = useDataManager();
const currentContentTypeMainField = get(
layout,
['contentType', 'settings', 'mainField'],
['settings', 'mainField'],
'id'
);
const isCreatingEntry = id === 'create';
@ -39,6 +39,8 @@ const Header = () => {
toggleWarningDelete();
};
console.log({ shouldShowLoadingState });
return (
<>
<PluginHeader
@ -50,15 +52,15 @@ const Header = () => {
toggleWarningCancel();
},
type: 'button',
// disabled: isSubmitting, // TODO STATE WHEN SUBMITING
disabled: shouldShowLoadingState,
},
{
kind: 'primary',
label: `${pluginId}.containers.Edit.submit`,
type: 'submit',
// loader: isSubmitting,
// style: isSubmitting ? { marginRight: '18px' } : {},
// disabled: isSubmitting, // TODO STATE WHEN SUBMITING
loader: shouldShowLoadingState,
style: shouldShowLoadingState ? { marginRight: '18px' } : {},
disabled: shouldShowLoadingState,
},
]}
subActions={
@ -72,7 +74,7 @@ const Header = () => {
toggleWarningDelete();
},
type: 'button',
// disabled: isSubmitting, // TODO STATE WHEN SUBMITING
disabled: shouldShowLoadingState,
},
]
}

View File

@ -49,9 +49,13 @@ const EditView = ({
const [reducerState, dispatch] = useReducer(reducer, initialState, () =>
init(initialState)
);
const allLayoutData = useMemo(() => get(layouts, [slug], {}), [
layouts,
slug,
]);
const currentContentTypeLayoutData = useMemo(
() => get(layouts, [slug, 'contentType'], {}),
[layouts, slug]
() => get(allLayoutData, ['contentType'], {}),
[allLayoutData]
);
const currentContentTypeLayout = useMemo(
() => get(currentContentTypeLayoutData, ['layouts', 'edit'], []),
@ -110,7 +114,7 @@ const EditView = ({
return (
<EditViewProvider layout={currentContentTypeLayoutData}>
<EditViewDataManagerProvider layout={currentContentTypeLayoutData}>
<EditViewDataManagerProvider allLayoutData={allLayoutData} slug={slug}>
<BackHeader onClick={() => redirectToPreviousPage()} />
<Container className="container-fluid">
<Header />
@ -133,7 +137,6 @@ const EditView = ({
return (
<div className="row" key={fieldsBlockIndex}>
{fieldsBlock.map(({ name, size }, fieldIndex) => {
console.log({ fieldIndex });
return (
<div className={`col-${size}`} key={name}>
<Inputs

View File

@ -1,17 +1,74 @@
import React, { useReducer } from 'react';
// import { useHistory, useLocation, useParams } from 'react-router-dom';
import React, { useEffect, useReducer } from 'react';
import { useLocation, useParams } from 'react-router-dom';
import PropTypes from 'prop-types';
// import { get } from 'lodash';
import { get } from 'lodash';
import {
getQueryParameters,
request,
LoadingIndicatorPage,
} from 'strapi-helper-plugin';
import pluginId from '../../pluginId';
import EditViewDataManagerContext from '../../contexts/EditViewDataManager';
import createYupSchema from './utils/schema';
import init from './init';
import reducer, { initialState } from './reducer';
// const getRequestUrl = path => `/${pluginId}/explorer/${path}`;
const getRequestUrl = path => `/${pluginId}/explorer/${path}`;
const EditViewDataManagerProvider = ({ children, layout }) => {
const EditViewDataManagerProvider = ({ allLayoutData, children, slug }) => {
const { id } = useParams();
// Retrieve the search
const { search } = useLocation();
const [reducerState, dispatch] = useReducer(reducer, initialState, init);
const { initialData, modifiedData } = reducerState.toJS();
const {
formErrors,
initialData,
isLoading,
modifiedData,
shouldShowLoadingState,
} = reducerState.toJS();
const currentContentTypeLayout = get(allLayoutData, ['contentType'], {});
const abortController = new AbortController();
const { signal } = abortController;
const isCreatingEntry = id === 'create';
const source = getQueryParameters(search, 'source');
useEffect(() => {
const fetchData = async () => {
try {
const data = await request(getRequestUrl(`${slug}/${id}`), {
method: 'GET',
params: { source },
signal,
});
dispatch({
type: 'GET_DATA_SUCCEEDED',
data,
});
} catch (err) {
if (err.code !== 20) {
strapi.notification.error(`${pluginId}.error.record.fetch`);
}
}
};
// Force state to be cleared when navigation from one entry to another
dispatch({ type: 'RESET_PROPS' });
if (!isCreatingEntry) {
fetchData();
} else {
// Will create default form
console.log('will create default form');
}
return () => {
abortController.abort();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, slug, source]);
const addRelation = ({ target: { name, value } }) => {
dispatch({
@ -37,11 +94,39 @@ const EditViewDataManagerProvider = ({ children, layout }) => {
});
};
const handleSubmit = e => {
const handleSubmit = async e => {
e.preventDefault();
dispatch({
type: 'SUBMIT_SUCCEEDED',
const schema = createYupSchema(currentContentTypeLayout, {
components: get(allLayoutData, 'components', {}),
});
try {
// Validate the form using yup
await schema.validate(modifiedData, { abortEarly: false });
// Set the loading state in the plugin header
dispatch({ type: 'IS_SUBMITING' });
} catch (err) {
const errors = get(err, 'inner', []).reduce((acc, curr) => {
acc[
curr.path
.split('[')
.join('.')
.split(']')
.join('')
] = { id: curr.message };
return acc;
}, {});
dispatch({
type: 'SUBMIT_ERRORS',
errors,
});
console.log({ errors });
}
// dispatch({
// type: 'SUBMIT_SUCCEEDED',
// });
};
const moveRelation = (dragIndex, overIndex, name) => {
@ -60,27 +145,36 @@ const EditViewDataManagerProvider = ({ children, layout }) => {
});
};
const showLoader = !isCreatingEntry && isLoading;
return (
<EditViewDataManagerContext.Provider
value={{
addRelation,
formErrors,
initialData,
layout,
layout: currentContentTypeLayout,
modifiedData,
moveRelation,
onChange: handleChange,
onRemoveRelation,
shouldShowLoadingState,
}}
>
<form onSubmit={handleSubmit}>{children}</form>
{showLoader ? (
<LoadingIndicatorPage />
) : (
<form onSubmit={handleSubmit}>{children}</form>
)}
</EditViewDataManagerContext.Provider>
);
};
EditViewDataManagerProvider.defaultProps = {};
EditViewDataManagerProvider.propTypes = {
allLayoutData: PropTypes.object.isRequired,
children: PropTypes.node.isRequired,
layout: PropTypes.object.isRequired,
slug: PropTypes.string.isRequired,
};
export default EditViewDataManagerProvider;

View File

@ -4,9 +4,11 @@ import {
} from 'immutable';
const initialState = fromJS({
formErrors: {},
isLoading: true,
initialData: {},
modifiedData: {},
shouldShowLoadingState: false,
});
const reducer = (state, action) => {
@ -25,6 +27,13 @@ const reducer = (state, action) => {
return fromJS([el]);
});
case 'GET_DATA_SUCCEEDED':
return state
.update('initialData', () => fromJS(action.data))
.update('modifiedData', () => fromJS(action.data))
.update('isLoading', () => false);
case 'IS_SUBMITING':
return state.update('shouldShowLoadingState', () => true);
case 'MOVE_FIELD':
return state.updateIn(['modifiedData', ...action.keys], list => {
return list
@ -51,6 +60,12 @@ const reducer = (state, action) => {
}
case 'REMOVE_RELATION':
return state.removeIn(['modifiedData', ...action.keys.split('.')]);
case 'RESET_PROPS':
return initialState;
case 'SUBMIT_ERRORS':
return state
.update('formErrors', () => fromJS(action.errors))
.update('shouldShowLoadingState', () => false);
default:
return state;
}

View File

@ -0,0 +1,198 @@
import {
get,
isBoolean,
isNaN,
isNumber,
isNull,
isArray,
isObject,
} from 'lodash';
import * as yup from 'yup';
import { translatedErrors as errorsTrads } from 'strapi-helper-plugin';
yup.addMethod(yup.mixed, 'defined', function() {
return this.test(
'defined',
errorsTrads.required,
value => value !== undefined
);
});
const getAttributes = data => get(data, ['schema', 'attributes'], {});
const createYupSchema = (model, { components }) => {
const attributes = getAttributes(model);
return yup.object().shape(
Object.keys(attributes).reduce((acc, current) => {
const attribute = attributes[current];
if (attribute.type !== 'relation' && attribute.type !== 'component') {
const formatted = createYupSchemaAttribute(attribute.type, attribute);
acc[current] = formatted;
}
if (attribute.type === 'relation') {
acc[current] = [
'oneWay',
'oneToOne',
'manyToOne',
'oneToManyMorph',
'oneToOneMorph',
].includes(attribute.relationType)
? yup.object().nullable()
: yup.array().nullable();
}
if (attribute.type === 'component') {
const componentFieldSchema = createYupSchema(
components[attribute.component],
{
components,
}
);
if (attribute.repeatable === true) {
const componentSchema =
attribute.required === true
? yup
.array()
.of(componentFieldSchema)
.defined()
: yup
.array()
.of(componentFieldSchema)
.nullable();
acc[current] = componentSchema;
return acc;
} else {
const componentSchema = yup.lazy(obj => {
if (obj !== undefined) {
return attribute.required === true
? componentFieldSchema.defined()
: componentFieldSchema.nullable();
}
return attribute.required === true
? yup.object().defined()
: yup.object().nullable();
});
acc[current] = componentSchema;
return acc;
}
}
return acc;
}, {})
);
};
const createYupSchemaAttribute = (type, validations) => {
let schema = yup.mixed();
if (
['string', 'text', 'richtext', 'email', 'password', 'enumeration'].includes(
type
)
) {
schema = yup.string();
}
if (type === 'json') {
schema = yup
.mixed(errorsTrads.json)
.test('isJSON', errorsTrads.json, value => {
try {
if (
isObject(value) ||
isBoolean(value) ||
isNumber(value) ||
isArray(value) ||
isNaN(value) ||
isNull(value)
) {
JSON.parse(JSON.stringify(value));
return true;
}
return false;
} catch (err) {
return false;
}
})
.nullable();
}
if (type === 'email') {
schema = schema.email(errorsTrads.email);
}
if (['number', 'integer', 'biginteger', 'float', 'decimal'].includes(type)) {
schema = yup
.number()
.transform(cv => (isNaN(cv) ? undefined : cv))
.typeError();
}
if (['date', 'datetime'].includes(type)) {
schema = yup.date();
}
Object.keys(validations).forEach(validation => {
const validationValue = validations[validation];
if (
!!validationValue ||
((!isBoolean(validationValue) &&
Number.isInteger(Math.floor(validationValue))) ||
validationValue === 0)
) {
switch (validation) {
case 'required':
schema = schema.required(errorsTrads.required);
break;
case 'max':
schema = schema.max(validationValue, errorsTrads.max);
break;
case 'maxLength':
schema = schema.max(validationValue, errorsTrads.maxLength);
break;
case 'min':
schema = schema.min(validationValue, errorsTrads.min);
break;
case 'minLength':
schema = schema.min(validationValue, errorsTrads.minLength);
break;
case 'regex':
schema = schema.matches(validationValue, errorsTrads.regex);
break;
case 'lowercase':
if (['text', 'textarea', 'email', 'string'].includes(type)) {
schema = schema.strict().lowercase();
}
break;
case 'uppercase':
if (['text', 'textarea', 'email', 'string'].includes(type)) {
schema = schema.strict().uppercase();
}
break;
case 'positive':
if (
['number', 'integer', 'bigint', 'float', 'decimal'].includes(type)
) {
schema = schema.positive();
}
break;
case 'negative':
if (
['number', 'integer', 'bigint', 'float', 'decimal'].includes(type)
) {
schema = schema.negative();
}
break;
default:
schema = schema.nullable();
}
}
});
return schema;
};
export default createYupSchema;