soupette 307bcf9a08 Fix multiple api calls
Signed-off-by: soupette <cyril.lpz@gmail.com>
2020-09-22 17:39:27 +02:00

820 lines
21 KiB
JavaScript

import React, { useCallback, useEffect, useMemo, useReducer, useState } from 'react';
import { cloneDeep, get, isEmpty, isEqual, pick, set } from 'lodash';
import PropTypes from 'prop-types';
import {
Prompt,
Redirect,
useParams,
useLocation,
useHistory,
useRouteMatch,
} from 'react-router-dom';
import {
LoadingIndicatorPage,
request,
useGlobalContext,
useUser,
useUserPermissions,
OverlayBlocker,
} from 'strapi-helper-plugin';
import EditViewDataManagerContext from '../../contexts/EditViewDataManager';
import { generatePermissionsObject, getTrad } from '../../utils';
import pluginId from '../../pluginId';
import init from './init';
import reducer, { initialState } from './reducer';
import {
cleanData,
createDefaultForm,
createYupSchema,
getFieldsActionMatchingPermissions,
getFilesToUpload,
getYupInnerErrors,
removePasswordFieldsFromData,
} from './utils';
const getRequestUrl = path => `/${pluginId}/explorer/${path}`;
const EditViewDataManagerProvider = ({
allLayoutData,
children,
isSingleType,
redirectToPreviousPage,
slug,
}) => {
const { id } = useParams();
const [reducerState, dispatch] = useReducer(reducer, initialState, init);
const { state } = useLocation();
const { push, replace } = useHistory();
// Here in case of a 403 response when fetching data we will either redirect to the previous page
// Or to the homepage if there's no state in the history stack
const from = get(state, 'from', '/');
const {
formErrors,
initialData,
isLoading,
modifiedData,
modifiedDZName,
shouldCheckErrors,
} = reducerState.toJS();
const [isCreatingEntry, setIsCreatingEntry] = useState(id === 'create');
const [status, setStatus] = useState('resolved');
const currentContentTypeLayout = get(allLayoutData, ['contentType'], {});
const shouldNotRunValidations = useMemo(() => {
const hasDraftAndPublish = get(
currentContentTypeLayout,
['schema', 'options', 'draftAndPublish'],
false
);
return hasDraftAndPublish && !initialData.published_at;
}, [currentContentTypeLayout, initialData]);
const {
params: { contentType },
} = useRouteMatch('/plugins/content-manager/:contentType');
// This is used for the readonly mode when updating an entry
const allDynamicZoneFields = useMemo(() => {
const attributes = get(currentContentTypeLayout, ['schema', 'attributes'], {});
const dynamicZoneFields = Object.keys(attributes).filter(attrName => {
return get(attributes, [attrName, 'type'], '') === 'dynamiczone';
});
return dynamicZoneFields;
}, [currentContentTypeLayout]);
const abortController = new AbortController();
const { signal } = abortController;
const { emitEvent, formatMessage } = useGlobalContext();
const userPermissions = useUser();
const generatedPermissions = useMemo(() => generatePermissionsObject(slug), [slug]);
const permissionsToApply = useMemo(() => {
const fieldsToPick = isCreatingEntry ? ['create'] : ['read', 'update'];
return pick(generatedPermissions, fieldsToPick);
}, [isCreatingEntry, generatedPermissions]);
const {
isLoading: isLoadingForPermissions,
allowedActions: { canCreate, canRead, canUpdate },
} = useUserPermissions(permissionsToApply);
const {
createActionAllowedFields,
readActionAllowedFields,
updateActionAllowedFields,
} = useMemo(() => {
return getFieldsActionMatchingPermissions(userPermissions, slug);
}, [userPermissions, slug]);
const shouldRedirectToHomepageWhenCreatingEntry = useMemo(() => {
if (isLoadingForPermissions || isLoading) {
return false;
}
if (!isCreatingEntry) {
return false;
}
if (canCreate === false) {
return true;
}
return false;
}, [isLoadingForPermissions, isCreatingEntry, canCreate, isLoading]);
const shouldRedirectToHomepageWhenEditingEntry = useMemo(() => {
if (isLoadingForPermissions || isLoading) {
return false;
}
if (isCreatingEntry) {
return false;
}
if (canRead === false && canUpdate === false) {
return true;
}
return false;
}, [isLoadingForPermissions, isLoading, isCreatingEntry, canRead, canUpdate]);
useEffect(() => {
if (!isLoading) {
checkFormErrors();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [shouldCheckErrors]);
useEffect(() => {
if (shouldRedirectToHomepageWhenEditingEntry) {
strapi.notification.info(getTrad('permissions.not-allowed.update'));
}
}, [shouldRedirectToHomepageWhenEditingEntry]);
useEffect(() => {
if (shouldRedirectToHomepageWhenCreatingEntry) {
strapi.notification.info(getTrad('permissions.not-allowed.create'));
}
}, [shouldRedirectToHomepageWhenCreatingEntry]);
useEffect(() => {
const fetchData = async () => {
try {
const data = await request(getRequestUrl(`${slug}/${id || ''}`), {
method: 'GET',
signal,
});
dispatch({
type: 'GET_DATA_SUCCEEDED',
data: removePasswordFieldsFromData(
data,
allLayoutData.contentType,
allLayoutData.components
),
});
} catch (err) {
console.log(err);
const resStatus = get(err, 'response.status', null);
if (id && resStatus === 403) {
strapi.notification.info(getTrad('permissions.not-allowed.update'));
push(from);
return;
}
if (id && err.code !== 20) {
strapi.notification.error(`${pluginId}.error.record.fetch`);
}
// Create a single type
if (!id && resStatus === 404) {
setIsCreatingEntry(true);
return;
}
// Not allowed to update or read a ST
if (!id && resStatus === 403) {
strapi.notification.info(getTrad('permissions.not-allowed.update'));
push(from);
}
}
};
const componentsDataStructure = Object.keys(allLayoutData.components).reduce((acc, current) => {
acc[current] = createDefaultForm(
get(allLayoutData, ['components', current, 'schema', 'attributes'], {}),
allLayoutData.components
);
return acc;
}, {});
const contentTypeDataStructure = createDefaultForm(
currentContentTypeLayout.schema.attributes,
allLayoutData.components
);
if (!isLoadingForPermissions) {
// Force state to be cleared when navigation from one entry to another
dispatch({ type: 'RESET_PROPS' });
dispatch({
type: 'SET_DEFAULT_DATA_STRUCTURES',
componentsDataStructure,
contentTypeDataStructure,
});
if (!isCreatingEntry) {
fetchData();
} else {
// Will create default form
dispatch({
type: 'SET_DEFAULT_MODIFIED_DATA_STRUCTURE',
contentTypeDataStructure,
});
}
}
return () => {
abortController.abort();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, slug, isLoadingForPermissions]);
const addComponentToDynamicZone = useCallback((keys, componentUid, shouldCheckErrors = false) => {
emitEvent('didAddComponentToDynamicZone');
dispatch({
type: 'ADD_COMPONENT_TO_DYNAMIC_ZONE',
keys: keys.split('.'),
componentUid,
shouldCheckErrors,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const addNonRepeatableComponentToField = useCallback((keys, componentUid) => {
dispatch({
type: 'ADD_NON_REPEATABLE_COMPONENT_TO_FIELD',
keys: keys.split('.'),
componentUid,
});
}, []);
const addRelation = useCallback(({ target: { name, value } }) => {
dispatch({
type: 'ADD_RELATION',
keys: name.split('.'),
value,
});
}, []);
const addRepeatableComponentToField = useCallback(
(keys, componentUid, shouldCheckErrors = false) => {
dispatch({
type: 'ADD_REPEATABLE_COMPONENT_TO_FIELD',
keys: keys.split('.'),
componentUid,
shouldCheckErrors,
});
},
[]
);
const checkFormErrors = async (dataToSet = {}) => {
const schema = createYupSchema(
currentContentTypeLayout,
{
components: get(allLayoutData, 'components', {}),
},
{ isCreatingEntry, isDraft: shouldNotRunValidations }
);
let errors = {};
const updatedData = cloneDeep(modifiedData);
if (!isEmpty(updatedData)) {
set(updatedData, dataToSet.path, dataToSet.value);
}
try {
// Validate the form using yup
await schema.validate(updatedData, { abortEarly: false });
} catch (err) {
errors = getYupInnerErrors(err);
if (modifiedDZName) {
errors = Object.keys(errors).reduce((acc, current) => {
const dzName = current.split('.')[0];
if (dzName !== modifiedDZName) {
acc[current] = errors[current];
}
return acc;
}, {});
}
}
dispatch({
type: 'SET_ERRORS',
errors,
});
};
const handleChange = useCallback(
({ target: { name, value, type } }, shouldSetInitialValue = false) => {
let inputValue = value;
// Empty string is not a valid date,
// Set the date to null when it's empty
if (type === 'date' && value === '') {
inputValue = null;
}
if (type === 'password' && !value) {
dispatch({
type: 'REMOVE_PASSWORD_FIELD',
keys: name.split('.'),
});
return;
}
// Allow to reset enum
if (type === 'select-one' && value === '') {
inputValue = null;
}
// Allow to reset number input
if (type === 'number' && value === '') {
inputValue = null;
}
dispatch({
type: 'ON_CHANGE',
keys: name.split('.'),
value: inputValue,
shouldSetInitialValue,
});
},
[]
);
const handleSubmit = async e => {
e.preventDefault();
// Create yup schema
const schema = createYupSchema(
currentContentTypeLayout,
{
components: get(allLayoutData, 'components', {}),
},
{ isCreatingEntry, isDraft: shouldNotRunValidations }
);
try {
// Validate the form using yup
await schema.validate(modifiedData, { abortEarly: false });
// Show a loading button in the EditView/Header.js
setStatus('submit-pending');
// Set the loading state in the plugin header
const filesToUpload = getFilesToUpload(modifiedData);
// Remove keys that are not needed
// Clean relations
const cleanedData = cleanData(
cloneDeep(modifiedData),
currentContentTypeLayout,
allLayoutData.components
);
const formData = new FormData();
formData.append('data', JSON.stringify(cleanedData));
Object.keys(filesToUpload).forEach(key => {
const files = filesToUpload[key];
files.forEach(file => {
formData.append(`files.${key}`, file);
});
});
// Change the request helper default headers so we can pass a FormData
const headers = {};
const method = isCreatingEntry ? 'POST' : 'PUT';
let endPoint;
// All endpoints for creation and edition are the same for both content types
// But, the id from the URL didn't exist for the single types.
// So, we use the id of the modified data if this one is setted.
if (isCreatingEntry) {
endPoint = slug;
} else if (modifiedData) {
endPoint = `${slug}/${modifiedData.id}`;
} else {
endPoint = `${slug}/${id}`;
}
emitEvent(isCreatingEntry ? 'willCreateEntry' : 'willEditEntry');
try {
// Time to actually send the data
const res = await request(
getRequestUrl(endPoint),
{
method,
headers,
body: formData,
signal,
},
false,
false
);
emitEvent(isCreatingEntry ? 'didCreateEntry' : 'didEditEntry');
setStatus('resolved');
dispatch({
type: 'SUBMIT_SUCCESS',
});
strapi.notification.success(`${pluginId}.success.record.save`);
setIsCreatingEntry(false);
if (!isSingleType) {
replace(`/plugins/${pluginId}/${contentType}/${slug}/${res.id}`);
}
} catch (err) {
console.error({ err });
setStatus('resolved');
const error = get(
err,
['response', 'payload', 'message', '0', 'messages', '0', 'id'],
'SERVER ERROR'
);
// Handle validations errors from the API
if (error === 'ValidationError') {
const errors = get(err, ['response', 'payload', 'data', '0', 'errors'], {});
const formattedErrors = Object.keys(errors).reduce((acc, current) => {
acc[current] = { id: errors[current][0] };
return acc;
}, {});
dispatch({
type: 'SUBMIT_ERRORS',
errors: formattedErrors,
});
} else {
emitEvent(isCreatingEntry ? 'didNotCreateEntry' : 'didNotEditEntry', {
error: err,
});
}
strapi.notification.error(error);
}
} catch (err) {
console.error({ err });
const errors = getYupInnerErrors(err);
setStatus('resolved');
dispatch({
type: 'SUBMIT_ERRORS',
errors,
});
}
};
const handlePublish = async e => {
e.preventDefault();
// Create yup schema
const schema = createYupSchema(
currentContentTypeLayout,
{
components: get(allLayoutData, 'components', {}),
},
{ isCreatingEntry }
);
try {
// Validate the form using yup
await schema.validate(modifiedData, { abortEarly: false });
// Show a loading button in the EditView/Header.js
setStatus('publish-pending');
try {
// Time to actually send the data
const data = await request(
getRequestUrl(`${slug}/publish/${id || modifiedData.id}`),
{
method: 'POST',
signal,
},
false,
false
);
setStatus('resolved');
dispatch({
type: 'PUBLISH_SUCCESS',
data,
});
strapi.notification.success(`${pluginId}.success.record.publish`);
} catch (err) {
// ---------- @Soupette Is this error handling still mandatory? ----------
// The api error send response.payload.message: 'The error message'.
// There isn't : response.payload.message[0].messages[0].id
console.error({ err });
setStatus('resolved');
const error = get(
err,
['response', 'payload', 'message', '0', 'messages', '0', 'id'],
'SERVER ERROR'
);
if (error === 'ValidationError') {
const errors = get(err, ['response', 'payload', 'data', '0', 'errors'], {});
const formattedErrors = Object.keys(errors).reduce((acc, current) => {
acc[current] = { id: errors[current][0] };
return acc;
}, {});
dispatch({
type: 'PUBLISH_ERRORS',
errors: formattedErrors,
});
}
const errorMessage = get(err, ['response', 'payload', 'message'], 'SERVER ERROR');
strapi.notification.error(errorMessage);
}
} catch (err) {
console.error({ err });
const errors = getYupInnerErrors(err);
setStatus('resolved');
dispatch({
type: 'PUBLISH_ERRORS',
errors,
});
}
};
const handleUnpublish = async e => {
e.preventDefault();
try {
setStatus('unpublish-pending');
const data = await request(
getRequestUrl(`${slug}/unpublish/${id || modifiedData.id}`),
{
method: 'POST',
signal,
},
false,
false
);
setStatus('resolved');
dispatch({
type: 'UNPUBLISH_SUCCESS',
data,
});
strapi.notification.success(`${pluginId}.success.record.unpublish`);
} catch (err) {
console.error({ err });
setStatus('resolved');
const errorMessage = get(err, ['response', 'payload', 'message'], 'SERVER ERROR');
strapi.notification.error(errorMessage);
}
};
const shouldCheckDZErrors = useCallback(
dzName => {
const doesDZHaveError = Object.keys(formErrors).some(key => key.split('.')[0] === dzName);
const shouldCheckErrors = !isEmpty(formErrors) && doesDZHaveError;
return shouldCheckErrors;
},
[formErrors]
);
const moveComponentDown = useCallback(
(dynamicZoneName, currentIndex) => {
emitEvent('changeComponentsOrder');
dispatch({
type: 'MOVE_COMPONENT_DOWN',
dynamicZoneName,
currentIndex,
shouldCheckErrors: shouldCheckDZErrors(dynamicZoneName),
});
// eslint-disable-next-line react-hooks/exhaustive-deps
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[shouldCheckDZErrors]
);
const moveComponentUp = useCallback(
(dynamicZoneName, currentIndex) => {
emitEvent('changeComponentsOrder');
dispatch({
type: 'MOVE_COMPONENT_UP',
dynamicZoneName,
currentIndex,
shouldCheckErrors: shouldCheckDZErrors(dynamicZoneName),
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[shouldCheckDZErrors]
);
const moveComponentField = useCallback((pathToComponent, dragIndex, hoverIndex) => {
dispatch({
type: 'MOVE_COMPONENT_FIELD',
pathToComponent,
dragIndex,
hoverIndex,
});
}, []);
const moveRelation = useCallback((dragIndex, overIndex, name) => {
dispatch({
type: 'MOVE_FIELD',
dragIndex,
overIndex,
keys: name.split('.'),
});
}, []);
const onRemoveRelation = useCallback(keys => {
dispatch({
type: 'REMOVE_RELATION',
keys,
});
}, []);
const removeComponentFromDynamicZone = useCallback((dynamicZoneName, index) => {
emitEvent('removeComponentFromDynamicZone');
dispatch({
type: 'REMOVE_COMPONENT_FROM_DYNAMIC_ZONE',
dynamicZoneName,
index,
shouldCheckErrors: shouldCheckDZErrors(dynamicZoneName),
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const removeComponentFromField = useCallback((keys, componentUid) => {
dispatch({
type: 'REMOVE_COMPONENT_FROM_FIELD',
keys: keys.split('.'),
componentUid,
});
}, []);
const removeRepeatableField = useCallback((keys, componentUid) => {
dispatch({
type: 'REMOVE_REPEATABLE_FIELD',
keys: keys.split('.'),
componentUid,
});
}, []);
const deleteSuccess = () => {
dispatch({
type: 'DELETE_SUCCEEDED',
});
};
const resetData = () => {
dispatch({
type: 'RESET_DATA',
});
};
const clearData = () => {
if (isSingleType) {
setIsCreatingEntry(true);
}
dispatch({
type: 'SET_DEFAULT_MODIFIED_DATA_STRUCTURE',
contentTypeDataStructure: {},
});
};
const triggerFormValidation = () => {
dispatch({
type: 'TRIGGER_FORM_VALIDATION',
});
};
const overlayBlockerParams = useMemo(
() => ({
children: <div />,
noGradient: true,
}),
[]
);
// Redirect the user to the homepage if he is not allowed to create a document
if (shouldRedirectToHomepageWhenCreatingEntry) {
return <Redirect to="/" />;
}
// Redirect the user to the previous page if he is not allowed to read/update a document
if (shouldRedirectToHomepageWhenEditingEntry) {
return <Redirect to={from} />;
}
return (
<EditViewDataManagerContext.Provider
value={{
addComponentToDynamicZone,
addNonRepeatableComponentToField,
addRelation,
addRepeatableComponentToField,
allLayoutData,
allDynamicZoneFields,
checkFormErrors,
clearData,
createActionAllowedFields,
deleteSuccess,
formErrors,
initialData,
isCreatingEntry,
isSingleType,
shouldNotRunValidations,
status,
layout: currentContentTypeLayout,
modifiedData,
moveComponentDown,
moveComponentField,
moveComponentUp,
moveRelation,
onChange: handleChange,
onPublish: handlePublish,
onUnpublish: handleUnpublish,
onRemoveRelation,
readActionAllowedFields,
redirectToPreviousPage,
removeComponentFromDynamicZone,
removeComponentFromField,
removeRepeatableField,
resetData,
slug,
triggerFormValidation,
updateActionAllowedFields,
}}
>
<>
<OverlayBlocker
key="overlayBlocker"
isOpen={status !== 'resolved'}
{...overlayBlockerParams}
/>
{isLoading ? (
<LoadingIndicatorPage />
) : (
<>
<Prompt
when={!isEqual(modifiedData, initialData)}
message={formatMessage({ id: 'global.prompt.unsaved' })}
/>
<form onSubmit={handleSubmit}>{children}</form>
</>
)}
</>
</EditViewDataManagerContext.Provider>
);
};
EditViewDataManagerProvider.defaultProps = {
redirectToPreviousPage: () => {},
};
EditViewDataManagerProvider.propTypes = {
allLayoutData: PropTypes.object.isRequired,
children: PropTypes.node.isRequired,
isSingleType: PropTypes.bool.isRequired,
redirectToPreviousPage: PropTypes.func,
slug: PropTypes.string.isRequired,
};
export default EditViewDataManagerProvider;