Created menu api

Signed-off-by: soupette <cyril.lpz@gmail.com>
This commit is contained in:
soupette 2021-06-16 13:05:21 +02:00
parent c68f815a51
commit ef208589d5
22 changed files with 111 additions and 273 deletions

View File

@ -63,6 +63,28 @@ class StrapiApp {
}
};
addCorePluginMenuLink = link => {
const stringifiedLink = JSON.stringify(link);
invariant(link.to, `link.to should be defined for ${stringifiedLink}`);
invariant(
typeof link.to === 'string',
`Expected link.to to be a string instead received ${typeof link.to}`
);
invariant(
['/plugins/content-manager', '/plugins/content-type-builder', '/plugins/upload'].includes(
link.to
),
'This method is not available for your plugin'
);
invariant(
link.intlLabel?.id && link.intlLabel?.defaultMessage,
`link.intlLabel.id & link.intlLabel.defaultMessage for ${stringifiedLink}`
);
this.menu.push(link);
};
addFields = fields => {
if (Array.isArray(fields)) {
fields.map(field => this.library.fields.add(field));
@ -87,6 +109,8 @@ class StrapiApp {
link.Component && typeof link.Component === 'function',
`link.Component should be a valid React Component`
);
this.menu.push(link);
};
addMiddlewares = middlewares => {
@ -133,6 +157,7 @@ class StrapiApp {
Object.keys(this.appPlugins).forEach(plugin => {
this.appPlugins[plugin].register({
addComponents: this.addComponents,
addCorePluginMenuLink: this.addCorePluginMenuLink,
addFields: this.addFields,
addMenuLink: this.addMenuLink,
addMiddlewares: this.addMiddlewares,
@ -286,6 +311,7 @@ class StrapiApp {
<Provider store={store}>
<StrapiAppProvider
getPlugin={this.getPlugin}
menu={this.menu}
plugins={this.plugins}
runHookParallel={this.runHookParallel}
runHookWaterfall={this.runHookWaterfall}

View File

@ -4,7 +4,6 @@ import { useSelector, useDispatch } from 'react-redux';
import getPluginSectionLinks from './utils/getPluginSectionLinks';
import getGeneralLinks from './utils/getGeneralLinks';
import { setSectionLinks, unsetIsLoading } from './actions';
import toPluginLinks from './utils/toPluginLinks';
import selectMenuLinks from './selectors';
const useMenuSections = () => {
@ -12,18 +11,16 @@ const useMenuSections = () => {
const dispatch = useDispatch();
const { allPermissions } = useRBACProvider();
const { shouldUpdateStrapi } = useAppInfos();
const { plugins } = useStrapiApp();
const { menu } = useStrapiApp();
// We are using a ref because we don't want our effect to have this in its dependencies array
const generalSectionLinksRef = useRef(state.generalSectionLinks);
const shouldUpdateStrapiRef = useRef(shouldUpdateStrapi);
// Once in the app lifecycle the plugins should not be added into any dependencies array, in order to prevent
// the effect to be run when another plugin is using one plugins internal api for instance
// so it's definitely ok to use a ref here
const pluginsRef = useRef(plugins);
// Once in the app lifecycle the menu should not be added into any dependencies array
const menuRef = useRef(menu);
const resolvePermissions = async (permissions = allPermissions) => {
const pluginsSectionLinks = toPluginLinks(pluginsRef.current);
const pluginsSectionLinks = menuRef.current;
const authorizedPluginSectionLinks = await getPluginSectionLinks(
permissions,
@ -46,8 +43,6 @@ const useMenuSections = () => {
resolvePermissionsRef.current(allPermissions);
}, [allPermissions, dispatch]);
// TODO remove the isDisplayed key from the links it's not useful anymore
return state;
};

View File

@ -1,57 +0,0 @@
import toPluginLinks from '../toPluginLinks';
describe('toPluginLinks', () => {
it('transforms a plugin object into an array of plugin page links', async () => {
const plugins = [
{
id: 'content-type-builder',
description: 'content-type-builder.plugin.description',
name: 'Content Type Builder',
menu: {
pluginsSectionLinks: [
{
destination: '/plugins/content-type-builder',
icon: 'paint-brush',
label: {
id: 'content-type-builder.plugin.name',
defaultMessage: 'Content-Types Builder',
},
name: 'Content Type Builder',
permissions: [
{
action: 'plugins::content-type-builder.read',
subject: null,
},
],
},
],
},
},
{
id: 'content-manager',
description: 'content-manager.plugin.description',
name: 'Content Manager',
},
];
const expected = [
{
destination: '/plugins/content-type-builder',
icon: 'paint-brush',
label: {
id: 'content-type-builder.plugin.name',
defaultMessage: 'Content-Types Builder',
},
permissions: [
{
action: 'plugins::content-type-builder.read',
subject: null,
},
],
},
];
const actual = toPluginLinks(plugins);
expect(actual).toEqual(expected);
});
});

View File

@ -1,19 +0,0 @@
import get from 'lodash/get';
import omit from 'lodash/omit';
import sortLinks from '../../../utils/sortLinks';
const toPluginLinks = plugins => {
const pluginsLinks = Object.values(plugins).reduce((acc, current) => {
const pluginsSectionLinks = get(current, 'menu.pluginsSectionLinks', []);
return [...acc, ...pluginsSectionLinks];
}, []);
const sortedLinks = sortLinks(pluginsLinks).map(link => {
return { ...omit(link, 'name') };
});
return sortedLinks;
};
export default toPluginLinks;

View File

@ -7,9 +7,9 @@ class Plugin {
this.injectionZones = pluginConf.injectionZones || {};
this.isReady = pluginConf.isReady !== undefined ? pluginConf.isReady : true;
this.isRequired = pluginConf.isRequired;
this.mainComponent = pluginConf.mainComponent || null;
// this.mainComponent = pluginConf.mainComponent || null;
// TODO
this.menu = pluginConf.menu || null;
// this.menu = pluginConf.menu || null;
this.name = pluginConf.name;
this.pluginId = pluginConf.id;
this.pluginLogo = pluginConf.pluginLogo;

View File

@ -4,10 +4,15 @@
*
*/
import React, { Suspense, useEffect, lazy } from 'react';
import React, { Suspense, useEffect, useMemo, lazy } from 'react';
import { Switch, Route } from 'react-router-dom';
// Components from @strapi/helper-plugin
import { CheckPagePermissions, useTracking, LoadingIndicatorPage } from '@strapi/helper-plugin';
import {
CheckPagePermissions,
useTracking,
LoadingIndicatorPage,
useStrapiApp,
} from '@strapi/helper-plugin';
import adminPermissions from '../../permissions';
import Header from '../../components/Header/index';
import NavTopRightWrapper from '../../components/NavTopRightWrapper';
@ -20,6 +25,7 @@ import { useReleaseNotification } from '../../hooks';
import Logout from './Logout';
import Wrapper from './Wrapper';
import Content from './Content';
import { createRoute } from '../../utils';
const HomePage = lazy(() => import(/* webpackChunkName: "Admin_homePage" */ '../HomePage'));
const InstalledPluginsPage = lazy(() =>
@ -29,9 +35,7 @@ const MarketplacePage = lazy(() =>
import(/* webpackChunkName: "Admin_marketplace" */ '../MarketplacePage')
);
const NotFoundPage = lazy(() => import('../NotFoundPage'));
const PluginDispatcher = lazy(() =>
import(/* webpackChunkName: "Admin_pluginDispatcher" */ '../PluginDispatcher')
);
const ProfilePage = lazy(() =>
import(/* webpackChunkName: "Admin_profilePage" */ '../ProfilePage')
);
@ -69,6 +73,13 @@ const Admin = () => {
useTrackUsage();
// TODO
const { isLoading, generalSectionLinks, pluginsSectionLinks } = useMenuSections();
const { menu } = useStrapiApp();
const routes = useMemo(() => {
return menu
.filter(link => link.Component)
.map(({ to, Component, exact }) => createRoute(Component, to, exact));
}, [menu]);
if (isLoading) {
return <LoadingIndicatorPage />;
@ -94,7 +105,7 @@ const Admin = () => {
<Route path="/plugins/content-manager" component={CM} />
<Route path="/plugins/content-type-builder" component={CTB} />
<Route path="/plugins/upload" component={Upload} />
<Route path="/plugins/:pluginId" component={PluginDispatcher} />
{routes}
<Route path="/settings/:settingId" component={SettingsPage} />
<Route path="/settings" component={SettingsPage} exact />
<Route path="/marketplace">

View File

@ -14,12 +14,12 @@ import {
TrackingContext,
} from '@strapi/helper-plugin';
import PrivateRoute from '../../components/PrivateRoute';
import { createRoute, makeUniqueRoutes } from '../../utils';
import AuthPage from '../AuthPage';
import NotFoundPage from '../NotFoundPage';
import { getUID } from './utils';
import { Content, Wrapper } from './components';
import routes from './utils/routes';
import { makeUniqueRoutes, createRoute } from '../SettingsPage/utils';
const AuthenticatedApp = lazy(() =>
import(/* webpackChunkName: "Admin-authenticatedApp" */ '../../components/AuthenticatedApp')

View File

@ -1,38 +0,0 @@
/**
*
* PluginDispatcher
*
*/
import React, { memo } from 'react';
import { Redirect, useParams } from 'react-router-dom';
import get from 'lodash/get';
import { ErrorBoundary } from 'react-error-boundary';
import { ErrorFallback, useStrapiApp } from '@strapi/helper-plugin';
import PageTitle from '../../components/PageTitle';
const PluginDispatcher = () => {
const { pluginId } = useParams();
const { plugins } = useStrapiApp();
const pluginToRender = get(plugins, pluginId, null);
if (!pluginToRender) {
return <Redirect to="/404" />;
}
const { mainComponent, name } = pluginToRender;
const PluginEntryComponent = mainComponent;
return (
<div>
<PageTitle title={`Strapi - ${name}`} />
<ErrorBoundary FallbackComponent={ErrorFallback}>
<PluginEntryComponent />
</ErrorBoundary>
</div>
);
};
export default memo(PluginDispatcher);
export { PluginDispatcher };

View File

@ -1,73 +0,0 @@
import React from 'react';
import { Router, Route, Link } from 'react-router-dom';
import { StrapiAppProvider } from '@strapi/helper-plugin';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { createMemoryHistory } from 'history';
import { PluginDispatcher } from '../index';
const Email = () => <div>Email Plugin</div>;
const makeApp = (history, plugins) => (
<StrapiAppProvider plugins={plugins}>
<Router history={history}>
<Link to="/plugins/email">Go to email</Link>
<Route path="/plugins/:pluginId" component={PluginDispatcher} />
<Route path="/404" component={() => <h1>404</h1>} />
</Router>
</StrapiAppProvider>
);
describe('<PluginDispatcher />', () => {
it('should not crash', () => {
const history = createMemoryHistory();
const App = makeApp(history, {});
const { container } = render(App);
expect(container.firstChild).toMatchInlineSnapshot(`
<a
href="/plugins/email"
>
Go to email
</a>
`);
});
it('should redirect to the 404 page if the params does not match the pluginId', () => {
const plugins = {
email: {
mainComponent: Email,
name: 'email',
},
};
const history = createMemoryHistory();
const route = '/plugins/email-test';
history.push(route);
const App = makeApp(history, plugins);
render(App);
expect(screen.getByText(/404/i)).toBeInTheDocument();
});
it('should match the pluginId params with the correct plugin', () => {
const plugins = {
email: {
mainComponent: Email,
name: 'email',
},
};
const history = createMemoryHistory();
const App = makeApp(history, plugins);
render(App);
const leftClick = { button: 0 };
userEvent.click(screen.getByText(/Go to email/i), leftClick);
expect(screen.getByText(/Email Plugin/i)).toBeInTheDocument();
});
});

View File

@ -22,17 +22,11 @@ import HeaderSearch from '../../components/HeaderSearch';
import PageTitle from '../../components/PageTitle';
import SettingsSearchHeaderProvider from '../../components/SettingsHeaderSearchContextProvider';
import { useSettingsMenu } from '../../hooks';
import { createRoute, makeUniqueRoutes } from '../../utils';
import ApplicationInfosPage from '../ApplicationInfosPage';
import { ApplicationDetailLink, MenuWrapper, StyledLeftMenu, Wrapper } from './components';
import {
createRoute,
createSectionsRoutes,
makeUniqueRoutes,
getSectionsToDisplay,
routes,
} from './utils';
import { createSectionsRoutes, getSectionsToDisplay, routes } from './utils';
function SettingsPage() {
const { settingId } = useParams();

View File

@ -1,5 +1,5 @@
import flatMap from 'lodash/flatMap';
import createRoute from './createRoute';
import { createRoute } from '../../../utils';
const createSectionsRoutes = settings => {
const allLinks = flatMap(settings, section => section.links);

View File

@ -1,5 +1,4 @@
export { default as createSectionsRoutes } from './createSectionsRoutes';
export { default as createRoute } from './createRoute';
export { default as getSectionsToDisplay } from './getSectionsToDisplay';
export { default as makeUniqueRoutes } from './makeUniqueRoutes';
export { default as routes } from './routes';

View File

@ -1,6 +1,8 @@
export { default as checkFormValidity } from './checkFormValidity';
export { default as createRoute } from './createRoute';
export { default as formatAPIErrors } from './formatAPIErrors';
export { default as getAttributesToDisplay } from './getAttributesToDisplay';
export { default as makeUniqueRoutes } from './makeUniqueRoutes';
export { default as sortLinks } from './sortLinks';
export { default as getExistingActions } from './getExistingActions';

View File

@ -18,6 +18,15 @@ const name = pluginPkg.strapi.name;
export default {
register(app) {
app.addReducers(reducers);
app.addCorePluginMenuLink({
to: `/plugins/${pluginId}`,
icon: 'book-open',
intlLabel: {
id: `${pluginId}.plugin.name`,
defaultMessage: 'Content manager',
},
permissions: pluginPermissions.main,
});
app.registerPlugin({
description: pluginDescription,
@ -31,19 +40,6 @@ export default {
isRequired: pluginPkg.strapi.required || false,
name,
pluginLogo,
menu: {
pluginsSectionLinks: [
{
to: `/plugins/${pluginId}`,
icon: 'book-open',
intlLabel: {
id: `${pluginId}.plugin.name`,
defaultMessage: 'Content manager',
},
permissions: pluginPermissions.main,
},
],
},
});
},
boot() {},

View File

@ -19,6 +19,16 @@ const name = pluginPkg.strapi.name;
export default {
register(app) {
app.addReducers(reducers);
app.addCorePluginMenuLink({
to: `/plugins/${pluginId}`,
icon,
intlLabel: {
id: `${pluginId}.plugin.name`,
defaultMessage: 'Content-Types Builder',
},
permissions: pluginPermissions.main,
});
app.registerPlugin({
description: pluginDescription,
icon,
@ -27,19 +37,6 @@ export default {
isReady: true,
name,
pluginLogo,
menu: {
pluginsSectionLinks: [
{
to: `/plugins/${pluginId}`,
icon,
intlLabel: {
id: `${pluginId}.plugin.name`,
defaultMessage: 'Content-Types Builder',
},
permissions: pluginPermissions.main,
},
],
},
// Internal APIs exposed by the CTB for the other plugins to use
apis: {
forms: formsAPI,

View File

@ -10,6 +10,7 @@ import StrapiAppContext from '../../contexts/StrapiAppContext';
const StrapiAppProvider = ({
children,
getPlugin,
menu,
plugins,
runHookParallel,
runHookWaterfall,
@ -20,6 +21,7 @@ const StrapiAppProvider = ({
<StrapiAppContext.Provider
value={{
getPlugin,
menu,
plugins,
runHookParallel,
runHookWaterfall,
@ -35,6 +37,18 @@ const StrapiAppProvider = ({
StrapiAppProvider.propTypes = {
children: PropTypes.node.isRequired,
getPlugin: PropTypes.func.isRequired,
menu: PropTypes.arrayOf(
PropTypes.shape({
to: PropTypes.string.isRequired,
icon: PropTypes.array,
intlLabel: PropTypes.shape({
id: PropTypes.string.isRequired,
defaultMessage: PropTypes.string.isRequired,
}).isRequired,
permissions: PropTypes.array,
Component: PropTypes.node,
})
).isRequired,
plugins: PropTypes.object.isRequired,
runHookParallel: PropTypes.func.isRequired,
runHookWaterfall: PropTypes.func.isRequired,

View File

@ -25,6 +25,17 @@ export default {
register(app) {
// TODO update doc and guides
app.addComponents({ name: 'media-library', Component: InputModalStepper });
app.addCorePluginMenuLink({
to: `/plugins/${pluginId}`,
icon,
intlLabel: {
id: `${pluginId}.plugin.name`,
defaultMessage: 'Media Library',
},
permissions: pluginPermissions.main,
});
// TODO update guide
app.addFields({ type: 'media', Component: InputMedia });
@ -40,20 +51,6 @@ export default {
isRequired: pluginPkg.strapi.required || false,
name,
pluginLogo,
menu: {
pluginsSectionLinks: [
{
to: `/plugins/${pluginId}`,
icon,
intlLabel: {
id: `${pluginId}.plugin.name`,
defaultMessage: 'Media Library',
},
permissions: pluginPermissions.main,
},
],
},
});
},
boot(app) {

View File

@ -17,30 +17,25 @@ const name = pluginPkg.strapi.name;
export default {
register(app) {
app.addMenuLink({
to: `/plugins/${pluginId}`,
icon,
intlLabel: {
id: `${pluginId}.plugin.name`,
defaultMessage: 'Documentation',
},
permissions: pluginPermissions.main,
Component: App,
});
app.registerPlugin({
description: pluginDescription,
icon,
id: pluginId,
isReady: true,
isRequired: pluginPkg.strapi.required || false,
// TODO
mainComponent: App,
name,
pluginLogo,
// TODO
menu: {
pluginsSectionLinks: [
{
to: `/plugins/${pluginId}`,
icon,
intlLabel: {
id: `${pluginId}.plugin.name`,
defaultMessage: 'Documentation',
},
permissions: pluginPermissions.main,
},
],
},
});
},
boot() {},

View File

@ -34,7 +34,6 @@ export default {
initializer: Initializer,
isReady: false,
isRequired: pluginPkg.strapi.required || false,
mainComponent: null,
name,
pluginLogo,
});