Merge pull request #12632 from strapi/CONTENT-47/uid-error-message

EditViewDataManagerProvider: Handle API errors independently from yup
This commit is contained in:
Gustav Hansen 2022-03-29 16:24:50 +02:00 committed by GitHub
commit 97f51652bb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 231 additions and 99 deletions

View File

@ -203,8 +203,6 @@ const CollectionTypeFormWrapper = ({ allLayoutData, children, slug, id, origin }
const displayErrors = useCallback(
err => {
const errorPayload = err.response.data;
console.error(errorPayload);
let errorMessage = get(errorPayload, ['error', 'message'], 'Bad Request');
// TODO handle errors correctly when back-end ready
@ -272,10 +270,14 @@ const CollectionTypeFormWrapper = ({ allLayoutData, children, slug, id, origin }
dispatch(setStatus('resolved'));
replace(`/content-manager/collectionType/${slug}/${data.id}${rawQuery}`);
return Promise.resolve(data);
} catch (err) {
trackUsageRef.current('didNotCreateEntry', { error: err, trackerProperty });
displayErrors(err);
trackUsageRef.current('didNotCreateEntry', { error: err, trackerProperty });
dispatch(setStatus('resolved'));
return Promise.reject(err);
}
},
[
@ -308,9 +310,13 @@ const CollectionTypeFormWrapper = ({ allLayoutData, children, slug, id, origin }
type: 'success',
message: { id: getTrad('success.record.publish') },
});
return Promise.resolve(data);
} catch (err) {
displayErrors(err);
dispatch(setStatus('resolved'));
return Promise.reject(err);
}
}, [cleanReceivedData, displayErrors, id, slug, dispatch, toggleNotification]);
@ -334,11 +340,15 @@ const CollectionTypeFormWrapper = ({ allLayoutData, children, slug, id, origin }
dispatch(submitSucceeded(cleanReceivedData(data)));
dispatch(setStatus('resolved'));
return Promise.resolve(data);
} catch (err) {
trackUsageRef.current('didNotEditEntry', { error: err, trackerProperty });
displayErrors(err);
dispatch(setStatus('resolved'));
return Promise.reject(err);
}
},
[cleanReceivedData, displayErrors, slug, id, dispatch, toggleNotification]
@ -362,9 +372,13 @@ const CollectionTypeFormWrapper = ({ allLayoutData, children, slug, id, origin }
dispatch(submitSucceeded(cleanReceivedData(data)));
dispatch(setStatus('resolved'));
return Promise.resolve(data);
} catch (err) {
dispatch(setStatus('resolved'));
displayErrors(err);
return Promise.reject(err);
}
}, [cleanReceivedData, displayErrors, id, slug, dispatch, toggleNotification]);

View File

