2
Help us understand the problem. What are the problem?

More than 1 year has passed since last update.

posted at

updated at

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

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

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

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"
    }
  }
}

ここまでの手順を踏んで起動すると、管理画面のサイドメニューのコレクションタイプにImportconfigsImporteditemsが、プラグインにimport-contentが表示されていると思います。ここからプラグインの中身を作成していきます。
スクリーンショット 2020-12-12 21.33.45.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/componentsUploadFileFormディレクトリを作成します。その中にindex.jsファイルを作成し、以下を記述します。

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

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

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

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/componentsRowディレクトリを作成します。その中にindex.jsファイルを作成し、以下を記述します。

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/componentsLabelディレクトリを作成します。その中にindex.jsファイルを作成し、以下を記述します。

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

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

2. インポートソースとインポート先の選択部分を作成する

HomePageindex.jsを書き換えます。

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";

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にファイルを解析する処理を加えます。

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
      });
  };

同じくUploadFileFormrender()内の、一番最後のRowタグの後に、解析用のボタンを設置ます。またexportの前に処理を加えます。

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

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);
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()の中を書き換えます

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

参考元: How to create your own plugin on strapi

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Sign upLogin
2
Help us understand the problem. What are the problem?