Add single type navigation

Signed-off-by: HichamELBSI <elabbassih@gmail.com>
This commit is contained in:
HichamELBSI 2020-02-17 10:01:56 +01:00
parent 79870fbd15
commit f5c6ef0a44
14 changed files with 171 additions and 60 deletions

View File

@ -26,9 +26,15 @@ const LeftMenuLinksSection = ({
); );
const getLinkDestination = link => { const getLinkDestination = link => {
return ['plugins', 'general'].includes(section) if (['plugins', 'general'].includes(section)) {
? link.destination return link.destination;
: `/plugins/${link.plugin}/${link.destination || link.uid}`; }
if (link.schema && link.schema.kind) {
return `/plugins/${link.plugin}/${link.schema.kind}/${link.destination ||
link.uid}`;
}
return `/plugins/${link.plugin}/${link.destination || link.uid}`;
}; };
return ( return (

View File

@ -3,14 +3,13 @@ import PropTypes from 'prop-types';
import { withRouter } from 'react-router'; import { withRouter } from 'react-router';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { upperFirst } from 'lodash'; import { upperFirst } from 'lodash';
import pluginId from '../../pluginId';
import useListView from '../../hooks/useListView'; import useListView from '../../hooks/useListView';
import TableHeader from './TableHeader'; import TableHeader from './TableHeader';
import { Table, TableEmpty, TableRow } from './styledComponents'; import { Table, TableEmpty, TableRow } from './styledComponents';
import ActionCollapse from './ActionCollapse'; import ActionCollapse from './ActionCollapse';
import Row from './Row'; import Row from './Row';
function CustomTable({ const CustomTable = ({
data, data,
headers, headers,
history: { history: {
@ -18,13 +17,12 @@ function CustomTable({
push, push,
}, },
isBulkable, isBulkable,
}) { }) => {
const { const {
emitEvent, emitEvent,
entriesToDelete, entriesToDelete,
label, label,
searchParams: { filters, _q }, searchParams: { filters, _q },
slug,
} = useListView(); } = useListView();
const redirectUrl = `redirectUrl=${pathname}${search}`; const redirectUrl = `redirectUrl=${pathname}${search}`;
@ -33,7 +31,7 @@ function CustomTable({
const handleGoTo = id => { const handleGoTo = id => {
emitEvent('willEditEntryFromList'); emitEvent('willEditEntryFromList');
push({ push({
pathname: `/plugins/${pluginId}/${slug}/${id}`, pathname: `${pathname}/${id}`,
search: redirectUrl, search: redirectUrl,
}); });
}; };
@ -88,7 +86,7 @@ function CustomTable({
</tbody> </tbody>
</Table> </Table>
); );
} };
CustomTable.defaultProps = { CustomTable.defaultProps = {
data: [], data: [],

View File

@ -7,7 +7,7 @@ const EditSettingsView = lazy(() => import('../EditSettingsView'));
const ListView = lazy(() => import('../ListView')); const ListView = lazy(() => import('../ListView'));
const ListSettingsView = lazy(() => import('../ListSettingsView')); const ListSettingsView = lazy(() => import('../ListSettingsView'));
const RecursivePath = props => { const CollectionTypeRecursivePath = props => {
const { url } = useRouteMatch(); const { url } = useRouteMatch();
const { slug } = useParams(); const { slug } = useParams();
@ -41,4 +41,4 @@ const RecursivePath = props => {
); );
}; };
export default RecursivePath; export default CollectionTypeRecursivePath;

View File

@ -1,5 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useParams } from 'react-router-dom'; import { useParams, useLocation } from 'react-router-dom';
import { Header as PluginHeader } from '@buffetjs/custom'; import { Header as PluginHeader } from '@buffetjs/custom';
import { import {
@ -20,6 +20,7 @@ const Header = () => {
const { formatMessage, emitEvent } = useGlobalContext(); const { formatMessage, emitEvent } = useGlobalContext();
const { id } = useParams(); const { id } = useParams();
const { pathname } = useLocation();
const { const {
deleteSuccess, deleteSuccess,
initialData, initialData,
@ -28,23 +29,28 @@ const Header = () => {
resetData, resetData,
setIsSubmitting, setIsSubmitting,
slug, slug,
clearData,
} = useDataManager(); } = useDataManager();
const isSingleType = pathname.split('/')[3] === 'singleType';
const currentContentTypeMainField = get( const currentContentTypeMainField = get(
layout, layout,
['settings', 'mainField'], ['settings', 'mainField'],
'id' 'id'
); );
const currentContentTypeName = get(layout, ['schema', 'info', 'name']);
const apiId = layout.uid.split('.')[1];
const isCreatingEntry = id === 'create'; const isCreatingEntry = id === 'create';
/* eslint-disable indent */ /* eslint-disable indent */
const headerTitle = isCreatingEntry const entryHeaderTitle = isCreatingEntry
? formatMessage({ ? formatMessage({
id: `${pluginId}.containers.Edit.pluginHeader.title.new`, id: `${pluginId}.containers.Edit.pluginHeader.title.new`,
}) })
: templateObject({ mainField: currentContentTypeMainField }, initialData) : templateObject({ mainField: currentContentTypeMainField }, initialData)
.mainField; .mainField;
/* eslint-enable indent */ /* eslint-enable indent */
const headerTitle = isSingleType ? currentContentTypeName : entryHeaderTitle;
const getHeaderActions = () => { const getHeaderActions = () => {
const headerActions = [ const headerActions = [
@ -101,6 +107,9 @@ const Header = () => {
title: { title: {
label: headerTitle && headerTitle.toString(), label: headerTitle && headerTitle.toString(),
}, },
content: isSingleType
? `${formatMessage({ id: `${pluginId}.api.id` })} : ${apiId}`
: '',
actions: getHeaderActions(), actions: getHeaderActions(),
}; };
@ -114,17 +123,21 @@ const Header = () => {
const handleConfirmDelete = async () => { const handleConfirmDelete = async () => {
toggleWarningDelete(); toggleWarningDelete();
setIsSubmitting(); setIsSubmitting();
try { try {
emitEvent('willDeleteEntry'); emitEvent('willDeleteEntry');
await request(getRequestUrl(`${slug}/${id}`), { await request(getRequestUrl(`${slug}/${initialData.id}`), {
method: 'DELETE', method: 'DELETE',
}); });
strapi.notification.success(`${pluginId}.success.record.delete`); strapi.notification.success(`${pluginId}.success.record.delete`);
deleteSuccess(); deleteSuccess();
emitEvent('didDeleteEntry'); emitEvent('didDeleteEntry');
redirectToPreviousPage();
if (!isSingleType) {
redirectToPreviousPage();
} else {
clearData();
}
} catch (err) { } catch (err) {
setIsSubmitting(false); setIsSubmitting(false);
emitEvent('didNotDeleteEntry', { error: err }); emitEvent('didNotDeleteEntry', { error: err });

View File

@ -39,8 +39,8 @@ const EditView = ({
formatLayoutRef.current = createAttributesLayout; formatLayoutRef.current = createAttributesLayout;
// Retrieve push to programmatically navigate between views // Retrieve push to programmatically navigate between views
const { push } = useHistory(); const { push } = useHistory();
// Retrieve the search // Retrieve the search and the location
const { search } = useLocation(); const { search, pathname } = useLocation();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
const [reducerState, dispatch] = useReducer(reducer, initialState, () => const [reducerState, dispatch] = useReducer(reducer, initialState, () =>
init(initialState) init(initialState)
@ -129,6 +129,7 @@ const EditView = ({
.filter((_, index) => index !== 0) .filter((_, index) => index !== 0)
.join(''); .join('');
const redirectToPreviousPage = () => push(redirectURL); const redirectToPreviousPage = () => push(redirectURL);
const isSingleType = pathname.includes('singleType');
return ( return (
<EditViewProvider <EditViewProvider
@ -152,7 +153,7 @@ const EditView = ({
redirectToPreviousPage={redirectToPreviousPage} redirectToPreviousPage={redirectToPreviousPage}
slug={slug} slug={slug}
> >
<BackHeader onClick={() => redirectToPreviousPage()} /> <BackHeader onClick={redirectToPreviousPage} />
<Container className="container-fluid"> <Container className="container-fluid">
<Header /> <Header />
<div className="row" style={{ paddingTop: 3 }}> <div className="row" style={{ paddingTop: 3 }}>
@ -276,7 +277,9 @@ const EditView = ({
}} }}
icon="layout" icon="layout"
key={`${pluginId}.link`} key={`${pluginId}.link`}
url="ctm-configurations/edit-settings/content-types" url={`${
isSingleType ? `${pathname}/` : ''
}ctm-configurations/edit-settings/content-types`}
onClick={() => { onClick={() => {
// emitEvent('willEditContentTypeLayoutFromEditView'); // emitEvent('willEditContentTypeLayoutFromEditView');
}} }}

View File

@ -1,21 +1,23 @@
import React, { useEffect, useReducer } from 'react';
import { Prompt, useParams } from 'react-router-dom';
import PropTypes from 'prop-types';
import { cloneDeep, get, isEmpty, isEqual, set } from 'lodash'; import { cloneDeep, get, isEmpty, isEqual, set } from 'lodash';
import PropTypes from 'prop-types';
import React, { useEffect, useReducer, useState } from 'react';
import { Prompt, useParams, useLocation } from 'react-router-dom';
import { import {
request,
LoadingIndicatorPage, LoadingIndicatorPage,
request,
useGlobalContext, useGlobalContext,
} from 'strapi-helper-plugin'; } from 'strapi-helper-plugin';
import pluginId from '../../pluginId';
import EditViewDataManagerContext from '../../contexts/EditViewDataManager'; import EditViewDataManagerContext from '../../contexts/EditViewDataManager';
import createYupSchema from './utils/schema'; import pluginId from '../../pluginId';
import createDefaultForm from './utils/createDefaultForm';
import getFilesToUpload from './utils/getFilesToUpload';
import cleanData from './utils/cleanData';
import getYupInnerErrors from './utils/getYupInnerErrors';
import init from './init'; import init from './init';
import reducer, { initialState } from './reducer'; import reducer, { initialState } from './reducer';
import {
createYupSchema,
getYupInnerErrors,
getFilesToUpload,
createDefaultForm,
cleanData,
} from './utils';
const getRequestUrl = path => `/${pluginId}/explorer/${path}`; const getRequestUrl = path => `/${pluginId}/explorer/${path}`;
@ -26,6 +28,7 @@ const EditViewDataManagerProvider = ({
slug, slug,
}) => { }) => {
const { id } = useParams(); const { id } = useParams();
const { pathname } = useLocation();
// Retrieve the search // Retrieve the search
const [reducerState, dispatch] = useReducer(reducer, initialState, init); const [reducerState, dispatch] = useReducer(reducer, initialState, init);
const { const {
@ -37,13 +40,12 @@ const EditViewDataManagerProvider = ({
shouldShowLoadingState, shouldShowLoadingState,
shouldCheckErrors, shouldCheckErrors,
} = reducerState.toJS(); } = reducerState.toJS();
const [isCreatingEntry, setIsCreatingEntry] = useState(id === 'create');
const currentContentTypeLayout = get(allLayoutData, ['contentType'], {}); const currentContentTypeLayout = get(allLayoutData, ['contentType'], {});
const abortController = new AbortController(); const abortController = new AbortController();
const { signal } = abortController; const { signal } = abortController;
const isCreatingEntry = id === 'create';
const { emitEvent, formatMessage } = useGlobalContext(); const { emitEvent, formatMessage } = useGlobalContext();
const isSingleType = pathname.split('/')[3] === 'singleType';
useEffect(() => { useEffect(() => {
if (!isLoading) { if (!isLoading) {
@ -55,7 +57,7 @@ const EditViewDataManagerProvider = ({
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
try { try {
const data = await request(getRequestUrl(`${slug}/${id}`), { const data = await request(getRequestUrl(`${slug}/${id || ''}`), {
method: 'GET', method: 'GET',
signal, signal,
}); });
@ -65,9 +67,12 @@ const EditViewDataManagerProvider = ({
data, data,
}); });
} catch (err) { } catch (err) {
if (err.code !== 20) { if (id && err.code !== 20) {
strapi.notification.error(`${pluginId}.error.record.fetch`); strapi.notification.error(`${pluginId}.error.record.fetch`);
} }
if (!id && err.response.status === 404) {
setIsCreatingEntry(true);
}
} }
}; };
@ -108,7 +113,7 @@ const EditViewDataManagerProvider = ({
abortController.abort(); abortController.abort();
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, slug]); }, [id, slug, isCreatingEntry]);
const addComponentToDynamicZone = ( const addComponentToDynamicZone = (
keys, keys,
@ -246,7 +251,15 @@ const EditViewDataManagerProvider = ({
// Change the request helper default headers so we can pass a FormData // Change the request helper default headers so we can pass a FormData
const headers = {}; const headers = {};
const method = isCreatingEntry ? 'POST' : 'PUT'; const method = isCreatingEntry ? 'POST' : 'PUT';
const endPoint = isCreatingEntry ? slug : `${slug}/${id}`; let endPoint;
if (isCreatingEntry) {
endPoint = slug;
} else if (modifiedData) {
endPoint = `${slug}/${modifiedData.id}`;
} else {
endPoint = `${slug}/${id}`;
}
emitEvent(isCreatingEntry ? 'willCreateEntry' : 'willEditEntry'); emitEvent(isCreatingEntry ? 'willCreateEntry' : 'willEditEntry');
@ -267,7 +280,13 @@ const EditViewDataManagerProvider = ({
dispatch({ dispatch({
type: 'SUBMIT_SUCCESS', type: 'SUBMIT_SUCCESS',
}); });
redirectToPreviousPage(); strapi.notification.success(`${pluginId}.success.record.save`);
if (isSingleType) {
setIsCreatingEntry(false);
} else {
redirectToPreviousPage();
}
} catch (err) { } catch (err) {
console.error({ err }); console.error({ err });
const error = get( const error = get(
@ -375,6 +394,31 @@ const EditViewDataManagerProvider = ({
dispatch({ type: 'IS_SUBMITTING', value }); dispatch({ type: 'IS_SUBMITTING', value });
}; };
const deleteSuccess = () => {
dispatch({
type: 'DELETE_SUCCEEDED',
});
};
const resetData = () => {
dispatch({
type: 'RESET_DATA',
});
};
const clearData = () => {
dispatch({
type: 'SET_DEFAULT_MODIFIED_DATA_STRUCTURE',
contentTypeDataStructure: {},
});
};
const triggerFormValidation = () => {
dispatch({
type: 'TRIGGER_FORM_VALIDATION',
});
};
const showLoader = !isCreatingEntry && isLoading; const showLoader = !isCreatingEntry && isLoading;
return ( return (
@ -386,11 +430,8 @@ const EditViewDataManagerProvider = ({
addRepeatableComponentToField, addRepeatableComponentToField,
allLayoutData, allLayoutData,
checkFormErrors, checkFormErrors,
deleteSuccess: () => { clearData,
dispatch({ deleteSuccess,
type: 'DELETE_SUCCEEDED',
});
},
formErrors, formErrors,
initialData, initialData,
layout: currentContentTypeLayout, layout: currentContentTypeLayout,
@ -405,19 +446,11 @@ const EditViewDataManagerProvider = ({
removeComponentFromDynamicZone, removeComponentFromDynamicZone,
removeComponentFromField, removeComponentFromField,
removeRepeatableField, removeRepeatableField,
resetData: () => { resetData,
dispatch({
type: 'RESET_DATA',
});
},
setIsSubmitting, setIsSubmitting,
shouldShowLoadingState, shouldShowLoadingState,
slug, slug,
triggerFormValidation: () => { triggerFormValidation,
dispatch({
type: 'TRIGGER_FORM_VALIDATION',
});
},
}} }}
> >
{showLoader ? ( {showLoader ? (

View File

@ -232,7 +232,9 @@ const reducer = (state, action) => {
.update('shouldShowLoadingState', () => false); .update('shouldShowLoadingState', () => false);
case 'SUBMIT_SUCCESS': case 'SUBMIT_SUCCESS':
case 'DELETE_SUCCEEDED': case 'DELETE_SUCCEEDED':
return state.update('initialData', () => state.get('modifiedData')); return state
.update('isLoading', () => false)
.update('initialData', () => state.get('modifiedData'));
case 'TRIGGER_FORM_VALIDATION': case 'TRIGGER_FORM_VALIDATION':
return state.update('shouldCheckErrors', v => { return state.update('shouldCheckErrors', v => {
const hasErrors = state.get('formErrors').keySeq().size > 0; const hasErrors = state.get('formErrors').keySeq().size > 0;

View File

@ -0,0 +1,5 @@
export { default as cleanData } from './cleanData';
export { default as createDefaultForm } from './createDefaultForm';
export { default as getFilesToUpload } from './getFilesToUpload';
export { default as getYupInnerErrors } from './getYupInnerErrors';
export { default as createYupSchema } from './schema';

View File

@ -23,12 +23,18 @@ const Initializer = ({ updatePlugin }) => {
try { try {
const { data } = await request(requestURL, { method: 'GET' }); const { data } = await request(requestURL, { method: 'GET' });
// Two thinks to know here:
// First, we group content types by schema.kind to get an object with two separated content types (singleTypes, collectionTypes)
// Then, we sort by name to keep collection types at the first position everytime.
// As all content types are sort by name, if a single type name start with abc, the single types section will be at the first position.
// However, we want to keep collection types at the first position in the admin menu
ref.current( ref.current(
pluginId, pluginId,
'leftMenuSections', 'leftMenuSections',
chain(data) chain(data)
.groupBy('schema.kind') .groupBy('schema.kind')
.map((value, key) => ({ name: key, links: value })) .map((value, key) => ({ name: key, links: value }))
.sortBy('name')
.value() .value()
); );
ref.current(pluginId, 'isReady', true); ref.current(pluginId, 'isReady', true);

View File

@ -25,7 +25,12 @@ import reducer from './reducer';
import makeSelectMain from './selectors'; import makeSelectMain from './selectors';
const EditSettingsView = lazy(() => import('../EditSettingsView')); const EditSettingsView = lazy(() => import('../EditSettingsView'));
const RecursivePath = lazy(() => import('../RecursivePath')); const CollectionTypeRecursivePath = lazy(() =>
import('../CollectionTypeRecursivePath')
);
const SingleTypeRecursivePath = lazy(() =>
import('../SingleTypeRecursivePath')
);
function Main({ function Main({
deleteLayout, deleteLayout,
@ -45,7 +50,7 @@ function Main({
strapi.useInjectReducer({ key: 'main', reducer, pluginId }); strapi.useInjectReducer({ key: 'main', reducer, pluginId });
const { emitEvent } = useGlobalContext(); const { emitEvent } = useGlobalContext();
const slug = pathname.split('/')[3]; const slug = pathname.split('/')[4];
const getDataRef = useRef(); const getDataRef = useRef();
const getLayoutRef = useRef(); const getLayoutRef = useRef();
const resetPropsRef = useRef(); const resetPropsRef = useRef();
@ -123,7 +128,8 @@ function Main({
path: 'ctm-configurations/edit-settings/:type/:componentSlug', path: 'ctm-configurations/edit-settings/:type/:componentSlug',
comp: EditSettingsView, comp: EditSettingsView,
}, },
{ path: ':slug', comp: RecursivePath }, { path: 'singleType/:slug', comp: SingleTypeRecursivePath },
{ path: 'collectionType/:slug', comp: CollectionTypeRecursivePath },
].map(({ path, comp }) => ( ].map(({ path, comp }) => (
<Route <Route
key={path} key={path}

View File

@ -0,0 +1,37 @@
import React, { Suspense, lazy } from 'react';
import { Switch, Route, useRouteMatch, useParams } from 'react-router-dom';
import { LoadingIndicatorPage } from 'strapi-helper-plugin';
const EditView = lazy(() => import('../EditView'));
const EditSettingsView = lazy(() => import('../EditSettingsView'));
const SingleTypeRecursivePath = props => {
const { url } = useRouteMatch();
const { slug } = useParams();
const renderRoute = (routeProps, Component) => {
return <Component {...props} {...routeProps} slug={slug} />;
};
const routes = [
{
path: 'ctm-configurations/edit-settings/:type',
comp: EditSettingsView,
},
{ path: '', comp: EditView },
].map(({ path, comp }) => (
<Route
key={path}
path={`${url}/${path}`}
render={props => renderRoute(props, comp)}
/>
));
return (
<Suspense fallback={<LoadingIndicatorPage />}>
<Switch>{routes}</Switch>
</Suspense>
);
};
export default SingleTypeRecursivePath;

View File

@ -1,4 +1,5 @@
{ {
"api.id": "API ID",
"models": "Collection Types", "models": "Collection Types",
"models.numbered": "Collection Types ({number})", "models.numbered": "Collection Types ({number})",
"groups": "Groups", "groups": "Groups",

View File

@ -482,6 +482,7 @@ const DataManagerProvider = ({ allIcons, children }) => {
chain(data) chain(data)
.groupBy('schema.kind') .groupBy('schema.kind')
.map((value, key) => ({ name: key, links: value })) .map((value, key) => ({ name: key, links: value }))
.sortBy('name')
.value() .value()
); );
} catch (err) { } catch (err) {

View File

@ -88,7 +88,7 @@ const FormModal = () => {
const dynamicZoneTarget = query.get('dynamicZoneTarget'); const dynamicZoneTarget = query.get('dynamicZoneTarget');
const forTarget = query.get('forTarget'); const forTarget = query.get('forTarget');
const modalType = query.get('modalType'); const modalType = query.get('modalType');
const contentTypeKind = query.get('kind'); const kind = query.get('kind');
const targetUid = query.get('targetUid'); const targetUid = query.get('targetUid');
const settingType = query.get('settingType'); const settingType = query.get('settingType');
const headerId = query.get('headerId'); const headerId = query.get('headerId');
@ -127,7 +127,7 @@ const FormModal = () => {
actionType, actionType,
attributeName, attributeName,
attributeType, attributeType,
contentTypeKind, kind,
dynamicZoneTarget, dynamicZoneTarget,
forTarget, forTarget,
modalType, modalType,
@ -626,7 +626,7 @@ const FormModal = () => {
// Create the content type schema // Create the content type schema
if (isCreating) { if (isCreating) {
createSchema( createSchema(
{ ...modifiedData, kind: state.contentTypeKind }, { ...modifiedData, kind: state.kind },
state.modalType, state.modalType,
uid uid
); );