StrapiでCSVからデータをインポートするプラグインを作る ~その１ファイル選択機能まで~

Strapi Advent Calenderの14日目は[Strapi公式ブログのCSVからデータをインポートするプラグインを作成する記事](https://strapi.io/blog/how-to-create-an-import-content-plugin-part-1-4)のpart1を日本語でまとめてみました。

こちらの記事を参考に作成していきます
https://strapi.io/blog/how-to-create-an-import-content-plugin-part-1-4

Strapiのカスタムプラグインについては、5日目の記事をどうぞ！
[StrapiのLocal Pluginsを利用して独自の画面を作成する](https://zenn.dev/fullsour/articles/2556127594ec80)

## プラグインとモデルを作成する
まず、プラグインの雛形と、プラグインの中で使用するモデルを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` に以下をコピペします。

```json: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`に以下をコピペします。

```json: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`が表示されていると思います。ここからプラグインの中身を作成していきます。
![スクリーンショット 2020-12-12 21.33.45.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/194954/a0badd04-ccf0-270b-2ee1-ff7daa8b9fae.png)


# 必要なパッケージをインストールする

今回のプラグインで必要になるパッケージは以下の通りです。
- 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`ファイルを作成し、以下を記述します。

```javascript: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`コンポーネントを読み込ませて表示させてみます。

```javascript:plugins/import-content/admin/src/containers/HomePage/index.js
import React, { memo } from "react";
import pluginId from "../../pluginId";
import UploadFileForm from "../../components/UploadFileForm";

const HomePage = () => {
  return <UploadFileForm />;
};
export default memo(HomePage);
```

ここまでで、CSVファイルを選択するフォームができました。見栄えをよくするために、コンポーネントをいくつか追加します。
![スクリーンショット 2020-12-12 21.55.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/194954/3df6dee4-1662-3f54-c6f6-a95b80564539.png)


次に、`plugins/import-content/admin/src/components`に`P`ディレクトリを作成します。その中に`index.js`ファイルを作成し、以下を記述します。

```javascript: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`ファイルを作成し、以下を記述します。

```javascript: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`ファイルを作成し、以下を記述します。

```javascript: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 `にインポートして、ファイルをドラッグ&ドロップで選択できるエリアも作成していきます。

```javascript:plugins/import-content/admin/src/components/UploadFileForm/index.js
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ファイルを解析する部分を作成します。
![スクリーンショット 2020-12-12 22.53.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/194954/d67d6e26-32bb-9ebb-c281-7d1101d419db.png)

#### 2. インポートソースとインポート先の選択部分を作成する
`HomePage`の`index.js`を書き換えます。

```javascript:javascript:plugins/import-content/admin/src/containers/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`にファイルを解析する処理を加えます。

```javascript:plugins/import-content/admin/src/components/UploadFileForm/index.js
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の前に処理を加えます。

```javascript:plugins/import-content/admin/src/components/UploadFileForm/index.js

~~省略~~
            <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`を作成し、それぞれのファイルに以下を記述します

```javascript:admin/src/components/Block/index.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);
```

```javascript:admin/src/components/Block/index.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);
```

`HomePage`にインポートと関数を追加し、render()の中を書き換えます

```javascript
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);
```

ここまでで、インポートソースとインポート先の選択部分が完成したと思います。
実際にインポートしていく部分は、また後日紹介したいと思います。

![スクリーンショット 2020-12-12 23.38.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/194954/6a46c2b2-fb2c-89f6-b1e9-a7dad63459d0.png)

参考元： [How to create your own plugin on strapi](https://strapi.io/blog/how-to-create-an-import-content-plugin-part-1-4)