Simply copy and paste the following command line in your terminal to create your first Strapi project.
npx create-strapi-app
my-project
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
// admin/src/index.js
import { prefixPluginTranslations } from '@strapi/helper-plugin';
import pluginPkg from '../../package.json';
import pluginId from './pluginId';
import Initializer from './components/Initializer';
import PluginIcon from './components/PluginIcon';
const name = pluginPkg.strapi.name;
export default {
register(app) {
app.addMenuLink({
to: `/plugins/${pluginId}`,
icon: PluginIcon,
intlLabel: {
id: `${pluginId}.plugin.name`,
defaultMessage: name,
},
Component: async () => {
const component = await import(/* webpackChunkName: "[request]" */ './pages/App');
return component;
},
permissions: [
// Uncomment to set the permissions of the plugin here
// {
// action: '', // the action name should be plugin::plugin-name.actionType
// subject: null,
// },
],
});
app.registerPlugin({
id: pluginId,
initializer: Initializer,
isReady: false,
name,
});
},
bootstrap(app) {},
async registerTrads({ locales }) {
const importedTrads = await Promise.all(
locales.map(locale => {
return import(`./translations/${locale}.json`)
.then(({ default: data }) => {
return {
data: prefixPluginTranslations(data, pluginId),
locale,
};
})
.catch(() => {
return {
data: {},
locale,
};
});
})
);
return Promise.resolve(importedTrads);
},
};
This file will, first, add your plugin to the menu link and register your plugin during the register phase:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
register(app) {
app.addMenuLink({
to: `/plugins/${pluginId}`,
icon: PluginIcon,
intlLabel: {
id: `${pluginId}.plugin.name`,
defaultMessage: name,
},
Component: async () => {
const component = await import(/* webpackChunkName: "[request]" */ './pages/App');
return component;
},
permissions: [
// Uncomment to set the permissions of the plugin here
// {
// action: '', // the action name should be plugin::plugin-name.actionType
// subject: null,
// },
],
});
app.registerPlugin({
id: pluginId,
initializer: Initializer,
isReady: false,
name,
});
},
Then, nothing is happening during the bootstrap phase:
1
bootstrap(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.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
async registerTrads({ locales }) {
const importedTrads = await Promise.all(
locales.map(locale => {
return import(`./translations/${locale}.json`)
.then(({ default: data }) => {
return {
data: prefixPluginTranslations(data, pluginId),
locale,
};
})
.catch(() => {
return {
data: {},
locale,
};
});
})
);
return Promise.resolve(importedTrads);
},
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
2
3
4
5
6
7
8
// admin/src/components/PluginIcon/index.js
// Replace the default Puzzle icon by a Brush one
import React from 'react';
import Brush from '@strapi/icons/Brush';
const PluginIcon = () => <Brush />;
export 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// server/routes/task.js
module.exports = {
type: 'admin',
routes: [
{
method: 'GET',
path: '/count',
handler: 'task.count',
config: {
policies: [],
auth: false,
},
},
// ...
],
};
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
2
3
4
5
6
7
8
9
10
// ./admin/src/api/task.js
import axiosInstance from '../../src/utils/axiosInstance';
const taskRequests = {
getTaskCount: async () => {
const data = await axiosInstance.get(`/todo/count`);
return data;
},
};
export 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// admin/src/pages/Homepage/index.js
/*
*
* HomePage
*
*/
import React, { memo, useState, useEffect } from 'react';
import taskRequests from '../../api/task';
import { Box } from '@strapi/design-system/Box';
import { Flex } from '@strapi/design-system/Flex';
import { Typography } from '@strapi/design-system/Typography';
import { EmptyStateLayout } from '@strapi/design-system/EmptyStateLayout';
import { BaseHeaderLayout, ContentLayout } from '@strapi/design-system/Layout';
import { Illo } from '../../components/Illo';
const HomePage = () => {
const [taskCount, setTaskCount] = useState(0);
useEffect(() => {
taskRequests.getTaskCount().then(res => {
setTaskCount(res.data);
});
}, [setTaskCount]);
return (
<>
<BaseHeaderLayout
title="Todo Plugin"
subtitle="Discover the number of tasks you have in your project"
as="h2"
/>
<ContentLayout>
{taskCount === 0 && (
<EmptyStateLayout icon={<Illo />} content="You don't have any tasks yet..." />
)}
{taskCount > 0 && (
<Box background="neutral0" hasRadius={true} shadow="filterShadow">
<Flex justifyContent="center" padding={8}>
<Typography variant="alpha">You have a total of {taskCount} tasks 🚀</Typography>
</Flex>
</Box>
)}
</ContentLayout>
</>
);
};
export 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// admin/src/components/Illo/index.js
import React from 'react';
export const Illo = () => (
<svg width="159" height="88" viewBox="0 0 159 88" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
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"
fill="#DBDBFA"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
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"
fill="white"
/>
<path
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"
stroke="#7E7BF6"
strokeWidth="2.5"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
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"
fill="#F0F0FF"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
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"
fill="white"
stroke="#7F7CFA"
strokeWidth="2.5"
/>
<path
d="M98.026 8.17871V16.6833C98.026 17.8983 99.011 18.8833 100.226 18.8833H106.044"
stroke="#807EFA"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
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"
stroke="#817FFA"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
You should be able to see the total number of tasks on your plugin homepage. Let's explore in detail what's happening.
1
2
3
4
5
6
7
8
9
10
// admin/src/pages/Homepage/index.js
import React, { memo, useState, useEffect } from 'react';
import taskRequests from '../../api/task';
import { Box } from '@strapi/design-system/Box';
import { Flex } from '@strapi/design-system/Flex';
import { Typography } from '@strapi/design-system/Typography';
import { EmptyStateLayout } from '@strapi/design-system/EmptyStateLayout';
import { 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.
1
2
3
4
5
6
7
const [taskCount, setTaskCount] = useState(0);
useEffect(() => {
taskRequests.getTaskCount().then(res => {
setTaskCount(res.data);
});
}, [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.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
return (
<>
<BaseHeaderLayout
title="Todo Plugin"
subtitle="Discover the number of tasks you have in your project"
as="h2"
/>
<ContentLayout>
{taskCount === 0 && (
<EmptyStateLayout icon={<Illo />} content="You don't have any tasks yet..." />
)}
{taskCount > 0 && (
<Box background="neutral0" hasRadius={true} shadow="filterShadow">
<Flex justifyContent="center" padding={8}>
<Typography variant="alpha">You have a total of {taskCount} tasks 🚀</Typography>
</Flex>
</Box>
)}
</ContentLayout>
</>
);
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
2
// admin/src/pages/Homepage/index.js
import { LoadingIndicatorPage } from '@strapi/helper-plugin';
1
const [isLoading, setIsLoading] = useState(true);
useEffect
hook:1
if (isLoading) return <LoadingIndicatorPage />;
Final code:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
// admin/src/pages/Homepage/index.js
/*
*
* HomePage
*
*/
import React, { memo, useState, useEffect } from 'react';
import taskRequests from '../../api/task';
import { LoadingIndicatorPage } from '@strapi/helper-plugin';
import { Box } from '@strapi/design-system/Box';
import { Flex } from '@strapi/design-system/Flex';
import { Typography } from '@strapi/design-system/Typography';
import { EmptyStateLayout } from '@strapi/design-system/EmptyStateLayout';
import { BaseHeaderLayout, ContentLayout } from '@strapi/design-system/Layout';
import { Illo } from '../../components/Illo';
const HomePage = () => {
const [taskCount, setTaskCount] = useState(0);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
taskRequests.getTaskCount().then(res => {
setTaskCount(res.data);
setIsLoading(false);
});
}, [setTaskCount]);
if (isLoading) return <LoadingIndicatorPage />;
return (
<>
<BaseHeaderLayout
title="Todo Plugin"
subtitle="Discover the number of tasks you have in your project"
as="h2"
/>
<ContentLayout>
{taskCount === 0 && (
<EmptyStateLayout icon={<Illo />} content="You don't have any tasks yet..." />
)}
{taskCount > 0 && (
<Box background="neutral0" hasRadius={true} shadow="filterShadow">
<Flex justifyContent="center" padding={8}>
<Typography variant="alpha">You have a total of {taskCount} tasks 🚀</Typography>
</Flex>
</Box>
)}
</ContentLayout>
</>
);
};
export 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// admin/src/api/task.js
import axiosInstance from '../../src/utils/axiosInstance';
const taskRequests = {
getTaskCount: async () => {
const data = await axiosInstance.get(`/todo/count`);
return data;
},
getSettings: async () => {
const data = await axiosInstance.get(`/todo/settings`);
return data;
},
setSettings: async data => {
return await axiosInstance.post(`/todo/settings`, {
body: data,
});
},
};
export 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
// admin/src/pages/Settings/index.js
import React, { useEffect, useState } from 'react';
import taskRequests from '../../api/task';
import { LoadingIndicatorPage, useNotification } from '@strapi/helper-plugin';
import { Box } from '@strapi/design-system/Box';
import { Stack } from '@strapi/design-system/Stack';
import { Button } from '@strapi/design-system/Button';
import { Grid, GridItem } from '@strapi/design-system/Grid';
import { HeaderLayout } from '@strapi/design-system/Layout';
import { ContentLayout } from '@strapi/design-system/Layout';
import { Typography } from '@strapi/design-system/Typography';
import { ToggleInput } from '@strapi/design-system/ToggleInput';
import Check from '@strapi/icons/Check';
const Settings = () => {
const [settings, setSettings] = useState();
const [isSaving, setIsSaving] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const toggleNotification = useNotification();
useEffect(() => {
taskRequests.getSettings().then(res => {
setSettings(res.data.settings);
setIsLoading(false);
});
}, [setSettings]);
const handleSubmit = async () => {
setIsSaving(true);
const res = await taskRequests.setSettings(settings);
setSettings(res.data.settings);
setIsSaving(false);
toggleNotification({
type: 'success',
message: 'Settings successfully updated',
});
};
return (
<>
<HeaderLayout
id="title"
title="Todo General settings"
subtitle="Manage the settings and behaviour of your todo plugin"
primaryAction={
isLoading ? (
<></>
) : (
<Button
onClick={handleSubmit}
startIcon={<Check />}
size="L"
disabled={isSaving}
loading={isSaving}
>
Save
</Button>
)
}
></HeaderLayout>
{isLoading ? (
<LoadingIndicatorPage />
) : (
<ContentLayout>
<Box
background="neutral0"
hasRadius
shadow="filterShadow"
paddingTop={6}
paddingBottom={6}
paddingLeft={7}
paddingRight={7}
>
<Stack size={3}>
<Typography>General settings</Typography>
<Grid gap={6}>
<GridItem col={12} s={12}>
<ToggleInput
checked={settings?.disabled ?? false}
hint="Cross or disable checkbox tasks marked as done"
offLabel="Cross"
onLabel="Disable"
onChange={e => {
setSettings({
disabled: e.target.checked,
});
}}
/>
</GridItem>
</Grid>
</Stack>
</Box>
</ContentLayout>
)}
</>
);
};
export 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// admin/src/index.js
//...
app.createSettingSection(
{
id: pluginId,
intlLabel: {
id: `${pluginId}.plugin.name`,
defaultMessage: 'Todo',
},
},
[
{
intlLabel: {
id: `${pluginId}.plugin.name`,
defaultMessage: 'General settings',
},
id: 'settings',
to: `/settings/${pluginId}`,
Component: async () => {
return import('./pages/Settings');
},
},
]
);
//..
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
2
3
├── translations
├── en.json
└── 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
2
3
4
5
// admin/src/translations/en.json
{
"Homepage.BaseHeaderLayout.title": "Todo Plugin",
// etc...
}
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
2
3
4
// admin/src/translations/en.json
{
"Homepage.BaseHeaderLayout.title": "Todo Plugin"
}
admin/src/translations/fr.json
file with the following code:1
2
3
4
// admin/src/translations/fr.json
{
"Homepage.BaseHeaderLayout.title": "Plugin des tâches" // French translation for Todo Plugin :)
}
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
2
3
4
5
// admin/src/pages/Homepage/index.js
//...
import { useIntl } from 'react-intl';
import getTrad from '../../utils/getTrad';
//...
formatMessage
function just before the useEffect
:1
2
3
//...
const { formatMessage } = useIntl();
//...
BaseHeaderLayout
component with the following:1
2
3
4
5
6
7
8
<BaseHeaderLayout
title={formatMessage({
id: getTrad('Homepage.BaseHeaderLayout.title'),
defaultMessage: 'Todo Plugin',
})}
subtitle="Discover the number of tasks you have in your project"
as="h2"
/>
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
// admin/src/pages/Homepage/index.js
/*
*
* HomePage
*
*/
import React, { memo, useState, useEffect } from 'react';
import { useIntl } from 'react-intl';
import getTrad from '../../utils/getTrad';
import taskRequests from '../../api/task';
import { LoadingIndicatorPage } from '@strapi/helper-plugin';
import { Box } from '@strapi/design-system/Box';
import { Flex } from '@strapi/design-system/Flex';
import { Typography } from '@strapi/design-system/Typography';
import { EmptyStateLayout } from '@strapi/design-system/EmptyStateLayout';
import { BaseHeaderLayout, ContentLayout } from '@strapi/design-system/Layout';
import { Illo } from '../../components/Illo';
const HomePage = () => {
const [taskCount, setTaskCount] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const { formatMessage } = useIntl();
useEffect(() => {
taskRequests.getTaskCount().then(data => {
setTaskCount(data);
setIsLoading(false);
});
}, [setTaskCount]);
if (isLoading) return <LoadingIndicatorPage />;
return (
<>
<BaseHeaderLayout
title={formatMessage({
id: getTrad('Homepage.BaseHeaderLayout.title'),
defaultMessage: 'Todo Plugin',
})}
subtitle="Discover the number of tasks you have in your project"
as="h2"
/>
<ContentLayout>
{taskCount === 0 && (
<EmptyStateLayout icon={<Illo />} content="You don't have any tasks yet..." />
)}
{taskCount > 0 && (
<Box background="neutral0" hasRadius={true} shadow="filterShadow">
<Flex justifyContent="center" padding={8}>
<Typography variant="alpha">You have a total of {taskCount} tasks 🚀</Typography>
</Flex>
</Box>
)}
</ContentLayout>
</>
);
};
export 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
2
3
4
// admin/src/index.js
//...
import TodoCard from './components/TodoCard';
//...
admin/src/index.js
file by injecting this component in the bootstrap lifecycle function:1
2
3
4
5
6
bootstrap(app) {
app.injectContentManagerComponent("editView", "right-links", {
name: "todo-component",
Component: TodoCard,
});
},
Your file should look like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
// admin/src/index.js
import { prefixPluginTranslations } from '@strapi/helper-plugin';
import pluginPkg from '../../package.json';
import pluginId from './pluginId';
import Initializer from './components/Initializer';
import PluginIcon from './components/PluginIcon';
import TodoCard from './components/TodoCard';
const name = pluginPkg.strapi.name;
export default {
register(app) {
app.addMenuLink({
to: `/plugins/${pluginId}`,
icon: PluginIcon,
intlLabel: {
id: `${pluginId}.plugin.name`,
defaultMessage: name,
},
Component: async () => {
const component = await import(/* webpackChunkName: "[request]" */ './pages/App');
return component;
},
permissions: [
// Uncomment to set the permissions of the plugin here
// {
// action: '', // the action name should be plugin::plugin-name.actionType
// subject: null,
// },
],
});
app.createSettingSection(
{
id: pluginId,
intlLabel: {
id: `${pluginId}.plugin.name`,
defaultMessage: 'Todo',
},
},
[
{
intlLabel: {
id: `${pluginId}.plugin.name`,
defaultMessage: 'General settings',
},
id: 'settings',
to: `/settings/${pluginId}`,
Component: async () => {
return import('./pages/Settings');
},
},
]
);
app.registerPlugin({
id: pluginId,
initializer: Initializer,
isReady: false,
name,
});
},
bootstrap(app) {
app.injectContentManagerComponent('editView', 'right-links', {
name: 'todo-component',
Component: TodoCard,
});
},
async registerTrads({ locales }) {
const importedTrads = await Promise.all(
locales.map(locale => {
return import(`./translations/${locale}.json`)
.then(({ default: data }) => {
return {
data: prefixPluginTranslations(data, pluginId),
locale,
};
})
.catch(() => {
return {
data: {},
locale,
};
});
})
);
return Promise.resolve(importedTrads);
},
};
Your application will not work since this TodoCard
doesn't exist yet.
admin/src/components/TodoCard/index.js
with the following code:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
import React, { useState, useEffect } from "react";
import { useCMEditViewDataManager } from "@strapi/helper-plugin";
import axiosInstance from "../../utils/axiosInstance";
import {
Box,
Typography,
Divider,
Checkbox,
Stack,
Flex,
Icon,
} from "@strapi/design-system";
import Plus from "@strapi/icons/Plus";
import TaskModal from "../TaskModal";
const TodoCard = () => {
const { initialData } = useCMEditViewDataManager();
const [tasks, setTasks] = useState(initialData.tasks);
const [settings, setSettings] = useState(false);
const [createModalIsShown, setCreateModalIsShown] = useState(false);
const fetchSettings = async () => {
try {
const { data } = await axiosInstance.get(`todo/settings`);
setSettings(data.settings.disabled);
} catch (e) {
console.log(e);
}
};
const updateTasks = async (taskId) => {
try {
let updatedTasks = tasks.map((task) => {
if (task.id === taskId) {
return { ...task, isDone: !task.isDone };
}
return task;
});
setTasks(updatedTasks);
} catch (e) {
console.log(e);
}
};
useEffect(() => {
fetchSettings();
}, [settings]);
const toggleTask = async (taskId, isChecked) => {
// Update task in database
const res = await axiosInstance.put(`/todo/update/${taskId}`, {
data: {
isDone: isChecked,
},
});
console.log(res);
if (res.status === 200) await updateTasks(taskId, isChecked);
};
const showTasks = () => {
// Loading state
if (status === "loading") {
return <p>Fetching todos...</p>;
}
// Error state
if (status === "error") {
return <p>Could not fetch tasks.</p>;
}
// Empty state
if (tasks == null || tasks.length === 0) {
return <p>No todo yet.</p>;
}
// Success state, show all tasks
return tasks.map((task) => (
<>
<Checkbox
value={task.isDone}
onValueChange={(isChecked) => toggleTask(task.id, isChecked)}
key={task.id}
disabled={task.isDone && settings ? true : false}
>
<span
style={{
textDecoration:
task.isDone && settings == false ? "line-through" : "none",
}}
>
{task.name}
</span>
</Checkbox>
</>
));
};
return (
<>
{createModalIsShown && (
<TaskModal
handleClose={() => setCreateModalIsShown(false)}
setTasks={setTasks}
tasks={tasks}
/>
)}
<Box
as="aside"
aria-labelledby="additional-informations"
background="neutral0"
borderColor="neutral150"
hasRadius
paddingBottom={4}
paddingLeft={4}
paddingRight={4}
paddingTop={3}
shadow="tableShadow"
>
<Typography
variant="sigma"
textColor="neutral600"
id="additional-informations"
>
Todos
</Typography>
<Box paddingTop={2} paddingBottom={6}>
<Box paddingBottom={2}>
<Divider />
</Box>
<Typography
fontSize={2}
textColor="primary600"
as="button"
type="button"
onClick={() => setCreateModalIsShown(true)}
>
<Flex>
<Icon
as={Plus}
color="primary600"
marginRight={2}
width={3}
height={3}
/>
Add todo
</Flex>
</Typography>
<Stack paddingTop={3} size={2}>
{showTasks()}
</Stack>
</Box>
</Box>
</>
);
};
export 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.1
2
3
4
5
6
7
8
const fetchSettings = async () => {
try {
const { data } = await axiosInstance.get(`todo/settings`);
setSettings(data.settings.disabled);
} catch (e) {
console.log(e);
}s
};
Then, depending on the value of these settings, it will render the checkbox:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
return tasks.map(task => (
<>
<Checkbox
value={task.isDone}
onValueChange={isChecked => toggleTask(task.id, isChecked)}
key={task.id}
disabled={task.isDone && settings ? true : false}
>
<span
style={{
textDecoration: task.isDone && settings == false ? 'line-through' : 'none',
}}
>
{task.name}
</span>
</Checkbox>
</>
));
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
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
...
{
method: "PUT",
path: "/update/:id",
handler: "task.update",
config: {
policies: [],
auth: false,
},
},
{
method: "POST",
path: "/create",
handler: "task.create",
config: {
policies: [],
auth: false,
},
},
...
/server/controllers/task.js
controller to implement the new update
and create
action:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
...
async create(ctx) {
try {
ctx.body = await strapi
.plugin("todo")
.service("task")
.create(ctx.request.body);
} catch (err) {
ctx.throw(500, err);
}
},
async update(ctx) {
try {
ctx.body = await strapi
.plugin("todo")
.service("task")
.update(ctx.params.id, ctx.request.body.data);
} catch (err) {
ctx.throw(500, err);
}
},
...
And finally, the /server/services/task.js
file with the corresponding services:
1
2
3
4
5
6
7
8
9
10
11
...
async create(data) {
return await strapi.query("plugin::todo.task").create(data);
},
async update(id, data) {
return await strapi.query("plugin::todo.task").update({
where: { id },
data,
});
},
...
The server part is ready! The TodoCard
component requires another one (TaskModal
).
admin/src/components/TaskModal/index.js
with the following code:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
// admin/src/components/TaskModal/index.js
import React, { useState } from "react";
import {
ModalLayout,
ModalHeader,
ModalBody,
ModalFooter,
Typography,
Button,
TextInput,
} from "@strapi/design-system";
import { useCMEditViewDataManager } from "@strapi/helper-plugin";
import axiosInstance from "../../utils/axiosInstance";
const TaskModal = ({ handleClose, setTasks, tasks }) => {
const [name, setName] = useState("");
const [status, setStatus] = useState();
const { slug, initialData } = useCMEditViewDataManager();
const handleSubmit = async (e) => {
// Prevent submitting parent form
e.preventDefault();
e.stopPropagation();
try {
// Show loading state
setStatus("loading");
// Create task and link it to the related entry
const res = await axiosInstance.post("/todo/create", {
data: {
name,
isDone: false,
related: {
__type: slug,
id: initialData.id,
},
},
});
setTasks([...tasks, res.data]);
// Remove loading and close popup
setStatus("success");
handleClose();
} catch (e) {
console.log(e);
setStatus("error");
}
};
const getError = () => {
// Form validation error
if (name.length > 40) {
return "Content is too long";
}
// API error
if (status === "error") {
return "Could not create todo";
}
return null;
};
return (
<ModalLayout
onClose={handleClose}
labelledBy="title"
as="form"
onSubmit={handleSubmit}
>
<ModalHeader>
<Typography fontWeight="bold" textColor="neutral800" as="h2" id="title">
Add todo
</Typography>
</ModalHeader>
<ModalBody>
<TextInput
placeholder="What do you need to do?"
label="Name"
name="text"
hint="Max 140 characters"
error={getError()}
onChange={(e) => setName(e.target.value)}
value={name}
/>
</ModalBody>
<ModalFooter
startActions={
<Button onClick={handleClose} variant="tertiary">
Cancel
</Button>
}
endActions={
<Button type="submit" loading={status === "loading"}>
{status === "loading" ? "Saving..." : "Save"}
</Button>
}
/>
</ModalLayout>
);
};
export 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:
1
2
3
4
5
6
7
8
9
10
11
12
const res = await axiosInstance.post("/todo/create", {
data: {
name,
isDone: false,
related: {
__type: slug,
id: initialData.id,
},
},
});
setTasks([...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
3
4
5
6
7
8
{
name, // name of the task
isDone: false,
related: {
__type: "api::article.article",
id: initialData.id, // id of the article
},
}
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.
1
2
3
git clone https://github.com/strapi/strapi.git
cd strapi
yarn 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
// admin/src/components/TodoCard/index.js
import React, { useState, useEffect } from 'react';
import { useCMEditViewDataManager } from '@strapi/helper-plugin';
import axiosInstance from '../../utils/axiosInstance';
import { Box, Typography, Divider, Checkbox, Stack, Flex, Icon } from '@strapi/design-system';
import Plus from '@strapi/icons/Plus';
import TaskModal from '../TaskModal';
function useRelatedTasks() {
const { initialData, isSingleType, slug } = useCMEditViewDataManager();
const [tasks, setTasks] = useState([]);
const [status, setStatus] = useState('loading');
const [settings, setSettings] = useState(false);
const fetchSettings = async () => {
try {
const { data } = await axiosInstance.get(`todo/settings`);
setSettings(data.disabled);
} catch (e) {
console.log(e);
}
};
const refetchTasks = async () => {
try {
const { data } = await axiosInstance.get(
`/content-manager/${isSingleType ? 'single-types' : 'collection-types'}/${slug}/${
isSingleType ? '' : initialData.id
}?populate=tasks`
);
setTasks(data.tasks);
setStatus('success');
} catch (e) {
setStatus('error');
}
};
useEffect(() => {
fetchSettings();
refetchTasks();
}, [initialData, isSingleType, axiosInstance, setTasks, setStatus, setSettings]);
return { status, tasks, refetchTasks, fetchSettings, settings };
}
const TodoCard = () => {
const { status, tasks, refetchTasks, settings } = useRelatedTasks();
const [createModalIsShown, setCreateModalIsShown] = useState(false);
const toggleTask = async (taskId, isChecked) => {
// Update task in database
await axiosInstance.put(`/content-manager/collection-types/plugin::todo.task/${taskId}`, {
isDone: isChecked,
});
// Call API to update local cache
await refetchTasks();
};
const showTasks = () => {
// Loading state
if (status === 'loading') {
return <p>Fetching todos...</p>;
}
// Error state
if (status === 'error') {
return <p>Could not fetch tasks.</p>;
}
// Empty state
if (tasks == null || tasks.length === 0) {
return <p>No todo yet.</p>;
}
// Success state, show all tasks
return tasks.map(task => (
<>
<Checkbox
value={task.isDone}
onValueChange={isChecked => toggleTask(task.id, isChecked)}
key={task.id}
disabled={task.isDone && settings ? true : false}
>
<span
style={{
textDecoration: task.isDone && settings == false ? 'line-through' : 'none',
}}
>
{task.name}
</span>
</Checkbox>
</>
));
};
return (
<>
{createModalIsShown && (
<TaskModal handleClose={() => setCreateModalIsShown(false)} refetchTasks={refetchTasks} />
)}
<Box
as="aside"
aria-labelledby="additional-informations"
background="neutral0"
borderColor="neutral150"
hasRadius
paddingBottom={4}
paddingLeft={4}
paddingRight={4}
paddingTop={3}
shadow="tableShadow"
>
<Typography variant="sigma" textColor="neutral600" id="additional-informations">
Todos
</Typography>
<Box paddingTop={2} paddingBottom={6}>
<Box paddingBottom={2}>
<Divider />
</Box>
<Typography
fontSize={2}
textColor="primary600"
as="button"
type="button"
onClick={() => setCreateModalIsShown(true)}
>
<Flex>
<Icon as={Plus} color="primary600" marginRight={2} width={3} height={3} />
Add todo
</Flex>
</Typography>
<Stack paddingTop={3} size={2}>
{showTasks()}
</Stack>
</Box>
</Box>
</>
);
};
export default TodoCard;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
// admin/src/components/TaskModal/index.js
import React, { useState } from 'react';
import {
ModalLayout,
ModalHeader,
ModalBody,
ModalFooter,
Typography,
Button,
TextInput,
} from '@strapi/design-system';
import { useCMEditViewDataManager } from '@strapi/helper-plugin';
import axiosInstance from '../../utils/axiosInstance';
const TaskModal = ({ handleClose, refetchTasks }) => {
const [name, setName] = useState('');
const [status, setStatus] = useState();
const { slug, initialData } = useCMEditViewDataManager();
const handleSubmit = async e => {
// Prevent submitting parent form
e.preventDefault();
e.stopPropagation();
try {
// Show loading state
setStatus('loading');
// Create task and link it to the related entry
await axiosInstance.post('/content-manager/collection-types/plugin::todo.task', {
name,
isDone: false,
related: {
__type: slug,
id: initialData.id,
},
});
// Refetch tasks list so it includes the created one
await refetchTasks();
// Remove loading and close popup
setStatus('success');
handleClose();
} catch (e) {
setStatus('error');
}
};
const getError = () => {
// Form validation error
if (name.length > 40) {
return 'Content is too long';
}
// API error
if (status === 'error') {
return 'Could not create todo';
}
return null;
};
return (
<ModalLayout onClose={handleClose} labelledBy="title" as="form" onSubmit={handleSubmit}>
<ModalHeader>
<Typography fontWeight="bold" textColor="neutral800" as="h2" id="title">
Add todo
</Typography>
</ModalHeader>
<ModalBody>
<TextInput
placeholder="What do you need to do?"
label="Name"
name="text"
hint="Max 40 characters"
error={getError()}
onChange={e => setName(e.target.value)}
value={name}
/>
</ModalBody>
<ModalFooter
startActions={
<Button onClick={handleClose} variant="tertiary">
Cancel
</Button>
}
endActions={
<Button type="submit" loading={status === 'loading'}>
{status === 'loading' ? 'Saving...' : 'Save'}
</Button>
}
/>
</ModalLayout>
);
};
export 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