diff --git a/packages/strapi-plugin-content-manager/admin/src/containers/CollectionTypeWrapper/index.js b/packages/strapi-plugin-content-manager/admin/src/containers/CollectionTypeWrapper/index.js new file mode 100644 index 0000000000..a55d745704 --- /dev/null +++ b/packages/strapi-plugin-content-manager/admin/src/containers/CollectionTypeWrapper/index.js @@ -0,0 +1,218 @@ +import { memo, useCallback, useEffect, useMemo, useRef, useReducer } from 'react'; +import { useParams, useLocation, useHistory } from 'react-router-dom'; +import { get } from 'lodash'; +import { request, useGlobalContext } from 'strapi-helper-plugin'; +import PropTypes from 'prop-types'; +import { createDefaultForm, getTrad, removePasswordFieldsFromData } from '../../utils'; +import pluginId from '../../pluginId'; +import { getRequestUrl } from './utils'; +import reducer, { initialState } from './reducer'; + +const CollectionTypeWrapper = ({ allLayoutData, children, slug }) => { + const { emitEvent } = useGlobalContext(); + const { push, replace } = useHistory(); + const { state } = useLocation(); + const { id } = useParams(); + const [ + { componentsDataStructure, contentTypeDataStructure, data, isLoading, status }, + dispatch, + ] = useReducer(reducer, initialState); + + const emitEventRef = useRef(emitEvent); + + // 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 isCreatingEntry = id === 'create'; + + const fetchURL = useMemo(() => { + if (isCreatingEntry) { + return null; + } + + return getRequestUrl(`${slug}/${id}`); + }, [slug, id, isCreatingEntry]); + + const cleanReceivedDataFromPasswords = useCallback( + data => { + return removePasswordFieldsFromData( + data, + allLayoutData.contentType, + allLayoutData.components + ); + }, + [allLayoutData.components, allLayoutData.contentType] + ); + + // SET THE DEFAULT LAYOUT the effect is applied when the slug changes + useEffect(() => { + 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( + allLayoutData.contentType.schema.attributes, + allLayoutData.components + ); + + dispatch({ + type: 'SET_DATA_STRUCTURES', + componentsDataStructure, + contentTypeDataStructure, + }); + }, [allLayoutData]); + + useEffect(() => { + const abortController = new AbortController(); + const { signal } = abortController; + + const getData = async signal => { + dispatch({ type: 'GET_DATA' }); + + try { + const data = await request(fetchURL, { method: 'GET', signal }); + + dispatch({ + type: 'GET_DATA_SUCCEEDED', + data: cleanReceivedDataFromPasswords(data), + }); + } catch (err) { + console.error(err); + const resStatus = get(err, 'response.status', null); + + if (resStatus === 404) { + push(from); + + return; + } + + // Not allowed to read a document + if (resStatus === 403) { + strapi.notification.info(getTrad('permissions.not-allowed.update')); + + push(from); + } + } + }; + + if (fetchURL) { + getData(signal); + } else { + dispatch({ type: 'INIT_FORM' }); + } + + return () => { + abortController.abort(); + }; + }, [fetchURL, push, from, cleanReceivedDataFromPasswords]); + + 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 + if (Array.isArray(errorMessage)) { + errorMessage = get(errorMessage, ['0', 'messages', '0', 'id']); + } + + if (typeof errorMessage === 'string') { + strapi.notification.error(errorMessage); + } + }, []); + + const onPost = useCallback( + async (formData, trackerProperty) => { + // const formData = createFormData(data); + const endPoint = getRequestUrl(slug); + + try { + // Show a loading button in the EditView/Header.js && lock the app => no navigation + dispatch({ type: 'SET_STATUS', status: 'submit-pending' }); + + const response = await request( + endPoint, + { method: 'POST', headers: {}, body: formData }, + false, + false + ); + + emitEventRef.current('didCreateEntry', trackerProperty); + strapi.notification.success(getTrad('success.record.save')); + + dispatch({ type: 'SUBMIT_SUCCEEDED', data: response }); + dispatch({ type: 'SET_STATUS', status: 'submit-pending' }); + + replace(`/plugins/${pluginId}/collectionType/${slug}/${response.id}`); + } catch (err) { + displayErrors(err); + emitEventRef.current('didNotCreateEntry', { error: err, trackerProperty }); + // Enable navigation and remove loaders + dispatch({ type: 'SET_STATUS', status: 'resolved' }); + } + }, + [displayErrors, replace, slug] + ); + + const onPut = useCallback( + async (formData, trackerProperty) => { + const endPoint = getRequestUrl(`${slug}/${id}`); + + try { + // Show a loading button in the EditView/Header.js && lock the app => no navigation + dispatch({ type: 'SET_STATUS', status: 'submit-pending' }); + emitEventRef.current('willEditEntry', trackerProperty); + + const response = await request( + endPoint, + { method: 'PUT', headers: {}, body: formData }, + false, + false + ); + + emitEventRef.current('didEditEntry', { trackerProperty }); + + dispatch({ type: 'SUBMIT_SUCCEEDED', data: cleanReceivedDataFromPasswords(response) }); + // Enable navigation and remove loaders + dispatch({ type: 'SET_STATUS', status: 'resolved' }); + } catch (err) { + displayErrors(err); + + emitEventRef.current('didNotEditEntry', { error: err, trackerProperty }); + // Enable navigation and remove loaders + dispatch({ type: 'SET_STATUS', status: 'resolved' }); + } + }, + [cleanReceivedDataFromPasswords, displayErrors, slug, id] + ); + + return children({ + componentsDataStructure, + contentTypeDataStructure, + data, + isCreatingEntry, + isLoadingForData: isLoading, + onPost, + onPut, + status, + }); +}; + +CollectionTypeWrapper.propTypes = { + allLayoutData: PropTypes.shape({ + components: PropTypes.object.isRequired, + contentType: PropTypes.object.isRequired, + }).isRequired, + // allowedActions: PropTypes.object.isRequired, + children: PropTypes.func.isRequired, + slug: PropTypes.string.isRequired, +}; + +export default memo(CollectionTypeWrapper); diff --git a/packages/strapi-plugin-content-manager/admin/src/containers/CollectionTypeWrapper/reducer.js b/packages/strapi-plugin-content-manager/admin/src/containers/CollectionTypeWrapper/reducer.js new file mode 100644 index 0000000000..fe7b9f3bda --- /dev/null +++ b/packages/strapi-plugin-content-manager/admin/src/containers/CollectionTypeWrapper/reducer.js @@ -0,0 +1,50 @@ +/* eslint-disable consistent-return */ +import produce from 'immer'; + +const initialState = { + componentsDataStructure: {}, + contentTypeDataStructure: {}, + isLoading: true, + data: {}, + status: 'resolved', +}; + +const reducer = (state, action) => + produce(state, draftState => { + switch (action.type) { + case 'GET_DATA': { + draftState.isLoading = true; + draftState.data = {}; + break; + } + case 'GET_DATA_SUCCEEDED': { + draftState.isLoading = false; + draftState.data = action.data; + break; + } + case 'INIT_FORM': { + draftState.isLoading = false; + draftState.data = state.contentTypeDataStructure; + break; + } + case 'SET_DATA_STRUCTURES': { + draftState.componentsDataStructure = action.componentsDataStructure; + draftState.contentTypeDataStructure = action.contentTypeDataStructure; + break; + } + case 'SET_STATUS': { + draftState.status = action.status; + break; + } + case 'SUBMIT_SUCCEEDED': { + draftState.data = action.data; + + break; + } + default: + return draftState; + } + }); + +export default reducer; +export { initialState }; diff --git a/packages/strapi-plugin-content-manager/admin/src/containers/CollectionTypeWrapper/utils/getRequestUrl.js b/packages/strapi-plugin-content-manager/admin/src/containers/CollectionTypeWrapper/utils/getRequestUrl.js new file mode 100644 index 0000000000..d6fa66a284 --- /dev/null +++ b/packages/strapi-plugin-content-manager/admin/src/containers/CollectionTypeWrapper/utils/getRequestUrl.js @@ -0,0 +1,5 @@ +import pluginId from '../../../pluginId'; + +const getRequestUrl = path => `/${pluginId}/explorer/${path}`; + +export default getRequestUrl; diff --git a/packages/strapi-plugin-content-manager/admin/src/containers/CollectionTypeWrapper/utils/index.js b/packages/strapi-plugin-content-manager/admin/src/containers/CollectionTypeWrapper/utils/index.js new file mode 100644 index 0000000000..966ba7fe36 --- /dev/null +++ b/packages/strapi-plugin-content-manager/admin/src/containers/CollectionTypeWrapper/utils/index.js @@ -0,0 +1,2 @@ +// eslint-disable-next-line import/prefer-default-export +export { default as getRequestUrl } from './getRequestUrl'; diff --git a/packages/strapi-plugin-content-manager/admin/src/containers/EditView/index.js b/packages/strapi-plugin-content-manager/admin/src/containers/EditView/index.js index ed75960997..b27c9e53d6 100644 --- a/packages/strapi-plugin-content-manager/admin/src/containers/EditView/index.js +++ b/packages/strapi-plugin-content-manager/admin/src/containers/EditView/index.js @@ -7,6 +7,7 @@ import { LiLink, LoadingIndicatorPage, CheckPermissions, + useUser, useUserPermissions, } from 'strapi-helper-plugin'; import { Padded } from '@buffetjs/core'; @@ -20,10 +21,11 @@ import SelectWrapper from '../../components/SelectWrapper'; import { ContentTypeLayoutContext } from '../../contexts'; import { useFetchContentTypeLayout } from '../../hooks'; import { generatePermissionsObject, getInjectedComponents } from '../../utils'; +import CollectionTypeWrapper from '../CollectionTypeWrapper'; import EditViewDataManagerProvider from '../EditViewDataManagerProvider'; import EditViewProvider from '../EditViewProvider'; import Header from './Header'; -import { createAttributesLayout } from './utils'; +import { createAttributesLayout, getFieldsActionMatchingPermissions } from './utils'; import { LinkWrapper, SubWrapper } from './components'; import DeleteLink from './DeleteLink'; import InformationCard from './InformationCard'; @@ -38,6 +40,15 @@ const EditView = ({ components, currentEnvironment, models, plugins, slug }) => const { allowedActions, isLoading: isLoadingForPermissions } = useUserPermissions( viewPermissions ); + const userPermissions = useUser(); + + const { + createActionAllowedFields, + readActionAllowedFields, + updateActionAllowedFields, + } = useMemo(() => { + return getFieldsActionMatchingPermissions(userPermissions, slug); + }, [userPermissions, slug]); const currentContentTypeLayoutData = useMemo(() => get(layout, ['contentType'], {}), [layout]); @@ -77,137 +88,170 @@ const EditView = ({ components, currentEnvironment, models, plugins, slug }) => models={models} > - - - -
-
-
- {formattedContentTypeLayout.map((block, blockIndex) => { - if (isDynamicZone(block)) { - const { - 0: { - 0: { name, fieldSchema, metadatas }, - }, - } = block; + + {({ + componentsDataStructure, + contentTypeDataStructure, + data, + isCreatingEntry, + isLoadingForData, + onPost, + onPut, + status, + }) => { + return ( + + + +
+
+
+ {formattedContentTypeLayout.map((block, blockIndex) => { + if (isDynamicZone(block)) { + const { + 0: { + 0: { name, fieldSchema, metadatas }, + }, + } = block; - return ( - - ); - } + return ( + + ); + } - return ( - - {block.map((fieldsBlock, fieldsBlockIndex) => { return ( -
- {fieldsBlock.map( - ({ name, size, fieldSchema, metadatas }, fieldIndex) => { - const isComponent = fieldSchema.type === 'component'; + + {block.map((fieldsBlock, fieldsBlockIndex) => { + return ( +
+ {fieldsBlock.map( + ({ name, size, fieldSchema, metadatas }, fieldIndex) => { + const isComponent = fieldSchema.type === 'component'; - if (isComponent) { - const { component, max, min, repeatable = false } = fieldSchema; - const componentUid = fieldSchema.component; + if (isComponent) { + const { + component, + max, + min, + repeatable = false, + } = fieldSchema; + const componentUid = fieldSchema.component; - return ( - - ); - } - - return ( -
- + ); } - fieldSchema={fieldSchema} - keys={name} - metadatas={metadatas} - /> -
+ + return ( +
+ +
+ ); + } + )} +
+ ); + })} +
+ ); + })} +
+
+ + + {currentContentTypeLayoutData.layouts.editRelations.length > 0 && ( + +
+ {currentContentTypeLayoutData.layouts.editRelations.map( + ({ name, fieldSchema, metadatas, queryInfos }) => { + return ( + ); } )}
- ); - })} - - ); - })} -
-
- - - {currentContentTypeLayoutData.layouts.editRelations.length > 0 && ( - -
- {currentContentTypeLayoutData.layouts.editRelations.map( - ({ name, fieldSchema, metadatas, queryInfos }) => { - return ( - - ); - } + )} + +
    + + { + // emitEvent('willEditContentTypeLayoutFromEditView'); + }} + /> + + {getInjectedComponents( + 'editView', + 'right.links', + plugins, + currentEnvironment, + slug + )} + {allowedActions.canDelete && } +
+
-
- )} - -
    - - { - // emitEvent('willEditContentTypeLayoutFromEditView'); - }} - /> - - {getInjectedComponents( - 'editView', - 'right.links', - plugins, - currentEnvironment, - slug - )} - {allowedActions.canDelete && } -
-
-
-
- - +
+ + + ); + }} + ); diff --git a/packages/strapi-plugin-content-manager/admin/src/containers/EditView/utils/getFieldsActionMatchingPermissions.js b/packages/strapi-plugin-content-manager/admin/src/containers/EditView/utils/getFieldsActionMatchingPermissions.js new file mode 100644 index 0000000000..2b2e533b84 --- /dev/null +++ b/packages/strapi-plugin-content-manager/admin/src/containers/EditView/utils/getFieldsActionMatchingPermissions.js @@ -0,0 +1,23 @@ +import { uniq, flatMap } from 'lodash'; +import { findMatchingPermissions } from 'strapi-helper-plugin'; + +const getFieldsActionMatchingPermissions = (userPermissions, slug) => { + const getMatchingPermissions = action => { + const matched = findMatchingPermissions(userPermissions, [ + { + action: `plugins::content-manager.explorer.${action}`, + subject: slug, + }, + ]); + + return uniq(flatMap(matched, 'fields')); + }; + + return { + createActionAllowedFields: getMatchingPermissions('create'), + readActionAllowedFields: getMatchingPermissions('read'), + updateActionAllowedFields: getMatchingPermissions('update'), + }; +}; + +export default getFieldsActionMatchingPermissions; diff --git a/packages/strapi-plugin-content-manager/admin/src/containers/EditView/utils/index.js b/packages/strapi-plugin-content-manager/admin/src/containers/EditView/utils/index.js index 670d33c1d9..4412abb24d 100644 --- a/packages/strapi-plugin-content-manager/admin/src/containers/EditView/utils/index.js +++ b/packages/strapi-plugin-content-manager/admin/src/containers/EditView/utils/index.js @@ -1,2 +1,3 @@ // eslint-disable-next-line import/prefer-default-export export { default as createAttributesLayout } from './createAttributesLayout'; +export { default as getFieldsActionMatchingPermissions } from './getFieldsActionMatchingPermissions'; diff --git a/packages/strapi-plugin-content-manager/admin/src/containers/EditView/utils/tests/getFieldsActionMatchingPermissions.test.js b/packages/strapi-plugin-content-manager/admin/src/containers/EditView/utils/tests/getFieldsActionMatchingPermissions.test.js new file mode 100644 index 0000000000..bc82fc04d7 --- /dev/null +++ b/packages/strapi-plugin-content-manager/admin/src/containers/EditView/utils/tests/getFieldsActionMatchingPermissions.test.js @@ -0,0 +1,18 @@ +import getFieldsActionMatchingPermissions from '../getFieldsActionMatchingPermissions'; +import { testData } from '../../../../testUtils'; + +const { permissions } = testData; + +describe('CONTENT MANAGER | CONTAINERS | EditViewDataManager | utils | getFieldsActionMatchingPermissions', () => { + it('should return an object with all the allowed action for the fields', () => { + const expected = { + createActionAllowedFields: [], + readActionAllowedFields: ['name', 'description', 'test'], + updateActionAllowedFields: ['name', 'description'], + }; + + expect(getFieldsActionMatchingPermissions(permissions, 'application::article.article')).toEqual( + expected + ); + }); +}); diff --git a/packages/strapi-plugin-content-manager/admin/src/containers/EditView/utils/tests/testData.js b/packages/strapi-plugin-content-manager/admin/src/containers/EditView/utils/tests/testData.js new file mode 100644 index 0000000000..e2be6e6dc4 --- /dev/null +++ b/packages/strapi-plugin-content-manager/admin/src/containers/EditView/utils/tests/testData.js @@ -0,0 +1,244 @@ +const testData = { + contentType: { + apiID: 'test', + schema: { + attributes: { + created_at: { type: 'timestamp' }, + dz: { type: 'dynamiczone', components: ['compos.test-compo', 'compos.sub-compo'] }, + id: { type: 'integer' }, + name: { type: 'string' }, + notrepeatable: { + type: 'component', + repeatable: false, + component: 'compos.test-compo', + }, + password: { type: 'password' }, + repeatable: { type: 'component', repeatable: true, component: 'compos.test-compo' }, + updated_at: { type: 'timestamp' }, + }, + }, + }, + components: { + 'compos.sub-compo': { + uid: 'compos.sub-compo', + category: 'compos', + schema: { + attributes: { + id: { type: 'integer' }, + name: { type: 'string' }, + password: { type: 'password' }, + }, + }, + }, + 'compos.test-compo': { + uid: 'compos.test-compo', + category: 'compos', + schema: { + attributes: { + id: { type: 'integer' }, + name: { type: 'string' }, + password: { type: 'password' }, + subcomponotrepeatable: { + type: 'component', + repeatable: false, + component: 'compos.sub-compo', + }, + subrepeatable: { + type: 'component', + repeatable: true, + component: 'compos.sub-compo', + }, + }, + }, + }, + }, + modifiedData: { + created_at: '2020-04-28T13:22:13.033Z', + dz: [ + { __component: 'compos.sub-compo', id: 7, name: 'name', password: 'password' }, + { + id: 4, + name: 'name', + password: 'password', + subcomponotrepeatable: null, + subrepeatable: [], + __component: 'compos.test-compo', + }, + { + id: 5, + name: 'name', + password: 'password', + subcomponotrepeatable: { id: 9, name: 'name', password: 'password' }, + subrepeatable: [{ id: 8, name: 'name', password: 'password' }], + __component: 'compos.test-compo', + }, + { + id: 6, + name: null, + password: null, + subcomponotrepeatable: null, + subrepeatable: [], + __component: 'compos.test-compo', + }, + ], + id: 1, + name: 'name', + notrepeatable: { + id: 1, + name: 'name', + password: 'password', + subcomponotrepeatable: { id: 4, name: 'name', password: 'password' }, + subrepeatable: [ + { id: 1, name: 'name', password: 'password' }, + { id: 2, name: 'name', password: 'password' }, + { id: 3, name: 'name', password: 'password' }, + ], + }, + password: 'password', + repeatable: [ + { + id: 2, + name: 'name', + password: 'password', + subrepeatable: [{ id: 5, name: 'name', password: 'password' }], + subcomponotrepeatable: { id: 6, name: 'name', password: 'password' }, + }, + { + id: 3, + name: 'name', + password: 'password', + subrepeatable: [], + subcomponotrepeatable: null, + }, + ], + updated_at: '2020-04-28T13:22:13.033Z', + }, + expectedModifiedData: { + created_at: '2020-04-28T13:22:13.033Z', + dz: [ + { __component: 'compos.sub-compo', id: 7, name: 'name' }, + { + id: 4, + name: 'name', + subcomponotrepeatable: null, + subrepeatable: [], + __component: 'compos.test-compo', + }, + { + id: 5, + name: 'name', + subcomponotrepeatable: { id: 9, name: 'name' }, + subrepeatable: [{ id: 8, name: 'name' }], + __component: 'compos.test-compo', + }, + { + id: 6, + name: null, + subcomponotrepeatable: null, + subrepeatable: [], + __component: 'compos.test-compo', + }, + ], + id: 1, + name: 'name', + notrepeatable: { + id: 1, + name: 'name', + subcomponotrepeatable: { id: 4, name: 'name' }, + subrepeatable: [ + { id: 1, name: 'name' }, + { id: 2, name: 'name' }, + { id: 3, name: 'name' }, + ], + }, + repeatable: [ + { + id: 2, + name: 'name', + subrepeatable: [{ id: 5, name: 'name' }], + subcomponotrepeatable: { id: 6, name: 'name' }, + }, + { + id: 3, + name: 'name', + subrepeatable: [], + subcomponotrepeatable: null, + }, + ], + updated_at: '2020-04-28T13:22:13.033Z', + }, +}; + +const permissions = [ + { + id: 11, + action: 'plugins::content-manager.explorer.read', + subject: 'application::article.article', + fields: ['name', 'description'], + conditions: ['admin::is-creator'], + }, + { + id: 12, + action: 'plugins::content-manager.explorer.update', + subject: 'application::article.article', + fields: ['name', 'description'], + conditions: ['admin::is-creator'], + }, + { + id: 22, + action: 'plugins::content-manager.explorer.read', + subject: 'plugins::users-permissions.user', + fields: [ + 'username', + 'email', + 'provider', + 'password', + 'resetPasswordToken', + 'confirmed', + 'blocked', + 'role', + ], + conditions: [], + }, + { + id: 24, + action: 'plugins::content-manager.explorer.update', + subject: 'plugins::users-permissions.user', + fields: [ + 'username', + 'email', + 'provider', + 'password', + 'resetPasswordToken', + 'confirmed', + 'blocked', + 'role', + ], + conditions: [], + }, + { + id: 28, + action: 'plugins::upload.read', + subject: null, + fields: null, + conditions: [], + }, + { + id: 39, + action: 'plugins::users-permissions.roles.update', + subject: null, + fields: null, + conditions: [], + }, + + { + id: 63, + action: 'plugins::content-manager.explorer.read', + subject: 'application::article.article', + fields: ['name', 'description', 'test'], + conditions: [], + }, +]; + +export default testData; +export { permissions }; diff --git a/packages/strapi-plugin-content-manager/admin/src/containers/EditViewDataManagerProvider/index.js b/packages/strapi-plugin-content-manager/admin/src/containers/EditViewDataManagerProvider/index.js index 363a2c479f..09a0165e79 100644 --- a/packages/strapi-plugin-content-manager/admin/src/containers/EditViewDataManagerProvider/index.js +++ b/packages/strapi-plugin-content-manager/admin/src/containers/EditViewDataManagerProvider/index.js @@ -1,28 +1,19 @@ import React, { useCallback, useEffect, useMemo, useRef, useReducer, useState } from 'react'; import { cloneDeep, get, isEmpty, isEqual, set } from 'lodash'; import PropTypes from 'prop-types'; -import { Prompt, Redirect, useParams, useLocation, useHistory } from 'react-router-dom'; +import { Prompt, Redirect, useLocation } from 'react-router-dom'; import { LoadingIndicatorPage, request, useGlobalContext, - useUser, OverlayBlocker, } from 'strapi-helper-plugin'; import EditViewDataManagerContext from '../../contexts/EditViewDataManager'; -import { getTrad } from '../../utils'; +import { getTrad, removePasswordFieldsFromData } 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'; +import { cleanData, createYupSchema, getFilesToUpload, getYupInnerErrors } from './utils'; const getRequestUrl = path => `/${pluginId}/explorer/${path}`; @@ -30,17 +21,26 @@ const EditViewDataManagerProvider = ({ allLayoutData, allowedActions: { canCreate, canRead, canUpdate }, children, + componentsDataStructure, + contentTypeDataStructure, + createActionAllowedFields, + initialValues, + isCreatingEntry, + isLoadingForData, isSingleType, + onPost, + onPut, + readActionAllowedFields, + // Not sure this is needed anymore redirectToPreviousPage, slug, + status, + updateActionAllowedFields, }) => { const [reducerState, dispatch] = useReducer(reducer, initialState, init); - const { id } = useParams(); - const isCreatingEntry = id === 'create'; - 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', '/'); @@ -48,16 +48,13 @@ const EditViewDataManagerProvider = ({ const { formErrors, initialData, - isLoading, modifiedData, modifiedDZName, shouldCheckErrors, } = reducerState.toJS(); - // This isCreatingEntry logic will be needed, but it needs to be passed from the parent - // TODO: this should be in the reducer - const [status, setStatus] = useState('resolved'); + const [, setStatus] = useState('resolved'); const currentContentTypeLayout = get(allLayoutData, ['contentType'], {}); @@ -71,15 +68,6 @@ const EditViewDataManagerProvider = ({ const { emitEvent, formatMessage } = useGlobalContext(); const emitEventRef = useRef(emitEvent); - const userPermissions = useUser(); - - const { - createActionAllowedFields, - readActionAllowedFields, - updateActionAllowedFields, - } = useMemo(() => { - return getFieldsActionMatchingPermissions(userPermissions, slug); - }, [userPermissions, slug]); const cleanReceivedDataFromPasswords = useCallback( data => { @@ -93,7 +81,7 @@ const EditViewDataManagerProvider = ({ ); const shouldRedirectToHomepageWhenCreatingEntry = useMemo(() => { - if (isLoading) { + if (isLoadingForData) { return false; } @@ -106,10 +94,10 @@ const EditViewDataManagerProvider = ({ } return false; - }, [isCreatingEntry, canCreate, isLoading]); + }, [isCreatingEntry, canCreate, isLoadingForData]); const shouldRedirectToHomepageWhenEditingEntry = useMemo(() => { - if (isLoading) { + if (isLoadingForData) { return false; } @@ -122,11 +110,11 @@ const EditViewDataManagerProvider = ({ } return false; - }, [isLoading, isCreatingEntry, canRead, canUpdate]); + }, [isLoadingForData, isCreatingEntry, canRead, canUpdate]); // TODO check this effect if it is really needed (not prio) useEffect(() => { - if (!isLoading) { + if (!isLoadingForData) { checkFormErrors(); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -144,94 +132,20 @@ const EditViewDataManagerProvider = ({ } }, [shouldRedirectToHomepageWhenCreatingEntry]); - // Reset all props when changing from one ct to another useEffect(() => { - dispatch({ type: 'RESET_PROPS' }); - }, [slug]); - - // Reset all props when navigating from one entry to another in the same ct - useEffect(() => { - dispatch({ type: 'RESET_FORM' }); - }, [id]); - - // SET THE DEFAULT LAYOUT the effect is applied when the slug changes - useEffect(() => { - 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 - ); - dispatch({ type: 'SET_DEFAULT_DATA_STRUCTURES', componentsDataStructure, contentTypeDataStructure, }); - }, [allLayoutData, currentContentTypeLayout.schema.attributes]); + }, [componentsDataStructure, contentTypeDataStructure]); useEffect(() => { - if (isCreatingEntry) { - dispatch({ type: 'INITIALIZE_FORM' }); - } - }, [isCreatingEntry]); - - const fetchURL = useMemo(() => { - if (isCreatingEntry) { - return null; - } - - return getRequestUrl(`${slug}/${id}`); - }, [slug, id, isCreatingEntry]); - - useEffect(() => { - const abortController = new AbortController(); - const { signal } = abortController; - - const getData = async signal => { - dispatch({ type: 'GET_DATA' }); - - try { - const data = await request(fetchURL, { method: 'GET', signal }); - - dispatch({ - type: 'GET_DATA_SUCCEEDED', - data: cleanReceivedDataFromPasswords(data), - }); - } catch (err) { - console.error(err); - const resStatus = get(err, 'response.status', null); - - if (resStatus === 404) { - push(from); - - return; - } - - // Not allowed to read a document - if (resStatus === 403) { - strapi.notification.info(getTrad('permissions.not-allowed.update')); - - push(from); - } - } - }; - - if (fetchURL) { - getData(signal); - } - - return () => { - abortController.abort(); - }; - }, [fetchURL, push, from, cleanReceivedDataFromPasswords]); + dispatch({ + type: 'INIT_FORM', + initialValues, + }); + }, [initialValues]); const addComponentToDynamicZone = useCallback((keys, componentUid, shouldCheckErrors = false) => { emitEventRef.current('didAddComponentToDynamicZone'); @@ -367,7 +281,7 @@ const EditViewDataManagerProvider = ({ const createFormData = useCallback( data => { // Set the loading state in the plugin header - const filesToUpload = getFilesToUpload(modifiedData); + const filesToUpload = getFilesToUpload(data); // Remove keys that are not needed // Clean relations const cleanedData = cleanData(data, currentContentTypeLayout, allLayoutData.components); @@ -389,7 +303,7 @@ const EditViewDataManagerProvider = ({ return formData; }, - [allLayoutData.components, currentContentTypeLayout, modifiedData] + [allLayoutData.components, currentContentTypeLayout] ); const trackerProperty = useMemo(() => { @@ -416,74 +330,6 @@ const EditViewDataManagerProvider = ({ } }, []); - const onPost = useCallback( - async data => { - const formData = createFormData(data); - const endPoint = getRequestUrl(slug); - - try { - // Show a loading button in the EditView/Header.js && lock the app => no navigation - setStatus('submit-pending'); - - const response = await request( - endPoint, - { method: 'POST', headers: {}, body: formData }, - false, - false - ); - - emitEventRef.current('didCreateEntry', trackerProperty); - strapi.notification.success(getTrad('success.record.save')); - // Enable navigation and remove loaders - setStatus('resolved'); - - dispatch({ type: 'SUBMIT_SUCCEEDED', data: response }); - - replace(`/plugins/${pluginId}/collectionType/${slug}/${response.id}`); - } catch (err) { - displayErrors(err); - emitEventRef.current('didNotCreateEntry', { error: err, trackerProperty }); - // Enable navigation and remove loaders - setStatus('resolved'); - } - }, - [createFormData, displayErrors, replace, slug, trackerProperty] - ); - - const onPut = useCallback( - async data => { - const formData = createFormData(data); - const endPoint = getRequestUrl(`${slug}/${data.id}`); - - try { - // Show a loading button in the EditView/Header.js && lock the app => no navigation - setStatus('submit-pending'); - emitEventRef.current('willEditEntry', trackerProperty); - - const response = await request( - endPoint, - { method: 'PUT', headers: {}, body: formData }, - false, - false - ); - - emitEventRef.current('didEditEntry', { trackerProperty }); - - // Enable navigation and remove loaders - setStatus('resolved'); - - dispatch({ type: 'SUBMIT_SUCCEEDED', data: cleanReceivedDataFromPasswords(response) }); - } catch (err) { - displayErrors(err); - - emitEventRef.current('didNotEditEntry', { error: err, trackerProperty }); - // Enable navigation and remove loaders - setStatus('resolved'); - } - }, - [cleanReceivedDataFromPasswords, createFormData, displayErrors, slug, trackerProperty] - ); - const handleSubmit = useCallback( async e => { e.preventDefault(); @@ -492,11 +338,16 @@ const EditViewDataManagerProvider = ({ // First validate the form try { await yupSchema.validate(modifiedData, { abortEarly: false }); + console.log({ modifiedData }); + + const formData = createFormData(modifiedData); + + // console.log() if (isCreatingEntry) { - onPost(modifiedData); + onPost(formData, trackerProperty); } else { - onPut(modifiedData); + onPut(formData, trackerProperty); } } catch (err) { console.error('ValidationError'); @@ -510,7 +361,7 @@ const EditViewDataManagerProvider = ({ errors, }); }, - [isCreatingEntry, modifiedData, onPost, onPut, yupSchema] + [createFormData, isCreatingEntry, modifiedData, onPost, onPut, trackerProperty, yupSchema] ); const handlePublish = useCallback(async () => { @@ -802,7 +653,7 @@ const EditViewDataManagerProvider = ({ isOpen={status !== 'resolved'} {...overlayBlockerParams} /> - {isLoading ? ( + {isLoadingForData ? ( ) : ( <> @@ -825,10 +676,21 @@ EditViewDataManagerProvider.defaultProps = { EditViewDataManagerProvider.propTypes = { allLayoutData: PropTypes.object.isRequired, allowedActions: PropTypes.object.isRequired, - children: PropTypes.node.isRequired, + children: PropTypes.arrayOf(PropTypes.element).isRequired, + componentsDataStructure: PropTypes.object.isRequired, + contentTypeDataStructure: PropTypes.object.isRequired, + createActionAllowedFields: PropTypes.array.isRequired, + initialValues: PropTypes.object.isRequired, + isCreatingEntry: PropTypes.bool.isRequired, + isLoadingForData: PropTypes.bool.isRequired, isSingleType: PropTypes.bool.isRequired, + onPost: PropTypes.func.isRequired, + onPut: PropTypes.func.isRequired, + readActionAllowedFields: PropTypes.array.isRequired, redirectToPreviousPage: PropTypes.func, slug: PropTypes.string.isRequired, + status: PropTypes.string.isRequired, + updateActionAllowedFields: PropTypes.array.isRequired, }; export default EditViewDataManagerProvider; diff --git a/packages/strapi-plugin-content-manager/admin/src/containers/EditViewDataManagerProvider/reducer.js b/packages/strapi-plugin-content-manager/admin/src/containers/EditViewDataManagerProvider/reducer.js index 48a7c36de4..c58929f743 100644 --- a/packages/strapi-plugin-content-manager/admin/src/containers/EditViewDataManagerProvider/reducer.js +++ b/packages/strapi-plugin-content-manager/admin/src/containers/EditViewDataManagerProvider/reducer.js @@ -84,6 +84,15 @@ const reducer = (state, action) => { .update('initialData', () => fromJS(action.data)) .update('modifiedData', () => fromJS(action.data)) .update('isLoading', () => false); + + case 'INIT_FORM': { + return state + .update('formErrors', () => fromJS({})) + .update('initialData', () => fromJS(action.initialValues)) + .update('modifiedData', () => fromJS(action.initialValues)) + .update('modifiedDZName', () => null) + .update('shouldCheckErrors', () => false); + } case 'INITIALIZE_FORM': { return state .update('isLoading', () => false) diff --git a/packages/strapi-plugin-content-manager/admin/src/containers/EditViewDataManagerProvider/utils/index.js b/packages/strapi-plugin-content-manager/admin/src/containers/EditViewDataManagerProvider/utils/index.js index 4a6e1ee11d..10b544205f 100644 --- a/packages/strapi-plugin-content-manager/admin/src/containers/EditViewDataManagerProvider/utils/index.js +++ b/packages/strapi-plugin-content-manager/admin/src/containers/EditViewDataManagerProvider/utils/index.js @@ -1,7 +1,7 @@ export { default as cleanData } from './cleanData'; -export { default as createDefaultForm } from './createDefaultForm'; -export { default as getFieldsActionMatchingPermissions } from './getFieldsActionMatchingPermissions'; +// export { default as createDefaultForm } from './createDefaultForm'; +// export { default as getFieldsActionMatchingPermissions } from './getFieldsActionMatchingPermissions'; export { default as getFilesToUpload } from './getFilesToUpload'; export { default as getYupInnerErrors } from './getYupInnerErrors'; export { default as createYupSchema } from './schema'; -export { default as removePasswordFieldsFromData } from './removePasswordFieldsFromData'; +// export { default as removePasswordFieldsFromData } from './removePasswordFieldsFromData'; diff --git a/packages/strapi-plugin-content-manager/admin/src/testUtils/data.js b/packages/strapi-plugin-content-manager/admin/src/testUtils/data.js new file mode 100644 index 0000000000..e2be6e6dc4 --- /dev/null +++ b/packages/strapi-plugin-content-manager/admin/src/testUtils/data.js @@ -0,0 +1,244 @@ +const testData = { + contentType: { + apiID: 'test', + schema: { + attributes: { + created_at: { type: 'timestamp' }, + dz: { type: 'dynamiczone', components: ['compos.test-compo', 'compos.sub-compo'] }, + id: { type: 'integer' }, + name: { type: 'string' }, + notrepeatable: { + type: 'component', + repeatable: false, + component: 'compos.test-compo', + }, + password: { type: 'password' }, + repeatable: { type: 'component', repeatable: true, component: 'compos.test-compo' }, + updated_at: { type: 'timestamp' }, + }, + }, + }, + components: { + 'compos.sub-compo': { + uid: 'compos.sub-compo', + category: 'compos', + schema: { + attributes: { + id: { type: 'integer' }, + name: { type: 'string' }, + password: { type: 'password' }, + }, + }, + }, + 'compos.test-compo': { + uid: 'compos.test-compo', + category: 'compos', + schema: { + attributes: { + id: { type: 'integer' }, + name: { type: 'string' }, + password: { type: 'password' }, + subcomponotrepeatable: { + type: 'component', + repeatable: false, + component: 'compos.sub-compo', + }, + subrepeatable: { + type: 'component', + repeatable: true, + component: 'compos.sub-compo', + }, + }, + }, + }, + }, + modifiedData: { + created_at: '2020-04-28T13:22:13.033Z', + dz: [ + { __component: 'compos.sub-compo', id: 7, name: 'name', password: 'password' }, + { + id: 4, + name: 'name', + password: 'password', + subcomponotrepeatable: null, + subrepeatable: [], + __component: 'compos.test-compo', + }, + { + id: 5, + name: 'name', + password: 'password', + subcomponotrepeatable: { id: 9, name: 'name', password: 'password' }, + subrepeatable: [{ id: 8, name: 'name', password: 'password' }], + __component: 'compos.test-compo', + }, + { + id: 6, + name: null, + password: null, + subcomponotrepeatable: null, + subrepeatable: [], + __component: 'compos.test-compo', + }, + ], + id: 1, + name: 'name', + notrepeatable: { + id: 1, + name: 'name', + password: 'password', + subcomponotrepeatable: { id: 4, name: 'name', password: 'password' }, + subrepeatable: [ + { id: 1, name: 'name', password: 'password' }, + { id: 2, name: 'name', password: 'password' }, + { id: 3, name: 'name', password: 'password' }, + ], + }, + password: 'password', + repeatable: [ + { + id: 2, + name: 'name', + password: 'password', + subrepeatable: [{ id: 5, name: 'name', password: 'password' }], + subcomponotrepeatable: { id: 6, name: 'name', password: 'password' }, + }, + { + id: 3, + name: 'name', + password: 'password', + subrepeatable: [], + subcomponotrepeatable: null, + }, + ], + updated_at: '2020-04-28T13:22:13.033Z', + }, + expectedModifiedData: { + created_at: '2020-04-28T13:22:13.033Z', + dz: [ + { __component: 'compos.sub-compo', id: 7, name: 'name' }, + { + id: 4, + name: 'name', + subcomponotrepeatable: null, + subrepeatable: [], + __component: 'compos.test-compo', + }, + { + id: 5, + name: 'name', + subcomponotrepeatable: { id: 9, name: 'name' }, + subrepeatable: [{ id: 8, name: 'name' }], + __component: 'compos.test-compo', + }, + { + id: 6, + name: null, + subcomponotrepeatable: null, + subrepeatable: [], + __component: 'compos.test-compo', + }, + ], + id: 1, + name: 'name', + notrepeatable: { + id: 1, + name: 'name', + subcomponotrepeatable: { id: 4, name: 'name' }, + subrepeatable: [ + { id: 1, name: 'name' }, + { id: 2, name: 'name' }, + { id: 3, name: 'name' }, + ], + }, + repeatable: [ + { + id: 2, + name: 'name', + subrepeatable: [{ id: 5, name: 'name' }], + subcomponotrepeatable: { id: 6, name: 'name' }, + }, + { + id: 3, + name: 'name', + subrepeatable: [], + subcomponotrepeatable: null, + }, + ], + updated_at: '2020-04-28T13:22:13.033Z', + }, +}; + +const permissions = [ + { + id: 11, + action: 'plugins::content-manager.explorer.read', + subject: 'application::article.article', + fields: ['name', 'description'], + conditions: ['admin::is-creator'], + }, + { + id: 12, + action: 'plugins::content-manager.explorer.update', + subject: 'application::article.article', + fields: ['name', 'description'], + conditions: ['admin::is-creator'], + }, + { + id: 22, + action: 'plugins::content-manager.explorer.read', + subject: 'plugins::users-permissions.user', + fields: [ + 'username', + 'email', + 'provider', + 'password', + 'resetPasswordToken', + 'confirmed', + 'blocked', + 'role', + ], + conditions: [], + }, + { + id: 24, + action: 'plugins::content-manager.explorer.update', + subject: 'plugins::users-permissions.user', + fields: [ + 'username', + 'email', + 'provider', + 'password', + 'resetPasswordToken', + 'confirmed', + 'blocked', + 'role', + ], + conditions: [], + }, + { + id: 28, + action: 'plugins::upload.read', + subject: null, + fields: null, + conditions: [], + }, + { + id: 39, + action: 'plugins::users-permissions.roles.update', + subject: null, + fields: null, + conditions: [], + }, + + { + id: 63, + action: 'plugins::content-manager.explorer.read', + subject: 'application::article.article', + fields: ['name', 'description', 'test'], + conditions: [], + }, +]; + +export default testData; +export { permissions }; diff --git a/packages/strapi-plugin-content-manager/admin/src/testUtils/index.js b/packages/strapi-plugin-content-manager/admin/src/testUtils/index.js new file mode 100644 index 0000000000..650eb45fea --- /dev/null +++ b/packages/strapi-plugin-content-manager/admin/src/testUtils/index.js @@ -0,0 +1,2 @@ +// eslint-disable-next-line import/prefer-default-export +export { default as testData } from './data'; diff --git a/packages/strapi-plugin-content-manager/admin/src/utils/createDefaultForm.js b/packages/strapi-plugin-content-manager/admin/src/utils/createDefaultForm.js new file mode 100644 index 0000000000..ca178cb9fb --- /dev/null +++ b/packages/strapi-plugin-content-manager/admin/src/utils/createDefaultForm.js @@ -0,0 +1,54 @@ +import { get } from 'lodash'; + +const createDefaultForm = (attributes, allComponentsSchema) => { + return Object.keys(attributes).reduce((acc, current) => { + const attribute = get(attributes, [current], {}); + const { default: defaultValue, component, type, required, min, repeatable } = attribute; + + if (type === 'json') { + acc[current] = null; + } + + if (type === 'json' && required === true) { + acc[current] = {}; + } + + if (defaultValue !== undefined) { + acc[current] = defaultValue; + } + + if (type === 'component') { + const currentComponentSchema = get( + allComponentsSchema, + [component, 'schema', 'attributes'], + {} + ); + const currentComponentDefaultForm = createDefaultForm( + currentComponentSchema, + allComponentsSchema + ); + + if (required === true) { + acc[current] = repeatable === true ? [] : currentComponentDefaultForm; + } + + if (min && repeatable === true && required) { + acc[current] = []; + + for (let i = 0; i < min; i += 1) { + acc[current].push(currentComponentDefaultForm); + } + } + } + + if (type === 'dynamiczone') { + if (required === true) { + acc[current] = []; + } + } + + return acc; + }, {}); +}; + +export default createDefaultForm; diff --git a/packages/strapi-plugin-content-manager/admin/src/utils/index.js b/packages/strapi-plugin-content-manager/admin/src/utils/index.js index 21eeebd401..fed39d0f49 100644 --- a/packages/strapi-plugin-content-manager/admin/src/utils/index.js +++ b/packages/strapi-plugin-content-manager/admin/src/utils/index.js @@ -1,4 +1,5 @@ export { default as checkIfAttributeIsDisplayable } from './checkIfAttributeIsDisplayable'; +export { default as createDefaultForm } from './createDefaultForm'; export { default as dateFormats } from './dateFormats'; export { default as generatePermissionsObject } from './generatePermissionsObject'; export { default as getInjectedComponents } from './getComponents'; @@ -6,3 +7,4 @@ export { default as getFieldName } from './getFieldName'; export { default as getRequestUrl } from './getRequestUrl'; export { default as getTrad } from './getTrad'; export { default as ItemTypes } from './ItemTypes'; +export { default as removePasswordFieldsFromData } from './removePasswordFieldsFromData'; diff --git a/packages/strapi-plugin-content-manager/admin/src/containers/EditViewDataManagerProvider/utils/removePasswordFieldsFromData.js b/packages/strapi-plugin-content-manager/admin/src/utils/removePasswordFieldsFromData.js similarity index 100% rename from packages/strapi-plugin-content-manager/admin/src/containers/EditViewDataManagerProvider/utils/removePasswordFieldsFromData.js rename to packages/strapi-plugin-content-manager/admin/src/utils/removePasswordFieldsFromData.js diff --git a/packages/strapi-plugin-content-manager/admin/src/containers/EditViewDataManagerProvider/utils/tests/removePasswordFieldsFromData.test.js b/packages/strapi-plugin-content-manager/admin/src/utils/tests/removePasswordFieldsFromData.test.js similarity index 91% rename from packages/strapi-plugin-content-manager/admin/src/containers/EditViewDataManagerProvider/utils/tests/removePasswordFieldsFromData.test.js rename to packages/strapi-plugin-content-manager/admin/src/utils/tests/removePasswordFieldsFromData.test.js index 41d33f89cf..88afcd06d7 100644 --- a/packages/strapi-plugin-content-manager/admin/src/containers/EditViewDataManagerProvider/utils/tests/removePasswordFieldsFromData.test.js +++ b/packages/strapi-plugin-content-manager/admin/src/utils/tests/removePasswordFieldsFromData.test.js @@ -1,7 +1,7 @@ +import { testData } from '../../testUtils'; import removePasswordFieldsFromData from '../removePasswordFieldsFromData'; -import testData from './testData'; -describe('CONTENT MANAGER | containers | EditViewDataManager | utils', () => { +describe('CONTENT MANAGER | utils', () => { describe('removePasswordFieldsFromData', () => { it('should return an empty object', () => { const { components, contentType } = testData;