Simply copy and paste the following command line in your terminal to create your first Strapi project.
npx create-strapi-app
my-project
This tutorial is part of the « How to create your own plugin »:
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.
Table of contents
You can reach the code for this tutorial at this link
We are going to setup our “analyzing endpoint” so our UI actually receives a proper response when it sends an “analyze request”.
Go to services
directory which is located in plugin’s root directory and create a new directory called utils
with a file named utils.js
inside it:
services/utils/utils.js
Paste the below code in that file:
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
"use strict";
const request = require("request");
const contentTypeParser = require("content-type-parser");
const RssParser = require("rss-parser");
const CsvParser = require("csv-parse/lib/sync");
const urlRegEx = /((([A-Za-z]{3,9}:(?:\/\/)?)(?:[\-;:&=\+\$,\w]+@)?[A-Za-z0-9\.\-]+|(?:www\.|[\- ;:&=\+\$,\w]+@)[A-Za-z0-9\.\-]+)((?:\/[\+~%\/\.\w\-]*)?\??(?:[\-\+=&;%@\.\w]*)#?(?:[\.\!\/\\\w]*))?)/g;
const URL_REGEXP = new RegExp(urlRegEx);
const validateUrl = url => {
URL_REGEXP.lastIndex = 0;
return URL_REGEXP.test(url);
};
const EMAIL_REGEXP = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
const stringIsEmail = data => {
EMAIL_REGEXP.lastIndex = 0;
return EMAIL_REGEXP.test(data);
};
const getDataFromUrl = url => {
return new Promise((resolve, reject) => {
if (!validateUrl(url)) return reject("invalid URL");
request(url, null, async (err, res, body) => {
if (err) {
reject(err);
}
resolve({ dataType: res.headers["content-type"], body });
});
});
};
const resolveDataFromRequest = async ctx => {
const { source, type, options, data } = ctx.request.body;
switch (source) {
case "upload":
return { dataType: type, body: data, options };
case "url":
const { dataType, body } = await getDataFromUrl(options.url);
return { dataType, body, options };
case "raw":
return {
dataType: type,
body: options.rawText,
options
};
}
};
We are about to use above utility functions across our services. resolveDataFromRequest
will receive the “analyze request” and detect the type of data alongside its content. For instance, if we are providing “external url” for our source of import, it will use getDataFromUrl
function to fetch that url in order to extract the data and its type. Now that we have the data, we must parse that data to extract its items;
utils.js
:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const getItemsFromData = ({ dataType, body, options }) =>
new Promise(async (resolve, reject) => {
const parsedContentType = contentTypeParser(dataType);
if (parsedContentType.isXML()) {
const parser = new RssParser();
const feed = await parser.parseString(body);
return resolve({ sourceType: "rss", items: feed.items });
}
if (dataType === "text/csv" || dataType === "application/vnd.ms-excel") {
const items = CsvParser(body, {
...options,
columns: true
});
return resolve({ sourceType: "csv", items });
}
reject({
contentType: parsedContentType.toString()
});
});
As you can see, we are using different parsers for different types of data. If the data type is xml
we are using rss-parser
and in case it is csv
, we are using csv-parser
.
utils.js
file: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
const urlIsMedia = url => {
try {
const parsed = new URL(url);
const extension = parsed.pathname
.split(".")
.pop()
.toLowerCase();
switch (extension) {
case "png":
case "gif":
case "jpg":
case "jpeg":
case "svg":
case "bmp":
case "tif":
case "tiff":
return true;
case "mp3":
case "wav":
case "ogg":
return true;
case "mp4":
case "avi":
return true;
default:
return false;
}
} catch (error) {
return false;
}
};
Well done! The only thing left, is to actually analyze the extracted items.
1
2
3
4
5
6
7
module.exports = {
resolveDataFromRequest,
getItemsFromData,
getDataFromUrl,
stringIsEmail,
urlIsMedia
};
Create a new file called fieldUtils.js
inside utils
directory:
services/utils/fieldUtils.js
Paste the below code inside that file:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const getUrls = require("get-urls");
const { urlIsMedia, stringIsEmail } = require("./utils");
const striptags = require("striptags");
const detectStringFieldFormat = data => {
if (new Date(data).toString() !== "Invalid Date") return "date";
if (stringIsEmail(data)) return "email";
if (data.length !== striptags(data).length) {
return "xml";
}
return "string";
};
const detectFieldFormat = data => {
switch (typeof data) {
case "number":
return "number";
case "boolean":
return "boolean";
case "object":
return "object";
case "string":
return detectStringFieldFormat(data);
}
};
The important factor in our analyzing process is that each item is consisted of several fields and for each field we want to know what exactly is that field’s format. For instance, the above detectFieldFormat
function which is doing the format detection for us, when has an encounter with a string field, will do further investigations to find out if that field is a date
or a url
or even an xml
as well.
Beside the field’s format, we want to learn more information such as if the field has a “media url” or what is the “length” of that field;
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
const compileStatsForFieldData = fieldData => {
const stats = {};
switch (typeof fieldData) {
case "string":
try {
const urls = Array.from(getUrls(fieldData));
const l = urls.length;
for (let i = 0; i < l; ++i) {
if (urlIsMedia(urls[i])) {
stats.hasMediaUrls = true;
break;
}
}
} catch (e) {
console.log(e);
}
stats.length = fieldData.length;
break;
case "object":
if (urlIsMedia(fieldData.url)) {
stats.hasMediaUrls = true;
}
stats.length = JSON.stringify(fieldData).length;
break;
default:
console.log(typeof fieldData, fieldData);
}
stats.format = detectFieldFormat(fieldData);
return stats;
};
1
2
3
4
5
6
7
8
const getMediaUrlsFromFieldData = fieldData => {
switch (typeof fieldData) {
case "string":
return Array.from(getUrls(fieldData)).filter(urlIsMedia);
case "object":
return urlIsMedia(fieldData.url) ? [fieldData.url] : [];
}
};
We are all set for the analyzing process!
1
2
3
4
5
6
module.exports = {
detectStringFieldFormat,
detectFieldFormat,
compileStatsForFieldData,
getMediaUrlsFromFieldData
};
Create a new file named analyzer.js
inside utils
directory:
services/utils/analyzer.js
Paste the below code inside that file:
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
"use strict";
const _ = require("lodash");
var ss = require("simple-statistics");
const { compileStatsForFieldData } = require("./fieldUtils");
const getFieldNameSet = items => {
const fieldNames = new Set();
items.forEach(item => {
try {
Object.keys(item).forEach(fieldName => fieldNames.add(fieldName));
} catch (e) {
console.log(e);
}
});
return fieldNames;
};
const analyze = (sourceType, items) => {
const fieldNames = getFieldNameSet(items);
const fieldAnalyses = {};
fieldNames.forEach(fieldName => (fieldAnalyses[fieldName] = []));
items.forEach(item => {
fieldNames.forEach(fieldName => {
const fieldData = item[fieldName];
const fieldStats = compileStatsForFieldData(fieldData);
fieldAnalyses[fieldName].push(fieldStats);
});
});
const fieldStats = Object.keys(fieldAnalyses).map(fieldName => {
const fieldAnalysis = fieldAnalyses[fieldName];
const fieldStat = { fieldName, count: fieldAnalysis.length };
try {
fieldStat.format = _.chain(fieldAnalysis)
.countBy("format")
.map((value, key) => ({ count: value, type: key }))
.sortBy("count")
.reverse()
.head()
.get("type")
.value();
} catch (e) {
console.log(e);
}
fieldStat.hasMediaUrls = fieldAnalysis.some(fa => Boolean(fa.hasMediaUrls));
const lengths = _.map(fieldAnalysis, "length");
fieldStat.minLength = ss.min(lengths);
fieldStat.maxLength = ss.max(lengths);
fieldStat.meanLength = ss.mean(lengths).toFixed(2);
return fieldStat;
});
return { itemCount: items.length, fieldStats };
};
module.exports = { getFieldNameSet, analyze };
The getFieldNameSet
will create a Set of unique field names available in every item. This way we will have all the fields across all the items without repeating ourselves. Then we receive the information about every field by calling compileStatsForFieldData
function and based on that information, we will make an educated analytics for each item such as the number of fields per item, the format of fields for every item, existence of any “media url” on the item fields and the calculation of 3 different type of length based on the length of all the fields per item.
Alright! it’s time to actually use our “analyze function”;
ImportContent.js
file inside services
directory and change its content to the following:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
"use strict";
/** * ImportContent.js service
* * @description: A set of functions similar to controller's actions to avoid code duplication. */
const { resolveDataFromRequest, getItemsFromData } = require("./utils/utils");
const analyzer = require("./utils/analyzer");
module.exports = {
preAnalyzeImportFile: async ctx => {
const { dataType, body, options } = await resolveDataFromRequest(ctx);
const { sourceType, items } = await getItemsFromData({
dataType,
body,
options
});
const analysis = analyzer.analyze(sourceType, items);
return { sourceType, ...analysis };
}
};
Now that we have our service setup, let’s use it inside our controller.
ImportContent.js
file inside controllers
directory, remove the default content and paste the following:1
2
3
4
5
6
7
8
9
10
11
12
13
14
"use strict";
module.exports = {
preAnalyzeImportFile: async ctx => {
const services = strapi.plugins["import-content"].services;
try {
const data = await services["importcontent"].preAnalyzeImportFile(ctx);
ctx.send(data);
} catch (error) {
console.log(error);
ctx.response.status = 406;
ctx.response.message = "could not parse: " + error;
}
}
};
Awesome! With an action for the “analyzing process” Let’s setup an end point for this action as well.
routes.json
inside config
directory and paste the following:1
2
3
4
5
6
7
8
9
10
11
12
{
"routes": [
{
"method": "POST",
"path": "/preAnalyzeImportFile",
"handler": "ImportContent.preAnalyzeImportFile",
"config": {
"policies": []
}
}
]
}
Congratulations, we are a hell of an analyzer!
Now the big question! How do we fill the content type with our imported data? Of course we can restrict the imported data to have the exact fields of our target content type, but this would be a huge disappointment for our plugin; because most of the times, our data will have different field counts and different field names in comparison to our content types; that being said, it is not always the field value that we are looking for, it might be a url and we may be interested in the media that the url is referring to, not the url itself. So what is the solution here? Yes, your guess is correct! We have to provide our users with a way to relate their imported data fields with the content type fields. And that is what we are going to do next
Go to the components
directory and create a new directory named MappingTable
with an index.js
file inside:
admin/src/components/MappingTable/index.js
Open the index.js
file and paste the following:
1
2
3
4
5
6
7
8
9
10
11
import React, { Component } from "react";
import PropTypes from "prop-types";
class MappingTable extends Component {
state = { mapping: {} };
}
MappingTable.propTypes = {
analysis: PropTypes.object.isRequired,
targetModel: PropTypes.object,
onChange: PropTypes.func
};
export default MappingTable;
Before we write our MappingTable
UI, there are 2 components that we want to use inside this component.
Create a new file named TargetFieldSelect.js
inside MappingTable
directory:
admin/src/components/MappingTable/TargetFieldSelect.js
Paste the following inside that file:
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
import React, { Component } from "react";
import { Select } from "@buffetjs/core";
import { get } from "lodash";
class TargetFieldSelect extends Component {
state = {
selectedTarget: ""
};
componentDidMount() {
const options = this.fillOptions();
this.setState({ selectedTarget: options && options[0] });
}
onChange(selectedTarget) {
this.props.onChange(selectedTarget);
this.setState({ selectedTarget });
}
fillOptions() {
const { targetModel } = this.props;
const schemaAttributes = get(targetModel, ["schema", "attributes"], {});
const options = Object.keys(schemaAttributes)
.map(fieldName => {
const attribute = get(schemaAttributes, [fieldName], {});
return attribute.type && { label: fieldName, value: fieldName };
})
.filter(obj => obj !== undefined);
return [{ label: "None", value: "none" }, ...options];
}
render() {
return (
<Select
name={"targetField"}
value={this.state.selectedTarget}
options={this.fillOptions()}
onChange={({ target: { value } }) => this.onChange(value)}
/>
);
}
}
export default TargetFieldSelect;
This component will show a “Select” element for choosing the desired field among “target content type" fields. This way our users can finally map fields of imported data with fields of their “target content type”. While this is sufficient for most cases, there are scenarios that we want to map certain qualities of a field to our “target content type” fields and not the direct value itself; for instance, if a field is pointing to a media url, we do not want the value of the “url”, instead we want the referenced media itself; or if the field contains any xml, do we want the contents with xml tags or without them (stripe tags).
So we have to show our TargetFieldSelect
once again for covering these scenarios. We consider these scenarios as “mapping options”.
Create a new file named MappingOptions.js
inside MappingTable
directory:
admin/src/components/MappingTable/MappingOptions.js
Paste the below code inside:
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
import React, { Component } from "react";
import TargetFieldSelect from "./TargetFieldSelect";
import { Label } from "@buffetjs/core";
const MappingOptions = ({ stat, onChange, targetModel }) => {
return (
<div>
{stat.format === "xml" && (
<div>
<Label htmlFor={"stripCheckbox"} message={"Strip Tags"} />
<input
name={"stripCheckbox"}
type="checkbox"
onChange={e => onChange({ stripTags: e.target.checked })}
/>
</div>
)}
{stat.hasMediaUrls && (
<div style={{ paddingTop: 8, paddingBottom: 8 }}>
<Label
htmlFor={"mediaTargetSelect"}
message={"Import Media to Field"}
/>
<TargetFieldSelect
name={"mediaTargetSelect"}
targetModel={targetModel}
onChange={targetField =>
onChange({ importMediaToField: targetField })
}
/>
</div>
)}
</div>
);
};
export default MappingOptions;
Go back to index.js
file inside MappingTable
directory to use our newly created components. As you can see we have a mapping
object defined in our state that we expect to get filled based on user specified field mappings.
We also expect our HomePage
to provide this component with the analysis
object which we received from our backend in the previous steps, the “target content type” which has been selected by the user and an onChange
function which we will call whenever the user changes our “field mappings”.
index.js
file:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
...
import MappingOptions from "./MappingOptions";
import TargetFieldSelect from "./TargetFieldSelect";
import _ from "lodash";
import Row from "../Row";
import { Table } from "@buffetjs/core";
import {
Bool as BoolIcon,
Json as JsonIcon,
Text as TextIcon,
NumberIcon,
Email as EmailIcon,
Calendar as DateIcon,
RichText as XmlIcon
} from "@buffetjs/icons";
...
For our table, we are going to use “Buffet js” table with a custom row which is filled with the analysis
object handed down from HomePage
component.
MappingTable
class: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
CustomRow = ({ row }) => {
const { fieldName, count, format, minLength, maxLength, meanLength } = row;
return (
<tr style={{ paddingTop: 18 }}>
<td>{fieldName}</td>
<td>
<p>{count}</p>
</td>
<td>
{format === "string" && <TextIcon fill="#fdd835" />}
{format === "number" && <NumberIcon fill="#fdd835" />}
{format === "boolean" && <BoolIcon fill="#fdd835" />}
{format === "object" && <JsonIcon fill="#fdd835" />}
{format === "email" && <EmailIcon fill="#fdd835" />}
{format === "date" && <DateIcon fill="#fdd835" />}
{format === "xml" && <XmlIcon fill="#fdd835" />} <p>{format}</p>
</td>
<td>
<span>{minLength}</span>
</td>
<td>
<p>{maxLength}</p>
</td>
<td>
<p>{meanLength}</p>
</td>
<td>
<MappingOptions
targetModel={this.props.targetModel}
stat={row}
onChange={this.changeMappingOptions(row)}
/>
</td>
<td>
{this.props.targetModel && (
<TargetFieldSelect
targetModel={this.props.targetModel}
onChange={targetField => this.setMapping(fieldName, targetField)}
/>
)}
</td>
</tr>
);
};
As you can see, we are using 2 methods named setMapping
and changeMappingOptions
which are as below:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
changeMappingOptions = stat => options => {
let newState = _.cloneDeep(this.state);
for (let key in options) {
_.set(newState, `mapping[${stat.fieldName}][${key}]`, options[key]);
}
this.setState(newState, () => this.props.onChange(this.state.mapping));
};
setMapping = (source, targetField) => {
const state = _.set(
this.state,
`mapping[${source}]['targetField']`,
targetField
);
this.setState(state, () => this.props.onChange(this.state.mapping));
console.log(this.state.mapping);
};
What we expect from above methods, is to fill our mapping
object based on user specified relations. changeMappingOptions
is going to fill the mapping
object with the specified options like this:
1
2
3
4
5
6
7
8
{
sourceField1: {
importMediaToField: targetField1
},
sourceField2: {
stripTags: true
}
}
and setMapping
is going to fill the mapping
object like below:
1
2
3
4
5
6
7
8
{
sourceField1: {
targetField: targetField1
},
sourceField2: {
targetField: targetField2
}
}
render
method: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
render() {
const { analysis } = this.props;
const props = {
title: "Field Mapping",
subtitle:
"Configure the Relationship between CSV Fields and Content type Fields"
};
const headers = [
{ name: "Field", value: "fieldName" },
{ name: "Count", value: "count" },
{ name: "Format", value: "format" },
{ name: "Min Length", value: "minLength" },
{ name: "Max Length", value: "maxLength" },
{ name: "Mean Length", value: "meanLength" },
{ name: "Options", value: "options" },
{ name: "Destination", value: "destination" }
];
const items = [...analysis.fieldStats];
return (
<Table
{...props}
headers={headers}
rows={items}
customRow={this.CustomRow}
/>
);
}
We are all set! Let’s use our MappingTable
inside HomePage
component.
index.js
file at HomePage
directory and append the following to the render
method output as the last child of parent div
(parent div
is the tag with container-fluid
as its className
):1
2
3
4
5
6
7
8
9
10
{this.state.analysis && (
<Row className="row">
<MappingTable
analysis={this.state.analysis}
targetModel={this.getTargetModel()}
onChange={this.setFieldMapping}
/>
</Row>
)}
Import MappingTable
by adding the following line:
import MappingTable from "../../components/MappingTable";
Append the following to index.js
file as well:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
state = {
loading: true,
modelOptions: [],
models: [],
importSource: "upload",
analyzing: false,
analysis: null,
selectedContentType: "",
fieldMapping: {} // <---
};
getTargetModel = () => { // <---
const { models } = this.state;
if (!models) return null;
return models.find(model => model.uid === this.state.selectedContentType);
};
setFieldMapping = fieldMapping => { // <---
this.setState({ fieldMapping });
};
Your 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
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
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
import React, { memo, Component } from "react";
import {request} from "strapi-helper-plugin";
import PropTypes from "prop-types";
import pluginId from "../../pluginId";
import UploadFileForm from "../../components/UploadFileForm";
import ExternalUrlForm from "../../components/ExternalUrlForm";
import RawInputForm from "../../components/RawInputForm";
import MappingTable from "../../components/MappingTable";
import {
HeaderNav,
LoadingIndicator,
PluginHeader
} from "strapi-helper-plugin";
import Row from "../../components/Row";
import Block from "../../components/Block";
import { Select, Label } from "@buffetjs/core";
import { get, has, isEmpty, pickBy, set } from "lodash";
const getUrl = to =>
to ? `/plugins/${pluginId}/${to}` : `/plugins/${pluginId}`;
class HomePage extends Component {
importSources = [
{ label: "External URL ", value: "url" },
{ label: "Upload file", value: "upload" },
{ label: "Raw text", value: "raw" }
];
state = {
loading: true,
modelOptions: [],
models: [],
importSource: "upload",
analyzing: false,
analysis: null,
selectedContentType: "",
fieldMapping: {}
};
getTargetModel = () => {
const { models } = this.state;
if (!models) return null;
return models.find(model => model.uid === this.state.selectedContentType);
};
setFieldMapping = fieldMapping => {
this.setState({ fieldMapping });
};
selectImportDest = selectedContentType => {
this.setState({ selectedContentType });
};
componentDidMount() {
this.getModels().then((res) => {
const {models, modelOptions} = res
this.setState({
models,
modelOptions,
selectedContentType: modelOptions ? modelOptions[0].value : ""
});
});
}
getModels = async () => {
this.setState({ loading: true });
try {
const response = await request("/content-type-builder/content-types", {
method: "GET"
});
// Remove content types from models
const models = get(response, ["data"], []).filter(
obj => !has(obj, "plugin")
);
const modelOptions = models.map(model => {
return {
label: get(model, ["schema", "name"], ""),
value: model.uid
};
});
this.setState({ loading: false });
return { models, modelOptions };
} catch (e) {
this.setState({ loading: false }, () => {
strapi.notification.error(`${e}`);
});
}
return [];
};
selectImportSource = importSource => {
this.setState({ importSource });
};
onRequestAnalysis = async analysisConfig => {
this.analysisConfig = analysisConfig;
this.setState({ analyzing: true }, async () => {
try {
const response = await request("/import-content/preAnalyzeImportFile", {
method: "POST",
body: analysisConfig
});
this.setState({ analysis: response, analyzing: false }, () => {
strapi.notification.success(`Analyzed Successfully`);
});
} catch (e) {
this.setState({ analyzing: false }, () => {
strapi.notification.error(`${e}`);
});
}
});
};
render() {
return (
<div className={"container-fluid"} style={{ padding: "18px 30px" }}>
<PluginHeader
title={"Import Content"}
description={"Import CSV and RSS-Feed into your Content Types"}
/>
<HeaderNav
links={[
{
name: "Import Data",
to: getUrl("")
},
{
name: "Import History",
to: getUrl("history")
}
]}
style={{ marginTop: "4.4rem" }}
/>
<div className="row">
<Block
title="General"
description="Configure the Import Source & Destination"
style={{ marginBottom: 12 }}
>
<Row className={"row"}>
<div className={"col-4"}>
<Label htmlFor="importSource">Import Source</Label>
<Select
name="importSource"
options={this.importSources}
value={this.state.importSource}
onChange={({ target: { value } }) =>
this.selectImportSource(value)
}
/>
</div>
<div className={"col-4"}>
<Label htmlFor="importDest">Import Destination</Label>
<Select
value={this.state.selectedContentType}
name="importDest"
options={this.state.modelOptions}
onChange={({ target: { value } }) =>
this.selectImportDest(value)
}
/>
</div>
</Row>
<Row>
{this.state.importSource === "upload" && (
<UploadFileForm
onRequestAnalysis={this.onRequestAnalysis}
loadingAnalysis={this.state.analyzing}
/>
)}
{this.state.importSource === "url" && (
<ExternalUrlForm
onRequestAnalysis={this.onRequestAnalysis}
loadingAnalysis={this.state.analyzing}
/>
)}
{this.state.importSource === "raw" && (
<RawInputForm
onRequestAnalysis={this.onRequestAnalysis}
loadingAnalysis={this.state.analyzing}
/>
)}
</Row>
</Block>
</div>
{this.state.analysis && (
<Row className="row">
<MappingTable
analysis={this.state.analysis}
targetModel={this.getTargetModel()}
onChange={this.setFieldMapping}
/>
</Row>
)}
</div>
);
}
}
export default memo(HomePage);
Congratulations! the last thing is to add an “Import Button” at the bottom of our MappingTable
component to actually “Run the Import“ for us
Button
just under your MappingTable
component tag inside HomePage/index.js
file as in below code and don't forget to import the Button
component at the top of the file:1
2
3
4
5
6
7
8
9
10
import { Button } from "@buffetjs/core";
...
<Button
style={{ marginTop: 12 }}
label={"Run the Import"}
onClick={this.onSaveImport}
/>
...
onSaveImport
method as below just under your state
:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
onSaveImport = async () => {
const { selectedContentType, fieldMapping } = this.state;
const { analysisConfig } = this;
const importConfig = {
...analysisConfig,
contentType: selectedContentType,
fieldMapping
};
try {
await request("/import-content", { method: "POST", body: importConfig });
this.setState({ saving: false }, () => {
strapi.notification.info("Import started");
});
} catch (e) {
strapi.notification.error(`${e}`);
}
};
Awesome! Whenever the user clicks on “Run the Import” button, we send the “analysis result” alongside “target content type” and user provided “field mapping” to our backend API, and then our import will be initiated
Let’s setup our endpoint.
services
directory and replace ImportContent.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
"use strict";
/** * ImportContent.js service
* * @description: A set of functions similar to controller's actions to avoid code duplication. */
const { resolveDataFromRequest, getItemsFromData } = require("./utils/utils");
const analyzer = require("./utils/analyzer");
const _ = require("lodash");
const importFields = require("./utils/importFields");
const importMediaFiles = require("./utils/importMediaFiles");
const import_queue = {};
module.exports = {
preAnalyzeImportFile: async ctx => {
const { dataType, body, options } = await resolveDataFromRequest(ctx);
const { sourceType, items } = await getItemsFromData({
dataType,
body,
options
});
const analysis = analyzer.analyze(sourceType, items);
return { sourceType, ...analysis };
},
importItems: (importConfig, ctx) =>
new Promise(async (resolve, reject) => {
const { dataType, body } = await resolveDataFromRequest(ctx);
console.log("importitems", importConfig);
try {
const { items } = await getItemsFromData({
dataType,
body,
options: importConfig.options
});
import_queue[importConfig.id] = items;
} catch (error) {
reject(error);
}
resolve({
status: "import started",
importConfigId: importConfig.id
});
importNextItem(importConfig);
}),
};
Similar to what we did in “analyzing”, we are extracting the data and parsing its items once again. Then we will queue those parsed items for further processing. What we need here is a function that iterates on that queue and stores every item.
module.exports
: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
const importNextItem = async importConfig => {
const sourceItem = import_queue[importConfig.id].shift();
if (!sourceItem) {
console.log("import complete");
await strapi
.query("importconfig", "import-content")
.update({ id: importConfig.id }, { ongoing: false });
return;
}
try {
const importedItem = await importFields(
sourceItem,
importConfig.fieldMapping
);
const savedContent = await strapi
.query(importConfig.contentType)
.create(importedItem);
const uploadedFiles = await importMediaFiles(
savedContent,
sourceItem,
importConfig
);
const fileIds = _.map(_.flatten(uploadedFiles), "id");
await strapi.query("importeditem", "import-content").create({
importconfig: importConfig.id,
ContentId: savedContent.id,
ContentType: importConfig.contentType,
importedFiles: { fileIds }
});
} catch (e) {
console.log(e);
}
const { IMPORT_THROTTLE } = strapi.plugins["import-content"].config;
setTimeout(() => importNextItem(importConfig), IMPORT_THROTTLE);
};
Our importing process at above code is consisted of creating the content type, importing the media files, and creating an “imported item” entry that will keep track of our created content types for the future; for instance, if we ever want to undo our imports, we must know which content types entries are related to this specific import.
Before we continue on to create our action, there are 2 functions that we are calling here: importFields
and importMediaFiles
.
Go to utils
directory and create a new file named importFields.js
:
services/utils/importFields.js
Paste the below code inside the new file:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const striptags = require("striptags");
const importFields = async (sourceItem, fieldMapping) => {
const importedItem = {};
Object.keys(fieldMapping).forEach(async sourceField => {
const { targetField, stripTags } = fieldMapping[sourceField];
if (!targetField || targetField === "none") {
return;
}
const originalValue = sourceItem[sourceField];
importedItem[targetField] = stripTags
? striptags(originalValue)
: originalValue;
});
return importedItem;
};
module.exports = importFields;
the “field mapping” object is a relation mapping between “imported data item” fields and “target content type” fields. The above function extracts the value for the target fields and generate the candidate object for our “content type” entry on top of those fields.
Create a new file named importMediaFiles.js
in utils
directory:
services/utils/importMediaFiles.js
Paste 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
const _ = require("lodash");
const request = require("request");
const fileFromBuffer = require("./fileFromBuffer");
const { getMediaUrlsFromFieldData } = require("../utils/fieldUtils");
const fetchFiles = url =>
new Promise((resolve, reject) => {
request({ url, method: "GET", encoding: null }, async (err, res, body) => {
if (err) {
reject(err);
}
const mimeType = res.headers["content-type"].split(";").shift();
const parsed = new URL(url);
const extension = parsed.pathname
.split(".")
.pop()
.toLowerCase();
resolve(fileFromBuffer(mimeType, extension, body));
});
});
const storeFiles = async file => {
const uploadProviderConfig = await strapi
.store({
environment: strapi.config.environment,
type: "plugin",
name: "upload"
})
.get({ key: "provider" });
return await strapi.plugins["upload"].services["upload"].upload(
[file],
uploadProviderConfig
);
};
const relateFileToContent = ({
contentType,
contentId,
targetField,
fileBuffer
}) => {
fileBuffer.related = [
{
refId: contentId,
ref: contentType,
source: "content-manager",
field: targetField
}
];
return fileBuffer;
};
const importMediaFiles = async (savedContent, sourceItem, importConfig) => {
const { fieldMapping, contentType } = importConfig;
const uploadedFileDescriptors = _.mapValues(
fieldMapping,
async (mapping, sourceField) => {
if (mapping.importMediaToField) {
const urls = getMediaUrlsFromFieldData(sourceItem[sourceField]);
const fetchPromises = _.uniq(urls).map(fetchFiles);
const fileBuffers = await Promise.all(fetchPromises);
const relatedContents = fileBuffers.map(fileBuffer =>
relateFileToContent({
contentType,
contentId: savedContent.id,
targetField: mapping.importMediaToField,
fileBuffer
})
);
const storePromises = relatedContents.map(storeFiles);
const storedFiles = await Promise.all(storePromises);
console.log(_.flatten(storedFiles));
return storedFiles;
}
}
);
return await Promise.all(_.values(uploadedFileDescriptors));
};
module.exports = importMediaFiles;
What we are doing here is to check if we have importMediaToField
specified on the mapping
or not, and in case we have, we first extract the urls
, then fetch the files for those urls
, relate them to the target content type and store them as well.
Create a new file named fileFromBuffer.js
in utils
directory:
services/utils/fileFromBuffer.js
Paste the following code inside that file:
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
const crypto = require('crypto')
const uuid = require("uuid/v4");
function niceHash(buffer) {
return crypto
.createHash("sha256")
.update(buffer)
.digest("base64")
.replace(/=/g, "")
.replace(/\//g, "-")
.replace(/\+/, "_");
}
const fileFromBuffer = (mimeType, extension, buffer) => {
const fid = uuid();
return {
buffer,
sha256: niceHash(buffer),
hash: fid.replace(/-/g, ""),
name: `${fid}.${extension}`,
ext: `.${extension}`,
mime: mimeType,
size: (buffer.length / 1000).toFixed(2)
};
};
module.exports = fileFromBuffer;
our service is ready!
ImportContent.js
file inside controllers
directory and add the following function:1
2
3
4
5
6
7
8
9
10
11
create: async ctx => {
const services = strapi.plugins["import-content"].services;
const importConfig = ctx.request.body;
importConfig.ongoing = true;
const record = await strapi
.query("importconfig", "import-content")
.create(importConfig);
console.log("create", record);
await services["importcontent"].importItems(record, ctx);
ctx.send(record);
}
For each import, we are storing a specific config entry called “import config”. This will help us keep details of our import like the date of this import and the identification of all the content types entries that are created because of this import.
routes.json
inside config
directory and define our new route:1
2
3
4
5
6
7
8
{
"method": "POST",
"path": "/",
"handler": "ImportContent.create",
"config": {
"policies": []
}
}
Awesome! Our import endpoint is implemented; The main purpose of this plugin is accomplished. The rest is to show our initiated imports to the users. Also we are going to allow our users to “undo” the initiated imports as well as “removing” their related configs.
ImportContent.js
file inside controllers
directory:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
index: async ctx => {
const entries = await strapi.query("importconfig", "import-content").find();
const withCounts = entries.map(entry => ({
...entry,
importedCount: entry.importeditems.length,
importeditems: []
}));
const withName = withCounts.map(entry =>
({
...entry,
contentType: strapi.contentTypes[entry.contentType].info.name ||
entry.contentType
}))
ctx.send(withName);
}
it will retrieve the stored configs for user initiated imports. This will allow us to provide our users with a history of their imports and show some details about each import. Beside retrieving the import configs, it would be nice to have a way to delete them as well.
1
2
3
4
5
6
7
8
9
10
11
12
delete: async ctx => {
const importId = ctx.params.importId;
const res = await strapi.query("importconfig", "import-content").delete({
id: importId
});
if (res && res.id) {
ctx.send(res.id);
} else {
ctx.response.status = 400;
ctx.response.message = "could not delete: the provided id might be wrong";
}
}
The only thing left is a way to undo our imports. For this we are about to implement a service similar to what we did for importing data.
ImportContent.js
file in services
directory, and add the following before the module.exports
: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
const undo_queue = {};
const removeImportedFiles = async (fileIds, uploadConfig) => {
const removePromises = fileIds.map(id =>
strapi.plugins["upload"].services.upload.remove({ id }, uploadConfig)
);
return await Promise.all(removePromises);
};
const undoNextItem = async (importConfig, uploadConfig) => {
const item = undo_queue[importConfig.id].shift();
if (!item) {
console.log("undo complete");
await strapi
.query("importconfig", "import-content")
.update({ id: importConfig.id }, { ongoing: false });
return;
}
try {
await strapi.query(importConfig.contentType).delete({ id: item.ContentId });
} catch (e) {
console.log(e);
}
try {
const importedFileIds = _.compact(item.importedFiles.fileIds);
await removeImportedFiles(importedFileIds, uploadConfig);
} catch (e) {
console.log(e);
}
try {
await strapi.query("importeditem", "import-content").delete({
id: item.id
});
} catch (e) {
console.log(e);
}
const { UNDO_THROTTLE } = strapi.plugins["import-content"].config;
setTimeout(() => undoNextItem(importConfig, uploadConfig), UNDO_THROTTLE);
};
The undoNextItem
function is iterating on the queue for removing every imported item. This undo process is consisted of removing the content type entry, removing the related files and the imported item entry as well.
module.exports
:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
undoItems: importConfig =>
new Promise(async (resolve, reject) => {
try {
undo_queue[importConfig.id] = importConfig.importeditems;
} catch (error) {
reject(error);
}
await strapi
.query("importconfig", "import-content")
.update({ id: importConfig.id }, { ongoing: true });
resolve({
status: "undo started",
importConfigId: importConfig.id
});
const uploadConfig = await strapi
.store({
environment: strapi.config.environment,
type: "plugin",
name: "upload"
})
.get({ key: "provider" });
undoNextItem(importConfig, uploadConfig);
}),
well done!
ImportContent.js
file inside controllers
directory and add the following function:1
2
3
4
5
6
7
8
9
10
undo: async ctx => {
const services = strapi.plugins["import-content"].services;
const importId = ctx.params.importId;
const importConfig = await strapi
.query("importconfig", "import-content")
.findOne({ id: importId });
console.log("undo", importId);
await services["importcontent"].undoItems(importConfig);
ctx.send(importConfig);
}
routes.json
file at config
directory and append the following routes:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{
"method": "GET",
"path": "/",
"handler": "ImportContent.index",
"config": {
"policies": []
}
},
{
"method": "DELETE",
"path": "/:importId",
"handler": "ImportContent.delete",
"config": {
"policies": []
}
},
{
"method": "POST",
"path": "/:importId/undo",
"handler": "ImportContent.undo",
"config": {
"policies": []
}
}
Now that we have all the endpoints we need, let’s go back to our admin
directory and implement the HistoryPage
. This page will show a history of all the user initiated imports, their status and 2 buttons for undo
and delete
actions.
Warning! while we are OK to continue to the next section, there is one point we have to mention here: Until now we have only provided endpoints for the functionalities that we already have like "analyzing", "importing" and running an "undo" operation. The point is that if we want to be consistent with other plugins like "graphql", we must define a set of controllers for each one of our content types which in our case are importconfig
& importeditem
. These controllers are the famous set of find
, findOne
, count
, update
and delete
which i bet you are already familiar with them. So in case that you want this plugin to be efficient in production environments, you must make it consistent. Before jumping to the next section, please do the followings:
ImportConfig
in controllers
directory:controllers/ImportConfig.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
module.exports = {
count: async ctx => {
const entries = await strapi.query("importconfig", "import-content").count(ctx.request.query)
ctx.send(entries)
},
findOne: async ctx => {
const entries = await strapi.query("importconfig", "import-content").findOne({id: ctx.params.importId})
ctx.send(entries)
},
find: async ctx => {
const entries = await strapi.query("importconfig", "import-content").find(ctx.request.query)
ctx.send(entries)
},
delete: async ctx => {
return strapi.query("importconfig", "import-content").delete({id: ctx.params.importId})
},
update: async ctx => {
return strapi.query("importconfig", "import-content").update({id: ctx.params.importId}, ctx.request.body)
}
};
ImportedItem
in controllers
directory:controllers/ImportedItem.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
module.exports = {
count: async ctx => {
const entries = await strapi.query("importeditem", "import-content").count(ctx.request.query)
ctx.send(entries)
},
findOne: async ctx => {
const entries = await strapi.query("importeditem", "import-content").findOne({id: ctx.params.importId})
ctx.send(entries)
},
find: async ctx => {
const entries = await strapi.query("importeditem", "import-content").find(ctx.request.query)
ctx.send(entries)
},
delete: async ctx => {
return strapi.query("importeditem", "import-content").delete({id: ctx.params.importId})
},
update: async ctx => {
return strapi.query("importeditem", "import-content").update({id: ctx.params.importId}, ctx.request.body)
}
};
routes.json
:config/routes.json
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
{
"method": "GET",
"path": "/importconfig",
"handler": "ImportConfig.find",
"config": {
"policies": []
}
},
{
"method": "GET",
"path": "/importconfig/:importId",
"handler": "ImportConfig.findOne",
"config": {
"policies": []
}
},
{
"method": "GET",
"path": "/importconfig/count",
"handler": "ImportConfig.count",
"config": {
"policies": []
}
},
{
"method": "PUT",
"path": "/importconfig/:importId",
"handler": "ImportConfig.update",
"config": {
"policies": []
}
},
{
"method": "DELETE",
"path": "/importconfig/:importId",
"handler": "ImportConfig.delete",
"config": {
"policies": []
}
},
{
"method": "GET",
"path": "/importeditem",
"handler": "ImportedItem.find",
"config": {
"policies": []
}
},
{
"method": "GET",
"path": "/importeditem/:importId",
"handler": "ImportedItem.findOne",
"config": {
"policies": []
}
},
{
"method": "GET",
"path": "/importeditem/count",
"handler": "ImportedItem.count",
"config": {
"policies": []
}
},
{
"method": "PUT",
"path": "/importeditem/:importId",
"handler": "ImportedItem.update",
"config": {
"policies": []
}
},
{
"method": "DELETE",
"path": "/importeditem/:importId",
"handler": "ImportedItem.delete",
"config": {
"policies": []
}
}
Great you finished the third part of this tutorial! Let's create the History page! https://strapi.io/blog/how-to-create-an-import-content-plugin-part-4-4
Pouya is an active member of the Strapi community, who has been contributing actively with contributions in the core and plugins.