Merge pull request #16387 from strapi/chore/cm-field-sizes

Refactor content manager field sizes management
This commit is contained in:
Rémi de Juvigny 2023-04-18 10:49:54 +02:00 committed by GitHub
commit ecf439ab56
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 375 additions and 241 deletions

View File

@ -1,12 +1,24 @@
import { GET_DATA, RESET_PROPS, SET_CONTENT_TYPE_LINKS } from './constants'; import { GET_INIT_DATA, RESET_INIT_DATA, SET_INIT_DATA } from './constants';
export const getData = () => ({ export const getInitData = () => ({
type: GET_DATA, type: GET_INIT_DATA,
}); });
export const resetProps = () => ({ type: RESET_PROPS }); export const resetInitData = () => ({ type: RESET_INIT_DATA });
export const setContentTypeLinks = (authorizedCtLinks, authorizedStLinks, models, components) => ({ export const setInitData = ({
type: SET_CONTENT_TYPE_LINKS, authorizedCollectionTypeLinks,
data: { authorizedCtLinks, authorizedStLinks, components, contentTypeSchemas: models }, authorizedSingleTypeLinks,
contentTypeSchemas,
components,
fieldSizes,
}) => ({
type: SET_INIT_DATA,
data: {
authorizedCollectionTypeLinks,
authorizedSingleTypeLinks,
components,
contentTypeSchemas,
fieldSizes,
},
}); });

View File

@ -1,3 +1,3 @@
export const GET_DATA = 'ContentManager/App/GET_DATA'; export const GET_INIT_DATA = 'ContentManager/App/GET_INIT_DATA';
export const RESET_PROPS = 'ContentManager/App/RESET_PROPS'; export const RESET_INIT_DATA = 'ContentManager/App/RESET_INIT_DATA';
export const SET_CONTENT_TYPE_LINKS = 'ContentManager/App/SET_CONTENT_TYPE_LINKS'; export const SET_INIT_DATA = 'ContentManager/App/SET_INIT_DATA';

View File

