📮

Amplify & React で懇親会用に話題投稿&話題抽選のwebアプリを作りました

2021/06/19に公開

前に書いた、とりあえず動かす!Cloud9 & React & Amplify & GraphQLの環境構築を基にして、Amplify & Reactで実際にwebアプリを作ってみました。
もともと新入社員歓迎会で使う予定だったのですが、webアプリに頼らずとも盛り上がったので、お蔵入りにしました。その後、別の機会で使いました。。

参考にした記事は、環境構築時の記事に加えて以下の記事です
https://qiita.com/laughingman/items/cab91e7dabc952247953
https://zenn.dev/enish/articles/3c760395d54d89

前提条件

とりあえず動かす!Cloud9 & React & Amplify & GraphQLの環境構築を開始地点にしますので、環境構築はできている前提で話を進めます。
基本的には、細かい説明を飛ばし読みして、必要なファイルの上書きを行えば動くようになります。(多分、、)

作ったもの


上にある画像の通りですが、こんなのを作りました。

簡単に用件を整理すると、

  • 話題の投稿と話題の抽選をする
  • 他の人が投稿した話題と自分が投稿した話題をリアルタイムに反映する
  • 投稿と抽選をタブで分ける
  • 最新の投稿を最上位に表示する
  • 最新の15件の話題に絞って表示する
  • 30分以内に投稿された話題に絞って表示する
  • 表示する話題が1件以下の場合は抽選できない
  • 話題の抽選中に他の人は投稿できるが、抽選している人は投稿できない

といったカンジです。

この記事では触れないこと

話題の投稿と話題の抽選に限った記事なので、実際に複数人で利用するにはEC2でReactのポート解放とリソースポリシーの設定が必要ですが、この記事では触れません。

schemaの定義を変更

以下のファイルを変更してschemaの定義を変更して、バックエンド側に反映させます。

  • reactプロジェクト配下/amplify/backend/api/{api名}/schema.graphql

もともとある定義をすべて削除して、以下に書き換えます。

schema.graphql
type Post @model @auth(rules: [{allow: public}]) {
  id: ID! @primaryKey
  postOwnerId: String! @index(name: "SortByCreatedAt", sortKeyFields: ["createdAt"], queryField: "listPostsSortByCreatedAt")
  question: String!
  createdAt: String!
  updatedAt: String
}

※Amplifyのv2に対応した記述に変更しました。

書き換えた後で、以下のコマンドでサーバに反映させます。コマンドはCloud9のターミナルから、Reactプロジェクト配下で行います。

amplify push -y

すでに存在するDynamoDBのテーブルを削除して、書き換え後のテーブルを適用する場合は以下のコマンドです。注意点は既存のテーブルが全て削除されるため、既存テーブルのデータを保持したい場合は以下コマンドを使用しないでください。

amplify push -y --allow-destructive-graphql-schema-update

用件にある以下の3つを満たすために、GraphQLのfilterとsortを使いました。

  • 最新の15件の話題に絞って表示する
  • 30分以内に投稿された話題に絞って表示する
  • 最新の投稿を最上位に表示する

@modelを付与した時に自動で生成される「createdAt」と「updatedAt」を利用して、filterとsortを行います。sort用のリゾルバで「listPostsSortByCreatedAt」を定義し、「createdAt」でsortします。
この部分の詳細な説明はApp.jsの方で行います。

App.jsの実装

タブの追加

タブを使うので以下のコマンドでReactプロジェクトにライブラリを追加します。Reactプロジェクトはyarnで統一しているので、yarnで追加します。

yarn add react-tabs

次にApp.jsにライブラリをインポートします

import { Tab, Tabs, TabList, TabPanel } from 'react-tabs';
import 'react-tabs/style/react-tabs.css';

「とにかく動かしたい!」という方は、ライブラリのインポート後に、App.jsの全体にあるコードをコピペして、見た目の修正をすれば、あとはcloud9のターミナルから「yarn start」で動くものが見れます。

話題投稿の処理

投稿した話題の文字列を「postedQuestion」に設定し、sort用の「createdAt」にフロントエンドの現在日時を設定します。フロントエンドの現在日時を設定するのは、取得処理でフロントエンドの日時を抽出条件にするためです。投稿の処理後に話題の文字列を空文字に設定します。

