Strapi Advent Calenderの14日目はStrapi公式ブログのCSVからデータをインポートするプラグインを作成する記事のpart1を日本語でまとめてみました。
こちらの記事を参考に作成していきます
https://strapi.io/blog/how-to-create-an-import-content-plugin-part-1-4
Strapiのカスタムプラグインについては、5日目の記事をどうぞ!
StrapiのLocal Pluginsを利用して独自の画面を作成する
プラグインとモデルを作成する
まず、プラグインの雛形と、プラグインの中で使用するモデルを2種類作成します。
$ strapi generate:plugin import-content
$ strapi generate:model importconfig --plugin import-content
$ strapi generate:model importeditem --plugin import-content
ディレクトリ構造は以下のような形になります。
plugins
|-- import-content
|-- admin
|-- config
|-- controllers
|-- models
|-- Importconfig.js
|-- Importconfig.settings.json
|-- Importeditem.js
|-- Importeditem.settings.json
|-- services
モデルに項目を追加する
plugins/import-content/models/Importconfig.settings.json
に以下をコピペします。
{
"connection": "default",
"collectionName": "",
"info": {
"name": "importconfig",
"description": ""
},
"options": { "timestamps": true, "increments": true, "comment": "" },
"attributes": {
"date": { "type": "date" },
"source": {
"type": "string"
},
"options": { "type": "json" },
"contentType": {
"type": "string"
},
"fieldMapping": { "type": "json" },
"ongoing": {
"type": "boolean"
},
"importeditems": {
"collection": "importeditem",
"via": "importconfig",
"plugin": "import-content"
}
}
}
次に、plugins/import-content/models/Importeditem.settings.json
に以下をコピペします。
{
"connection": "default",
"collectionName": "",
"info": {
"name": "importeditem",
"description": ""
},
"options": { "increments": true, "timestamps": true, "comment": "" },
"attributes": {
"ContentType": {
"type": "string"
},
"ContentId": { "type": "integer" },
"importconfig": {
"model": "importconfig",
"via": "importeditems",
"plugin": "import-content"
},
"importedFiles": {
"type": "json"
}
}
}
ここまでの手順を踏んで起動すると、管理画面のサイドメニューのコレクションタイプにImportconfigs
とImporteditems
が、プラグインにimport-content
が表示されていると思います。ここからプラグインの中身を作成していきます。
必要なパッケージをインストールする
今回のプラグインで必要になるパッケージは以下の通りです。
- content-type-parser
- csv-parse
- get-urls
- moment rss-parser
- request
- simple-statistics
- striptags
- lodash
yarn add content-type-parser csv-parse get-urls moment rss-parser request simple-statistics striptags lodash
コンポーネントを作成する
1. ファイル選択フォームを作成する
plugins/import-content/admin/src
の中にcomponents
ディレクトリを作成します。
plugins/import-content/admin/src/components
にUploadFileForm
ディレクトリを作成します。その中にindex.js
ファイルを作成し、以下を記述します。
import React, { Component } from "react";
import PropTypes from "prop-types";
class UploadFileForm extends Component {
state = {
file: null,
type: null,
options: {
filename: null
}
};
onChangeImportFile = file => {
file &&
this.setState({
file,
type: file.type,
options: { ...this.state.options, filename: file.name }
});
};
render() {
return <input
onChange={({target:{files}}) => files && this.onChangeImportFile(files[0])} name="file_input" accept=".csv" type="file" />;
}
}
export default UploadFileForm;
次に、plugins/import-content/admin/src/containers/HomePage/index.js
に、先ほど作成したUploadFileForm
コンポーネントを読み込ませて表示させてみます。
import React, { memo } from "react";
import pluginId from "../../pluginId";
import UploadFileForm from "../../components/UploadFileForm";
const HomePage = () => {
return <UploadFileForm />;
};
export default memo(HomePage);
ここまでで、CSVファイルを選択するフォームができました。見栄えをよくするために、コンポーネントをいくつか追加します。
次に、plugins/import-content/admin/src/components
にP
ディレクトリを作成します。その中にindex.js
ファイルを作成し、以下を記述します。
import styled from "styled-components";
const P = styled.p`
margin-top: 10px;
text-align: center;
font-size: 13px;
color: #9ea7b8;
u {
color: #1c5de7;
}
`;
export default P;
plugins/import-content/admin/src/components
にRow
ディレクトリを作成します。その中にindex.js
ファイルを作成し、以下を記述します。
import styled from "styled-components";
const Row = styled.div`
padding-top: 18px;
`;
export default Row;
plugins/import-content/admin/src/components
にLabel
ディレクトリを作成します。その中にindex.js
ファイルを作成し、以下を記述します。
import styled, { css, keyframes } from "styled-components";
const Label = styled.label`
position: relative;
height: 146px;
width: 100%;
padding-top: 28px;
border: 2px dashed #e3e9f3;
border-radius: 2px;
text-align: center;
> input {
display: none;
}
.icon {
width: 82px;
path {
fill: ${({ showLoader }) => (showLoader ? "#729BEF" : "#ccd0da")};
transition: fill 0.3s ease;
}
}
.isDragging {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
.underline {
color: #1c5de7;
text-decoration: underline;
cursor: pointer;
}
&:hover {
cursor: pointer;
}
${({ isDragging }) => {
if (isDragging) {
return css`
background-color: rgba(28, 93, 231, 0.01) !important;
border: 2px dashed rgba(28, 93, 231, 0.1) !important;
`;
}
}}
${({ showLoader }) => {
if (showLoader) {
return css`
animation: ${smoothBlink("transparent", "rgba(28,93,231,0.05)")} 2s
linear infinite;
`;
}
}}
`;
const smoothBlink = (firstColor, secondColor) => keyframes`
0% {
fill: ${firstColor}; background-color: ${firstColor}; } 26% {
fill: ${secondColor}; background-color: ${secondColor}; } 76% {
fill: ${firstColor}; background-color: ${firstColor}; } `;
export default Label;
作成した3つのコンポーネントを、UploadFileForm
にインポートして、ファイルをドラッグ&ドロップで選択できるエリアも作成していきます。
import React, { Component } from "react";
import PropTypes from "prop-types";
import P from "../P"; // 追加
import Row from "../Row"; // 追加
import Label from "../Label"; // 追加
import { Button } from "@buffetjs/core"; // 追加
class UploadFileForm extends Component {
state = {
file: null,
type: null,
options: {
filename: null
},
isDragging: false // 追加
};
onChangeImportFile = file => {
file &&
this.setState({
file,
type: file.type,
options: { ...this.state.options, filename: file.name }
});
};
handleDragEnter = () => this.setState({ isDragging: true }); // 追加
handleDragLeave = () => this.setState({ isDragging: false }); // 追加
handleDrop = e => { // 追加
e.preventDefault();
this.setState({ isDragging: false });
const file = e.dataTransfer.files[0];
this.onChangeImportFile(file);
};
render() {
return ( // 書き換え
<div className={"col-12"}>
<Row className={"row"}>
<Label
showLoader={this.props.loadingAnalysis}
isDragging={this.state.isDragging}
onDrop={this.handleDrop}
onDragEnter={this.handleDragEnter}
onDragOver={e => {
e.preventDefault();
e.stopPropagation();
}}
>
<svg
className="icon"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 104.40317 83.13328"
>
<g>
<rect
x="5.02914"
y="8.63138"
width="77.33334"
height="62.29167"
rx="4"
ry="4"
transform="translate(-7.45722 9.32921) rotate(-12)"
fill="#fafafb"
/>
<rect
x="5.52914"
y="9.13138"
width="76.33334"
height="61.29167"
rx="4"
ry="4"
transform="translate(-7.45722 9.32921) rotate(-12)"
fill="none"
stroke="#979797"
/>
<path
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"
transform="translate(-0.14193 -0.62489)"
fill="#333740"
/>
<rect
x="26.56627"
y="4.48824"
width="62.29167"
height="77.33333"
rx="4"
ry="4"
transform="translate(0.94874 87.10632) rotate(-75)"
fill="#fafafb"
/>
<rect
x="27.06627"
y="4.98824"
width="61.29167"
height="76.33333"
rx="4"
ry="4"
transform="translate(0.94874 87.10632) rotate(-75)"
fill="none"
stroke="#979797"
/>
<path
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-
10
.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"
transform="translate(-0.14193 -0.62489)"
fill="#333740"
/>
</g>
</svg>
<P>
<span>
Drag & drop your file into this area or
<span className={"underline"}>browse</span> for a file to upload
</span>
</P>
<div onDragLeave={this.handleDragLeave} className="isDragging" />
<input
name="file_input"
accept=".csv"
onChange={({ target: { files } }) =>
files && this.onChangeImportFile(files[0])
}
type="file"
/>
</Label>
</Row>
</div>
);
}}
export default UploadFileForm;
ここまでで、ファイルをドラッグ&ドロップで選択できるエリアができました。
次は選択したCSVファイルを解析する部分を作成します。
2. インポートソースとインポート先の選択部分を作成する
HomePage
のindex.js
を書き換えます。
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";
class HomePage extends Component {
state = { // 追加
analyzing: false,
analysis: null
};
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(`Analyze Failed, try again`);
strapi.notification.error(`${e}`);
});
}
});
};
render() {
return ( // 書き換え
<UploadFileForm
onRequestAnalysis={this.onRequestAnalysis}
loadingAnalysis={this.state.analyzing}
/>
);
}
}
export default memo(HomePage);
次に、UploadFileForm
にファイルを解析する処理を加えます。
readFileContent = file => {
const reader = new FileReader();
return new Promise((resolve, reject) => {
reader.onload = event => resolve(event.target.result);
reader.onerror = reject;
reader.readAsText(file);
});
};
clickAnalyzeUploadFile = async () => {
const { file, options } = this.state;
const data = file && (await this.readFileContent(file));
data &&
this.props.onRequestAnalysis({
source: "upload",
type: file.type,
options,
data
});
};
同じくUploadFileForm
のrender()
内の、一番最後のRow
タグの後に、解析用のボタンを設置ます。またexportの前に処理を加えます。
~~省略~~
<input
name="file_input"
accept=".csv"
onChange={({ target: { files } }) =>
files && this.onChangeImportFile(files[0])
}
type="file"
/>
</Label>
</Row>
<Row className={"row"}> // 追加
<Button
label={"Analyze"}
color={this.state.file ? "secondary" : "cancel"}
disabled={!this.state.file}
onClick={this.clickAnalyzeUploadFile}
/>
</Row>
</div>
);
}}
UploadFileForm.propTypes = { // 追加
onRequestAnalysis: PropTypes.func.isRequired,
loadingAnalysis: PropTypes.bool.isRequired
};
export default UploadFileForm;
components
ディレクトリに、Block
ディレクトリを追加し、その中にindex.js
とcomponents.js
を作成し、それぞれのファイルに以下を記述します
import React, { memo } from "react";
import PropTypes from "prop-types";
import { Wrapper, Sub } from "./components";
const Block = ({ children, description, style, title }) => (
<div className="col-md-12">
<Wrapper style={style}>
<Sub>
{!!title && <p>{title} </p>} {!!description && <p>{description} </p>}
</Sub>
{children}
</Wrapper>
</div>
);
Block.defaultProps = {
children: null,
description: null,
style: {},
title: null
};
Block.propTypes = {
children: PropTypes.any,
description: PropTypes.string,
style: PropTypes.object,
title: PropTypes.string
};
export default memo(Block);
import React, { memo } from "react";
import PropTypes from "prop-types";
import { Wrapper, Sub } from "./components";
const Block = ({ children, description, style, title }) => (
<div className="col-md-12">
<Wrapper style={style}>
<Sub>
{!!title && <p>{title} </p>} {!!description && <p>{description} </p>}
</Sub>
{children}
</Wrapper>
</div>
);
Block.defaultProps = {
children: null,
description: null,
style: {},
title: null
};
Block.propTypes = {
children: PropTypes.any,
description: PropTypes.string,
style: PropTypes.object,
title: PropTypes.string
};
export default memo(Block);
HomePage
にインポートと関数を追加し、render()の中を書き換えます
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 { // 追加
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: "" // 追加
};
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 non-user 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"], ""), // (name is used for display_name)
value: model.uid // (uid is used for table creations)
};
});
this.setState({ loading: false });
return { models, modelOptions };
} catch (e) {
this.setState({ loading: false }, () => {
strapi.notification.error(`${e}`);
});
}
return [];
};
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(`Analyze Failed, try again`);
strapi.notification.error(`${e}`);
});
}
});
};
selectImportSource = importSource => { // 追加
this.setState({ importSource });
};
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>
<UploadFileForm
onRequestAnalysis={this.onRequestAnalysis}
loadingAnalysis={this.state.analyzing}
/>
</Block>
</div>
</div>
);
}
}
export default memo(HomePage);
ここまでで、インポートソースとインポート先の選択部分が完成したと思います。
実際にインポートしていく部分は、また後日紹介したいと思います。