日本一わかりやすいReact入門【実践編】#6~9 学習備忘録

## 概要
この記事は、Youtubeチャンネル『[トラハックのエンジニア学習ゼミ【とらゼミ】](https://www.youtube.com/channel/UC-bOAxx-YOsviSmqh8COR0w)』の『[日本一わかりやすいReact入門【実践編】](https://www.youtube.com/watch?v=MzJkWO73S70&list=PLX8Rsrpnn3IVOk48awq_nKW0aFP0MGpnn)』の自分用学習備忘録です。

\#1~5までの記事は[こちら](https://qiita.com/ddpmntcpbr/items/77a2db9d8dab6e3b2525)。

## #6...条件分岐とコールバック関数のbind

続いて、チャット部分のコンポーネントを作成します。

- Chats
  - Chat
  - Chat
  - Chat
  - ...


AnswersList -> Answer と同じ構造。

これと並行して、Answerコンポーネントをクリックすることで、次の回答に遷移するよう、App.jsxで定義した selectAnswer()関数を、bindによってAnswerコンポーネントへ渡していきます。

修正ファイルは、
- src/App.jsx
- components/Chats.jsx
- components/Chat.jsx
- components/AnswersList.jsx
- components/Answer.jsx
- components/index.js


```react:src/App.jsx

import React from 'react';
import './assets/styles/style.css';
import defaultDataset from "./dataset"
import {AnswersList, Chats} from "./components/index"

export default class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      answers: [],
      chats: [],
      currentId: "init",
      dataset: defaultDataset,
      open: false
    }
    this.selectAnswer = this.selectAnswer.bind(this)
  }

  displayNextQuestion = (nextQuestionId) => {
    const chats = this.state.chats
    console.log(nextQuestionId)
    chats.push({
      text: this.state.dataset[nextQuestionId].question,
      type: 'question'
    })

    this.setState({
      answers: this.state.dataset[nextQuestionId].answers,
      chats: chats,
      currentId: nextQuestionId
    })
  }

  selectAnswer = (selectedAnswer, nextQuestionId) => {
    switch(true) {
      case (nextQuestionId === 'init'):
        this.displayNextQuestion(nextQuestionId)
        break;
      default:
        const chats = this.state.chats;
        chats.push({
          text: selectedAnswer,
          type: 'answer'
        })

        this.setState({
          chats: chats
        })

        this.displayNextQuestion(nextQuestionId)
        break;
    }
  }

  componentDidMount() {
    const initAnswer = "";
    this.selectAnswer(initAnswer, this.state.currentId)
  }

  render() {
    return (
      <section className="c-section">
        <div className="c-box">
          <Chats chats={this.state.chats}/>
          <AnswersList
            answers={this.state.answers}
            select={this.selectAnswer}
          />
        </div>
      </section>
    );
  }
}
```

ここは実行順序が少し複雑なので、文字で書き起こして整理してみる。

- 初回render時の挙動
  - componentDidMount()により、selectAnswer()に初期の引数が渡される。第1引数は空の文字列、第2引数は'init'
  - selectAnswer()において、``case (nextQuestionId === 'init'):``が実行され、``this.displayNextQuestion()``に``'init'``が渡される。
  - displayNextQuestion()で、``this.state.dataset['init'].question``が呼び出され、空っぽの``chats``に最初のchatが入る(typeはquestion)。
  - answers対しても、nextQuestionId='init'に対応する値が、datasetから呼び出され代入される。
  - currentIdの更新がこのタイミングで入る。今回はcurrentId='init'が代入される。
  - これらの``chats``、``answers``が、それぞれChatsコンポーネントとAnswersコンポーネントに渡される。

- チャット欄が更新されていく流れ
  - AnswersListコンポーネントに渡しているselectAnswer()関数は、最終的にAnswerコンポーネントに渡り、onClickイベントにより実行されることになる。その際、Answerコンポーネントが保有している``props.content``と``props.nextId``が、``selectAnswer(props.content,props.nextId)``の形で渡される
  - selectAnswer()のswitch分岐のうち、defaultの方が実行される。まず、selectedAnswer引数に渡された``props.content``がchatsに追加される(typeはanswer)。その後、nextQuestionId引数に渡されたprops.nextIdがdisplayNextQuestion（）に渡り、answers、currentIdが更新される
  - 更新された``chats``、``answers``が、それぞれChatsコンポーネントとAnswersコンポーネントに渡される。

```react:component/Chats.jsx
import React from 'react';
import { makeStyles } from '@material-ui/core/styles';
import List from '@material-ui/core/List';
import {Chat} from "./index"

const useStyles = makeStyles((theme) => ({
  root: {
    width: '100%',
    maxWidth: '36ch',
    backgroundColor: theme.palette.background.paper,
  },
}));

const Chats = (props) => {
  const classes = useStyles();

  return (
    <List className={classes.root}>
      {props.chats.map((chat,index) => {
        return <Chat text={chat.text} type={chat.type} key={index.toString()} />
      })}
    </List>
  );
}

export default Chats
```

- Material-UIの[List React Component](https://material-ui.com/components/lists/)を一部使用。
- App.jsxよりもらった``props.chats``は、AnswersListコンポーネントと同様に、map関数で繰り返し処理。

```react:component/Chat.jsx
import React from 'react';
import ListItem from '@material-ui/core/ListItem';
import ListItemAvatar from '@material-ui/core/ListItemAvatar';
import Avatar from '@material-ui/core/Avatar';
import UserIcon from '../assets/img/no-profile.png'
import BotIcon from '../assets/img/ai_computer_sousa_robot.png'

const Chat = (props) => {
  const isQuestion = (props.type === 'question');
  const classes = isQuestion ? 'p-chat__row' : 'p-chat__reverse';

  return (
    <ListItem className={classes}>
      <ListItemAvatar>
        {isQuestion ? (
          <Avatar alt="icon" src={BotIcon} />
          ) : (
          <Avatar alt="icon" src={UserIcon} />
        )}
      </ListItemAvatar>
      <div className="p-chat__bubble">{props.text}</div>
    </ListItem>
  )
}

export default Chat

```

- Material-UIの[List React Component](https://material-ui.com/components/lists/)を一部使用。
- chatの種類（type）が「question」か「answer」かで条件分岐を行っている。

```react:component/AnswersList.jsx
import React from 'react'
import {Answer} from './index'

const AnswersList = (props) => {
  return (
    <div className="c-grid__answer">
      {props.answers.map((value, index) => {
        return <Answer content={value.content} nextId={value.nextId} key={index.toString()} select={props.select} />
      })}
    </div>
  )
}

export default AnswersList
```

- 新たにnextIdとselect(selectAnswer関数のこと)を、Answerコンポーネントに渡している
<br/>

```react:component/Answer.jsx
import React from 'react';
import { makeStyles } from '@material-ui/core/styles';
import Button from '@material-ui/core/Button';

const useStyles = makeStyles((theme) => ({
  root: {
  },
}));

const Answer = (props) => {
  return (
    <Button variant="contained" color="primary" onClick={() => props.select(props.content,props.nextId)} >
      {props.content}
    </Button>
  )
}

export default Answer
```

- ``onClick``により、select(=selectAnswer関数)が実行される。
<br/>

```react:components/index.js
export {default as AnswersList} from './AnswersList'
export {default as Answer} from './Answer'
export {default as Chats} from './Chats'
export {default as Chat} from './Chat'
```

- コンポーネントが増え次第、ここに追記する。

上手く行けば、質問をクリックするたびチャット欄が更新され、また新たな質問が表示されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/694f30b7-8d98-be7f-1599-c6ccac581f8a.png)

ここまでで、チャットボットとしての最低限の機能は実装できました！

## #7...Material-UIのスタイルをカスタマイズしよう

Hook APIを用いて、Material-UIのスタイルを変更する。まずはHook APIをインストールする。

```terminal:terminal
$ npm install --save @material-ui/styles
```
Answerコンポーネント、Chatsコンポーネントにstyleを定義します。

```react:components/Answers.jsx
import React from 'react';
import { makeStyles, createStyles } from '@material-ui/core/styles';
import Button from '@material-ui/core/Button';

const useStyles = makeStyles(() => (
  createStyles({
    "button": {
      borderColor: '#FFB549',
      color: '#FFB549',
      fontWeight: 600,
      marginBottom: '8px',
      "&:hover": {
        backgroundColor: '#FFB549',
        color: '#fff'
      }
    }
  })
));

const Answer = (props) => {
  const classes = useStyles();

  return (
    <Button className={classes.button} variant="outlined" onClick={() => props.select(props.content,props.nextId)} >
      {props.content}
    </Button>
  )
}

export default Answer
```

- @material-ui/core/stylesから``createStyles``をimportする。
- Material-UIでは、この``createStyles()``の中にstyleを定義する
- ``const classes = useStyles();``で定義したclassを、styleを適用させたいタグに入れる
<br/>

```react:components/Chats.jsx
import React from 'react';
import { makeStyles, createStyles } from '@material-ui/core/styles';
import List from '@material-ui/core/List';
import {Chat} from "./index"

const useStyles = makeStyles(() => (
  createStyles({
    "chats": {
      height: 400,
      padding: '0',
      overflow: 'auto'
    }
  })
));

const Chats = (props) => {
  const classes = useStyles();

  return (
    <List className={classes.chats}>
      {props.chats.map((chat,index) => {
        return <Chat text={chat.text} type={chat.type} key={index.toString()} />
      })}
    </List>
  );
}

export default Chats
```

- ``overflow: 'auto'``により、``<List>``タグの子要素が``height:400``を超えると、スクロールバーが表示される

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/5f55f671-6ec4-f065-67a9-c2b5a86c0d10.png)

styleが上手く当てられていると、
- 選択肢ボタンについて、通常時は白背景の黄色文字、マウスホバー時は黄色背景の白文字に。
- チャットを何度か繰り返すと、スクロールバーが表示される
のようになっているはず。

## #8...ライフサイクルの活用

### 自動スクロール機能
チャットが更新されたときに、スクロールが自動で下まで移動する仕様を実装する。ライフサイクルメソッドのうち、componentDidUpdate()を用いる。

```react:components/Chats.jsx
...

<List className={classes.chats} id={"scroll-area"} >

...
```

スクロールをさせる``<List>``タグにidを持たせる。

```react:component/App.jsx
...

componentDidUpdate(){
  const scrollArea = document.getElementById("scroll-area")
  if (scrollArea) {
    scrollArea.scrollTop = scrollArea.scrollHeight
  }
}

...
```

Chatsコンポーネントの値が更新されるたびにcomponentDidUpdate()が実行され、自動スクロールが実行される。
<br/>

### ディレイ機能

チャットボット側の返信を意図的に遅らせる。setTimeout()関数を使用する。

```react:src/App.jsx
...

  selectAnswer = (selectedAnswer, nextQuestionId) => {
    switch(true) {
      case (nextQuestionId === 'init'):
        // this.displayNextQuestion(nextQuestionId)
        setTimeout(() => this.displayNextQuestion(nextQuestionId), 500);
        break;
      default:
        const chats = this.state.chats;
        chats.push({
          text: selectedAnswer,
          type: 'answer'
        })

        this.setState({
          chats: chats
        })
        // this.displayNextQuestion(nextQuestionId)
        setTimeout(() => this.displayNextQuestion(nextQuestionId), 1000);
        break;
    }
  }

...
```

displayNextQuestion（）の実行にディレイをかける。setTimeoutの単位はms(ミリセカンド)。・

### 外部サイトへの転送

selectedAnswer()に渡されたnextQuestionIdが``http:~``から始まるURL文字列の時、datasetから値を取り出すのではなく、該当するURLへページを転送させるように実装します。

```react:src/App.jsx
selectAnswer = (selectedAnswer, nextQuestionId) => {
    switch(true) {
      case (nextQuestionId === 'init'):
        setTimeout(() => this.displayNextQuestion(nextQuestionId), 500);
        break;

      // ここから追記
      case (/^https:*/.test(nextQuestionId)):
        const a = document.createElement('a');
        a.href = nextQuestionId;
        a.target = '_blank';
        a.click();
        break;
      // ここまで追記

      default:
      ...

    }
  }
```

- 正規表現の``/^https:*/``を用いて、nextQuestionIdの先頭文字列が``http:~``で始めるかどうかをチェック
- ``<a>``タグを生成し、クリック処理をすることで転送させる

ここまでで「問い合わせる」(nextQuestionId=‘contact’)以外の選択肢については、エラーを出さなくなりました
<br/>

## #9...問い合わせ用モーダルとSlack通知を実装しよう

###　問い合わせ用モーダル

FormDialogコンポーネントと、TextInputコンポーネントを新たに作ります。

FormDialogコンポーネントが問合せモーダル自体の表し、その中に入力スペースの部分のみを、TextInputコンポーネントが担当するイメージです。

新規作成or修正ファイルは以下の通り。

- src/App.jsx
- component/Form/FormDialog.jsx
- component/Form/TextInput/jsx
- component/index.js


```react:src/App.jsx
import React from 'react';
import './assets/styles/style.css';
import defaultDataset from "./dataset"
// FormDialogコンポーネントを追加
import {AnswersList, Chats, FormDialog} from "./components/index"

export default class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      answers: [],
      chats: [],
      currentId: "init",
      dataset: defaultDataset,
      open: false
    }
    this.selectAnswer = this.selectAnswer.bind(this)
    
    // FormDialogコンポーネントに渡す関数をバインド
    this.handleClickOpen = this.handleClickOpen.bind(this)
    this.handleClose = this.handleClose.bind(this)
  }

  displayNextQuestion = (nextQuestionId) => {
    const chats = this.state.chats
    chats.push({
      text: this.state.dataset[nextQuestionId].question,
      type: 'question'
    })

    this.setState({
      answers: this.state.dataset[nextQuestionId].answers,
      chats: chats,
      currentId: nextQuestionId
    })
  }

  selectAnswer = (selectedAnswer, nextQuestionId) => {
    switch(true) {
      case (nextQuestionId === 'init'):
        setTimeout(() => this.displayNextQuestion(nextQuestionId), 500);
        break;
      
      // ユーザーが'問合せたい'をクリックした時に、this.handleClickOpen()が実行される
      case (nextQuestionId === 'contact'):
        this.handleClickOpen()
        break;

      case (/^https:*/.test(nextQuestionId)):
        const a = document.createElement('a');
        a.href = nextQuestionId;
        a.target = '_blank';
        a.click();
        break;

      default:
        const chats = this.state.chats;
        chats.push({
          text: selectedAnswer,
          type: 'answer'
        })

        this.setState({
          chats: chats
        })
        setTimeout(() => this.displayNextQuestion(nextQuestionId), 1000);
        break;
    }
  }

  // openの値でモーダルの開閉を制御
  handleClickOpen = () => {
    this.setState({
      open: true
    });
  };

  // openの値でモーダルの開閉を制御
  handleClose = () => {
    this.setState({
      open: false
    });
  };

  componentDidMount() {
    const initAnswer = "";
    this.selectAnswer(initAnswer, this.state.currentId)
  }


  componentDidUpdate(){
    const scrollArea = document.getElementById("scroll-area")
    if (scrollArea) {
      scrollArea.scrollTop = scrollArea.scrollHeight
    }
  }

  render() {
    return (
      <section className="c-section">
        <div className="c-box">
          <Chats chats={this.state.chats}/>
          <AnswersList answers={this.state.answers} select={this.selectAnswer} />
          
          // openの値と、openの値を変更する関数を渡す
          <FormDialog open={this.state.open} handleClose={this.handleClose} />
        </div>
      </section>
    );
  }
}

```

- 問合せモーダルを開くタイミングは、ユーザーが「問い合わせる」の選択肢を選んだときのみなので、``handleClickOpen``関数は``App.jsx``のみで使用する
- 一方、問合せモーダルを閉じるタイミングは、①情報が送信されたとき、と②問合せモーダル内の「キャンセル」を押したときであり、②を実装するためには、``handleClose``関数をFormDialogコンポーネントに渡しておく必要がある。
<br/>

```react:components/Forms/FormDialog.jsx
import React from 'react'
import Button from '@material-ui/core/Button';
import Dialog from '@material-ui/core/Dialog';
import DialogActions from '@material-ui/core/DialogActions';
import DialogContent from '@material-ui/core/DialogContent';
import DialogTitle from '@material-ui/core/DialogTitle';
import {TextInput} from "../index"

export default class FormDialog extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      name: "",
      email: "",
      description: ""
    }
    this.inputName = this.inputName.bind(this)
    this.inputEmail = this.inputEmail.bind(this)
    this.inputDescription = this.inputDescription.bind(this)
  }

  inputName = (event) => {
    this.setState({ name: event.target.value })
  }

  inputEmail = (event) => {
    this.setState({ email: event.target.value })
  }
  inputDescription = (event) => {
    this.setState({ description: event.target.value })
  }

  render() {
    return (
      <Dialog
        open={this.props.open}
        onClose={this.props.handleClose}
        aria-labelledby="alert-dialog-title"
        aria-describedby="alert-dialog-description"
      >
        <DialogTitle id="alert-dialog-title">{"問い合せフォーム"}</DialogTitle>
        <DialogContent>

          <TextInput
            label={"お名前"}
            multiline={false}
            rows={1}
            value={this.state.name}
            type={"text"}
            onChange={this.inputName}
          />

          <TextInput
            label={"メールアドレス"}
            multiline={false}
            rows={1}
            value={this.state.email}
            type={"email"}
            onChange={this.inputEmail}
          />

          <TextInput
            label={"お問い合わせ内容"}
            multiline={true}
            rows={5}
            value={this.state.description}
            type={"text"}
            onChange={this.inputDescription}
          />

        </DialogContent>
        <DialogActions>
          <Button onClick={this.props.handleClose} color="primary">
            キャンセル
          </Button>
          <Button onClick={this.props.handleClose} color="primary" autoFocus>
            送信する
          </Button>
        </DialogActions>
      </Dialog>
    )
  }
}

```

- reactでは、inputで送信する値に関しても、stateとして定義・管理する必要がある。
- inputName()、inputEmail()、inputDescription()を定義して、テキストフィールドに入力された内容を逐次stateとして保存する処理を行う。
- この時点では、「送信する」ボタンを押しても、情報は送信されずに問合せモーダルが閉じる処理だけが行われる
<br/>

```react:components/Forms/TextInput.jsx
import React from 'react';
import TextField from '@material-ui/core/TextField';

const TextInput = (props) => {
  console.log(props.multiline)
  console.log(props.rows)
  return (
    <TextField
      fullWidth={true}
      label={props.label}
      margin={"dense"}
      multiline={props.multiline}
      rows={props.rows}
      value={props.value}
      type={props.type}
      onChange={props.onChange}
    />
  )
}

export default TextInput
```

- Material-UIの『[Text Field](https://material-ui.com/components/text-fields/)』を使用。
<br/>

```javascript:components/index.js
export {default as AnswersList} from './AnswersList'
export {default as Answer} from './Answer'
export {default as Chats} from './Chats'
export {default as Chat} from './Chat'
export {default as FormDialog} from './Forms/FormDialog'
export {default as TextInput} from './Forms/TextInput'
```

- FormDialigコンポーネント、TextInputコンポーネントを作成したので、追記。
<br/>

### Slack通知機能

モーダルに入力された内容を、Slackまで通知させます。まず、Slack側の設定を行い、WebhookURLを取得します。流れとしては、

1. (未作成なら)自分一人だけのワークスペースを新たに作る
2. 作成したワークスペースに対して、Slackアプリを作成する
3. Incoming Webhooksを有効にし、WebhookURLを取得する

詳細は[『slackのIncoming webhookが新しくなっていたのでまとめてみた』](https://qiita.com/kshibata101/items/0e13c420080a993c5d16)というQiita記事が参考になりました。

WebhookURLを取得できたら、FormDialog.jsxに、Slackに通知させる関数を定義します。

```react:components/Forms/FormDialog.jsx
...

submitForm = () => {
    const name = this.state.name
    const email = this.state.email
    const description = this.state.description

    const payload = {
      text: 'お問い合わせがありました\n' +
            'お名前: ' + name + '\n' +
            'Email: ' + email + '\n' +
            'お問い合わせ内容:\n' + description
    }

    const url = 'ここにWebhookURLを記述'

    fetch(url, {
      method: 'POST',
      body: JSON.stringify(payload)
    }).then(() => {
      alert('送信が完了しました！しばらくお待ちください')
      this.setState({
        name: "",
        email: "",
        description: ""
      })
      return this.props.handleClose()
    })
  }

...

// <Button onClick={this.props.handleClose} color="primary" autoFocus>
<Button onClick={this.submitForm} color="primary" autoFocus>
  送信する
</Button>

...
```

「送信する」ボタンのonClickイベントに、定義したsubmitForm()関数を設定します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/0a5fe945-88e3-697c-9161-ae19308ce318.png)

お問い合わせフォームに適切な値を入力し送信すると、、、

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/ee884686-4a7e-5a2d-50d3-a99b6ecd11a2.png)

無事、通知が届きました！

Slackへの通知は初実装でしたが、意外と簡単にできました！


記事が長くなったのでいったんここまで！続きは[こちら](https://qiita.com/ddpmntcpbr/items/950bfebf7e8bb8ac2ae2)。

## 参考URL
- 『[日本一わかりやすいReact入門](https://www.youtube.com/watch?v=Otrc2zAlJyM&list=PLX8Rsrpnn3IWKz6H5ZEPWBY8AKWwb9qq7)』
- 『[日本一わかりやすいReact入門【実践編】]((https://www.youtube.com/watch?v=MzJkWO73S70&list=PLX8Rsrpnn3IVOk48awq_nKW0aFP0MGpnn))』