なお、話題を未入力で投稿しても何も処理をしないようにバリデーションチェックしています。

  createPost = async() => {

    // バリデーションチェック
    if (this.state.postedQuestion === '') {
      return
    }

    // 新規登録 mutation
    const createPostInput = {
      question: this.state.postedQuestion,
      postOwnerId: "validid",
      createdAt: new Date(),
    }

    // 登録処理
    try {
      await API.graphql(graphqlOperation(mutations.createPost, { input: createPostInput }))
      this.setState({ postedQuestion: "" })
    }
    catch (e) {
      console.log(e)
    }
  }

余談

postOwnerIdについては、用件のロジックには直接関係ありませんが、@keyのfieldsでPKとSKの制約があるため(fieldsの第一引数がPK、fieldsの第二引数がsort条件)、schemaで定義して条件抽出時に値を指定します。この点についてはカスタムリゾルバを勉強してから、不要なロジックがない綺麗な実装に修正します。
(「validid」という文字列を設定してますが、この文字列は判定条件や表示には利用しません。。設定しないとエラーになるから設定しているだけです。。「勉強不足だ〜〜〜」)

filterとsortで話題の取得処理を実装

先に記載した以下3つの用件を満たすための処理を実装します。

  • 最新の15件の話題に絞って表示する
  • 30分以内に投稿された話題に絞って表示する
  • 最新の投稿を最上位に表示する

投稿した話題を条件に絞って取得する処理は以下の通りです。

  getFilterdQuestion = async() => {

    // 30分前までの質問に絞り込み
    const postedMinutesAgo = 30
    let targetDate = new Date()
    targetDate = targetDate.setMinutes(targetDate.getMinutes() - postedMinutesAgo)
    let dateString = new Date(targetDate).toISOString()

    try {
      const filterObj = {
        and: [{
          question: { ne: "" },
          updatedAt: { ge: dateString }
        }]
      }
      // 表示する質問の件数
      const questionLimit = 15
      // 表示する質問の数を設定して、最新の質問を上に表示する
      let posts = await API.graphql({
        query: query.listPostsSortByCreatedAt,
        variables: {
          postOwnerId: "validid",
          sortDirection: "DESC",
          filter: filterObj,
          limit: questionLimit,
        }
      })
      let questionList = posts.data.listPostsSortByCreatedAt.items.filter((post) => {
        return this.checkQuestion(post)
      })
      // 質問が複数投稿されるまで抽選できないように制御
      if (questionList.length >= 2) {
        this.setState({
          isQuestionExist: false,
        })
      }
      this.setState({
        posts: questionList
      })
    }
    catch (e) {
      console.log(e)
    }
  }

重要な点はAPI.graphqlの引数に指定する「variables」の部分です。この詳細を説明していきます。
sortDirectionはschemaの@keyでfieldsの第二引数に指定したcreatedAtのsort条件になります。filterは指定した変数「filterObj」にある設定の「話題が空文字でなく、かつupdatedAtが現在時刻の30分以内」という条件を設定しています。limitは取得する件数の上限です。
(postOwnerIdは先に記載した通り、今回作るwebアプリの用件には直接関係ありません)

話題を抽選できるようになるのは話題が2件以上ある時なので、取得した件数でボタン表示用変数の判定をしています。

// 質問が複数投稿されるまで抽選できないように制御
if (questionList.length >= 2) {
    this.setState({
      isQuestionExist: false,
    })
}

以下の処理で、取得した全件を配列に代入して表示してますが、

this.setState({
    posts: questionList
})

カスタムリゾルバを勉強中なので、
現在の配列にある話題から30分を超過した話題を削除して、そのあとで、話題の抽出条件を現在の配列にない最新の話題、かつ30分以内に投稿された話題、かつ現在の配列数に加えても15を超過しない件数
としたかったのですが、できなくて残念です。。
同じ理由で、フロントエンド側で取得した話題配列を操作できるようにメソッドを用意しましたが、

  // フロント側でやるfilter
  checkQuestion(content) {
    return true
  }

実際にはtrue返却してるだけです。

話題投稿時のリアルタイム反映