@ -1,5 +1,9 @@
import React, { useCallback, useEffect, useMemo, useRef, useReducer } from 'react';
import { cloneDeep, get, isEmpty, isEqual, set } from 'lodash';
import isEmpty from 'lodash/isEmpty';
import cloneDeep from 'lodash/cloneDeep';
import get from 'lodash/get';
import isEqual from 'lodash/isEqual';
import set from 'lodash/set';
import PropTypes from 'prop-types';
import { useIntl } from 'react-intl';
import { Prompt, Redirect } from 'react-router-dom';
@ -10,10 +14,13 @@ import {
useNotification,
useOverlayBlocker,
useTracking,
getYupInnerErrors,
} from '@strapi/helper-plugin';
import { getTrad, removeKeyInObject } from '../../utils';
import reducer, { initialState } from './reducer';
import { cleanData, createYupSchema, getYupInnerErrors } from './utils';
import { cleanData, createYupSchema } from './utils';
import { getAPIInnerError } from './utils/getAPIInnerError';
const EditViewDataManagerProvider = ({
allLayoutData,
@ -262,7 +269,7 @@ const EditViewDataManagerProvider = ({
);
const createFormData = useCallback(
data => {
(data) => {
// First we need to remove the added keys needed for the dnd
const preparedData = removeKeyInObject(cloneDeep(data), '__temp_key__');
// Then we need to apply our helper
@ -286,34 +293,31 @@ const EditViewDataManagerProvider = ({
}, [hasDraftAndPublish, shouldNotRunValidations]);
const handleSubmit = useCallback(
async e => {
async (e) => {
e.preventDefault();
let errors = {};
// First validate the form
try {
await yupSchema.validate(modifiedData, { abortEarly: false });
} catch (err) {
errors = getYupInnerErrors(err);
}
const formData = createFormData(modifiedData);
try {
if (isEmpty(errors)) {
const formData = createFormData(modifiedData);
if (isCreatingEntry) {
onPost(formData, trackerProperty);
} else {
onPut(formData, trackerProperty);
if (isCreatingEntry) {
await onPost(formData, trackerProperty);
} else {
await onPut(formData, trackerProperty);
}
}
} catch (err) {
console.log('ValidationError');
console.log(err);
errors = getYupInnerErrors(err);
toggleNotification({
type: 'warning',
message: {
id: getTrad('containers.EditView.notification.errors'),
defaultMessage: 'The form contains some errors',
},
});
errors = {
...errors,
...getAPIInnerError(err),
};
}
dispatch({
@ -321,16 +325,7 @@ const EditViewDataManagerProvider = ({
errors,
});
},
[
createFormData,
isCreatingEntry,
modifiedData,
onPost,
onPut,
toggleNotification,
trackerProperty,
yupSchema,
]
[createFormData, isCreatingEntry, modifiedData, onPost, onPut, trackerProperty, yupSchema]
);
const handlePublish = useCallback(async () => {
@ -345,17 +340,22 @@ const EditViewDataManagerProvider = ({
let errors = {};
try {
// Validate the form using yup
await schema.validate(modifiedData, { abortEarly: false });
onPublish();
} catch (err) {
console.error('ValidationError');
console.error(err);
errors = getYupInnerErrors(err);
}
try {
if (isEmpty(errors)) {
await onPublish();
}
} catch (err) {
errors = {
...errors,
...getAPIInnerError(err),
};
}
dispatch({
type: 'SET_FORM_ERRORS',
errors,
@ -363,8 +363,8 @@ const EditViewDataManagerProvider = ({
}, [allLayoutData, currentContentTypeLayout, isCreatingEntry, modifiedData, onPublish]);
const shouldCheckDZErrors = useCallback(
dzName => {
const doesDZHaveError = Object.keys(formErrors).some(key => key.split('.')[0] === dzName);
(dzName) => {
const doesDZHaveError = Object.keys(formErrors).some((key) => key.split('.')[0] === dzName);
const shouldCheckErrors = !isEmpty(formErrors) && doesDZHaveError;
return shouldCheckErrors;
@ -418,7 +418,7 @@ const EditViewDataManagerProvider = ({
});
}, []);
const onRemoveRelation = useCallback(keys => {
const onRemoveRelation = useCallback((keys) => {
dispatch({
type: 'REMOVE_RELATION',
keys,

View File

@ -0,0 +1,18 @@
import { getTrad } from '../../../utils';
export function getAPIInnerError(error) {
const errorPayload = error.response.data.error.details.errors;
const validationErrors = errorPayload.reduce((acc, err) => {
acc[err.path.join('.')] = {
id: getTrad(`apiError.${err.message}`),
defaultMessage: err.message,
values: {
field: err.path[err.path.length - 1],
},
};
return acc;
}, {});
return validationErrors;
}

View File

@ -1,17 +0,0 @@
import { get } from 'lodash';
const getYupInnerErrors = error => {
return get(error, 'inner', []).reduce((acc, curr) => {
acc[
curr.path
.split('[')
.join('.')
.split(']')
.join('')
] = { id: curr.message };
return acc;
}, {});
};
export default getYupInnerErrors;

View File

@ -0,0 +1,15 @@
import { getTrad } from '../../../utils';
export function handleAPIError(error) {
const errorPayload = error.response.data.error.details.errors;
const validationErrors = errorPayload.reduce((acc, err) => {
acc[err.path.join('.')] = {
id: getTrad(`apiError.${err.message}`),
defaultMessage: err.message,
};
return acc;
}, {});
return validationErrors;
}

View File

@ -1,4 +1,3 @@
export { default as moveFields } from './moveFields';
export { default as cleanData } from './cleanData';
export { default as getYupInnerErrors } from './getYupInnerErrors';
export { default as createYupSchema } from './schema';

View File

@ -0,0 +1,36 @@
import { getAPIInnerError } from '../getAPIInnerError';
const API_ERROR_FIXTURE = {
response: {
data: {
error: {
details: {
errors: [
{
path: ['field', '0', 'name'],
message: 'Field contains errors',
},
{
path: ['field'],
message: 'Field must be unique',
},
],
},
},
},
},
};
describe('getAPIInnerError', () => {
test('transforms API errors into errors, which can be rendered by the CM', () => {
expect(getAPIInnerError(API_ERROR_FIXTURE)).toMatchObject({
'field.0.name': {
id: 'content-manager.apiError.Field contains errors',
},
field: {
id: 'content-manager.apiError.Field must be unique',
},
});
});
});

View File

@ -0,0 +1,36 @@
import { handleAPIError } from '../handleAPIError';
const API_ERROR_FIXTURE = {
response: {
data: {
error: {
details: {
errors: [
{
path: ['field', '0', 'name'],
message: 'Field contains errors',
},
{
path: ['field'],
message: 'Field must be unique',
},
],
},
},
},
},
};
describe('handleAPIError', () => {
test('transforms API errors into errors, which can be rendered by the CM', () => {
expect(handleAPIError(API_ERROR_FIXTURE)).toMatchObject({
'field.0.name': {
id: 'content-manager.apiError.Field contains errors',
},
field: {
id: 'content-manager.apiError.Field must be unique',
},
});
});
});

View File

@ -82,7 +82,6 @@ const InputUID = ({
onChange({ target: { name, value: data, type: 'text' } }, shouldSetInitialValue);
setIsLoading(false);
} catch (err) {
console.error({ err });
setIsLoading(false);
}
};
@ -107,7 +106,6 @@ const InputUID = ({
setIsLoading(false);
} catch (err) {
console.error({ err });
setIsLoading(false);
}
};
@ -184,12 +182,10 @@ const InputUID = ({
onChange(e);
};
const formattedError = error ? formatMessage({ id: error, defaultMessage: error }) : undefined;
return (
<TextInput
disabled={disabled}
error={formattedError}
error={error}
endAction={
<EndActionWrapper>
{availability && availability.isAvailable && !regenerateLabel && (

View File

@ -42,10 +42,7 @@ function Inputs({
const disabled = useMemo(() => !get(metadatas, 'editable', true), [metadatas]);
const type = fieldSchema.type;
const errorId = useMemo(() => {
return get(formErrors, [keys, 'id'], null);
}, [formErrors, keys]);
const error = get(formErrors, [keys], null);
const fieldName = useMemo(() => {
return getFieldName(keys);
@ -160,10 +157,10 @@ function Inputs({
return disabled;
}, [disabled, isCreatingEntry, isUserAllowedToEditField, isUserAllowedToReadField]);
const options = useMemo(() => generateOptions(fieldSchema.enum || [], isRequired), [
fieldSchema,
isRequired,
]);
const options = useMemo(
() => generateOptions(fieldSchema.enum || [], isRequired),
[fieldSchema, isRequired]
);
const { label, description, placeholder, visible } = metadatas;
@ -177,7 +174,7 @@ function Inputs({
description={description ? { id: description, defaultMessage: description } : null}
intlLabel={{ id: label, defaultMessage: label }}
labelAction={labelAction}
error={errorId}
error={error && formatMessage(error)}
name={keys}
required={isRequired}
/>
@ -215,6 +212,7 @@ function Inputs({
}
queryInfos={queryInfos}
value={value}
error={error && formatMessage(error)}
/>
);
}
@ -228,7 +226,7 @@ function Inputs({
isNullable={inputType === 'bool' && [null, undefined].includes(fieldSchema.default)}
description={description ? { id: description, defaultMessage: description } : null}
disabled={shouldDisableField}
error={errorId}
error={error}
labelAction={labelAction}
contentTypeUID={currentContentTypeLayout.uid}
customInputs={{

View File

@ -144,8 +144,6 @@ const SingleTypeFormWrapper = ({ allLayoutData, children, slug }) => {
const displayErrors = useCallback(
err => {
const errorPayload = err.response.payload;
console.error(errorPayload);
let errorMessage = get(errorPayload, ['message'], 'Bad Request');
// TODO handle errors correctly when back-end ready
@ -178,10 +176,12 @@ const SingleTypeFormWrapper = ({ allLayoutData, children, slug }) => {
} catch (err) {
trackUsageRef.current('didNotDeleteEntry', { error: err, ...trackerProperty });
displayErrors(err);
return Promise.reject(err);
}
},
[slug, toggleNotification, searchToSend]
[slug, displayErrors, toggleNotification, searchToSend]
);
const onDeleteSucceeded = useCallback(() => {
@ -211,12 +211,16 @@ const SingleTypeFormWrapper = ({ allLayoutData, children, slug }) => {
setIsCreatingEntry(false);
dispatch(setStatus('resolved'));
return Promise.resolve(data);
} catch (err) {
trackUsageRef.current('didNotCreateEntry', { error: err, trackerProperty });
displayErrors(err);
dispatch(setStatus('resolved'));
return Promise.reject(err);
}
},
[cleanReceivedData, displayErrors, slug, dispatch, rawQuery, toggleNotification, setCurrentStep]
@ -239,10 +243,14 @@ const SingleTypeFormWrapper = ({ allLayoutData, children, slug }) => {
dispatch(submitSucceeded(cleanReceivedData(data)));
dispatch(setStatus('resolved'));
return Promise.resolve(data);
} catch (err) {
displayErrors(err);
dispatch(setStatus('resolved'));
return Promise.reject(err);
}
}, [cleanReceivedData, displayErrors, slug, searchToSend, dispatch, toggleNotification]);
@ -267,12 +275,16 @@ const SingleTypeFormWrapper = ({ allLayoutData, children, slug }) => {
dispatch(submitSucceeded(cleanReceivedData(data)));
dispatch(setStatus('resolved'));
return Promise.resolve(data);
} catch (err) {
displayErrors(err);
trackUsageRef.current('didNotEditEntry', { error: err, trackerProperty });
dispatch(setStatus('resolved'));
return Promise.reject(err);
}
},
[cleanReceivedData, displayErrors, slug, dispatch, rawQuery, toggleNotification]

View File

@ -118,7 +118,6 @@ const Wysiwyg = ({
)
: '';
const errorMessage = error ? formatMessage({ id: error, defaultMessage: error }) : '';
const label = intlLabel.id
? formatMessage(
{ id: intlLabel.id, defaultMessage: intlLabel.defaultMessage },
@ -157,7 +156,7 @@ const Wysiwyg = ({
disabled={disabled}
isExpandMode={isExpandMode}
editorRef={editorRef}
error={errorMessage}
error={error}
isPreviewMode={isPreviewMode}
name={name}
onChange={onChange}
@ -171,10 +170,10 @@ const Wysiwyg = ({
<Hint description={description} name={name} error={error} />
</Stack>
{errorMessage && (
{error && (
<Box paddingTop={1}>
<Typography variant="pi" textColor="danger600" data-strapi-field-error>
{errorMessage}
{error}
</Typography>
</Box>
)}

View File

@ -642,6 +642,8 @@
"content-manager.success.record.save": "Saved",
"content-manager.success.record.unpublish": "Unpublished",
"content-manager.utils.data-loaded": "The {number, plural, =1 {entry has} other {entries have}} successfully been loaded",
"content-manager.apiError.This attribute must be unique": "{field} must be unique",
"form.button.continue": "Continue",
"form.button.done": "Done",
"global.actions": "Actions",
"global.back": "Back",

View File

@ -60,6 +60,30 @@ const GenericInput = ({
*/
const valueWithEmptyStringFallback = value ?? '';
function getErrorMessage(error) {
if (!error) {
return null;
}
const values = {
...error.values,
};
if (typeof error === 'string') {
return formatMessage({ id: error, defaultMessage: error }, values);
}
return formatMessage(
{
id: error.id,
defaultMessage: error?.defaultMessage ?? error.id,
},
values
);
}
const errorMessage = getErrorMessage(error);
if (CustomInput) {
return (
<CustomInput
@ -68,7 +92,7 @@ const GenericInput = ({
disabled={disabled}
intlLabel={intlLabel}
labelAction={labelAction}
error={error}
error={errorMessage}
name={name}
onChange={onChange}
options={options}
@ -101,8 +125,6 @@ const GenericInput = ({
)
: '';
const errorMessage = error ? formatMessage({ id: error, defaultMessage: error }) : '';
switch (type) {
case 'bool': {
const clearProps = {
@ -440,7 +462,13 @@ GenericInput.propTypes = {
values: PropTypes.object,
}),
disabled: PropTypes.bool,
error: PropTypes.string,
error: PropTypes.oneOfType([
PropTypes.string,
PropTypes.shape({
id: PropTypes.string.isRequired,
defaultMessage: PropTypes.string,
}),
]),
intlLabel: PropTypes.shape({
id: PropTypes.string.isRequired,
defaultMessage: PropTypes.string.isRequired,

View File

@ -1,14 +1,11 @@
import { get } from 'lodash';
const getYupInnerErrors = error => {
const getYupInnerErrors = (error) => {
return get(error, 'inner', []).reduce((acc, curr) => {
acc[
curr.path
.split('[')
.join('.')
.split(']')
.join('')
] = { id: curr.message };
acc[curr.path.split('[').join('.').split(']').join('')] = {
id: curr.message,
defaultMessage: curr.message,
};
return acc;
}, {});

View File

@ -117,7 +117,6 @@ export const MediaLibraryInput = ({
setUploadedFiles(prev => [...prev, ...uploadedFiles]);
};
const errorMessage = error ? formatMessage({ id: error, defaultMessage: error }) : '';
const hint = description
? formatMessage(
{ id: description.id, defaultMessage: description.defaultMessage },
@ -148,7 +147,7 @@ export const MediaLibraryInput = ({
onEditAsset={handleAssetEdit}
onNext={handleNext}
onPrevious={handlePrevious}
error={errorMessage}
error={error}
hint={hint}
required={required}
selectedAssetIndex={selectedIndex}
@ -198,7 +197,7 @@ MediaLibraryInput.propTypes = {
defaultMessage: PropTypes.string,
values: PropTypes.shape({}),
}),
error: PropTypes.shape({ id: PropTypes.string, defaultMessage: PropTypes.string }),
error: PropTypes.string,
intlLabel: PropTypes.shape({ id: PropTypes.string, defaultMessage: PropTypes.string }),
multiple: PropTypes.bool,
onChange: PropTypes.func.isRequired,