@ -20,13 +20,14 @@ import NoContentType from '../NoContentType';
import NoPermissions from '../NoPermissions'; import NoPermissions from '../NoPermissions';
import SingleTypeRecursivePath from '../SingleTypeRecursivePath'; import SingleTypeRecursivePath from '../SingleTypeRecursivePath';
import LeftMenu from './LeftMenu'; import LeftMenu from './LeftMenu';
import useModels from './useModels'; import useContentManagerInitData from './useContentManagerInitData';
const cmPermissions = permissions.contentManager; const cmPermissions = permissions.contentManager;
const App = () => { const App = () => {
const contentTypeMatch = useRouteMatch(`/content-manager/:kind/:uid`); const contentTypeMatch = useRouteMatch(`/content-manager/:kind/:uid`);
const { status, collectionTypeLinks, singleTypeLinks, models, refetchData } = useModels(); const { status, collectionTypeLinks, singleTypeLinks, models, refetchData } =
useContentManagerInitData();
const authorisedModels = sortBy([...collectionTypeLinks, ...singleTypeLinks], (model) => const authorisedModels = sortBy([...collectionTypeLinks, ...singleTypeLinks], (model) =>
model.title.toLowerCase() model.title.toLowerCase()
); );

View File

@ -4,7 +4,7 @@
*/ */
/* eslint-disable consistent-return */ /* eslint-disable consistent-return */
import produce from 'immer'; import produce from 'immer';
import { GET_DATA, RESET_PROPS, SET_CONTENT_TYPE_LINKS } from './constants'; import { GET_INIT_DATA, RESET_INIT_DATA, SET_INIT_DATA } from './constants';
const initialState = { const initialState = {
components: [], components: [],
@ -20,22 +20,23 @@ const initialState = {
const mainReducer = (state = initialState, action) => const mainReducer = (state = initialState, action) =>
produce(state, (draftState) => { produce(state, (draftState) => {
switch (action.type) { switch (action.type) {
case GET_DATA: { case GET_INIT_DATA: {
draftState.status = 'loading'; draftState.status = 'loading';
break; break;
} }
case RESET_PROPS: { case RESET_INIT_DATA: {
return initialState; return initialState;
} }
case SET_CONTENT_TYPE_LINKS: { case SET_INIT_DATA: {
draftState.collectionTypeLinks = action.data.authorizedCtLinks.filter( draftState.collectionTypeLinks = action.data.authorizedCollectionTypeLinks.filter(
({ isDisplayed }) => isDisplayed ({ isDisplayed }) => isDisplayed
); );
draftState.singleTypeLinks = action.data.authorizedStLinks.filter( draftState.singleTypeLinks = action.data.authorizedSingleTypeLinks.filter(
({ isDisplayed }) => isDisplayed ({ isDisplayed }) => isDisplayed
); );
draftState.components = action.data.components; draftState.components = action.data.components;
draftState.models = action.data.contentTypeSchemas; draftState.models = action.data.contentTypeSchemas;
draftState.fieldSizes = action.data.fieldSizes;
draftState.status = 'resolved'; draftState.status = 'resolved';
break; break;
} }

View File

@ -23,10 +23,13 @@ const makeSelectModelAndComponentSchemas = () =>
schemas: [...components, ...models], schemas: [...components, ...models],
})); }));
const selectFieldSizes = createSelector(selectAppDomain(), (state) => state.fieldSizes);
export default makeSelectApp; export default makeSelectApp;
export { export {
makeSelectModelAndComponentSchemas, makeSelectModelAndComponentSchemas,
makeSelectModelLinks, makeSelectModelLinks,
makeSelectModels, makeSelectModels,
selectFieldSizes,
selectAppDomain, selectAppDomain,
}; };

View File

@ -1,27 +1,32 @@
import { setContentTypeLinks } from '../actions'; import { setInitData } from '../actions';
describe('Content Manager | App | actions', () => { describe('Content Manager | App | actions', () => {
it('should format the setContentTypeLinks action', () => { it('should format the setInitData action', () => {
const authorizedCtLinks = [{ title: 'addresses', uid: 'address' }]; const authorizedCollectionTypeLinks = [{ title: 'addresses', uid: 'address' }];
const authorizedStLinks = [{ title: 'Home page', uid: 'homepage' }]; const authorizedSingleTypeLinks = [{ title: 'Home page', uid: 'homepage' }];
const models = [ const contentTypeSchemas = [
{ kind: 'singleType', uid: 'homepage' }, { kind: 'singleType', uid: 'homepage' },
{ kind: 'collectionType', uid: 'address' }, { kind: 'collectionType', uid: 'address' },
]; ];
const components = []; const components = [];
const expected = { const expected = {
type: 'ContentManager/App/SET_CONTENT_TYPE_LINKS', type: 'ContentManager/App/SET_INIT_DATA',
data: { data: {
authorizedCtLinks, authorizedCollectionTypeLinks,
authorizedStLinks, authorizedSingleTypeLinks,
contentTypeSchemas: models, contentTypeSchemas,
components, components,
}, },
}; };
expect(setContentTypeLinks(authorizedCtLinks, authorizedStLinks, models, components)).toEqual( expect(
expected setInitData({
); authorizedCollectionTypeLinks,
authorizedSingleTypeLinks,
contentTypeSchemas,
components,
})
).toEqual(expected);
}); });
}); });

View File

@ -13,9 +13,9 @@ import Theme from '../../../../components/Theme';
import ThemeToggleProvider from '../../../../components/ThemeToggleProvider'; import ThemeToggleProvider from '../../../../components/ThemeToggleProvider';
import { App as ContentManagerApp } from '..'; import { App as ContentManagerApp } from '..';
import cmReducers from '../../../../reducers'; import cmReducers from '../../../../reducers';
import useModels from '../useModels'; import useContentManagerInitData from '../useContentManagerInitData';
jest.mock('../useModels', () => jest.mock('../useContentManagerInitData', () =>
jest.fn(() => { jest.fn(() => {
return {}; return {};
}) })
@ -88,7 +88,7 @@ describe('Content manager | App | main', () => {
components: [], components: [],
status: 'resolved', status: 'resolved',
}; };
useModels.mockImplementation(() => contentManagerState); useContentManagerInitData.mockImplementation(() => contentManagerState);
const rootReducer = combineReducers(cmReducers); const rootReducer = combineReducers(cmReducers);
const store = createStore(rootReducer, { 'content-manager_app': contentManagerState }); const store = createStore(rootReducer, { 'content-manager_app': contentManagerState });
const history = createMemoryHistory(); const history = createMemoryHistory();
@ -815,7 +815,7 @@ describe('Content manager | App | main', () => {
components: [], components: [],
status: 'resolved', status: 'resolved',
}; };
useModels.mockImplementation(() => contentManagerState); useContentManagerInitData.mockImplementation(() => contentManagerState);
const rootReducer = combineReducers(cmReducers); const rootReducer = combineReducers(cmReducers);
const store = createStore(rootReducer, { 'content-manager_app': contentManagerState }); const store = createStore(rootReducer, { 'content-manager_app': contentManagerState });
const history = createMemoryHistory(); const history = createMemoryHistory();
@ -857,8 +857,8 @@ describe('Content manager | App | main', () => {
components: [], components: [],
status: 'resolved', status: 'resolved',
}; };
useModels.mockImplementation(() => contentManagerState); useContentManagerInitData.mockImplementation(() => contentManagerState);
jest.mock('../useModels', () => jest.mock('../useContentManagerInitData', () =>
jest.fn(() => { jest.fn(() => {
return contentManagerState; return contentManagerState;
}) })
@ -902,8 +902,8 @@ describe('Content manager | App | main', () => {
components: [], components: [],
status: 'resolved', status: 'resolved',
}; };
useModels.mockImplementation(() => contentManagerState); useContentManagerInitData.mockImplementation(() => contentManagerState);
jest.mock('../useModels', () => jest.mock('../useContentManagerInitData', () =>
jest.fn(() => { jest.fn(() => {
return contentManagerState; return contentManagerState;
}) })

View File

@ -1,5 +1,5 @@
import produce from 'immer'; import produce from 'immer';
import { getData, setContentTypeLinks, resetProps } from '../actions'; import { getInitData, setInitData, resetInitData } from '../actions';
import mainReducer from '../reducer'; import mainReducer from '../reducer';
describe('Content Manager | App | reducer', () => { describe('Content Manager | App | reducer', () => {
@ -12,6 +12,7 @@ describe('Content Manager | App | reducer', () => {
models: [], models: [],
collectionTypeLinks: [], collectionTypeLinks: [],
singleTypeLinks: [], singleTypeLinks: [],
fieldSizes: {},
}; };
}); });
@ -19,18 +20,18 @@ describe('Content Manager | App | reducer', () => {
expect(mainReducer(state, {})).toEqual(state); expect(mainReducer(state, {})).toEqual(state);
}); });
it('should handle the getData action correctly', () => { it('should handle the getInitData action correctly', () => {
state.status = 'resolved'; state.status = 'resolved';
const expected = produce(state, (draft) => { const expected = produce(state, (draft) => {
draft.status = 'loading'; draft.status = 'loading';
}); });
expect(mainReducer(state, getData())).toEqual(expected); expect(mainReducer(state, getInitData())).toEqual(expected);
}); });
it('should handle the getData action correctly', () => { it('should handle the setInitData action correctly', () => {
const collectionTypeLinks = [ const authorizedCollectionTypeLinks = [
{ {
name: 'authorizedCt', name: 'authorizedCt',
isDisplayed: true, isDisplayed: true,
@ -40,7 +41,7 @@ describe('Content Manager | App | reducer', () => {
isDisplayed: false, isDisplayed: false,
}, },
]; ];
const singleTypeLinks = [ const authorizedSingleTypeLinks = [
{ {
name: 'authorizedSt', name: 'authorizedSt',
isDisplayed: false, isDisplayed: false,
@ -71,15 +72,21 @@ describe('Content Manager | App | reducer', () => {
expect( expect(
mainReducer( mainReducer(
state, state,
setContentTypeLinks(collectionTypeLinks, singleTypeLinks, ['test'], ['test']) setInitData({
authorizedCollectionTypeLinks,
authorizedSingleTypeLinks,
contentTypeSchemas: ['test'],
components: ['test'],
fieldSizes: {},
})
) )
).toEqual(expected); ).toEqual(expected);
}); });
it('should handle the resetProps action correctly', () => { it('should handle the resetInitData action correctly', () => {
state = 'test'; state = 'test';
expect(mainReducer(state, resetProps())).toEqual({ expect(mainReducer(state, resetInitData())).toEqual({
components: [], components: [],
models: [], models: [],
collectionTypeLinks: [], collectionTypeLinks: [],

View File

@ -11,11 +11,11 @@ import axios from 'axios';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import { MUTATE_COLLECTION_TYPES_LINKS, MUTATE_SINGLE_TYPES_LINKS } from '../../../exposedHooks'; import { MUTATE_COLLECTION_TYPES_LINKS, MUTATE_SINGLE_TYPES_LINKS } from '../../../exposedHooks';
import { getRequestUrl, getTrad } from '../../utils'; import { getRequestUrl, getTrad } from '../../utils';
import { getData, resetProps, setContentTypeLinks } from './actions'; import { getInitData, resetInitData, setInitData } from './actions';
import { selectAppDomain } from './selectors'; import { selectAppDomain } from './selectors';
import getContentTypeLinks from './utils/getContentTypeLinks'; import getContentTypeLinks from './utils/getContentTypeLinks';
const useModels = () => { const useContentManagerInitData = () => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const toggleNotification = useNotification(); const toggleNotification = useNotification();
const state = useSelector(selectAppDomain()); const state = useSelector(selectAppDomain());
@ -29,22 +29,14 @@ const useModels = () => {
const { get } = useFetchClient(); const { get } = useFetchClient();
const fetchData = async () => { const fetchData = async () => {
dispatch(getData()); dispatch(getInitData());
try { try {
const [ const {
{ data: {
data: { data: components }, data: { components, contentTypes: models, fieldSizes },
}, },
{ } = await get(getRequestUrl('init'), { cancelToken: source.token });
data: { data: models },
},
] = await Promise.all(
['components', 'content-types'].map((endPoint) =>
get(getRequestUrl(endPoint), { cancelToken: source.token })
)
);
notifyStatus( notifyStatus(
formatMessage({ formatMessage({
id: getTrad('App.schemas.data-loaded'), id: getTrad('App.schemas.data-loaded'),
@ -52,22 +44,31 @@ const useModels = () => {
}) })
); );
const { authorizedCtLinks, authorizedStLinks } = await getContentTypeLinks( const unmutatedContentTypeLinks = await getContentTypeLinks({
models, models,
allPermissions, userPermissions: allPermissions,
toggleNotification toggleNotification,
});
const { ctLinks: authorizedCollectionTypeLinks } = runHookWaterfall(
MUTATE_COLLECTION_TYPES_LINKS,
{
ctLinks: unmutatedContentTypeLinks.authorizedCollectionTypeLinks,
models,
}
); );
const { stLinks: authorizedSingleTypeLinks } = runHookWaterfall(MUTATE_SINGLE_TYPES_LINKS, {
const { ctLinks } = runHookWaterfall(MUTATE_COLLECTION_TYPES_LINKS, { stLinks: unmutatedContentTypeLinks.authorizedSingleTypeLinks,
ctLinks: authorizedCtLinks,
models,
});
const { stLinks } = runHookWaterfall(MUTATE_SINGLE_TYPES_LINKS, {
stLinks: authorizedStLinks,
models, models,
}); });
const actionToDispatch = setContentTypeLinks(ctLinks, stLinks, models, components); const actionToDispatch = setInitData({
authorizedCollectionTypeLinks,
authorizedSingleTypeLinks,
contentTypeSchemas: models,
components,
fieldSizes,
});
dispatch(actionToDispatch); dispatch(actionToDispatch);
} catch (err) { } catch (err) {
@ -88,7 +89,7 @@ const useModels = () => {
return () => { return () => {
source.cancel('Operation canceled by the user.'); source.cancel('Operation canceled by the user.');
dispatch(resetProps()); dispatch(resetInitData());
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [dispatch, toggleNotification]); }, [dispatch, toggleNotification]);
@ -96,4 +97,4 @@ const useModels = () => {
return { ...state, refetchData: fetchDataRef.current }; return { ...state, refetchData: fetchDataRef.current };
}; };
export default useModels; export default useContentManagerInitData;

View File

@ -52,12 +52,12 @@ const generateModelsLinks = (models, modelsConfigurations) => {
const [collectionTypes, singleTypes] = sortBy(groupedModels, 'name'); const [collectionTypes, singleTypes] = sortBy(groupedModels, 'name');
return { return {
collectionTypesSectionLinks: generateLinks( collectionTypeSectionLinks: generateLinks(
collectionTypes?.links || [], collectionTypes?.links || [],
'collectionTypes', 'collectionTypes',
modelsConfigurations modelsConfigurations
), ),
singleTypesSectionLinks: generateLinks(singleTypes?.links ?? [], 'singleTypes'), singleTypeSectionLinks: generateLinks(singleTypes?.links ?? [], 'singleTypes'),
}; };
}; };

View File

@ -3,36 +3,38 @@ import generateModelsLinks from './generateModelsLinks';
import checkPermissions from './checkPermissions'; import checkPermissions from './checkPermissions';
import { getRequestUrl } from '../../../utils'; import { getRequestUrl } from '../../../utils';
const getContentTypeLinks = async (models, userPermissions, toggleNotification) => { const getContentTypeLinks = async ({ models, userPermissions, toggleNotification }) => {
const { get } = getFetchClient(); const { get } = getFetchClient();
try { try {
const { const {
data: { data: contentTypeConfigurations }, data: { data: contentTypeConfigurations },
} = await get(getRequestUrl('content-types-settings')); } = await get(getRequestUrl('content-types-settings'));
const { collectionTypesSectionLinks, singleTypesSectionLinks } = generateModelsLinks( const { collectionTypeSectionLinks, singleTypeSectionLinks } = generateModelsLinks(
models, models,
contentTypeConfigurations contentTypeConfigurations
); );
// Content Types verifications // Collection Types verifications
const ctLinksPermissionsPromises = checkPermissions( const collectionTypeLinksPermissions = await Promise.all(
userPermissions, checkPermissions(userPermissions, collectionTypeSectionLinks)
collectionTypesSectionLinks
); );
const ctLinksPermissions = await Promise.all(ctLinksPermissionsPromises); const authorizedCollectionTypeLinks = collectionTypeSectionLinks.filter(
const authorizedCtLinks = collectionTypesSectionLinks.filter( (_, index) => collectionTypeLinksPermissions[index]
(_, index) => ctLinksPermissions[index]
); );
// Single Types verifications // Single Types verifications
const stLinksPermissionsPromises = checkPermissions(userPermissions, singleTypesSectionLinks); const singleTypeLinksPermissions = await Promise.all(
const stLinksPermissions = await Promise.all(stLinksPermissionsPromises); checkPermissions(userPermissions, singleTypeSectionLinks)
const authorizedStLinks = singleTypesSectionLinks.filter( );
(_, index) => stLinksPermissions[index] const authorizedSingleTypeLinks = singleTypeSectionLinks.filter(
(_, index) => singleTypeLinksPermissions[index]
); );
return { authorizedCtLinks, authorizedStLinks }; return {
authorizedCollectionTypeLinks,
authorizedSingleTypeLinks,
};
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@ -41,7 +43,7 @@ const getContentTypeLinks = async (models, userPermissions, toggleNotification)
message: { id: 'notification.error' }, message: { id: 'notification.error' },
}); });
return { authorizedCtLinks: [], authorizedStLinks: [], contentTypes: [] }; return { authorizedCollectionTypeLinks: [], authorizedSingleTypeLinks: [] };
} }
}; };

View File

@ -119,7 +119,7 @@ describe('ADMIN | LeftMenu | utils', () => {
]; ];
const expected = { const expected = {
collectionTypesSectionLinks: [ collectionTypeSectionLinks: [
{ {
isDisplayed: true, isDisplayed: true,
search: null, search: null,
@ -140,7 +140,7 @@ describe('ADMIN | LeftMenu | utils', () => {
], ],
}, },
], ],
singleTypesSectionLinks: [ singleTypeSectionLinks: [
{ {
isDisplayed: true, isDisplayed: true,
kind: 'singleType', kind: 'singleType',

View File

@ -1,4 +1,4 @@
import { request, hasPermissions } from '@strapi/helper-plugin'; import { getFetchClient, hasPermissions } from '@strapi/helper-plugin';
import getContentTypeLinks from '../getContentTypeLinks'; import getContentTypeLinks from '../getContentTypeLinks';
// FIXME // FIXME
@ -44,24 +44,24 @@ describe('checkPermissions', () => {
}, },
]; ];
const data = [ const contentTypes = [
{ {
uid: 'api::address.address',
isDisplayed: true,
apiID: 'address', apiID: 'address',
kind: 'collectionType',
info: { info: {
label: 'address', displayName: 'Address',
}, },
isDisplayed: true,
kind: 'collectionType',
uid: 'api::address.address',
}, },
{ {
uid: 'api::article.article',
isDisplayed: true,
apiID: 'article', apiID: 'article',
kind: 'collectionType',
info: { info: {
label: 'article', displayName: 'Article',
}, },
isDisplayed: true,
kind: 'collectionType',
uid: 'api::article.article',
pluginOptions: { pluginOptions: {
i18n: { i18n: {
localized: true, localized: true,
@ -70,32 +70,36 @@ describe('checkPermissions', () => {
}, },
]; ];
request.mockImplementation((url) => { getFetchClient.mockImplementation(() => ({
if (url === '/content-manager/content-types') { get(url) {
return Promise.resolve({ data }); if (url === '/content-manager/content-types-settings') {
} return Promise.resolve({
data: {
return Promise.resolve({ data: [
data: [ {
{ uid: 'api::address.address',
uid: 'api::address.address', settings: {
settings: { pageSize: 10,
pageSize: 10, defaultSortBy: 'name',
defaultSortBy: 'name', defaultSortOrder: 'ASC',
defaultSortOrder: 'ASC', },
},
],
}, },
}, });
], }
});
}); // To please the linter
return Promise.resolve(null);
},
}));
const expected = { const expected = {
authorizedCtLinks: [ authorizedCollectionTypeLinks: [
{ {
destination: '/content-manager/collectionType/api::address.address',
icon: 'circle',
isDisplayed: true, isDisplayed: true,
label: 'address', kind: 'collectionType',
name: 'api::address.address',
permissions: [ permissions: [
{ {
action: 'plugin::content-manager.explorer.create', action: 'plugin::content-manager.explorer.create',
@ -107,13 +111,14 @@ describe('checkPermissions', () => {
}, },
], ],
search: 'page=1&pageSize=10&sort=name:ASC', search: 'page=1&pageSize=10&sort=name:ASC',
title: 'Address',
to: '/content-manager/collectionType/api::address.address',
uid: 'api::address.address',
}, },
{ {
destination: '/content-manager/collectionType/api::article.article',
icon: 'circle',
isDisplayed: true, isDisplayed: true,
label: 'article', kind: 'collectionType',
search: null, name: 'api::article.article',
permissions: [ permissions: [
{ {
action: 'plugin::content-manager.explorer.create', action: 'plugin::content-manager.explorer.create',
@ -124,12 +129,15 @@ describe('checkPermissions', () => {
subject: 'api::article.article', subject: 'api::article.article',
}, },
], ],
search: null,
title: 'Article',
to: '/content-manager/collectionType/api::article.article',
uid: 'api::article.article',
}, },
], ],
authorizedStLinks: [], authorizedSingleTypeLinks: [],
contentTypes: data,
}; };
const actual = await getContentTypeLinks(userPermissions); const actual = await getContentTypeLinks({ userPermissions, models: contentTypes });
expect(actual).toEqual(expected); expect(actual).toEqual(expected);
}); });
@ -139,11 +147,13 @@ describe('checkPermissions', () => {
const toggleNotification = jest.fn(); const toggleNotification = jest.fn();
const userPermissions = []; const userPermissions = [];
request.mockImplementation(() => { getFetchClient.mockImplementation(() => ({
throw new Error('Something went wrong'); get() {
}); throw new Error('Something went wrong');
},
}));
await getContentTypeLinks(userPermissions, toggleNotification); await getContentTypeLinks({ userPermissions, toggleNotification });
expect(toggleNotification).toBeCalled(); expect(toggleNotification).toBeCalled();
}); });
}); });

View File

@ -6,7 +6,7 @@ import { useSelector, shallowEqual } from 'react-redux';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import { useLayoutDnd } from '../../../hooks'; import { useLayoutDnd } from '../../../hooks';
import { createPossibleMainFieldsForModelsAndComponents, getInputProps } from '../utils'; import { createPossibleMainFieldsForModelsAndComponents, getInputProps } from '../utils';
import { makeSelectModelAndComponentSchemas } from '../../App/selectors'; import { makeSelectModelAndComponentSchemas, selectFieldSizes } from '../../App/selectors';
import getTrad from '../../../utils/getTrad'; import getTrad from '../../../utils/getTrad';
import GenericInput from './GenericInput'; import GenericInput from './GenericInput';
@ -17,8 +17,6 @@ const FIELD_SIZES = [
[12, '100%'], [12, '100%'],
]; ];
const NON_RESIZABLE_FIELD_TYPES = ['dynamiczone', 'component', 'json', 'richtext'];
const TIME_FIELD_OPTIONS = [1, 5, 10, 15, 30, 60]; const TIME_FIELD_OPTIONS = [1, 5, 10, 15, 30, 60];
const TIME_FIELD_TYPES = ['datetime', 'time']; const TIME_FIELD_TYPES = ['datetime', 'time'];
@ -28,6 +26,7 @@ const ModalForm = ({ onMetaChange, onSizeChange }) => {
const { modifiedData, selectedField, attributes, fieldForm } = useLayoutDnd(); const { modifiedData, selectedField, attributes, fieldForm } = useLayoutDnd();
const schemasSelector = useMemo(makeSelectModelAndComponentSchemas, []); const schemasSelector = useMemo(makeSelectModelAndComponentSchemas, []);
const { schemas } = useSelector((state) => schemasSelector(state), shallowEqual); const { schemas } = useSelector((state) => schemasSelector(state), shallowEqual);
const fieldSizes = useSelector(selectFieldSizes);
const formToDisplay = useMemo(() => { const formToDisplay = useMemo(() => {
if (!selectedField) { if (!selectedField) {
@ -103,7 +102,7 @@ const ModalForm = ({ onMetaChange, onSizeChange }) => {
); );
}); });
const canResize = !NON_RESIZABLE_FIELD_TYPES.includes(attributes[selectedField].type); const { isResizable } = fieldSizes[attributes[selectedField].type];
const sizeField = ( const sizeField = (
<GridItem col={6} key="size"> <GridItem col={6} key="size">
@ -152,7 +151,7 @@ const ModalForm = ({ onMetaChange, onSizeChange }) => {
return ( return (
<> <>
{metaFields} {metaFields}
{canResize && sizeField} {isResizable && sizeField}
{hasTimePicker && timeStepField} {hasTimePicker && timeStepField}
</> </>
); );

View File

@ -26,6 +26,7 @@ import {
Divider, Divider,
} from '@strapi/design-system'; } from '@strapi/design-system';
import { ArrowLeft, Check } from '@strapi/icons'; import { ArrowLeft, Check } from '@strapi/icons';
import { useSelector } from 'react-redux';
import { getTrad } from '../../utils'; import { getTrad } from '../../utils';
import reducer, { initialState } from './reducer'; import reducer, { initialState } from './reducer';
import init from './init'; import init from './init';
@ -34,6 +35,7 @@ import ModalForm from './components/FormModal';
import LayoutDndProvider from '../../components/LayoutDndProvider'; import LayoutDndProvider from '../../components/LayoutDndProvider';
import { unformatLayout } from './utils/layout'; import { unformatLayout } from './utils/layout';
import putCMSettingsEV from './utils/api'; import putCMSettingsEV from './utils/api';
import { selectFieldSizes } from '../App/selectors';
const EditSettingsView = ({ mainLayout, components, isContentTypeView, slug, updateLayout }) => { const EditSettingsView = ({ mainLayout, components, isContentTypeView, slug, updateLayout }) => {
const [reducerState, dispatch] = useReducer(reducer, initialState, () => const [reducerState, dispatch] = useReducer(reducer, initialState, () =>
@ -49,6 +51,7 @@ const EditSettingsView = ({ mainLayout, components, isContentTypeView, slug, upd
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const modelName = get(mainLayout, ['info', 'displayName'], ''); const modelName = get(mainLayout, ['info', 'displayName'], '');
const attributes = get(modifiedData, ['attributes'], {}); const attributes = get(modifiedData, ['attributes'], {});
const fieldSizes = useSelector(selectFieldSizes);
const entryTitleOptions = Object.keys(attributes).filter((attr) => { const entryTitleOptions = Object.keys(attributes).filter((attr) => {
const type = get(attributes, [attr, 'type'], ''); const type = get(attributes, [attr, 'type'], '');
@ -318,6 +321,7 @@ const EditSettingsView = ({ mainLayout, components, isContentTypeView, slug, upd
dispatch({ dispatch({
type: 'ON_ADD_FIELD', type: 'ON_ADD_FIELD',
name: field, name: field,
fieldSizes,
}); });
}} }}
onRemoveField={(rowId, index) => { onRemoveField={(rowId, index) => {

View File

@ -2,9 +2,10 @@ import produce from 'immer';
import set from 'lodash/set'; import set from 'lodash/set';
import get from 'lodash/get'; import get from 'lodash/get';
import cloneDeep from 'lodash/cloneDeep'; import cloneDeep from 'lodash/cloneDeep';
import { arrayMoveItem } from '../../utils'; import { arrayMoveItem } from '../../utils';
import { formatLayout, getDefaultInputSize, getFieldSize, setFieldSize } from './utils/layout'; import { formatLayout, getFieldSize, setFieldSize } from './utils/layout';
const DEFAULT_FIELD_SIZE = 6;
const initialState = { const initialState = {
fieldForm: {}, fieldForm: {},
@ -29,9 +30,8 @@ const reducer = (state = initialState, action) =>
} }
case 'ON_ADD_FIELD': { case 'ON_ADD_FIELD': {
const newState = cloneDeep(state); const newState = cloneDeep(state);
const size = getDefaultInputSize( const type = get(newState, ['modifiedData', 'attributes', action.name, 'type'], '');
get(newState, ['modifiedData', 'attributes', action.name, 'type'], '') const size = action.fieldSizes[type]?.default ?? DEFAULT_FIELD_SIZE;
);
const listSize = get(newState, layoutPathEdit, []).length; const listSize = get(newState, layoutPathEdit, []).length;
const actualRowContentPath = [...layoutPathEdit, listSize - 1, 'rowContent']; const actualRowContentPath = [...layoutPathEdit, listSize - 1, 'rowContent'];
const rowContentToSet = get(newState, actualRowContentPath, []); const rowContentToSet = get(newState, actualRowContentPath, []);
@ -149,8 +149,7 @@ const reducer = (state = initialState, action) =>
draftState.metaToEdit = action.name; draftState.metaToEdit = action.name;
draftState.metaForm = { draftState.metaForm = {
metadata: get(state, ['modifiedData', 'metadatas', action.name, 'edit'], {}), metadata: get(state, ['modifiedData', 'metadatas', action.name, 'edit'], {}),
size: size: getFieldSize(action.name, state.modifiedData?.layouts?.edit) ?? DEFAULT_FIELD_SIZE,
getFieldSize(action.name, state.modifiedData?.layouts?.edit) ?? getDefaultInputSize(),
}; };
break; break;

View File

@ -4,10 +4,13 @@ import { Router } from 'react-router-dom';
import { createMemoryHistory } from 'history'; import { createMemoryHistory } from 'history';
import { IntlProvider } from 'react-intl'; import { IntlProvider } from 'react-intl';
import { QueryClient, QueryClientProvider } from 'react-query'; import { QueryClient, QueryClientProvider } from 'react-query';
import { combineReducers, createStore } from 'redux';
import { Provider } from 'react-redux';
import { DndProvider } from 'react-dnd'; import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend'; import { HTML5Backend } from 'react-dnd-html5-backend';
import { ThemeProvider, lightTheme } from '@strapi/design-system'; import { ThemeProvider, lightTheme } from '@strapi/design-system';
import EditSettingsView from '../index'; import EditSettingsView from '../index';
import cmReducers from '../../../../reducers';
jest.mock('@strapi/helper-plugin', () => ({ jest.mock('@strapi/helper-plugin', () => ({
...jest.requireActual('@strapi/helper-plugin'), ...jest.requireActual('@strapi/helper-plugin'),
@ -57,22 +60,31 @@ const makeApp = (history, layout) => {
compo1: { uid: 'compo1' }, compo1: { uid: 'compo1' },
}; };
const rootReducer = combineReducers(cmReducers);
const store = createStore(rootReducer, {
'content-manager_app': {
fieldSizes: {},
},
});
return ( return (
<Router history={history}> <Router history={history}>
<QueryClientProvider client={client}> <Provider store={store}>
<IntlProvider messages={{ en: {} }} textComponent="span" locale="en"> <QueryClientProvider client={client}>
<ThemeProvider theme={lightTheme}> <IntlProvider messages={{ en: {} }} textComponent="span" locale="en">
<DndProvider backend={HTML5Backend}> <ThemeProvider theme={lightTheme}>
<EditSettingsView <DndProvider backend={HTML5Backend}>
mainLayout={layout || mainLayout} <EditSettingsView
components={components} mainLayout={layout || mainLayout}
isContentTypeView components={components}
slug="api::address.address" isContentTypeView
/> slug="api::address.address"
</DndProvider> />
</ThemeProvider> </DndProvider>
</IntlProvider> </ThemeProvider>
</QueryClientProvider> </IntlProvider>
</QueryClientProvider>
</Provider>
</Router> </Router>
); );
}; };

View File

@ -1,5 +1,11 @@
import reducer from '../reducer'; import reducer from '../reducer';
const fieldSizes = {
richtext: { default: 12, isResizable: false },
string: { default: 6, isResizable: true },
boolean: { default: 4, isResizable: true },
};
describe('CONTENT MANAGER | CONTAINERS | EditSettingsView | reducer', () => { describe('CONTENT MANAGER | CONTAINERS | EditSettingsView | reducer', () => {
let state; let state;
@ -82,7 +88,11 @@ describe('CONTENT MANAGER | CONTAINERS | EditSettingsView | reducer', () => {
}, },
}, },
}; };
const action = { type: 'ON_ADD_FIELD', name: 'description' }; const action = {
type: 'ON_ADD_FIELD',
name: 'description',
fieldSizes,
};
expect(reducer(state, action)).toEqual(expected); expect(reducer(state, action)).toEqual(expected);
}); });
@ -121,7 +131,7 @@ describe('CONTENT MANAGER | CONTAINERS | EditSettingsView | reducer', () => {
}, },
}, },
}; };
const action = { type: 'ON_ADD_FIELD', name: 'title' }; const action = { type: 'ON_ADD_FIELD', name: 'title', fieldSizes };
expect(reducer(state, action)).toEqual(expected); expect(reducer(state, action)).toEqual(expected);
}); });
@ -163,7 +173,7 @@ describe('CONTENT MANAGER | CONTAINERS | EditSettingsView | reducer', () => {
}, },
}, },
}; };
const action = { type: 'ON_ADD_FIELD', name: 'isActive' }; const action = { type: 'ON_ADD_FIELD', name: 'isActive', fieldSizes };
expect(reducer(state, action)).toEqual(expected); expect(reducer(state, action)).toEqual(expected);
}); });
@ -214,7 +224,7 @@ describe('CONTENT MANAGER | CONTAINERS | EditSettingsView | reducer', () => {
}, },
}, },
}; };
const action = { type: 'ON_ADD_FIELD', name: 'title' }; const action = { type: 'ON_ADD_FIELD', name: 'title', fieldSizes };
expect(reducer(state, action)).toEqual(expected); expect(reducer(state, action)).toEqual(expected);
}); });
}); });

View File

@ -1,4 +1,3 @@
/* eslint-disable indent */
const getRowSize = (arr) => arr.reduce((sum, value) => sum + value.size, 0); const getRowSize = (arr) => arr.reduce((sum, value) => sum + value.size, 0);
const createLayout = (arr) => { const createLayout = (arr) => {
@ -75,26 +74,6 @@ const unformatLayout = (arr) => {
}, []); }, []);
}; };
const getDefaultInputSize = (type) => {
switch (type) {
case 'boolean':
case 'date':
case 'integer':
case 'float':
case 'biginteger':
case 'decimal':
case 'time':
return 4;
case 'json':
case 'component':
case 'richtext':
case 'dynamiczone':
return 12;
default:
return 6;
}
};
const getFieldSize = (name, layouts = []) => { const getFieldSize = (name, layouts = []) => {
return layouts.reduce((acc, { rowContent }) => { return layouts.reduce((acc, { rowContent }) => {
const size = rowContent.find((row) => row.name === name)?.size ?? null; const size = rowContent.find((row) => row.name === name)?.size ?? null;
@ -124,12 +103,4 @@ const setFieldSize = (name, size, layouts = []) => {
}); });
}; };
export { export { createLayout, formatLayout, getFieldSize, setFieldSize, getRowSize, unformatLayout };
createLayout,
formatLayout,
getDefaultInputSize,
getFieldSize,
setFieldSize,
getRowSize,
unformatLayout,
};

View File

@ -1,7 +1,6 @@
import { import {
createLayout, createLayout,
formatLayout, formatLayout,
getDefaultInputSize,
getFieldSize, getFieldSize,
setFieldSize, setFieldSize,
getRowSize, getRowSize,
@ -118,24 +117,6 @@ describe('Content Manager | containers | EditSettingsView | utils | layout', ()
}); });
}); });
describe('getDefaultInputSize', () => {
it('Should return 6 if the type is unknown, undefined or text', () => {
expect(getDefaultInputSize(undefined)).toBe(6);
expect(getDefaultInputSize('unkown')).toBe(6);
expect(getDefaultInputSize('text')).toBe(6);
});
it('Should return 12 if the type is either json, component or richtext', () => {
expect(getDefaultInputSize('json')).toBe(12);
expect(getDefaultInputSize('richtext')).toBe(12);
expect(getDefaultInputSize('component')).toBe(12);
});
it('Should return 4 if the type is boolean', () => {
expect(getDefaultInputSize('boolean')).toBe(4);
});
});
describe('getFieldSize', () => { describe('getFieldSize', () => {
const fixture = [ const fixture = [
{ {

View File

@ -3,6 +3,7 @@
const collectionTypes = require('./collection-types'); const collectionTypes = require('./collection-types');
const components = require('./components'); const components = require('./components');
const contentTypes = require('./content-types'); const contentTypes = require('./content-types');
const init = require('./init');
const relations = require('./relations'); const relations = require('./relations');
const singleTypes = require('./single-types'); const singleTypes = require('./single-types');
const uid = require('./uid'); const uid = require('./uid');
@ -11,6 +12,7 @@ module.exports = {
'collection-types': collectionTypes, 'collection-types': collectionTypes,
components, components,
'content-types': contentTypes, 'content-types': contentTypes,
init,
relations, relations,
'single-types': singleTypes, 'single-types': singleTypes,
uid, uid,

View File

@ -0,0 +1,20 @@
'use strict';
const { getService } = require('../utils');
module.exports = {
getInitData(ctx) {
const { toDto } = getService('data-mapper');
const { findAllComponents } = getService('components');
const { getAllFieldSizes } = getService('field-sizes');
const { findAllContentTypes } = getService('content-types');
ctx.body = {
data: {
fieldSizes: getAllFieldSizes(),
components: findAllComponents().map(toDto),
contentTypes: findAllContentTypes().map(toDto),
},
};
},
};

View File

@ -5,6 +5,14 @@ const { routing } = require('../middlewares');
module.exports = { module.exports = {
type: 'admin', type: 'admin',
routes: [ routes: [
{
method: 'GET',
path: '/init',
handler: 'init.getInitData',
config: {
policies: [],
},
},
{ {
method: 'GET', method: 'GET',
path: '/content-types', path: '/content-types',

View File

@ -0,0 +1,33 @@
'use strict';
const fieldSizesService = require('../field-sizes');
describe('field sizes service', () => {
it('should return the correct field sizes', () => {
const { getAllFieldSizes } = fieldSizesService();
const fieldSizes = getAllFieldSizes();
Object.values(fieldSizes).forEach((fieldSize) => {
expect(typeof fieldSize.isResizable).toBe('boolean');
expect([4, 6, 8, 12]).toContain(fieldSize.default);
});
});
it('should return the correct field size for a given type', () => {
const { getFieldSize } = fieldSizesService();
const fieldSize = getFieldSize('string');
expect(fieldSize.isResizable).toBe(true);
expect(fieldSize.default).toBe(6);
});
it('should throw an error if the type is not found', () => {
const { getFieldSize } = fieldSizesService();
expect(() => getFieldSize('not-found')).toThrowError(
'Could not find field size for type not-found'
);
});
it('should throw an error if the type is not provided', () => {
const { getFieldSize } = fieldSizesService();
expect(() => getFieldSize()).toThrowError('The type is required');
});
});

View File

@ -0,0 +1,63 @@
'use strict';
const needsFullSize = {
default: 12,
isResizable: false,
};
const smallSize = {
default: 4,
isResizable: true,
};
const defaultSize = {
default: 6,
isResizable: true,
};
const fieldSizes = {
// Full row and not resizable
dynamiczone: needsFullSize,
component: needsFullSize,
json: needsFullSize,
richtext: needsFullSize,
// Small and resizable
checkbox: smallSize,
boolean: smallSize,
date: smallSize,
time: smallSize,
biginteger: smallSize,
decimal: smallSize,
float: smallSize,
integer: smallSize,
number: smallSize,
// Medium and resizable
datetime: defaultSize,
email: defaultSize,
enumeration: defaultSize,
media: defaultSize,
password: defaultSize,
relation: defaultSize,
string: defaultSize,
text: defaultSize,
timestamp: defaultSize,
uid: defaultSize,
};
module.exports = () => ({
getAllFieldSizes() {
return fieldSizes;
},
getFieldSize(type) {
if (!type) {
throw new Error('The type is required');
}
const fieldSize = fieldSizes[type];
if (!fieldSize) {
throw new Error(`Could not find field size for type ${type}`);
}
return fieldSize;
},
});

View File

@ -3,19 +3,21 @@
const components = require('./components'); const components = require('./components');
const contentTypes = require('./content-types'); const contentTypes = require('./content-types');
const dataMapper = require('./data-mapper'); const dataMapper = require('./data-mapper');
const entityManager = require('./entity-manager');
const fieldSizes = require('./field-sizes');
const metrics = require('./metrics'); const metrics = require('./metrics');
const permissionChecker = require('./permission-checker'); const permissionChecker = require('./permission-checker');
const permission = require('./permission'); const permission = require('./permission');
const uid = require('./uid'); const uid = require('./uid');
const entityManager = require('./entity-manager');
module.exports = { module.exports = {
components, components,
'content-types': contentTypes, 'content-types': contentTypes,
'data-mapper': dataMapper, 'data-mapper': dataMapper,
'entity-manager': entityManager,
'field-sizes': fieldSizes,
metrics, metrics,
'permission-checker': permissionChecker, 'permission-checker': permissionChecker,
permission, permission,
uid, uid,
'entity-manager': entityManager,
}; };

View File

@ -1,42 +1,28 @@
'use strict'; 'use strict';
const _ = require('lodash'); const _ = require('lodash');
const { getService } = require('../../../utils');
const { isListable, hasEditableAttribute, hasRelationAttribute } = require('./attributes'); const { isListable, hasEditableAttribute, hasRelationAttribute } = require('./attributes');
const DEFAULT_LIST_LENGTH = 4; const DEFAULT_LIST_LENGTH = 4;
const MAX_ROW_SIZE = 12; const MAX_ROW_SIZE = 12;
const FIELD_TYPES_FULL_SIZE = ['dynamiczone', 'component', 'json', 'richtext'];
const FIELD_TYPES_SMALL = [
'checkbox',
'boolean',
'date',
'time',
'biginteger',
'decimal',
'float',
'integer',
'number',
];
const isAllowedFieldSize = (type, size) => { const isAllowedFieldSize = (type, size) => {
if (FIELD_TYPES_FULL_SIZE.includes(type)) { const { getFieldSize } = getService('field-sizes');
return size === MAX_ROW_SIZE; const fieldSize = getFieldSize(type);
// Check if field was locked to another size
if (!fieldSize.isResizable && size !== fieldSize.default) {
return false;
} }
// validate, whether the field has 4, 6, 8 or 12 columns? // Otherwise allow unless it's bigger than a row
return size <= MAX_ROW_SIZE; return size <= MAX_ROW_SIZE;
}; };
const getDefaultFieldSize = (type) => { const getDefaultFieldSize = (type) => {
if (FIELD_TYPES_FULL_SIZE.includes(type)) { const { getFieldSize } = getService('field-sizes');
return MAX_ROW_SIZE; return getFieldSize(type).default;
}
if (FIELD_TYPES_SMALL.includes(type)) {
return MAX_ROW_SIZE / 3;
}
return MAX_ROW_SIZE / 2;
}; };
async function createDefaultLayouts(schema) { async function createDefaultLayouts(schema) {

View File

@ -3,6 +3,7 @@ import * as configuration from '../services/configuration';
import * as contentTypes from '../services/content-types'; import * as contentTypes from '../services/content-types';
import * as dataMapper from '../services/data-mapper'; import * as dataMapper from '../services/data-mapper';
import * as entityManager from '../services/entity-manager'; import * as entityManager from '../services/entity-manager';
import * as fieldSizes from '../services/field-sizes';
import * as metris from '../services/metris'; import * as metris from '../services/metris';
import * as permissionChecker from '../services/permission-checker'; import * as permissionChecker from '../services/permission-checker';
import * as permission from '../services/permission'; import * as permission from '../services/permission';
@ -15,6 +16,7 @@ type S = {
['permission-checker']: typeof permissionChecker; ['permission-checker']: typeof permissionChecker;
components: typeof components; components: typeof components;
configuration: typeof configuration; configuration: typeof configuration;
['field-sizes']: typeof fieldSizes;
metris: typeof metris; metris: typeof metris;
permission: typeof permission; permission: typeof permission;
uid: typeof uid; uid: typeof uid;