This article is a continuation of the following content: Server customization part 4/6
A plugin allows you to customize the front-end part of your Strapi application. In fact, you can:
We are going to explore the entry point of the admin of your plugin: ./src/plugins/todo/admin/src/index.js
.
By default, your admin/src/index.js
file should look like this:
1// admin/src/index.js
2import { prefixPluginTranslations } from '@strapi/helper-plugin';
3import pluginPkg from '../../package.json';
4import pluginId from './pluginId';
5import Initializer from './components/Initializer';
6import PluginIcon from './components/PluginIcon';
7
8const name = pluginPkg.strapi.name;
9
10export default {
11 register(app) {
12 app.addMenuLink({
13 to: `/plugins/${pluginId}`,
14 icon: PluginIcon,
15 intlLabel: {
16 id: `${pluginId}.plugin.name`,
17 defaultMessage: name,
18 },
19 Component: async () => {
20 const component = await import(/* webpackChunkName: "[request]" */ './pages/App');
21
22 return component;
23 },
24 permissions: [
25 // Uncomment to set the permissions of the plugin here
26 // {
27 // action: '', // the action name should be plugin::plugin-name.actionType
28 // subject: null,
29 // },
30 ],
31 });
32 app.registerPlugin({
33 id: pluginId,
34 initializer: Initializer,
35 isReady: false,
36 name,
37 });
38 },
39
40 bootstrap(app) {},
41 async registerTrads({ locales }) {
42 const importedTrads = await Promise.all(
43 locales.map(locale => {
44 return import(`./translations/${locale}.json`)
45 .then(({ default: data }) => {
46 return {
47 data: prefixPluginTranslations(data, pluginId),
48 locale,
49 };
50 })
51 .catch(() => {
52 return {
53 data: {},
54 locale,
55 };
56 });
57 })
58 );
59
60 return Promise.resolve(importedTrads);
61 },
62};
This file will, first, add your plugin to the menu link and register your plugin during the register phase:
1register(app) {
2 app.addMenuLink({
3 to: `/plugins/${pluginId}`,
4 icon: PluginIcon,
5 intlLabel: {
6 id: `${pluginId}.plugin.name`,
7 defaultMessage: name,
8 },
9 Component: async () => {
10 const component = await import(/* webpackChunkName: "[request]" */ './pages/App');
11
12 return component;
13 },
14 permissions: [
15 // Uncomment to set the permissions of the plugin here
16 // {
17 // action: '', // the action name should be plugin::plugin-name.actionType
18 // subject: null,
19 // },
20 ],
21 });
22 app.registerPlugin({
23 id: pluginId,
24 initializer: Initializer,
25 isReady: false,
26 name,
27 });
28 },
Then, nothing is happening during the bootstrap phase:
1bootstrap(app) {},
This is the phase where we are going to inject our components later. Finally, it handles the translation files in your plugin allowing you to make it i18n friendly.
1async registerTrads({ locales }) {
2 const importedTrads = await Promise.all(
3 locales.map(locale => {
4 return import(`./translations/${locale}.json`)
5 .then(({ default: data }) => {
6 return {
7 data: prefixPluginTranslations(data, pluginId),
8 locale,
9 };
10 })
11 .catch(() => {
12 return {
13 data: {},
14 locale,
15 };
16 });
17 })
18 );
19
20 return Promise.resolve(importedTrads);
21 },
There are a lot of things you can already do here. You may want to customize the icon of your plugin in the menu link for example by updating the admin/src/components/PluginIcon/index.js
file. Find another icon in the Strapi Design System website and update it:
1// admin/src/components/PluginIcon/index.js
2// Replace the default Puzzle icon by a Brush one
3import React from 'react';
4import Brush from '@strapi/icons/Brush';
5
6const PluginIcon = () => <Brush />;
7
8export default PluginIcon;
Tip: Don't forget to build your admin or to run your project with the --watch-admin
option.
If you don't want your plugin to be listed in the menu link for some reason, you can remove this function.
Now is the time to get into the front-end part of this plugin but first, an introduction to our Design System is necessary.
Strapi Design System provides guidelines and tools to help anyone make Strapi's contributions more cohesive and to build plugins more efficiently. You can find the guidelines for publishing a plugin to the marketplace. As you can see: Plugins compatible with Strapi v4 MUST use the Strapi Design System for the UI.
.
Caution: We insist on the fact that v4 plugins must use the Design System.
Feel free to browse the Design System website but what is more important for you is the components. This is every React component you can use within your project to give a beautiful UI to your plugin.
You can also find every icon you can use. Click on them to have the import js line copied in your clipboard.
You don't need to install anything, you can directly create components, import some items from the Design System like this: import { Button } from '@strapi/design-system/Button';
and that's it.
A Strapi plugin can have a homepage or not, you decide if it is necessary. For our todo use case, it doesn't make sense to have a specific homepage since the most important part would be to inject a todo on every entry we have (article, product, etc...)
But this section will cover this anyway. We are going to use the route we created to get the total number of tasks in order to display it on this homepage.
The route we created is the following:
1// server/routes/task.js
2module.exports = {
3 type: 'admin',
4 routes: [
5 {
6 method: 'GET',
7 path: '/count',
8 handler: 'task.count',
9 config: {
10 policies: [],
11 auth: false,
12 },
13 },
14 // ...
15 ],
16};
Tip: This is an admin route, which means that it will be directly accessible from the admin. Not from the outside (/api/...)
We are going to create a file that will execute every HTTP request to our server in a specific folder but feel free to name it differently or manage this file in another way. We are going to use the provided axiosInstance
to send these requests.
./admin/src/api/task.js
file containing the following:1// ./admin/src/api/task.js
2import axiosInstance from '../../src/utils/axiosInstance';
3
4const taskRequests = {
5 getTaskCount: async () => {
6 const data = await axiosInstance.get(`/todo/count`);
7 return data;
8 },
9};
10export default taskRequests;
Tip: We are using the axiosInstance
provided byt the plugin. You can also use the request function from the helper-plugin too. This plugin is a core package containing hooks, components, functions and more. You can learn more about the request function for making HTTP request.
Feel free to take some time exploring the strapi repository, a lot of useful things, sometimes not yet documented, can be found.
admin/src/pages/Homepage/index.js
file with the following:1// admin/src/pages/Homepage/index.js
2/*
3 *
4 * HomePage
5 *
6 */
7
8import React, { memo, useState, useEffect } from 'react';
9
10import taskRequests from '../../api/task';
11
12import { Box } from '@strapi/design-system/Box';
13import { Flex } from '@strapi/design-system/Flex';
14import { Typography } from '@strapi/design-system/Typography';
15import { EmptyStateLayout } from '@strapi/design-system/EmptyStateLayout';
16import { BaseHeaderLayout, ContentLayout } from '@strapi/design-system/Layout';
17
18import { Illo } from '../../components/Illo';
19
20const HomePage = () => {
21 const [taskCount, setTaskCount] = useState(0);
22
23 useEffect(() => {
24 taskRequests.getTaskCount().then(res => {
25 setTaskCount(res.data);
26 });
27 }, [setTaskCount]);
28
29 return (
30 <>
31 <BaseHeaderLayout
32 title="Todo Plugin"
33 subtitle="Discover the number of tasks you have in your project"
34 as="h2"
35 />
36 <ContentLayout>
37 {taskCount === 0 && (
38 <EmptyStateLayout icon={<Illo />} content="You don't have any tasks yet..." />
39 )}
40 {taskCount > 0 && (
41 <Box background="neutral0" hasRadius={true} shadow="filterShadow">
42 <Flex justifyContent="center" padding={8}>
43 <Typography variant="alpha">You have a total of {taskCount} tasks 🚀</Typography>
44 </Flex>
45 </Box>
46 )}
47 </ContentLayout>
48 </>
49 );
50};
51
52export default memo(HomePage);
For this homepage to work, you'll just need to create the Illo
icon that the EmptyStateLayout
is using.
admin/src/components/Illo/index.js
file containing the following:1// admin/src/components/Illo/index.js
2import React from 'react';
3
4export const Illo = () => (
5 <svg width="159" height="88" viewBox="0 0 159 88" fill="none" xmlns="http://www.w3.org/2000/svg">
6 <path
7 fillRule="evenodd"
8 clipRule="evenodd"
9 d="M134.933 17.417C137.768 17.417 140.067 19.7153 140.067 22.5503C140.067 25.3854 137.768 27.6837 134.933 27.6837H105.6C108.435 27.6837 110.733 29.9819 110.733 32.817C110.733 35.6521 108.435 37.9503 105.6 37.9503H121.733C124.568 37.9503 126.867 40.2486 126.867 43.0837C126.867 45.9187 124.568 48.217 121.733 48.217H114.272C110.698 48.217 107.8 50.5153 107.8 53.3503C107.8 55.2404 109.267 56.9515 112.2 58.4837C115.035 58.4837 117.333 60.7819 117.333 63.617C117.333 66.4521 115.035 68.7503 112.2 68.7503H51.3333C48.4982 68.7503 46.2 66.4521 46.2 63.617C46.2 60.7819 48.4982 58.4837 51.3333 58.4837H22.7333C19.8982 58.4837 17.6 56.1854 17.6 53.3503C17.6 50.5153 19.8982 48.217 22.7333 48.217H52.0666C54.9017 48.217 57.2 45.9187 57.2 43.0837C57.2 40.2486 54.9017 37.9503 52.0666 37.9503H33.7333C30.8982 37.9503 28.6 35.6521 28.6 32.817C28.6 29.9819 30.8982 27.6837 33.7333 27.6837H63.0666C60.2316 27.6837 57.9333 25.3854 57.9333 22.5503C57.9333 19.7153 60.2316 17.417 63.0666 17.417H134.933ZM134.933 37.9503C137.768 37.9503 140.067 40.2486 140.067 43.0837C140.067 45.9187 137.768 48.217 134.933 48.217C132.098 48.217 129.8 45.9187 129.8 43.0837C129.8 40.2486 132.098 37.9503 134.933 37.9503Z"
10 fill="#DBDBFA"
11 />
12 <path
13 fillRule="evenodd"
14 clipRule="evenodd"
15 d="M95.826 16.6834L102.647 66.4348L103.26 71.4261C103.458 73.034 102.314 74.4976 100.706 74.695L57.7621 79.9679C56.1542 80.1653 54.6906 79.0219 54.4932 77.4139L47.8816 23.5671C47.7829 22.7631 48.3546 22.0313 49.1586 21.9326C49.1637 21.932 49.1688 21.9313 49.1739 21.9307L52.7367 21.5311L95.826 16.6834ZM55.6176 21.208L58.9814 20.8306Z"
16 fill="white"
17 />
18 <path
19 d="M55.6176 21.208L58.9814 20.8306M95.826 16.6834L102.647 66.4348L103.26 71.4261C103.458 73.034 102.314 74.4976 100.706 74.695L57.7621 79.9679C56.1542 80.1653 54.6906 79.0219 54.4932 77.4139L47.8816 23.5671C47.7829 22.7631 48.3546 22.0313 49.1586 21.9326C49.1637 21.932 49.1688 21.9313 49.1739 21.9307L52.7367 21.5311L95.826 16.6834Z"
20 stroke="#7E7BF6"
21 strokeWidth="2.5"
22 />
23 <path
24 fillRule="evenodd"
25 clipRule="evenodd"
26 d="M93.9695 19.8144L100.144 64.9025L100.699 69.4258C100.878 70.8831 99.8559 72.2077 98.416 72.3845L59.9585 77.1065C58.5185 77.2833 57.2062 76.2453 57.0272 74.7881L51.0506 26.112C50.9519 25.308 51.5236 24.5762 52.3276 24.4775L57.0851 23.8934"
27 fill="#F0F0FF"
28 />
29 <path
30 fillRule="evenodd"
31 clipRule="evenodd"
32 d="M97.701 7.33301H64.2927C63.7358 7.33301 63.2316 7.55873 62.8667 7.92368C62.5017 8.28862 62.276 8.79279 62.276 9.34967V65.083C62.276 65.6399 62.5017 66.1441 62.8667 66.509C63.2316 66.874 63.7358 67.0997 64.2927 67.0997H107.559C108.116 67.0997 108.62 66.874 108.985 66.509C109.35 66.1441 109.576 65.6399 109.576 65.083V19.202C109.576 18.6669 109.363 18.1537 108.985 17.7755L99.1265 7.92324C98.7484 7.54531 98.2356 7.33301 97.701 7.33301Z"
33 fill="white"
34 stroke="#7F7CFA"
35 strokeWidth="2.5"
36 />
37 <path
38 d="M98.026 8.17871V16.6833C98.026 17.8983 99.011 18.8833 100.226 18.8833H106.044"
39 stroke="#807EFA"
40 strokeWidth="2.5"
41 strokeLinecap="round"
42 strokeLinejoin="round"
43 />
44 <path
45 d="M70.1594 56.2838H89.2261M70.1594 18.8838H89.2261H70.1594ZM70.1594 27.6838H101.693H70.1594ZM70.1594 37.2171H101.693H70.1594ZM70.1594 46.7505H101.693H70.1594Z"
46 stroke="#817FFA"
47 strokeWidth="2.5"
48 strokeLinecap="round"
49 strokeLinejoin="round"
50 />
51 </svg>
52);
You should be able to see the total number of tasks on your plugin homepage. Let's explore in detail what's happening.
1// admin/src/pages/Homepage/index.js
2import React, { memo, useState, useEffect } from 'react';
3
4import taskRequests from '../../api/task';
5
6import { Box } from '@strapi/design-system/Box';
7import { Flex } from '@strapi/design-system/Flex';
8import { Typography } from '@strapi/design-system/Typography';
9import { EmptyStateLayout } from '@strapi/design-system/EmptyStateLayout';
10import { BaseHeaderLayout, ContentLayout } from '@strapi/design-system/Layout';
First, we import every hook from React that we'll need for this to work. We import the function to get the task count we created just before and then we import every Design system components we'll need.
1const [taskCount, setTaskCount] = useState(0);
2
3useEffect(() => {
4 taskRequests.getTaskCount().then(res => {
5 setTaskCount(res.data);
6 });
7}, [setTaskCount]);
We create a taskCount
state with 0 as a default value. We use the useEffect React hook to fetch our task count using the function we created earlier and replace our state variable with the result.
1return (
2 <>
3 <BaseHeaderLayout
4 title="Todo Plugin"
5 subtitle="Discover the number of tasks you have in your project"
6 as="h2"
7 />
8 <ContentLayout>
9 {taskCount === 0 && (
10 <EmptyStateLayout icon={<Illo />} content="You don't have any tasks yet..." />
11 )}
12 {taskCount > 0 && (
13 <Box background="neutral0" hasRadius={true} shadow="filterShadow">
14 <Flex justifyContent="center" padding={8}>
15 <Typography variant="alpha">You have a total of {taskCount} tasks 🚀</Typography>
16 </Flex>
17 </Box>
18 )}
19 </ContentLayout>
20 </>
21);
Then, depending on the number of tasks you have, it will display something on the homepage of your plugin. As mentioned before, for this use case, making this homepage is not really necessary but you are now familiar with it and with the Design System.
Let's add something that may be useful in the future. We want to display a loading indicator while the request is pending and for this, we'll use the helper-plugin again.
LoadingIndicatorPage
component from the helper-plugin
1// admin/src/pages/Homepage/index.js
2import { LoadingIndicatorPage } from '@strapi/helper-plugin';
1const [isLoading, setIsLoading] = useState(true);
useEffect
hook:1if (isLoading) return <LoadingIndicatorPage />;
Final code:
1// admin/src/pages/Homepage/index.js
2/*
3 *
4 * HomePage
5 *
6 */
7
8import React, { memo, useState, useEffect } from 'react';
9
10import taskRequests from '../../api/task';
11
12import { LoadingIndicatorPage } from '@strapi/helper-plugin';
13
14import { Box } from '@strapi/design-system/Box';
15import { Flex } from '@strapi/design-system/Flex';
16import { Typography } from '@strapi/design-system/Typography';
17import { EmptyStateLayout } from '@strapi/design-system/EmptyStateLayout';
18import { BaseHeaderLayout, ContentLayout } from '@strapi/design-system/Layout';
19
20import { Illo } from '../../components/Illo';
21
22const HomePage = () => {
23 const [taskCount, setTaskCount] = useState(0);
24 const [isLoading, setIsLoading] = useState(true);
25
26 useEffect(() => {
27 taskRequests.getTaskCount().then(res => {
28 setTaskCount(res.data);
29 setIsLoading(false);
30 });
31 }, [setTaskCount]);
32
33 if (isLoading) return <LoadingIndicatorPage />;
34
35 return (
36 <>
37 <BaseHeaderLayout
38 title="Todo Plugin"
39 subtitle="Discover the number of tasks you have in your project"
40 as="h2"
41 />
42
43 <ContentLayout>
44 {taskCount === 0 && (
45 <EmptyStateLayout icon={<Illo />} content="You don't have any tasks yet..." />
46 )}
47 {taskCount > 0 && (
48 <Box background="neutral0" hasRadius={true} shadow="filterShadow">
49 <Flex justifyContent="center" padding={8}>
50 <Typography variant="alpha">You have a total of {taskCount} tasks 🚀</Typography>
51 </Flex>
52 </Box>
53 )}
54 </ContentLayout>
55 </>
56 );
57};
58
59export default memo(HomePage);
The content of your page will only be displayed if the request has returned something.
We strongly advise you to take a look at the helper-plugin which deserves to have this name.
You can find the source code of the LoadingIndicatorPage
component here.
We created a settings API in a previous section. Now is the time to create the settings view in the admin to be able to interact with it. By default, Strapi creates a Homepage folder, the one you just played with, but it doesn't create the Settings which is needed to implement a settings section for your plugin in the main settings view.
We need to create the necessary HTTP requests from the admin.
admin/src/api/task.js
file with the following content:1// admin/src/api/task.js
2import axiosInstance from '../../src/utils/axiosInstance';
3
4const taskRequests = {
5 getTaskCount: async () => {
6 const data = await axiosInstance.get(`/todo/count`);
7 return data;
8 },
9 getSettings: async () => {
10 const data = await axiosInstance.get(`/todo/settings`);
11 return data;
12 },
13 setSettings: async data => {
14 return await axiosInstance.post(`/todo/settings`, {
15 body: data,
16 });
17 },
18};
19export default taskRequests;
These two new functions will request the API you created earlier for the settings.
admin/src/pages/Settings
folder with an index.js
file inside of it with the following:1// admin/src/pages/Settings/index.js
2import React, { useEffect, useState } from 'react';
3
4import taskRequests from '../../api/task';
5
6import { LoadingIndicatorPage, useNotification } from '@strapi/helper-plugin';
7
8import { Box } from '@strapi/design-system/Box';
9import { Stack } from '@strapi/design-system/Stack';
10import { Button } from '@strapi/design-system/Button';
11import { Grid, GridItem } from '@strapi/design-system/Grid';
12import { HeaderLayout } from '@strapi/design-system/Layout';
13import { ContentLayout } from '@strapi/design-system/Layout';
14import { Typography } from '@strapi/design-system/Typography';
15import { ToggleInput } from '@strapi/design-system/ToggleInput';
16
17import Check from '@strapi/icons/Check';
18
19const Settings = () => {
20 const [settings, setSettings] = useState();
21 const [isSaving, setIsSaving] = useState(false);
22 const [isLoading, setIsLoading] = useState(true);
23 const toggleNotification = useNotification();
24
25 useEffect(() => {
26 taskRequests.getSettings().then(res => {
27 setSettings(res.data.settings);
28 setIsLoading(false);
29 });
30 }, [setSettings]);
31
32 const handleSubmit = async () => {
33 setIsSaving(true);
34 const res = await taskRequests.setSettings(settings);
35 setSettings(res.data.settings);
36 setIsSaving(false);
37 toggleNotification({
38 type: 'success',
39 message: 'Settings successfully updated',
40 });
41 };
42
43 return (
44 <>
45 <HeaderLayout
46 id="title"
47 title="Todo General settings"
48 subtitle="Manage the settings and behaviour of your todo plugin"
49 primaryAction={
50 isLoading ? (
51 <></>
52 ) : (
53 <Button
54 onClick={handleSubmit}
55 startIcon={<Check />}
56 size="L"
57 disabled={isSaving}
58 loading={isSaving}
59 >
60 Save
61 </Button>
62 )
63 }
64 ></HeaderLayout>
65 {isLoading ? (
66 <LoadingIndicatorPage />
67 ) : (
68 <ContentLayout>
69 <Box
70 background="neutral0"
71 hasRadius
72 shadow="filterShadow"
73 paddingTop={6}
74 paddingBottom={6}
75 paddingLeft={7}
76 paddingRight={7}
77 >
78 <Stack size={3}>
79 <Typography>General settings</Typography>
80 <Grid gap={6}>
81 <GridItem col={12} s={12}>
82 <ToggleInput
83 checked={settings?.disabled ?? false}
84 hint="Cross or disable checkbox tasks marked as done"
85 offLabel="Cross"
86 onLabel="Disable"
87 onChange={e => {
88 setSettings({
89 disabled: e.target.checked,
90 });
91 }}
92 />
93 </GridItem>
94 </Grid>
95 </Stack>
96 </Box>
97 </ContentLayout>
98 )}
99 </>
100 );
101};
102
103export default Settings;
This settings page is displaying a toggle that will allow you to manage if you want to cross or disable your tasks marked as done.
The next thing that is necessary, tell your plugin to create a settings section. You can achieve this by using the createSettingSection
function. Learn more about it just right here.
admin/src/index.js
file by adding the following code just before the app.registerPlugin
call:1// admin/src/index.js
2//...
3app.createSettingSection(
4 {
5 id: pluginId,
6 intlLabel: {
7 id: `${pluginId}.plugin.name`,
8 defaultMessage: 'Todo',
9 },
10 },
11 [
12 {
13 intlLabel: {
14 id: `${pluginId}.plugin.name`,
15 defaultMessage: 'General settings',
16 },
17 id: 'settings',
18 to: `/settings/${pluginId}`,
19 Component: async () => {
20 return import('./pages/Settings');
21 },
22 },
23 ]
24);
25//..
If you go to the main settings of your application, you should be able to see a TODO - General settings
section. From there, you can save the way you'll want to display your tasks marked as done in the store.
We'll be able to get this setting directly from the component that we'll inject later.
You can translate your plugin into several languages if you wish. It's quite simple, you just have to do 2 things:
By default, a plugin contains an English JSON file and also a french one inside the admin/src/translations
folder:
1├── translations
2 ├── en.json
3 └── fr.json
The format of this file is straight to the point. You need to create a key which is an id corresponding to a translation string:
For the en.json
file, we can have:
1// admin/src/translations/en.json
2{
3 "Homepage.BaseHeaderLayout.title": "Todo Plugin",
4 // etc...
5}
You are free to name the ids as you would like to, but we advise you to name them correctly so it's easy to understand in your front-end code.
Homepage.BaseHeaderLayout.title
corresponds to the title prop of the BaseHeaderLayout
component that we use on the homepage. Instead of having a fixed value, we can now use this translation with this id.
admin/src/translations/en.json
file with the following code:1// admin/src/translations/en.json
2{
3 "Homepage.BaseHeaderLayout.title": "Todo Plugin"
4}
admin/src/translations/fr.json
file with the following code:1// admin/src/translations/fr.json
2{
3 "Homepage.BaseHeaderLayout.title": "Plugin des tâches" // French translation for Todo Plugin :)
4}
The only thing that is needed is to replace hard text by dynamically loading the translations in your pages/components.
useIntl
hook from react-intl
and the admin/src/utils/getTrad.js
function to internationalize your plugin.admin/src/pages/Homepage/index.js
by importing these two elements:1// admin/src/pages/Homepage/index.js
2//...
3import { useIntl } from 'react-intl';
4import getTrad from '../../utils/getTrad';
5//...
formatMessage
function just before the useEffect
:1//...
2const { formatMessage } = useIntl();
3//...
BaseHeaderLayout
component with the following:1<BaseHeaderLayout
2 title={formatMessage({
3 id: getTrad('Homepage.BaseHeaderLayout.title'),
4 defaultMessage: 'Todo Plugin',
5 })}
6 subtitle="Discover the number of tasks you have in your project"
7 as="h2"
8/>
You can have a defaultMessage
in case it doesn't succeed to load the requested translation.
This is what your file should look like:
1// admin/src/pages/Homepage/index.js
2/*
3 *
4 * HomePage
5 *
6 */
7
8import React, { memo, useState, useEffect } from 'react';
9
10import { useIntl } from 'react-intl';
11import getTrad from '../../utils/getTrad';
12
13import taskRequests from '../../api/task';
14
15import { LoadingIndicatorPage } from '@strapi/helper-plugin';
16
17import { Box } from '@strapi/design-system/Box';
18import { Flex } from '@strapi/design-system/Flex';
19import { Typography } from '@strapi/design-system/Typography';
20import { EmptyStateLayout } from '@strapi/design-system/EmptyStateLayout';
21import { BaseHeaderLayout, ContentLayout } from '@strapi/design-system/Layout';
22
23import { Illo } from '../../components/Illo';
24
25const HomePage = () => {
26 const [taskCount, setTaskCount] = useState(0);
27 const [isLoading, setIsLoading] = useState(true);
28
29 const { formatMessage } = useIntl();
30
31 useEffect(() => {
32 taskRequests.getTaskCount().then(data => {
33 setTaskCount(data);
34 setIsLoading(false);
35 });
36 }, [setTaskCount]);
37
38 if (isLoading) return <LoadingIndicatorPage />;
39
40 return (
41 <>
42 <BaseHeaderLayout
43 title={formatMessage({
44 id: getTrad('Homepage.BaseHeaderLayout.title'),
45 defaultMessage: 'Todo Plugin',
46 })}
47 subtitle="Discover the number of tasks you have in your project"
48 as="h2"
49 />
50
51 <ContentLayout>
52 {taskCount === 0 && (
53 <EmptyStateLayout icon={<Illo />} content="You don't have any tasks yet..." />
54 )}
55 {taskCount > 0 && (
56 <Box background="neutral0" hasRadius={true} shadow="filterShadow">
57 <Flex justifyContent="center" padding={8}>
58 <Typography variant="alpha">You have a total of {taskCount} tasks 🚀</Typography>
59 </Flex>
60 </Box>
61 )}
62 </ContentLayout>
63 </>
64 );
65};
66
67export default memo(HomePage);
Feel free to change the language of your admin in the general settings to see your different translations.
A plugin allows you to inject React components where the admin allows you to. In fact, some areas were designed to receive any kind of components. This is possible thanks to the injection Zones.
You need to use the bootstrap
lifecycle function to inject your components in the admin/src/index.js
. For this use case, we'll simply tell our plugin to inject a TodoCard
React component in the editView
> right-links
injection zone.
admin/src/index.js
file by importing the component:1// admin/src/index.js
2//...
3import TodoCard from './components/TodoCard';
4//...
admin/src/index.js
file by injecting this component in the bootstrap lifecycle function:1 bootstrap(app) {
2 app.injectContentManagerComponent("editView", "right-links", {
3 name: "todo-component",
4 Component: TodoCard,
5 });
6 },
Your file should look like this:
1// admin/src/index.js
2import { prefixPluginTranslations } from '@strapi/helper-plugin';
3import pluginPkg from '../../package.json';
4import pluginId from './pluginId';
5import Initializer from './components/Initializer';
6import PluginIcon from './components/PluginIcon';
7import TodoCard from './components/TodoCard';
8
9const name = pluginPkg.strapi.name;
10
11export default {
12 register(app) {
13 app.addMenuLink({
14 to: `/plugins/${pluginId}`,
15 icon: PluginIcon,
16 intlLabel: {
17 id: `${pluginId}.plugin.name`,
18 defaultMessage: name,
19 },
20 Component: async () => {
21 const component = await import(/* webpackChunkName: "[request]" */ './pages/App');
22
23 return component;
24 },
25 permissions: [
26 // Uncomment to set the permissions of the plugin here
27 // {
28 // action: '', // the action name should be plugin::plugin-name.actionType
29 // subject: null,
30 // },
31 ],
32 });
33 app.createSettingSection(
34 {
35 id: pluginId,
36 intlLabel: {
37 id: `${pluginId}.plugin.name`,
38 defaultMessage: 'Todo',
39 },
40 },
41 [
42 {
43 intlLabel: {
44 id: `${pluginId}.plugin.name`,
45 defaultMessage: 'General settings',
46 },
47 id: 'settings',
48 to: `/settings/${pluginId}`,
49 Component: async () => {
50 return import('./pages/Settings');
51 },
52 },
53 ]
54 );
55 app.registerPlugin({
56 id: pluginId,
57 initializer: Initializer,
58 isReady: false,
59 name,
60 });
61 },
62 bootstrap(app) {
63 app.injectContentManagerComponent('editView', 'right-links', {
64 name: 'todo-component',
65 Component: TodoCard,
66 });
67 },
68 async registerTrads({ locales }) {
69 const importedTrads = await Promise.all(
70 locales.map(locale => {
71 return import(`./translations/${locale}.json`)
72 .then(({ default: data }) => {
73 return {
74 data: prefixPluginTranslations(data, pluginId),
75 locale,
76 };
77 })
78 .catch(() => {
79 return {
80 data: {},
81 locale,
82 };
83 });
84 })
85 );
86
87 return Promise.resolve(importedTrads);
88 },
89};
Your application will not work since this TodoCard
doesn't exist yet.
admin/src/components/TodoCard/index.js
with the following code:1import React, { useState, useEffect } from "react";
2
3import { useCMEditViewDataManager } from "@strapi/helper-plugin";
4
5import axiosInstance from "../../utils/axiosInstance";
6
7import {
8 Box,
9 Typography,
10 Divider,
11 Checkbox,
12 Stack,
13 Flex,
14 Icon,
15} from "@strapi/design-system";
16
17import Plus from "@strapi/icons/Plus";
18
19import TaskModal from "../TaskModal";
20
21const TodoCard = () => {
22 const { initialData } = useCMEditViewDataManager();
23
24 const [tasks, setTasks] = useState(initialData.tasks);
25 const [settings, setSettings] = useState(false);
26
27 const [createModalIsShown, setCreateModalIsShown] = useState(false);
28
29 const fetchSettings = async () => {
30 try {
31 const { data } = await axiosInstance.get(`todo/settings`);
32 setSettings(data.settings.disabled);
33 } catch (e) {
34 console.log(e);
35 }
36 };
37
38 const updateTasks = async (taskId) => {
39 try {
40 let updatedTasks = tasks.map((task) => {
41 if (task.id === taskId) {
42 return { ...task, isDone: !task.isDone };
43 }
44 return task;
45 });
46
47 setTasks(updatedTasks);
48 } catch (e) {
49 console.log(e);
50 }
51 };
52
53 useEffect(() => {
54 fetchSettings();
55 }, [settings]);
56
57 const toggleTask = async (taskId, isChecked) => {
58 // Update task in database
59 const res = await axiosInstance.put(`/todo/update/${taskId}`, {
60 data: {
61 isDone: isChecked,
62 },
63 });
64 console.log(res);
65 if (res.status === 200) await updateTasks(taskId, isChecked);
66 };
67
68 const showTasks = () => {
69 // Loading state
70 if (status === "loading") {
71 return <p>Fetching todos...</p>;
72 }
73
74 // Error state
75 if (status === "error") {
76 return <p>Could not fetch tasks.</p>;
77 }
78
79 // Empty state
80 if (tasks == null || tasks.length === 0) {
81 return <p>No todo yet.</p>;
82 }
83
84 // Success state, show all tasks
85 return tasks.map((task) => (
86 <>
87 <Checkbox
88 value={task.isDone}
89 onValueChange={(isChecked) => toggleTask(task.id, isChecked)}
90 key={task.id}
91 disabled={task.isDone && settings ? true : false}
92 >
93 <span
94 style={{
95 textDecoration:
96 task.isDone && settings == false ? "line-through" : "none",
97 }}
98 >
99 {task.name}
100 </span>
101 </Checkbox>
102 </>
103 ));
104 };
105
106 return (
107 <>
108 {createModalIsShown && (
109 <TaskModal
110 handleClose={() => setCreateModalIsShown(false)}
111 setTasks={setTasks}
112 tasks={tasks}
113 />
114 )}
115 <Box
116 as="aside"
117 aria-labelledby="additional-informations"
118 background="neutral0"
119 borderColor="neutral150"
120 hasRadius
121 paddingBottom={4}
122 paddingLeft={4}
123 paddingRight={4}
124 paddingTop={3}
125 shadow="tableShadow"
126 >
127 <Typography
128 variant="sigma"
129 textColor="neutral600"
130 id="additional-informations"
131 >
132 Todos
133 </Typography>
134 <Box paddingTop={2} paddingBottom={6}>
135 <Box paddingBottom={2}>
136 <Divider />
137 </Box>
138 <Typography
139 fontSize={2}
140 textColor="primary600"
141 as="button"
142 type="button"
143 onClick={() => setCreateModalIsShown(true)}
144 >
145 <Flex>
146 <Icon
147 as={Plus}
148 color="primary600"
149 marginRight={2}
150 width={3}
151 height={3}
152 />
153 Add todo
154 </Flex>
155 </Typography>
156
157 <Stack paddingTop={3} size={2}>
158 {showTasks()}
159 </Stack>
160 </Box>
161 </Box>
162 </>
163 );
164};
165
166export default TodoCard;
There are 2 important things to see in this code:
useCMEditViewDataManager
. This hook is coming from the helper-plugin. It allows you to have access, from the content-manager to the data of the actual entry. In this case, we are fetching the initialData
of the entry.You can have access to more data with it. Learn more about this hook.
fetchSettings
will execute an API call to the route we created in one of the previous sections.1const fetchSettings = async () => {
2 try {
3 const { data } = await axiosInstance.get(`todo/settings`);
4 setSettings(data.settings.disabled);
5 } catch (e) {
6 console.log(e);
7 }s
8};
Then, depending on the value of these settings, it will render the checkbox:
1return tasks.map(task => (
2 <>
3 <Checkbox
4 value={task.isDone}
5 onValueChange={isChecked => toggleTask(task.id, isChecked)}
6 key={task.id}
7 disabled={task.isDone && settings ? true : false}
8 >
9 <span
10 style={{
11 textDecoration: task.isDone && settings == false ? 'line-through' : 'none',
12 }}
13 >
14 {task.name}
15 </span>
16 </Checkbox>
17 </>
18));
As you can see, this component is calling a new route to update your tasks from your server:
axiosInstance.put(
/todo/update/${taskId}...`We'll need to create it right away but the next component will also need a new route for creating tasks. Let's create them both right now:
/server/routes/tasks.js
like the following:1...
2{
3 method: "PUT",
4 path: "/update/:id",
5 handler: "task.update",
6 config: {
7 policies: [],
8 auth: false,
9 },
10},
11{
12 method: "POST",
13 path: "/create",
14 handler: "task.create",
15 config: {
16 policies: [],
17 auth: false,
18 },
19},
20...
/server/controllers/task.js
controller to implement the new update
and create
action:1...
2async create(ctx) {
3 try {
4 ctx.body = await strapi
5 .plugin("todo")
6 .service("task")
7 .create(ctx.request.body);
8 } catch (err) {
9 ctx.throw(500, err);
10 }
11},
12async update(ctx) {
13 try {
14 ctx.body = await strapi
15 .plugin("todo")
16 .service("task")
17 .update(ctx.params.id, ctx.request.body.data);
18 } catch (err) {
19 ctx.throw(500, err);
20 }
21},
22...
And finally, the /server/services/task.js
file with the corresponding services:
1...
2async create(data) {
3 return await strapi.query("plugin::todo.task").create(data);
4},
5async update(id, data) {
6 return await strapi.query("plugin::todo.task").update({
7 where: { id },
8 data,
9 });
10},
11...
The server part is ready! The TodoCard
component requires another one (TaskModal
).
admin/src/components/TaskModal/index.js
with the following code:1// admin/src/components/TaskModal/index.js
2import React, { useState } from "react";
3import {
4 ModalLayout,
5 ModalHeader,
6 ModalBody,
7 ModalFooter,
8 Typography,
9 Button,
10 TextInput,
11} from "@strapi/design-system";
12
13import { useCMEditViewDataManager } from "@strapi/helper-plugin";
14
15import axiosInstance from "../../utils/axiosInstance";
16
17const TaskModal = ({ handleClose, setTasks, tasks }) => {
18 const [name, setName] = useState("");
19 const [status, setStatus] = useState();
20
21 const { slug, initialData } = useCMEditViewDataManager();
22
23 const handleSubmit = async (e) => {
24 // Prevent submitting parent form
25 e.preventDefault();
26 e.stopPropagation();
27
28 try {
29 // Show loading state
30 setStatus("loading");
31
32 // Create task and link it to the related entry
33 const res = await axiosInstance.post("/todo/create", {
34 data: {
35 name,
36 isDone: false,
37 related: {
38 __type: slug,
39 id: initialData.id,
40 },
41 },
42 });
43
44 setTasks([...tasks, res.data]);
45
46 // Remove loading and close popup
47 setStatus("success");
48 handleClose();
49 } catch (e) {
50 console.log(e);
51 setStatus("error");
52 }
53 };
54
55 const getError = () => {
56 // Form validation error
57 if (name.length > 40) {
58 return "Content is too long";
59 }
60 // API error
61 if (status === "error") {
62 return "Could not create todo";
63 }
64 return null;
65 };
66
67 return (
68 <ModalLayout
69 onClose={handleClose}
70 labelledBy="title"
71 as="form"
72 onSubmit={handleSubmit}
73 >
74 <ModalHeader>
75 <Typography fontWeight="bold" textColor="neutral800" as="h2" id="title">
76 Add todo
77 </Typography>
78 </ModalHeader>
79 <ModalBody>
80 <TextInput
81 placeholder="What do you need to do?"
82 label="Name"
83 name="text"
84 hint="Max 140 characters"
85 error={getError()}
86 onChange={(e) => setName(e.target.value)}
87 value={name}
88 />
89 </ModalBody>
90 <ModalFooter
91 startActions={
92 <Button onClick={handleClose} variant="tertiary">
93 Cancel
94 </Button>
95 }
96 endActions={
97 <Button type="submit" loading={status === "loading"}>
98 {status === "loading" ? "Saving..." : "Save"}
99 </Button>
100 }
101 />
102 </ModalLayout>
103 );
104};
105
106export default TaskModal;
The most important thing here, is that we create tasks using the API routes we created just before and then we update the tasks state:
1const res = await axiosInstance.post("/todo/create", {
2 data: {
3 name,
4 isDone: false,
5 related: {
6 __type: slug,
7 id: initialData.id,
8 },
9 },
10});
11
12setTasks([...tasks, res.data]);
We create the association by adding a related
object containing the id
of the current entry and the internal slug
for the __type
field. A task creation for an article will have this data:
1{
2 name, // name of the task
3 isDone: false,
4 related: {
5 __type: "api::article.article",
6 id: initialData.id, // id of the article
7 },
8}
Note: For this use case we created our custom routes as usual to update and create tasks from the server. It is good to know that you can use an undocumented API that can allows to do the same without setting anything on the server side: The Strapi Content Manager API can hellp you fetch, from the admin only, your data using the /content-manager/
endpoint.
1git clone https://github.com/strapi/strapi.git
2cd strapi
3yarn doc:api core/content-manager
Caution: It is internal documentation. There might be breaking changes.
This is what both your components could look like using this API:
1// admin/src/components/TodoCard/index.js
2import React, { useState, useEffect } from 'react';
3
4import { useCMEditViewDataManager } from '@strapi/helper-plugin';
5
6import axiosInstance from '../../utils/axiosInstance';
7
8import { Box, Typography, Divider, Checkbox, Stack, Flex, Icon } from '@strapi/design-system';
9
10import Plus from '@strapi/icons/Plus';
11
12import TaskModal from '../TaskModal';
13
14function useRelatedTasks() {
15 const { initialData, isSingleType, slug } = useCMEditViewDataManager();
16
17 const [tasks, setTasks] = useState([]);
18 const [status, setStatus] = useState('loading');
19 const [settings, setSettings] = useState(false);
20
21 const fetchSettings = async () => {
22 try {
23 const { data } = await axiosInstance.get(`todo/settings`);
24 setSettings(data.disabled);
25 } catch (e) {
26 console.log(e);
27 }
28 };
29
30 const refetchTasks = async () => {
31 try {
32 const { data } = await axiosInstance.get(
33 `/content-manager/${isSingleType ? 'single-types' : 'collection-types'}/${slug}/${
34 isSingleType ? '' : initialData.id
35 }?populate=tasks`
36 );
37
38 setTasks(data.tasks);
39 setStatus('success');
40 } catch (e) {
41 setStatus('error');
42 }
43 };
44
45 useEffect(() => {
46 fetchSettings();
47 refetchTasks();
48 }, [initialData, isSingleType, axiosInstance, setTasks, setStatus, setSettings]);
49
50 return { status, tasks, refetchTasks, fetchSettings, settings };
51}
52
53const TodoCard = () => {
54 const { status, tasks, refetchTasks, settings } = useRelatedTasks();
55 const [createModalIsShown, setCreateModalIsShown] = useState(false);
56
57 const toggleTask = async (taskId, isChecked) => {
58 // Update task in database
59 await axiosInstance.put(`/content-manager/collection-types/plugin::todo.task/${taskId}`, {
60 isDone: isChecked,
61 });
62 // Call API to update local cache
63 await refetchTasks();
64 };
65
66 const showTasks = () => {
67 // Loading state
68 if (status === 'loading') {
69 return <p>Fetching todos...</p>;
70 }
71
72 // Error state
73 if (status === 'error') {
74 return <p>Could not fetch tasks.</p>;
75 }
76
77 // Empty state
78 if (tasks == null || tasks.length === 0) {
79 return <p>No todo yet.</p>;
80 }
81
82 // Success state, show all tasks
83 return tasks.map(task => (
84 <>
85 <Checkbox
86 value={task.isDone}
87 onValueChange={isChecked => toggleTask(task.id, isChecked)}
88 key={task.id}
89 disabled={task.isDone && settings ? true : false}
90 >
91 <span
92 style={{
93 textDecoration: task.isDone && settings == false ? 'line-through' : 'none',
94 }}
95 >
96 {task.name}
97 </span>
98 </Checkbox>
99 </>
100 ));
101 };
102
103 return (
104 <>
105 {createModalIsShown && (
106 <TaskModal handleClose={() => setCreateModalIsShown(false)} refetchTasks={refetchTasks} />
107 )}
108 <Box
109 as="aside"
110 aria-labelledby="additional-informations"
111 background="neutral0"
112 borderColor="neutral150"
113 hasRadius
114 paddingBottom={4}
115 paddingLeft={4}
116 paddingRight={4}
117 paddingTop={3}
118 shadow="tableShadow"
119 >
120 <Typography variant="sigma" textColor="neutral600" id="additional-informations">
121 Todos
122 </Typography>
123 <Box paddingTop={2} paddingBottom={6}>
124 <Box paddingBottom={2}>
125 <Divider />
126 </Box>
127 <Typography
128 fontSize={2}
129 textColor="primary600"
130 as="button"
131 type="button"
132 onClick={() => setCreateModalIsShown(true)}
133 >
134 <Flex>
135 <Icon as={Plus} color="primary600" marginRight={2} width={3} height={3} />
136 Add todo
137 </Flex>
138 </Typography>
139
140 <Stack paddingTop={3} size={2}>
141 {showTasks()}
142 </Stack>
143 </Box>
144 </Box>
145 </>
146 );
147};
148
149export default TodoCard;
1// admin/src/components/TaskModal/index.js
2import React, { useState } from 'react';
3import {
4 ModalLayout,
5 ModalHeader,
6 ModalBody,
7 ModalFooter,
8 Typography,
9 Button,
10 TextInput,
11} from '@strapi/design-system';
12
13import { useCMEditViewDataManager } from '@strapi/helper-plugin';
14
15import axiosInstance from '../../utils/axiosInstance';
16
17const TaskModal = ({ handleClose, refetchTasks }) => {
18 const [name, setName] = useState('');
19 const [status, setStatus] = useState();
20
21 const { slug, initialData } = useCMEditViewDataManager();
22
23 const handleSubmit = async e => {
24 // Prevent submitting parent form
25 e.preventDefault();
26 e.stopPropagation();
27
28 try {
29 // Show loading state
30 setStatus('loading');
31
32 // Create task and link it to the related entry
33 await axiosInstance.post('/content-manager/collection-types/plugin::todo.task', {
34 name,
35 isDone: false,
36 related: {
37 __type: slug,
38 id: initialData.id,
39 },
40 });
41
42 // Refetch tasks list so it includes the created one
43 await refetchTasks();
44
45 // Remove loading and close popup
46 setStatus('success');
47 handleClose();
48 } catch (e) {
49 setStatus('error');
50 }
51 };
52
53 const getError = () => {
54 // Form validation error
55 if (name.length > 40) {
56 return 'Content is too long';
57 }
58 // API error
59 if (status === 'error') {
60 return 'Could not create todo';
61 }
62 return null;
63 };
64
65 return (
66 <ModalLayout onClose={handleClose} labelledBy="title" as="form" onSubmit={handleSubmit}>
67 <ModalHeader>
68 <Typography fontWeight="bold" textColor="neutral800" as="h2" id="title">
69 Add todo
70 </Typography>
71 </ModalHeader>
72 <ModalBody>
73 <TextInput
74 placeholder="What do you need to do?"
75 label="Name"
76 name="text"
77 hint="Max 40 characters"
78 error={getError()}
79 onChange={e => setName(e.target.value)}
80 value={name}
81 />
82 </ModalBody>
83 <ModalFooter
84 startActions={
85 <Button onClick={handleClose} variant="tertiary">
86 Cancel
87 </Button>
88 }
89 endActions={
90 <Button type="submit" loading={status === 'loading'}>
91 {status === 'loading' ? 'Saving...' : 'Save'}
92 </Button>
93 }
94 />
95 </ModalLayout>
96 );
97};
98
99export default TaskModal;
Your todo plugin should be 100% working now. Give it a try.
This guide doesn't cover yet middlewares and policies, this will come later.
Next article: Publish on npm part 6/6
Maxime started to code in 2015 and quickly joined the Growth team of Strapi. He particularly likes to create useful content for the awesome Strapi community. Send him a meme on Twitter to make his day: @MaxCastres