Disclaimer: This is a guest post by Pouya Miralayi, a member of the Strapi community who volunteered to write a tutorial on how to create your own plugin on Strapi. I'll let him explain what you'll learn today
Please note: Since we initially published this blog post, we released new versions of Strapi and tutorials may be outdated. Sorry for the inconvenience if it's the case. Please help us by reporting it here.
Hello there! I am proud to be here talking about how we can develop a plugin in Strapi, our favorite headless CMS of all times! The plugin we are about to learn is originally developed by Joe Beuckman and here is a link to his wonderful project
Before we begin, I must say that we will cover the basics of React too. So if you are not an expert on React, there is nothing to be worry about. Here is a glimpse of what the final project is going to look like:
Using this plugin, you can import external data’s right into your “content types”. You can make an import by uploading a file, providing an external url, or writing your data manually. The type of data supported by this plugin is “CSV” & “RSS” at the moment. Also you can see the history of all your imports and perform some actions on them including “undo” & “delete”.
You can reach the code for this tutorial at this link
This tutorial is part of the « How to create your own plugin »:
Table of contents
You need to have node v.12 installed and that's all.
Let's create the Strapi application, our plugin and two models
1yarn create strapi-app import-content-tutorial --quickstart --no-run
2cd import-content-tutorial
3strapi generate:plugin import-content
4strapi generate:model importconfig --plugin import-content
5strapi generate:model importeditem --plugin import-content
plugins
directory structure to be exactly like this:1 plugins
2 |-- import-content
3 |-- admin
4 |-- config
5 |-- controllers
6 |-- models
7 |-- Importconfig.js
8 |-- Importconfig.settings.json
9 |-- Importeditem.js
10 |-- Importeditem.settings.json
11 |-- services
Open Importconfig.settings.json
inside models
directory:
plugins/import-content/models/Importconfig.settings.json
Paste the following:
1{
2 "connection": "default",
3 "collectionName": "",
4 "info": {
5 "name": "importconfig",
6 "description": ""
7 },
8 "options": { "timestamps": true, "increments": true, "comment": "" },
9 "attributes": {
10 "date": { "type": "date" },
11 "source": {
12 "type": "string"
13 },
14 "options": { "type": "json" },
15 "contentType": {
16 "type": "string"
17 },
18 "fieldMapping": { "type": "json" },
19 "ongoing": {
20 "type": "boolean"
21 },
22 "importeditems": {
23 "collection": "importeditem",
24 "via": "importconfig",
25 "plugin": "import-content"
26 }
27 }
28}
Open Importeditem.settings.json
inside models
directory:
plugins/import-content/models/Importeditem.settings.json
Paste the following:
1{
2 "connection": "default",
3 "collectionName": "",
4 "info": {
5 "name": "importeditem",
6 "description": ""
7 },
8 "options": { "increments": true, "timestamps": true, "comment": "" },
9 "attributes": {
10 "ContentType": {
11 "type": "string"
12 },
13 "ContentId": { "type": "integer" },
14 "importconfig": {
15 "model": "importconfig",
16 "via": "importeditems",
17 "plugin": "import-content"
18 },
19 "importedFiles": {
20 "type": "json"
21 }
22 }
23}
Now it’s time to install our dependencies.
yarn add content-type-parser@1.0.2 csv-parse@4.8.2 get-urls@9.2.0 moment rss-parser@3.7.3 request simple-statistics@7.0.7 striptags@3.1.1 lodash
yarn build
yarn develop --watch-admin
Let’s take a look at our plugin’s admin
directory structure inside plugins/import-content
directory:
1admin
2 |-- src
3 |-- containers
4 |-- translations
5 |-- utils
6 |-- index.js
7 |-- lifecycles.js
8 |-- pluginId.js
As you can see we have a src
directory which contains 3 other directories named containers
, translations
and utils
. The containers
directory is the main piece of puzzle and holds our plugin pages and their related logic. A react app as our plugin is one, is consisted of multiple pages, and each page is a composition of tiny blocks named components
. The usage of components helps us to prevent repeating ourselves and keep the reusable code inside a component and use it in different places which for our case are pages (worth mentioning that our pages are also components themselves). So we can come up with another directory named components
next to our containers
directory, and keep the reusable code inside that directory:
components
directory1admin
2 |-- src
3 |-- containers
4 |-- components
5 |-- translations
6 |-- utils
7 |-- index.js
8 |-- lifecycles.js
9 |-- pluginId.js
Now we are ready to start creating pages that are containers of those components. For more on components take a look at below link after you finished this tutorial! ReactJs
By default, Strapi has created a HomePage
for us that acts as our starting page:
1admin
2 |-- src
3 |-- containers
4 |-- App
5 |-- HomePage
6 |-- index.js
7 |-- Initializer
8 |-- components
9 |-- translations
10 |-- utils
11 |-- index.js
12 |-- lifecycles.js
13 |-- pluginId.js
Now if you open up the index.js
file inside this HomePage
directory, you will be represented by this:
1/*
2 *
3 * HomePage
4 *
5 */
6
7import React, { memo } from 'react';
8// import PropTypes from 'prop-types';
9import pluginId from '../../pluginId';
10
11const HomePage = () => {
12 return (
13 <div>
14 <h1>{pluginId}'s HomePage</h1>
15 <p>Happy coding</p>
16 </div>
17 );
18};
19
20export default memo(HomePage);
a react rendered page which is telling you: Happy coding! Before we actually change the contents of this page, Let’s build up the first component that we are about to use on this page: a form that will allow users to upload their data files:
Go to components
directory and create a new directory called UploadFileForm
with an empty index.js
file inside:
admin/src/components/UploadFileForm/index.js
Open the new index.js
file and paste the following code:
1import React, { Component } from "react";
2import PropTypes from "prop-types";
3class UploadFileForm extends Component {}
4export default UploadFileForm;
what we are doing here is to create a new Component class called UploadFileForm
. This class will represent our component for uploading files. There are some benefits on defining our Component as a class; the first benefit is that we can define a class member called state
which will keep track of our reactive variables as the component’s state. This way, the UI will re-render itself based on changes upon the state. The second benefit would be the structure that will be introduced into our component; for instance, we are allowed to define a peculiar method that is responsible for rendering our UI.
For now, we want to keep track of a few properties of the uploaded file like it’s type, its name and the actual file itself:
1class UploadFileForm extends Component {
2 state = {
3 file: null,
4 type: null,
5 options: {
6 filename: null
7 }
8 };
9}
10export default UploadFileForm;
render
method:1class UploadFileForm extends Component {
2 state = {
3 file: null,
4 type: null,
5 options: {
6 filename: null
7 }
8 };
9 render() {}
10}
1class UploadFileForm extends Component {
2 state = {
3 file: null,
4 type: null,
5 options: {
6 filename: null
7 }
8 };
9 render() {
10 return <input name="file_input" accept=".csv" type="file" />;
11 }
12}
1class UploadFileForm extends Component {
2 state = {
3 file: null,
4 type: null,
5 options: {
6 filename: null
7 }
8 };
9 onChangeImportFile = file => { // <---
10 file &&
11 this.setState({
12 file,
13 type: file.type,
14 options: { ...this.state.options, filename: file.name }
15 });
16 };
17 render() {
18 return <input name="file_input" accept=".csv" type="file" />;
19 }
20}
As you can see, we are using a method called setState
that will allow us to update our state in the right way and that would be another benefit of inheriting from “React Component”.
1class UploadFileForm extends Component {
2 state = {
3 file: null,
4 type: null,
5 options: {
6 filename: null
7 }
8 };
9 onChangeImportFile = file => {
10 file &&
11 this.setState({
12 file,
13 type: file.type,
14 options: { ...this.state.options, filename: file.name }
15 });
16 };
17 render() {
18 return <input
19 onChange={({target:{files}}) => files && this.onChangeImportFile(files[0])} name="file_input" accept=".csv" type="file" />;
20 }
21}
Well Done! Now we have an input for uploading files ;) the thing is that although it is working, it’s not as beautiful as other plugins look like. To work around this, we will create 3 other components in the components
directory which are actually a bunch of CSS and nothing more:
Create these files:
admin/src/components/Label/index.js
admin/src/components/P/index.js
admin/src/components/Row/index.js
Go to P
directory and paste the following code inside index.js
file:
1import styled from "styled-components";
2const P = styled.p`
3 margin-top: 10px;
4 text-align: center;
5 font-size: 13px;
6 color: #9ea7b8;
7 u {
8 color: #1c5de7;
9 }
10`;
11export default P;
Basically, what we are doing is to map our desired styles on a specific component. This will save us boilerplate code, and gives us the chance to provide CSS styles in a structured fashion as in separate components. No need to mention that we are writing our CSS inside JavaScript which is an awesome thing for itself! The other great benefit of using “styled components” is that it allows us to adopt styles based on “props”, so there wouldn’t be much difference between these components and those of react; You can see an example of using props in the Label
component which we are going to write later. Worth mentioning that Strapi UI components library “Buffet js” is using “styled components” to provide us a collection of nicely designed UI components. Here is some of the “Buffet js” components we can use:
There are a lot more to “styled components” that you can look for on this link(https://www.styled- components.com/docs/basics#motivation) You can checkout Strapi “Buffet js” components at this link
Row
directory and paste the following inside index.js
file:1import styled from "styled-components";
2const Row = styled.div`
3 padding-top: 18px;
4`;
5export default Row;
Label
:1import styled, { css, keyframes } from "styled-components";
2const Label = styled.label`
3 position: relative;
4 height: 146px;
5 width: 100%;
6 padding-top: 28px;
7 border: 2px dashed #e3e9f3;
8 border-radius: 2px;
9 text-align: center;
10 > input {
11 display: none;
12 }
13 .icon {
14 width: 82px;
15 path {
16 fill: ${({ showLoader }) => (showLoader ? "#729BEF" : "#ccd0da")};
17 transition: fill 0.3s ease;
18 }
19 }
20 .isDragging {
21 position: absolute;
22 top: 0;
23 bottom: 0;
24 left: 0;
25 right: 0;
26 }
27 .underline {
28 color: #1c5de7;
29 text-decoration: underline;
30 cursor: pointer;
31 }
32 &:hover {
33 cursor: pointer;
34 }
35 ${({ isDragging }) => {
36 if (isDragging) {
37 return css`
38 background-color: rgba(28, 93, 231, 0.01) !important;
39 border: 2px dashed rgba(28, 93, 231, 0.1) !important;
40 `;
41 }
42 }}
43 ${({ showLoader }) => {
44 if (showLoader) {
45 return css`
46 animation: ${smoothBlink("transparent", "rgba(28,93,231,0.05)")} 2s
47 linear infinite;
48 `;
49 }
50 }}
51`;
52const smoothBlink = (firstColor, secondColor) => keyframes`
530% {
54fill: ${firstColor}; background-color: ${firstColor}; } 26% {
55fill: ${secondColor}; background-color: ${secondColor}; } 76% {
56fill: ${firstColor}; background-color: ${firstColor}; } `;
57export default Label;
The styling for our Label
is expecting 2 props provided by its parent: isDragging
& showLoader
. We are also leveraging from a custom defined function named smoothBlink
for an animated blink. When the user is dragging something on the input, the background will get changed and whenever we are loading something, there would be a nice blink on our input which is really awesome!
let’s use these new beautiful pieces of CSS;
index.js
file inside UploadFileForm
directory and import the newly created components:1import React, { Component } from "react";
2import PropTypes from "prop-types";
3import P from "../P";
4import Row from "../Row";
5import Label from "../Label";
1class UploadFileForm extends Component {
2 state = {
3 file: null,
4 type: null,
5 options: {
6 filename: null
7 },
8 isDragging: false
9 };
10 onChangeImportFile = file => {
11 file &&
12 this.setState({
13 file,
14 type: file.type,
15 options: { ...this.state.options, filename: file.name }
16 });
17 };
18 handleDragEnter = () => this.setState({ isDragging: true }); // <---
19 handleDragLeave = () => this.setState({ isDragging: false }); // <---
20 handleDrop = e => { // <---
21 e.preventDefault();
22 this.setState({ isDragging: false });
23 const file = e.dataTransfer.files[0];
24 this.onChangeImportFile(file);
25 };
26...
as you can see, they are operating on a specific state property called isDragging
.
1state = {
2 file: null,
3 type: null,
4 options: {
5 filename: null
6 },
7 isDragging: false // <---
8 };
it’s time to show our beautiful upload form!
render
method as below:1 render() {
2 return (
3 <div className={"col-12"}>
4 <Row className={"row"}>
5 <Label
6 isDragging={this.state.isDragging}
7 onDrop={this.handleDrop}
8 onDragEnter={this.handleDragEnter}
9 onDragOver={e => {
10 e.preventDefault();
11 e.stopPropagation();
12 }}
13 >
14 <svg
15 className="icon"
16 xmlns="http://www.w3.org/2000/svg"
17 viewBox="0 0 104.40317 83.13328"
18 >
19 <g>
20 <rect
21 x="5.02914"
22 y="8.63138"
23 width="77.33334"
24 height="62.29167"
25 rx="4"
26 ry="4"
27 transform="translate(-7.45722 9.32921) rotate(-12)"
28 fill="#fafafb"
29 />
30 <rect
31 x="5.52914"
32 y="9.13138"
33 width="76.33334"
34 height="61.29167"
35 rx="4"
36 ry="4"
37 transform="translate(-7.45722 9.32921) rotate(-12)"
38 fill="none"
39 stroke="#979797"
40 />
41 <path
42 d="M74.25543,36.05041l3.94166,18.54405L20.81242,66.79194l-1.68928-7.94745,10.2265-16.01791,7.92872,5.2368,16.3624-25.62865ZM71.974,6.07811,6.76414,19.93889a1.27175,1.27175,0,0,0- .83343.58815,1.31145,1.31145,0,0,0-.18922,1.01364L16.44028,71.87453a1.31145,1.31145,0,0,0,.58515.849,1.27176,1.27176,0,0,0,1.0006.19831L83.23586,59.06111a1.27177,1.27177,0,0,0,.83343- .58815,1.31146,1.31146,0,0,0,.18922-1.01364L73.55972,7.12547a1.31146,1.31146,0,0,0-.58514-.849A1.27177,1.27177,0,0,0,71.974,6.07811Zm6.80253- .0615L89.4753,56.35046A6.5712,6.5712,0,0,1,88.554,61.435a6.37055,6.37055,0,0,1-4.19192,2.92439L19.15221,78.22019a6.37056,6.37056,0,0,1-5.019-.96655,6.57121,6.57121,0,0,1-2.90975- 4.27024L.5247,22.64955A6.57121,6.57121,0,0,1,1.446,17.565a6.37056,6.37056,0,0,1,4.19192-2.92439L70.84779.77981a6.37055,6.37055,0,0,1,5.019.96655A6.5712,6.5712,0,0,1,78.77651,6.01661Z"
43 transform="translate(-0.14193 -0.62489)"
44 fill="#333740"
45 />
46 <rect
47 x="26.56627"
48 y="4.48824"
49 width="62.29167"
50 height="77.33333"
51 rx="4"
52 ry="4"
53 transform="translate(0.94874 87.10632) rotate(-75)"
54 fill="#fafafb"
55 />
56 <rect
57 x="27.06627"
58 y="4.98824"
59 width="61.29167"
60 height="76.33333"
61 rx="4"
62 ry="4"
63 transform="translate(0.94874 87.10632) rotate(-75)"
64 fill="none"
65 stroke="#979797"
66 />
67 <path
68 d="M49.62583,26.96884A7.89786,7.89786,0,0,1,45.88245,31.924a7.96,7.96,0,0,1-10.94716-2.93328,7.89786,7.89786,0,0,1-.76427-6.163,7.89787,7.89787,0,0,1,3.74338- 4.95519,7.96,7.96,0,0,1,10.94716,2.93328A7.89787,7.89787,0,0,1,49.62583,26.96884Zm37.007,26.73924L81.72608,72.02042,25.05843,56.83637l2.1029- 7.84815L43.54519,39.3589l4.68708,8.26558L74.44644,32.21756ZM98.20721,25.96681,33.81216,8.71221a1.27175,1.27175,0,0,0-1.00961.14568,1.31145,1.31145,0,0,0-
6910
70.62878.81726L18.85537,59.38007a1.31145,1.31145,0,0,0,.13591,1.02215,1.27176,1.27176,0,0,0,.80151.631l64.39506,17.2546a1.27177,1.27177,0,0,0,1.0096-.14567,1.31146,1.31146,0,0,0,.62877-.81726l13.3184- 49.70493a1.31146,1.31146,0,0,0-.13591-1.02215A1.27177,1.27177,0,0,0,98.20721,25.96681Zm6.089,3.03348L90.97784,78.70523a6.5712,6.5712,0,0,1-3.12925,4.1121,6.37055,6.37055,0,0,1- 5.06267.70256L18.39086,66.26529a6.37056,6.37056,0,0,1-4.03313-3.13977,6.57121,6.57121,0,0,1-.654-5.12581L27.02217,8.29477a6.57121,6.57121,0,0,1,3.12925-4.11211,6.37056,6.37056,0,0,1,5.06267- .70255l64.39506,17.2546a6.37055,6.37055,0,0,1,4.03312,3.13977A6.5712,6.5712,0,0,1,104.29623,29.0003Z"
71 transform="translate(-0.14193 -0.62489)"
72 fill="#333740"
73 />
74 </g>
75 </svg>
76 <P>
77 <span>
78 Drag & drop your file into this area or
79 <span className={"underline"}>browse</span> for a file to upload
80 </span>
81 </P>
82 <div onDragLeave={this.handleDragLeave} className="isDragging" />
83 <input
84 name="file_input"
85 accept=".csv"
86 onChange={({ target: { files } }) =>
87 files && this.onChangeImportFile(files[0])
88 }
89 type="file"
90 />
91 </Label>
92 </Row>
93 </div>
94 );
95 }
We are passing isDragging
down to our Label
; This way our Label
will receive the value for isDragging
from its parent and that is what we call a "prop" in react.
containers
directory and open the index.js
file inside HomePage
directory; change the contents with this:1import React, { memo } from "react";
2// import PropTypes from "prop-types";
3import pluginId from "../../pluginId";
4import UploadFileForm from "../../components/UploadFileForm";
5
6const HomePage = () => {
7 return <UploadFileForm />;
8};
9export default memo(HomePage);
Visit http://localhost:8000/admin/plugins/import-content
Valla! Let’s take this even further by appending a button to the bottom of our beautiful upload form;
index.js
file at UploadFileForm
directory. Append this Row
at the end of the render method output under the first Row
tag:1...
2 <Row className={"row"}>
3 <Button
4 label={"Analyze"}
5 color={this.state.file ? "secondary" : "cancel"}
6 disabled={!this.state.file}
7 />
8 </Row>
9...
So your file should look like this:
1import React, { Component } from "react";
2import PropTypes from "prop-types";
3import P from "../P";
4import Row from "../Row";
5import Label from "../Label";
6
7class UploadFileForm extends Component {
8 state = {
9 file: null,
10 type: null,
11 options: {
12 filename: null
13 },
14 isDragging: false
15 };
16 onChangeImportFile = file => {
17 file &&
18 this.setState({
19 file,
20 type: file.type,
21 options: { ...this.state.options, filename: file.name }
22 });
23 };
24 handleDragEnter = () => this.setState({ isDragging: true });
25 handleDragLeave = () => this.setState({ isDragging: false });
26 handleDrop = e => {
27 e.preventDefault();
28 this.setState({ isDragging: false });
29 const file = e.dataTransfer.files[0];
30 this.onChangeImportFile(file);
31 };
32 render() {
33 return (
34 <div className={"col-12"}>
35 <Row className={"row"}>
36 <Label
37 isDragging={this.state.isDragging}
38 onDrop={this.handleDrop}
39 onDragEnter={this.handleDragEnter}
40 onDragOver={e => {
41 e.preventDefault();
42 e.stopPropagation();
43 }}
44 >
45 <svg
46 className="icon"
47 xmlns="http://www.w3.org/2000/svg"
48 viewBox="0 0 104.40317 83.13328"
49 >
50 <g>
51 <rect
52 x="5.02914"
53 y="8.63138"
54 width="77.33334"
55 height="62.29167"
56 rx="4"
57 ry="4"
58 transform="translate(-7.45722 9.32921) rotate(-12)"
59 fill="#fafafb"
60 />
61 <rect
62 x="5.52914"
63 y="9.13138"
64 width="76.33334"
65 height="61.29167"
66 rx="4"
67 ry="4"
68 transform="translate(-7.45722 9.32921) rotate(-12)"
69 fill="none"
70 stroke="#979797"
71 />
72 <path
73 d="M74.25543,36.05041l3.94166,18.54405L20.81242,66.79194l-1.68928-7.94745,10.2265-16.01791,7.92872,5.2368,16.3624-25.62865ZM71.974,6.07811,6.76414,19.93889a1.27175,1.27175,0,0,0- .83343.58815,1.31145,1.31145,0,0,0-.18922,1.01364L16.44028,71.87453a1.31145,1.31145,0,0,0,.58515.849,1.27176,1.27176,0,0,0,1.0006.19831L83.23586,59.06111a1.27177,1.27177,0,0,0,.83343- .58815,1.31146,1.31146,0,0,0,.18922-1.01364L73.55972,7.12547a1.31146,1.31146,0,0,0-.58514-.849A1.27177,1.27177,0,0,0,71.974,6.07811Zm6.80253- .0615L89.4753,56.35046A6.5712,6.5712,0,0,1,88.554,61.435a6.37055,6.37055,0,0,1-4.19192,2.92439L19.15221,78.22019a6.37056,6.37056,0,0,1-5.019-.96655,6.57121,6.57121,0,0,1-2.90975- 4.27024L.5247,22.64955A6.57121,6.57121,0,0,1,1.446,17.565a6.37056,6.37056,0,0,1,4.19192-2.92439L70.84779.77981a6.37055,6.37055,0,0,1,5.019.96655A6.5712,6.5712,0,0,1,78.77651,6.01661Z"
74 transform="translate(-0.14193 -0.62489)"
75 fill="#333740"
76 />
77 <rect
78 x="26.56627"
79 y="4.48824"
80 width="62.29167"
81 height="77.33333"
82 rx="4"
83 ry="4"
84 transform="translate(0.94874 87.10632) rotate(-75)"
85 fill="#fafafb"
86 />
87 <rect
88 x="27.06627"
89 y="4.98824"
90 width="61.29167"
91 height="76.33333"
92 rx="4"
93 ry="4"
94 transform="translate(0.94874 87.10632) rotate(-75)"
95 fill="none"
96 stroke="#979797"
97 />
98 <path
99 d="M49.62583,26.96884A7.89786,7.89786,0,0,1,45.88245,31.924a7.96,7.96,0,0,1-10.94716-2.93328,7.89786,7.89786,0,0,1-.76427-6.163,7.89787,7.89787,0,0,1,3.74338- 4.95519,7.96,7.96,0,0,1,10.94716,2.93328A7.89787,7.89787,0,0,1,49.62583,26.96884Zm37.007,26.73924L81.72608,72.02042,25.05843,56.83637l2.1029- 7.84815L43.54519,39.3589l4.68708,8.26558L74.44644,32.21756ZM98.20721,25.96681,33.81216,8.71221a1.27175,1.27175,0,0,0-1.00961.14568,1.31145,1.31145,0,0,0-
10010
101.62878.81726L18.85537,59.38007a1.31145,1.31145,0,0,0,.13591,1.02215,1.27176,1.27176,0,0,0,.80151.631l64.39506,17.2546a1.27177,1.27177,0,0,0,1.0096-.14567,1.31146,1.31146,0,0,0,.62877-.81726l13.3184- 49.70493a1.31146,1.31146,0,0,0-.13591-1.02215A1.27177,1.27177,0,0,0,98.20721,25.96681Zm6.089,3.03348L90.97784,78.70523a6.5712,6.5712,0,0,1-3.12925,4.1121,6.37055,6.37055,0,0,1- 5.06267.70256L18.39086,66.26529a6.37056,6.37056,0,0,1-4.03313-3.13977,6.57121,6.57121,0,0,1-.654-5.12581L27.02217,8.29477a6.57121,6.57121,0,0,1,3.12925-4.11211,6.37056,6.37056,0,0,1,5.06267- .70255l64.39506,17.2546a6.37055,6.37055,0,0,1,4.03312,3.13977A6.5712,6.5712,0,0,1,104.29623,29.0003Z"
102 transform="translate(-0.14193 -0.62489)"
103 fill="#333740"
104 />
105 </g>
106 </svg>
107 <P>
108 <span>
109 Drag & drop your file into this area or
110 <span className={"underline"}>browse</span> for a file to upload
111 </span>
112 </P>
113 <div onDragLeave={this.handleDragLeave} className="isDragging" />
114 <input
115 name="file_input"
116 accept=".csv"
117 onChange={({ target: { files } }) =>
118 files && this.onChangeImportFile(files[0])
119 }
120 type="file"
121 />
122 </Label>
123 </Row>
124 {/*---HERE---*/}
125 <Row className={"row"}>
126 <Button
127 label={"Analyze"}
128 color={this.state.file ? "secondary" : "cancel"}
129 disabled={!this.state.file}
130 />
131 </Row>
132 </div>
133 );
134 }
135}
136export default UploadFileForm;
index.js
file, we must append the import for our Button
as well:1import React, { Component } from "react";
2import PropTypes from "prop-types";
3import P from "../P";
4import Row from "../Row";
5import Label from "../Label";
6import { Button } from "@buffetjs/core";
![]
The only thing left for this component is that when the user clicks on our “Analyze” button we must send some information about the uploaded file to our backend for analyzing. This information consists of the “file name”, the source of import (for our case “file upload”), the type of imported data (like “csv”), the data to import (the contents of the file) & etc.
For this we need to define a method named onRequestAnalysis
in our HomePage
and pass it down to our UploadFileForm
. So whenever users click on “Analyze” button, this method will get called from our UploadFileForm
component:
index.js
file at HomePage
directory as below:1import React, { memo, Component } from "react";
2import {request} from "strapi-helper-plugin";
3import PropTypes from "prop-types";
4import pluginId from "../../pluginId";
5import UploadFileForm from "../../components/UploadFileForm";
6
7class HomePage extends Component {
8 render() {
9 return <UploadFileForm />;
10 };
11}
12export default memo(HomePage);
HomePage
adopted to being a class based component, let’s define a state for it:1class HomePage extends Component {
2 state = {
3 analyzing: false,
4 analysis: null
5 };
6...
analyzing
will indicate if we are currently analyzing data or not; this way we can show our animated blink while analyzing request is being processed by the backend. analysis
on the other hand is the result of analyzing process that we will receive from our backend per request.
1...
2onRequestAnalysis = async analysisConfig => {
3 this.analysisConfig = analysisConfig;
4 this.setState({ analyzing: true }, async () => {
5 try {
6 const response = await request("/import-content/preAnalyzeImportFile", {
7 method: "POST",
8 body: analysisConfig
9 });
10
11 this.setState({ analysis: response, analyzing: false }, () => {
12 strapi.notification.success(`Analyzed Successfully`);
13 });
14 } catch (e) {
15 this.setState({ analyzing: false }, () => {
16 strapi.notification.error(`Analyze Failed, try again`);
17 strapi.notification.error(`${e}`);
18 });
19 }
20 });
21 };
22...
We will pass this method down to UploadFileForm
component and basically what we are doing in this method is to send an “analyzing request” that contains information about our data (uploaded file). Notice how we are using strapi notification
to inform users of the response, or show them an error. This information has been passed to this method from the UploadFileForm
component.
UploadFileForm
component:1...
2render() {
3 return <UploadFileForm onRequestAnalysis={this.onRequestAnalysis} />;
4 }
5...
UploadFileForm
has a way to send requests, let’s introduce the last thing to this component:1...
2render() {
3 return (
4 <UploadFileForm
5 onRequestAnalysis={this.onRequestAnalysis}
6 loadingAnalysis={this.state.analyzing}
7 />
8 );
9}
10...
loadingAnalysis
allows UploadFileForm
component to show that nice blinking animation when we are in the middle of an analyzing process. Now that our UploadFileForm
component has everything at hand, it is time to actually use them!
index.js
file at UploadFileForm
directory and add this function to the Label:1<Label
2 showLoader={this.props.loadingAnalysis} // <---
3 isDragging={this.state.isDragging}
4 onDrop={this.handleDrop}
5 onDragEnter={this.handleDragEnter}
6 onDragOver={e => {
7 e.preventDefault();
8 e.stopPropagation();
9 }}
10>
The above code will do the job for blinking! Now it’s time for the “analyze request”; define the new methods as below:
1readFileContent = file => {
2 const reader = new FileReader();
3 return new Promise((resolve, reject) => {
4 reader.onload = event => resolve(event.target.result);
5 reader.onerror = reject;
6 reader.readAsText(file);
7 });
8 };
9
10 clickAnalyzeUploadFile = async () => {
11 const { file, options } = this.state;
12 const data = file && (await this.readFileContent(file));
13 data &&
14 this.props.onRequestAnalysis({
15 source: "upload",
16 type: file.type,
17 options,
18 data
19 });
20 };
The readFileContent
will read the contents of the file (we need it for the analyzing request) and the clickAnalyzeUploadFile
is the click handler for our “Analyze” button; as we said before, onRequestAnalysis
is passed to this component as a prop and we are calling it with the information of the uploaded file to make our “analyze request”.
1<Row className={"row"}>
2 <Button
3 label={"Analyze"}
4 color={this.state.file ? "secondary" : "cancel"}
5 disabled={!this.state.file}
6 onClick={this.clickAnalyzeUploadFile} // <---
7 />
8</Row>
1...
2UploadFileForm.propTypes = {
3 onRequestAnalysis: PropTypes.func.isRequired,
4 loadingAnalysis: PropTypes.bool.isRequired
5};
6export default UploadFileForm;
Now that we have a working UploadFileForm
component,
Let’s make our HomePage
more like an actual plugin.
Go to components
directory and create a new directory named Block
:
admin/src/components/Block/index.js
admin/src/components/Block/components.js
Define 2 files named index.js
and components.js
inside that directory, then open the components.js
file and paste the following:
1import styled from "styled-components";
2const Wrapper = styled.div`
3 margin-bottom: 35px;
4 background: #ffffff;
5 padding: 22px 28px 18px;
6 border-radius: 2px;
7 box-shadow: 0 2px 4px #e3e9f3;
8 -webkit-font-smoothing: antialiased;
9`;
10const Sub = styled.div`
11 padding-top: 0px;
12 line-height: 18px;
13 > p:first-child {
14 margin-bottom: 1px;
15 font-weight: 700;
16 color: #333740;
17 font-size: 1.8rem;
18 }
19 > p {
20 color: #787e8f;
21 font-size: 13px;
22 }
23`;
24export { Wrapper, Sub };
index.js
file and paste the following as well:1import React, { memo } from "react";
2import PropTypes from "prop-types";
3import { Wrapper, Sub } from "./components";
4const Block = ({ children, description, style, title }) => (
5 <div className="col-md-12">
6 <Wrapper style={style}>
7 <Sub>
8 {!!title && <p>{title} </p>} {!!description && <p>{description} </p>}
9 </Sub>
10 {children}
11 </Wrapper>
12 </div>
13);
14Block.defaultProps = {
15 children: null,
16 description: null,
17 style: {},
18 title: null
19};
20Block.propTypes = {
21 children: PropTypes.any,
22 description: PropTypes.string,
23 style: PropTypes.object,
24 title: PropTypes.string
25};
26export default memo(Block);
index.js
file at HomePage
directory and add the following at the top:1...
2import {
3 HeaderNav,
4 LoadingIndicator,
5 PluginHeader
6} from "strapi-helper-plugin";
7import Row from "../../components/Row";
8import Block from "../../components/Block";
9import { Select, Label } from "@buffetjs/core";
10import { get, has, isEmpty, pickBy, set } from "lodash";
11
12const getUrl = to =>
13 to ? `/plugins/${pluginId}/${to}` : `/plugins/${pluginId}`;
14...
render
output as below:1 <div className={"container-fluid"} style={{ padding: "18px 30px" }}>
2 <PluginHeader
3 title={"Import Content"}
4 description={"Import CSV and RSS-Feed into your Content Types"}
5 />
6 <HeaderNav
7 links={[
8 {
9 name: "Import Data",
10 to: getUrl("")
11 },
12 {
13 name: "Import History",
14 to: getUrl("history")
15 }
16 ]}
17 style={{ marginTop: "4.4rem" }}
18 />
19 <div className="row">
20 <Block
21 title="General"
22 description="Configure the Import Source & Destination"
23 style={{ marginBottom: 12 }}
24 >
25 <UploadFileForm
26 onRequestAnalysis={this.onRequestAnalysis}
27 loadingAnalysis={this.state.analyzing}
28 />
29 </Block>
30 </div>
31 </div>
Valla! Now it looks like an actual plugin, well done!
Notice how we leveraged from “Strapi Helper Plugin” UI components like PluginHeader
& HeaderNav
. PluginHeader
will render our plugin’s title & description; and HeaderNav
will give us a tab based structure to navigate between pages.
For now, we have provided our users a way to upload their data files and this is really good, but what if they don’t possess a file at all? What if their data is an “RSS feed” or they want to manually type it? So users need more data sources than the current one. Do not worry about this because we are going to provide our users with more options as their data sources.
1class HomePage extends Component {
2
3 importSources = [
4 { label: "External URL ", value: "url" },
5 { label: "Upload file", value: "upload" },
6 { label: "Raw text", value: "raw" }
7 ];
8...
1state = {
2 importSource: "upload", // <---
3 analyzing: false,
4 analysis: null
5};
render
output as below:1 return (
2 <div className={"container-fluid"} style={{ padding: "18px 30px" }}>
3 <PluginHeader
4 title={"Import Content"}
5 description={"Import CSV and RSS-Feed into your Content Types"}
6 />
7 <HeaderNav
8 links={[
9 {
10 name: "Import Data",
11 to: getUrl("")
12 },
13 {
14 name: "Import History",
15 to: getUrl("history")
16 }
17 ]}
18 style={{ marginTop: "4.4rem" }}
19 />
20 <div className="row">
21 <Block
22 title="General"
23 description="Configure the Import Source & Destination"
24 style={{ marginBottom: 12 }}
25 >
26 <Row className={"row"}>
27 <div className={"col-4"}>
28 <Label htmlFor="importSource">Import Source</Label>
29 <Select
30 name="importSource"
31 options={this.importSources}
32 value={this.state.importSource}
33 />
34 </div>
35 </Row>
36 <UploadFileForm
37 onRequestAnalysis={this.onRequestAnalysis}
38 loadingAnalysis={this.state.analyzing}
39 />
40 </Block>
41 </div>
42 </div>
43 );
Very nice! But if you notice we cannot change the default option.
state
:1...
2selectImportSource = importSource => {
3 this.setState({ importSource });
4 };
5...
Select
component:1...
2<Select
3 name="importSource"
4 options={this.importSources}
5 value={this.state.importSource}
6 onChange={({ target: { value } }) =>
7 this.selectImportSource(value)
8 }
9/>
10...
Good! Now that our users know how to import their data, it’s time to know where to import that data! We need to show the users a list of Content Types” to choose from;
For this we are going to use an already defined endpoint named getcontenttypes
from the “Content Type Builder” plugin;
1state = {
2 loading: true, // <---
3 modelOptions: [], // <---
4 models: [], // <---
5 importSource: "upload",
6 analyzing: false,
7 analysis: null
8};
getModels
method:1getModels = async () => {
2 this.setState({ loading: true });
3 try {
4 const response = await request("/content-type-builder/content-types", {
5 method: "GET"
6 });
7
8 // Remove non-user content types from models
9 const models = get(response, ["data"], []).filter(
10 obj => !has(obj, "plugin")
11 );
12 const modelOptions = models.map(model => {
13 return {
14 label: get(model, ["schema", "name"], ""), // (name is used for display_name)
15 value: model.uid // (uid is used for table creations)
16 };
17 });
18
19 this.setState({ loading: false });
20
21 return { models, modelOptions };
22 } catch (e) {
23 this.setState({ loading: false }, () => {
24 strapi.notification.error(`${e}`);
25 });
26 }
27 return [];
28 };
Awesome! We want this getModels
to be called when the users navigate to our plugin page in order to show them a list of “Content Types”.
The best way to do this is to call our method in a “life cycle hook”
state
like the following:1...
2state = {
3 loading: true,
4 modelOptions: [],
5 models: [],
6 importSource: "upload",
7 analyzing: false,
8 analysis: null,
9 selectedContentType: "" // <---
10};
11...
componentDidMount
method just under your state:1componentDidMount() {
2 this.getModels().then(res => {
3 const { models, modelOptions } = res;
4 this.setState({
5 models,
6 modelOptions,
7 selectedContentType: modelOptions ? modelOptions[0].value : ""
8 });
9 });
10}
selectedContentType
is a way to keep track of user selected “Content Type” and we will fill it with the first “Content Type” as the default selected option. componentDidMount
is a react life cycle hook which is called whenever our component is mounted; so every time the user comes to our plugin, this function will get called.
div
just after the "Import source" div
:1...
2<div className={"col-4"}>
3 <Label htmlFor="importDest">Import Destination</Label>
4 <Select
5 value={this.state.selectedContentType}
6 name="importDest"
7 options={this.state.modelOptions}
8 />
9</div>
10...
Your code should look like this:
1import React, { memo, Component } from "react";
2import {request} from "strapi-helper-plugin";
3import PropTypes from "prop-types";
4import pluginId from "../../pluginId";
5import UploadFileForm from "../../components/UploadFileForm";
6
7import {
8 HeaderNav,
9 LoadingIndicator,
10 PluginHeader
11} from "strapi-helper-plugin";
12import Row from "../../components/Row";
13import Block from "../../components/Block";
14import { Select, Label } from "@buffetjs/core";
15import { get, has, isEmpty, pickBy, set } from "lodash";
16
17const getUrl = to =>
18 to ? `/plugins/${pluginId}/${to}` : `/plugins/${pluginId}`;
19
20class HomePage extends Component {
21 importSources = [
22 { label: "External URL ", value: "url" },
23 { label: "Upload file", value: "upload" },
24 { label: "Raw text", value: "raw" }
25 ];
26
27 state = {
28 loading: true,
29 modelOptions: [],
30 models: [],
31 importSource: "upload",
32 analyzing: false,
33 analysis: null,
34 selectedContentType: ""
35 };
36
37 componentDidMount() {
38 this.getModels().then(res => {
39 const { models, modelOptions } = res;
40 this.setState({
41 models,
42 modelOptions,
43 selectedContentType: modelOptions ? modelOptions[0].value : ""
44 });
45 });
46 }
47
48 getModels = async () => {
49 this.setState({ loading: true });
50 try {
51 const response = await request("/content-type-builder/content-types", {
52 method: "GET"
53 });
54
55 // Remove content types from models
56 const models = get(response, ["data"], []).filter(
57 obj => !has(obj, "plugin")
58 );
59 const modelOptions = models.map(model => {
60 return {
61 label: get(model, ["schema", "name"], ""),
62 value: model.uid
63 };
64 });
65
66 this.setState({ loading: false });
67
68 return { models, modelOptions };
69 } catch (e) {
70 this.setState({ loading: false }, () => {
71 strapi.notification.error(`${e}`);
72 });
73 }
74 return [];
75 };
76
77 selectImportSource = importSource => {
78 this.setState({ importSource });
79 };
80
81 onRequestAnalysis = async analysisConfig => {
82 this.analysisConfig = analysisConfig;
83 this.setState({ analyzing: true }, async () => {
84 try {
85 const response = await request("/import-content/preAnalyzeImportFile", {
86 method: "POST",
87 body: analysisConfig
88 });
89
90 this.setState({ analysis: response, analyzing: false }, () => {
91 strapi.notification.success(`Analyzed Successfully`);
92 });
93 } catch (e) {
94 this.setState({ analyzing: false }, () => {
95 strapi.notification.error(`Analyze Failed, try again`);
96 strapi.notification.error(`${e}`);
97 });
98 }
99 });
100 };
101
102 render() {
103 return (
104 <div className={"container-fluid"} style={{ padding: "18px 30px" }}>
105 <PluginHeader
106 title={"Import Content"}
107 description={"Import CSV and RSS-Feed into your Content Types"}
108 />
109 <HeaderNav
110 links={[
111 {
112 name: "Import Data",
113 to: getUrl("")
114 },
115 {
116 name: "Import History",
117 to: getUrl("history")
118 }
119 ]}
120 style={{ marginTop: "4.4rem" }}
121 />
122 <div className="row">
123 <Block
124 title="General"
125 description="Configure the Import Source & Destination"
126 style={{ marginBottom: 12 }}
127 >
128 <Row className={"row"}>
129 <div className={"col-4"}>
130 <Label htmlFor="importSource">Import Source</Label>
131 <Select
132 name="importSource"
133 options={this.importSources}
134 value={this.state.importSource}
135 onChange={({ target: { value } }) =>
136 this.selectImportSource(value)
137 }
138 />
139 </div>
140 <div className={"col-4"}>
141 <Label htmlFor="importDest">Import Destination</Label>
142 <Select
143 value={this.state.selectedContentType}
144 name="importDest"
145 options={this.state.modelOptions}
146 />
147 </div>
148 </Row>
149 <UploadFileForm
150 onRequestAnalysis={this.onRequestAnalysis}
151 loadingAnalysis={this.state.analyzing}
152 />
153 </Block>
154 </div>
155 </div>
156 );
157 }
158}
159export default memo(HomePage);
Note Just before seeing the result, make sure that you have at least 1 “Content Type” available
state
:1...
2selectImportDest = selectedContentType => {
3 this.setState({ selectedContentType });
4};
5...
onChange
function:1...
2<Select
3 value={this.state.selectedContentType}
4 name="importDest"
5 options={this.state.modelOptions}
6 onChange={({ target: { value } }) =>
7 this.selectImportDest(value)
8 }
9/>
10...
Awesome you finished the first part of this tutorial! Let's import data from url or raw input text now ! https://strapi.io/blog/how-to-create-an-import-content-plugin-part-2-4
Pouya is an active member of the Strapi community, who has been contributing actively with contributions in the core and plugins.