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:
, noGradient: true, }), [] ); // Redirect the user to the homepage if he is not allowed to create a document if (shouldRedirectToHomepageWhenCreatingEntry) { return ; } // Redirect the user to the previous page if he is not allowed to read/update a document if (shouldRedirectToHomepageWhenEditingEntry) { return ; } return ( <> {isLoading ? ( ) : ( <>
{children}
)}
); }; 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;