[HeadlessCMS]strapi + Reactでプラグインの拡張を行う方法

##はじめに
[[環境構築] HeadlessCMSのstrapi + MySQL + GraphQLでローカルにDockerで環境を構築しました](https://qiita.com/disney_Lady_Pg/items/68130fe675b37064abb7)

前回こちらの記事で環境構築について記載しました。
今回は管理画面のプラグインを拡張する方法をまとめていきます。

##参考URL
[strapi技術ブログ](https://strapi.io/blog/how-to-create-an-import-content-plugin-part-1-4)
[公式ドキュメント](https://strapi.io/documentation/v3.x/plugin-development/quick-start.html)

##今回作る物
ちょっと痒いところに手が届かないという経験ありませんか？
例えば記事の順番を並び替えたいとか、別の記事データをコピーしてきたいとか、プレビューを並べて出したいとか。
そういう時にこのプラグインの拡張で自作してあげることでやりたいことが実現できるようになりそうです。

今回はデータ編集したものをそのままプレビューとして表示する機能を作成していこうと思います。
イメージはQiitaの記事投稿画面です。

##さっそく作ってみよう

今回は前回の環境構築した環境ではなくローカルに直で作るので、Docker環境でそのまま使いたい方は適宜読み返してもらえると助かります。

###プロジェクトの作成
環境そのままの人は無視してください。
０から作りたい方は一緒にやっていきましょう

```
$ yarn create strapi-app plugin-sample --quickstart --no-run
```

まずはプロジェクトを作成します
「plugin-sample」という名前のプロジェクトを作成します。
--quickstartのオプションをつけているのでDBはSQLiteが選択されています。
また--no-runで作成と同時に起動しないようにしています。

###プラグインの新規作成
それでは次はプラグインを作成します。

```
$ cd plugin-sample
$ yarn strapi generate:plugin preview-content
```

プロジェクトのフォルダに入り、generate:pluginでプラグインを作成します。
今回は「preview-content」という名称にしました。
そうすると「plugins」フォルダ内に今回作成したプラグイン用のフォルダが出来上がっています。

それではいったん起動してみましょう。

```
$ yarn build
$ yarn develop --watch-admin
```
一度ビルドした後に起動します。
--watch-adminでwatch機能をつけました。
これでファイル変更が監視されるのでプラグイン作成中はonにしておくと良いです。

`http://localhost:1337`

上記のローカルホストにアクセスしてダッシュボードにいきます。
そうするとメニューに今作成した「preview-content」が追加されているはずです。

<img width="1439" alt="スクリーンショット 2020-07-17 8.41.07.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/488594/83763c61-030d-7a0b-d7cd-9bcf7bf62846.png">

これで下準備は完了です。
それでは実際に画面を作成していきましょう。

###テーブルの用意
とりあえずテーブルを用意しましょう。
テーブルがないと何も始まらないので・・・

<img width="1439" alt="スクリーンショット 2020-07-17 8.59.19.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/488594/e63017cd-f713-5c07-e747-4ecf8762ae0a.png">


今回はこんな簡単な作りにしておきました（サンプルなので）
ついでに権限許可もしておいてください。
とりあえずcount以外を許可しておいてもらえると良いかと思います。

##プラグインの実装

まずはプライグインのフォルダ構成です

```
plugin-sample/plugins/preview-content
|--.editorconfig
|--.gitattributes
|--.gitignore
|--README.md
|--admin
|  |--src
|  |  |--containers
|  |  |  |--App
|  |  |  |  |--index.js
|  |  |  |--HomePage
|  |  |  |  |--index.js
|  |  |  |--Initializer
|  |  |  |  |--index.js
|  |  |--index.js
|  |  |--lifecycles.js
|  |  |--pluginId.js
|  |  |--translations
|  |  |  |--ar.json
|  |  |  |--cs.json
|  |  |  |--de.json
|  |  |  |--en.json
|  |  |  |--es.json
|  |  |  |--fr.json
|  |  |  |--index.js
|  |  |  |--it.json
|  |  |  |--ko.json
|  |  |  |--ms.json
|  |  |  |--nl.json
|  |  |  |--pl.json
|  |  |  |--pt-BR.json
|  |  |  |--pt.json
|  |  |  |--ru.json
|  |  |  |--sk.json
|  |  |  |--tr.json
|  |  |  |--uk.json
|  |  |  |--vi.json
|  |  |  |--zh-Hans.json
|  |  |  |--zh.json
|  |  |--utils
|  |  |  |--getTrad.js
|--config
|  |--routes.json
|--controllers
|  |--preview-content.js
|--node_modules
|  |--.yarn-integrity
|--package.json
|--services
|  |--preview-content.js
|--yarn.lock

```

こんな感じになっています。
いじるのは基本的にadmin/srcになります。
とりあえずデータを１件くらい画面から登録しておくとあとが楽かもしれません。
今回はReactで作っていきます。

###画面の実装をする前に・・・

今回はデザインまで面倒みないのでざっくり画面で容赦ください、とりあえずデザインは手抜きしたいのでマテリアルUIを使います。
必要なライブラリなどがあればこの際に追加してあげてください。

```
$ cd plugins/preview-content
$ yarn add @material-ui/core
```

それでは画面を作っていきましょう。

基本的には既存のテンプレートにそのままコンポーネントを載せていけば問題ないです。
Reactの書き方とかになると僕もそこまで詳しくないので調べてもらえると助かります。

ポイントとしてはデータの取得・更新をどうするかというところですが、これは`strapi-helper-plugin`という便利なライブラリが用意されていますのでこれを使います。
こいつからrequestメソッドを呼び出して使う感じです。
さくっと書くとこんな感じになります。

```ContentRequest.js
// requestメソッドは第一引数にURL, 第二引数にoptionを取ります
// 中でfetchを使っていたので、fetchと同じ引数を渡せば良さそうです
import { request } from 'strapi-helper-plugin';

// Contentテーブルから全件検索
const list = await request('/contents', { method: 'GET'});

// ContentテーブルからID検索
const content = await request(`/contents/${id}`, { method: 'GET' });

// ID検索したデータの更新（※戻り値は更新後のデータ）
await request(`/contents/${id}`, { method: 'PUT', body: content });

// ID検索したデータの削除
await request(`/contents/${id}`, { method: 'DELETE' });

// Contentテーブルへ新規登録（戻り値は登録後のデータ）
await request('/contents', { method: 'POST', body: content });
```

権限の設定をした際にURLやリクエストのメソッドは隣に表示されていたと思うので、わからない時はそちらを参照してください。
また、strapiのプロジェクトのフォルダのapiフォルダの中を編集するとAPIそのものに手を加えることができるようになります。
そのあたりは後ほど気が向いたら投稿しますが、公式をみてもらうのが一番早そうです。

###実装してみた
ということでざっくり実装してみました。
作成したのは以下の３つのコンポーネントと、既存のHomePageを編集しました。

`src/components/List/index.js　→　データの一覧表示画面`
`src/components/Edit/index.js　→　編集画面`
`src/components/EditView/index.js　→　編集のプレビュー画面`

実装はこんな感じです。
Promiseの処理が適切じゃないとかあると思いますがしっかり作り込んでいないのでご容赦ください・・・

```src/components/List/index.js
import React from 'react';
import { makeStyles } from '@material-ui/core/styles';
import Table from '@material-ui/core/Table';
import TableBody from '@material-ui/core/TableBody';
import TableCell from '@material-ui/core/TableCell';
import TableContainer from '@material-ui/core/TableContainer';
import TableHead from '@material-ui/core/TableHead';
import TableRow from '@material-ui/core/TableRow';
import Paper from '@material-ui/core/Paper';
import Button from '@material-ui/core/Button'
import { request } from 'strapi-helper-plugin';

const useStyles = makeStyles({
  table: {
    minWidth: 300,
    maxHeight: 500,
  },
});

export default function List({contents, searchFunc, editFunc}) {
  const classes = useStyles();
  // 削除処理
  const deleteClick = async (id) => {
    await request(`/contents/${id}`, { method: 'DELETE' });
    searchFunc();
  }
  return (
    <TableContainer component={Paper}>
      <Table className={classes.table} aria-label="simple table">
        {/* テーブルヘッダー */}
        <TableHead>
          <TableRow>
            <TableCell>ID</TableCell>
            <TableCell>Title</TableCell>
            <TableCell>Header</TableCell>
            <TableCell>Action</TableCell>
          </TableRow>
        </TableHead>
        {/* テーブルボディ */}
        <TableBody>
          {contents.map(content => (
            <TableRow key={`tablerow_${content.id}`}>
              <TableCell component="th" scope="row">{content.id}</TableCell>
              <TableCell>{content.title || ''}</TableCell>
              <TableCell>{content.header || ''}</TableCell>
              <TableCell>
                <Button variant="contained" color="primary" onClick={() => editFunc(content.id)}>編集</Button>
                <Button variant="contained" color="secondary" onClick={() => deleteClick(content.id)}>削除</Button>
              </TableCell>
            </TableRow>
          ))}
        </TableBody>
      </Table>
    </TableContainer>
  );
}
```

```/src/components/Edit/index.js
import React, { useState } from 'react';
import Button from '@material-ui/core/Button';
import { makeStyles } from '@material-ui/core/styles';
import TextField from '@material-ui/core/TextField';
import { request } from "strapi-helper-plugin";

const useStyles = makeStyles((theme) => ({
  root: {
    '& > *': {
      margin: theme.spacing(1),
      width: '25ch',
    },
    width: '100%',
  },
  input: {
    width: '95%'
  }
}));

export default function Edit({ id, title, header, description, searchFunc, changeInputFunc }) {
  const classes = useStyles();
  // 登録・更新処理
  const regist = async (url, method) => {
    const value = { title: title, header: header, description: description };
    await request(`/contents${url}`, { method: method, body: value });
    searchFunc();
  }
  const update = async () => await regist(`/${id}`, 'PUT')
  const add = async () => await regist('', 'POST')
  // ボタン表示用
  const buttonProp = id ?
    { text: '更新', func: () => update() } : { text: '登録', func: () => add() };
  return (
    <form className={classes.root} noValidate autoComplete="off">
      {/* タイトル */}
      <TextField
        className={classes.input}
        id="filled-basic"
        label="title"
        variant="filled"
        value={title || ''}
        onChange={(e) => changeInputFunc(e.target, 'title') }
      />
      {/* ヘッダー */}
      <TextField
        className={classes.input}
        id="filled-basic"
        label="header"
        variant="filled"
        value={header || ''}
        onChange={(e) => changeInputFunc(e.target, 'header') }
      />
      {/* 説明 */}
      <TextField
        className={classes.input}
        id="filled-multiline-static"
        label="description"
        multiline
        rows={4}
        defaultValue="Default Value"
        value={description || ''}
        onChange={(e) => changeInputFunc(e.target, 'description') }
      />
      {/* ボタン */}
      <Button 
        variant="contained"
        color="default"
        onClick={() => {buttonProp.func()}}
      >
        {buttonProp.text}
      </Button>
    </form>
  );
}
```

```/src/components/EditView/index.js
import React from 'react';
import { makeStyles } from '@material-ui/core/styles';
import Card from '@material-ui/core/Card';
import CardContent from '@material-ui/core/CardContent';
import Typography from '@material-ui/core/Typography';

const useStyles = makeStyles((theme) => ({
  root: {
    display: 'flex',
  },
  details: {
    display: 'flex',
    flexDirection: 'column',
    width: '30vh'
  },
  content: {
    flex: '1 0 auto',
  },
  right: {
    padding: 10
  }
}));

export default function EditView({ title, header, description }) {
  const classes = useStyles();
  return (
    <Card className={classes.root}>
      <div className={classes.details}>
        <CardContent className={classes.content}>
          <Typography component="h5" variant="h4">
            {title}
          </Typography>
          <Typography variant="h5" color="textSecondary">
            {header}
          </Typography>
        </CardContent>
      </div>
      <div className={classes.right}>
        <Typography variant="h6" gutterBottom>
          {description}
        </Typography>
      </div>
    </Card>
  );
}
```

```/src/containers/HomePage/index.js
import React, { memo, useState, useEffect } from 'react';
import { makeStyles } from '@material-ui/core/styles';
import Button from '@material-ui/core/Button'
import { request } from "strapi-helper-plugin";
import pluginId from '../../pluginId';
import List from '../../components/List';
import Edit from '../../components/Edit';
import EditView from '../../components/EditView'

const useStyles = makeStyles((theme) => ({
  area: {
    display: 'flex',
    flexDirection: 'row',
  },
  col: {
    width: '50%',
    padding: 20,
    margin: 5,
    border: '1px #C5C5C5 solid'
  }
}));

// デフォルトのContent情報
const defContent = {
  id: 0, title: '', header: '', description: ''
}
const HomePage = () => {
  const classes = useStyles();
  const [contents, setContents] = useState([]);
  const [oneContent, setOneContent] = useState(null)
  // 一覧検索処理
  const searchList = async () => {
    const list = await request('/contents', { method: 'GET' });
    setContents(list);
    setOneContent(null);
  };
  useEffect(() => {
    searchList();
  }, []);
  // 編集ボタン処理
  const editContent = async (searchId) => {
    const {id, title, header, description} = await request(`/contents/${searchId}`, { method: 'GET' });
    setOneContent({ id: id, title: title, header: header, description: description});
  }
  // 入力値変更
  const changeInput = (target, key) => {
    oneContent[key] = target.value;
    setOneContent(Object.assign({}, oneContent));
  }
  return (
    <div style={{padding: 20}}>
      <h1>{pluginId}&apos;s HomePage</h1>
      <div style={{padding: 10}}>
        <h2>データ一覧</h2>
        <Button 
          variant="contained"
          color="default"
          onClick={() => setOneContent(defContent)}
        >
          新規登録
        </Button>
        <List
          contents={contents}
          searchFunc={() => searchList()}
          editFunc={(id) => editContent(id)}
        />
      </div>
      {
        oneContent && (
          <div style={{padding: 10}}>
            <h2>編集データ</h2>
            <div className={classes.area}>
              <div className={classes.col}>
                <Edit
                  {...oneContent}
                  searchFunc={() => searchList()}
                  changeInputFunc={(target, key) => changeInput(target, key)}
                />
              </div>
              <div className={classes.col}>
                <EditView {...oneContent} />
              </div>
            </div>
          </div>
        )
      }
    </div>
  );
};

export default memo(HomePage);
```

こんな感じになりました。
実際に動かした画面がこんな感じになります。

![YOSI.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/488594/e8a8c34a-f6fe-4d8c-f4f9-2d78e8d8018a.gif)

**とりあえず動いているからヨシ！！！！！**

##まとめ
こんな感じでstrapiはプラグインの作成をすることで管理画面の拡張も可能です。
また公式のチュートリアルでは、本来のデータ入力するフィールドの上書きも紹介されていました。
まだまだ細かい設定などは見切れていないので、また元気があればこうやって記事にしておこうと思います。
（結局フロントエンドとの連携はやっていない問題）