mutationsのcreatePostをトリガーにして、現在の話題配列をリアルタイム反映(即時更新)しています。上記でも触れましたが、抽出条件を厳密に綺麗に実装できなかったのでsubscriptionでも話題を該当する条件に従って取得しています。

      API.graphql({
        query: subscriptions.onCreatePost,
      }).subscribe({
        next: (data) => {
          this.getFilterdQuestion()
        },
        error: (err) => {
          console.log("subscription error : ", err);
        }
      });

話題の抽選を実装

主題ではありませんが、簡単に触れておきます。
スタートボタンを押下すると一定間隔の乱数を生成処理を開始し、その乱数に基づいて話題配列のX番目にある話題を表示しています。スタートボタンを押下した場合は投稿できないように投稿ボタンを非活性にしています。
ストップボタンを押下すると一定間隔の乱数を生成処理を停止し、投稿ボタンを活性にします。

  start = () => {
    let ruoState = setInterval(() => {
      const maxLen = this.state.posts.length
      // 0〜maxLenの範囲でランダムな数値を作成
      var idx = Math.floor(Math.random() * maxLen)
      this.setState({ selectedQuestion: this.state.posts[idx].question })
    }, 80);
    this.setState({ nowDrawing: ruoState })
  }

  stop = () => {
    if (this.state.nowDrawing) {
      clearInterval(this.state.nowDrawing)
    }
    this.setState({ nowDrawing: false })
  }

ボタンの実装は以下の通りです。

<button className='selectButton' onClick={this.start} disabled={this.state.nowDrawing || this.state.isQuestionExist}>スタート</button>
<button className='selectButton' onClick={this.stop} disabled={!this.state.nowDrawing}>ストップ</button>

話題の表示

条件に該当する話題は取得処理で行なっているので、話題配列にある話題を順番に表示するだけです。

<div className='childA-block'>
        {this.state.posts.map((post,idx) => {
            return <div key={idx}><div className='questionTitle'>{post.question}</div></div>})
        }
</div>

App.jsの全体

App.jsの全体像は以下の通りです。

App.js
import { Component } from 'react';
import { Tab, Tabs, TabList, TabPanel } from 'react-tabs';
import 'react-tabs/style/react-tabs.css';
import './App.css';
import { API, graphqlOperation } from 'aws-amplify';
import * as query from './graphql/queries';
import * as subscriptions from './graphql/subscriptions';
import * as mutations from './graphql/mutations';

class App extends Component {

  state = {
    postedQuestion: "",
    selectedQuestion: "話題は何かな?",
    posts: [],
    nowDrawing: false,
    isQuestionExist: true,
  }

  async componentDidMount() {

    try {
      this.getFilterdQuestion()
      API.graphql({
        query: subscriptions.onCreatePost,
      }).subscribe({
        next: (data) => {
          this.getFilterdQuestion()
        },
        error: (err) => {
          console.log("subscription error : ", err);
        }
      });
    }
    catch (e) {
      console.log("error : ", e);
    }
  }

  // フロント側でやるfilter
  checkQuestion(content) {
    return true
  }

  getFilterdQuestion = async() => {

    // 30分前までの質問に絞り込み
    const postedMinutesAgo = 30
    let targetDate = new Date()
    targetDate = targetDate.setMinutes(targetDate.getMinutes() - postedMinutesAgo)
    let dateString = new Date(targetDate).toISOString()

    try {
      const filterObj = {
        and: [{
          question: { ne: "" },
          updatedAt: { ge: dateString }
        }]
      }
      // 表示する質問の件数
      const questionLimit = 15
      // 表示する質問の数を設定して、最新の質問を上に表示する
      let posts = await API.graphql({
        query: query.listPostsSortByCreatedAt,
        variables: {
          postOwnerId: "validid",
          sortDirection: "DESC",
          filter: filterObj,
          limit: questionLimit,
        }
      })
      let questionList = posts.data.listPostsSortByCreatedAt.items.filter((post) => {
        return this.checkQuestion(post)
      })
      // 質問が複数投稿されるまで抽選できないように制御
      if (questionList.length >= 2) {
        this.setState({
          isQuestionExist: false,
        })
      }
      this.setState({
        posts: questionList
      })
    }
    catch (e) {
      console.log(e)
    }
  }

  createPost = async() => {

    // バリデーションチェック
    if (this.state.postedQuestion === '') {
      return
    }

    // 新規登録 mutation
    const createPostInput = {
      question: this.state.postedQuestion,
      postOwnerId: "validid",
      createdAt: new Date(),
    }

    // 登録処理
    try {
      await API.graphql(graphqlOperation(mutations.createPost, { input: createPostInput }))
      this.setState({ postedQuestion: "" })
    }
    catch (e) {
      console.log(e)
    }
  }

  onChange = e => {
    this.setState({
      [e.target.name]: e.target.value
    })
  }

  start = () => {
    let ruoState = setInterval(() => {
      const maxLen = this.state.posts.length
      // 0〜maxLenの範囲でランダムな数値を作成
      var idx = Math.floor(Math.random() * maxLen)
      this.setState({ selectedQuestion: this.state.posts[idx].question })
    }, 80);
    this.setState({ nowDrawing: ruoState })
  }

  stop = () => {
    if (this.state.nowDrawing) {
      clearInterval(this.state.nowDrawing)
    }
    this.setState({ nowDrawing: false })
  }

  render() {
    return (
      <Tabs>
        <TabList>
          <Tab>話題を投稿</Tab>
          <Tab>話題を抽選</Tab>
        </TabList>
    
        <TabPanel className='tabPanel'>
          <div>
            <div className='questionTitle'>話題</div>
            <input className='questionInput' value={this.state.postedQuestion} name="postedQuestion" maxLength={20} onChange={this.onChange}></input>
            <button className='postButton' onClick={this.createPost} disabled={this.state.nowDrawing}>投稿</button>
          </div>
          <div className='childA-block'>
            {this.state.posts.map((post,idx) => {
              return <div key={idx}><div className='questionTitle'>{post.question}</div></div>})
            }
          </div>
        </TabPanel>
        <TabPanel className='tabPanel'>
          <div className='mainQuestion'>{this.state.selectedQuestion}</div>
          <div>
            <button className='selectButton' onClick={this.start} disabled={this.state.nowDrawing || this.state.isQuestionExist}>スタート</button>
            <button className='selectButton' onClick={this.stop} disabled={!this.state.nowDrawing}>ストップ</button>
          </div>
          <div className='parent-block'>
            <div className='childA-block'>
              {this.state.posts.map((post,idx) => {
                return <div key={idx}><div className='questionTitle'>{post.question}</div></div>})
              }
            </div>
          </div>
        </TabPanel>
      </Tabs>
    );
  }
}

export default App;

見た目の修正

主題ではないので、対象ファイルの該当部分か全体を列挙して詳細な説明はしません。

index.css
body {
  background-image: url("https://1.bp.blogspot.com/-MJ9kxWWrLg4/XLAcxVsOVCI/AAAAAAABSS0/lUn-55yy1wg182e8UZGGP5Xy8cwNrLYtgCLcBGAs/s800/bg_chiheisen_green.jpg");
  background-repeat: repeat-x;
  margin: 0;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
    'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

code {
  font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
    monospace;
}
public/index.html
-   <title>React App</title>
+   <title>聞きたいことは?</title>
App.css
.App {
  text-align: center;
  height: 100%; 

  background-position: center;
  background-repeat: no-repeat;
}

.box{
 display: inline-block;
 background-color:  #ccc;
}

.parent-block {
  position: relative;
  height: 600px;
  overflow: scroll;
}

.childA-block {
  position: relative;
  display: inline-block;
  vertical-align: top;
}

.mainQuestion {
  font-weight: bold;
  color: red;
  font-size: 65px;
  position: relative;
  display: inline-block;
}

.kanpaiImg {
  width: 60px;
}

.selectButton {
  font-size: 50px;
  margin-top: 10px;
  margin: 20px;
  border-radius: 40px;
}

.namelabel {
  font-size: 30px;
}

.questionInput {
  width: 600px;
  font-size: 30px;
  margin: 10px;
}

.postButton {
  border-radius: 20px;
  font-size: 25px;
}

.imgData {
  width: 500px;
  margin-right: 10px;
}

.questionTitle {
  font-size: 25px;
  font-weight: bold;
}

.tabPanel {
  text-align: center;
}

あとはcloud9のターミナルで

yarn start

すれば完成です。

感想

「とりあえず動けばOK!」であれば、この程度でも良いのですが、やはりカスタムリゾルバをちゃんと勉強して、「用件に沿った実装、不要なロジックがない実装」にしたいですね〜。

Discussion