車輪の二度漬け

railsを触っていましたが、最近はreactに興味あります。

Next.js + Vercel + TypeScript + Firebaseでアプリを作る。GraphQL事前準備編

0. 今回やること

前回の記事はこちら
今回は、GraphQLを使うための前準備をする。
やることとしては、Cloud Firestoreに入っているデータをGraphQLを使って返す、と言う感じ。

1. 事前準備

データの設定

まずはFirestoreの設定から。
と言っても、今回はまだデプロイしたりはしないので、どちらかと言うと開発用のデータの設定からやっていく。
今回使うデータは以下:

userProfiles: [
  {
    id: 1,
   bio: "hogehoge"
  },
  {
    id: 2,
   bio: "fugafuga"
  }
]

このデータを、Dockerコンテナの中で firebase emulators:start をして立ち上がるローカル用コンソール画面 http://localhost:4000Firestore の項目から入力。
その後、一度コンテナ内で

$ firebase emulators:export ./seed

をして、プロジェクトルートディレクトリ直下の /seedというディレクトリにこのデータをエクスポートしておく。
その後、package.jsonスクリプトにあるserve

"serve": "npm run build && firebase emulators:start --only functions"

から

"serve": "firebase emulators:start --import=../seed",

にしておく。
こうすることで、エミュレータ立ち上げ時に、エクスポートしたデータがインポートされる(npm run buildを無くした理由は後述)。
ただ、これだと手動でエクスポートする羽目になるので、前回の記事にも書いたこの方法をしたほうがいいかも。

<2020/11/26 追記>
Firebase Firestoreのエミュレーターを使ってローカルで開発する←こちらを読ませてもらったところ、--export-on-exitというすごく便利なoptionがあることがわかった。

/hogehoge # firebase emulators:start -h
Usage: firebase emulators:start [options]

start the local Firebase emulators

Options:

  ...中略...

  --import [dir]              import emulator data from a previous export (see emulators:export)
  --export-on-exit [dir]      automatically export emulator data (emulators:export) when the emulators make a clean exit (SIGINT), when no dir is provided the
                              location of --import [dir] is used
  -h, --help                  output usage information

とのことらしいので、このオプションもつけておけばよさそう。
感謝。
公式を見てもエミュレータの細かい使い方がいまいちわからなかったんだけど、-hやるのは大事だね。

Cloud Functionsで使うTypeScriptファイルの自動再コンパイル設定

やることとしては、

tsc --watch

を追加するだけなのだが、package.jsonスクリプト

"serve": "tsc --watch && firebase emulators:start --import=../seed"

という風にしても、tsc --watch と firebaseエミュレータの両方でターミナルを使うため、うまく行かない。
なので 、

"watch": "tsc --watch",
"serve": "firebase emulators:start --import=../seed",

という風にして、watchエミュレータを異なるターミナルで実行できるようにした。
ターミナルのタブを2つ使うのがめんどくさいけど、もっと良い方法があるのかな。

これで事前準備は終わり。

2. Apollo Serverの設定

次はApollo Serverの設定をしていく。
Apollo ServerはGraphQLを用いてのデータのやり取りをするためのサーバー。
Cloud Functions向けのライブラリも出てるので、これを使うことにした。
と言っても、今の段階では
Cloud Functions for Firebaseを使ってApollo Serverを構築する - 雑食日誌
Apollo ServerからFireStoreのデータを取得する - 雑食日誌
こちらの記事を参考にさせてもらった感じ。
まずは必要なライブラリのインストールから。

# npm i apollo-server-cloud-functions 

で、現時点でのfunctions/src/index.ts

const { ApolloServer, gql } = require('apollo-server-cloud-functions');

const typeDefs = gql`
  type UserProfile {
    id: Int!
    bio: String!
  }

  type Query {
    userProfiles: [UserProfile]!
  }
`;

const resolvers = {
  Query: {
    userProfiles: async () => {
      const snapshot = await db.collection('userProfiles').get()
      return snapshot.docs.map((doc) => doc.data())
    },
  },
}

const server = new ApolloServer({
  typeDefs,
  resolvers,
})

export const helloWorld = functions.https.onRequest(server.createHandler())

という感じ。
resolversは別ファイルに分けてもいいかも。
これでコンパイルが通ったら、http://localhost:5001/<エミュレータ立ち上げ時に出るpath>/helloWorldにPostmanなどを使ってPOST

query {
    userProfiles {
        id,
        bio
    }
}

のようなリクエストを投げて、設定したデータが返ってきたらOK。
この記事を書いてて気づいたけど、resolversは別ファイルにしたほうがよさそう。

3. GraphQLの設定

↑の例だと、gqlに渡すテンプレートリテラルの中でGraphQLのスキーマを設定しているので他のファイルに外出ししたい。
ちなみに、Apolloのサンプルコードも大体スキーマはテンプレートリテラルに直書きとなってることが多い。
ただ、これだとスキーマが増えたりした時に可読性が下がって萎えるので、今のうちからファイルを分けておくことにした。
色々ググってみたところ、GraphQL Toolsこの記事が役に立ちそうだったので、これでやってみる。

まずは必要なライブラリのインストールから。

# npm i graphql-tools

で、ディレクトリ構成はこんな感じにした:

.
└── /project root/
    └── /functions/
        ├── /src/
        │   └── index.ts // Cloud Functions用のファイル
        ├── /graphql/
        │   ├── /schemas/
        │   │   └── userProfile.gql
        │   └── /queries/
        │       └── userProfiles.gql
        └── /lib // index.tsのコンパイル後のファイルの格納先

各gqlファイルは以下:

# functions/graphql/schemas/userProfile.gql

type UserProfile {
  id: Int!
  bio: String!
}
# functions/graphql/queries/userProfiles.gql

type Query {
  userProfiles: [UserProfile]!
}

その後、gqlファイルを読み込むために functions/src/index.tsを以下のように変更:

# functions/src/index.ts

import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { ApolloServer } from 'apollo-server-cloud-functions'
import path from 'path'
import { mergeTypeDefs, loadFilesSync } from 'graphql-tools'

const typesArray = loadFilesSync(path.resolve(__dirname, '../graphql'), {
  recursive: true,
})

const typeDefs = mergeTypeDefs(typesArray)

admin.initializeApp()

const db = admin.firestore()

const resolvers = {
  Query: {
    userProfiles: async () => {
      const snapshot = await db.collection('userProfiles').get()
      return snapshot.docs.map((doc) => doc.data())
    },
  },
}

const server = new ApolloServer({
  typeDefs,
  resolvers,
})

export const helloWorld = functions.https.onRequest(server.createHandler())

これでテンプレートリテラルスキーマを書かなくても良くなった。

4. VSCodeの設定

最後に、VSCodeスキーマの定義をしやすくするために、ファイルをまたいでの補完ができるようにしておく。
GraphQLのスキーマ定義に役立つVSCode拡張機能をざっと見た限り、 https://marketplace.visualstudio.com/items?itemName=GraphQL.vscode-graphql:title
https://marketplace.visualstudio.com/items?itemName=kumar-harsh.graphql-for-vscode:title
の2つが主だった拡張機能の様子。
ただ、設定方法を見ると、graphql-for-vscodevscode-graphqlに比べて、watchman@playlyfe/gqlと言ったライブラリの事前インストールが必要というところが面倒に感じたので、vscode-graphqlを使うことにした。
なので、まずVSCodeでこの拡張機能をインストール後、ルートディレクトリ直下に.graphqlrc.ymlという設定ファイルを作成し、設定手順にあるとおりに以下の内容を記述する:

# .graphqlrc.yml

schema: ./functions/graphql/**/*.gql
documents: null

ちなみに、この設定ファイルは、JSON形式やjsファイルでも書けるようになっている。
内部でGraphQL Configを使っているようで、GraphQL Configのドキュメントにより設定ファイルの書き方の詳細が載っている。
その後、functions/graphql/queries/userProfiles.gqlUserProfileと入力して補完されればOK...となるはずだが、現時点(v0.3.12)では補完が効かない。
色々調べてみると、こういうissueが上がっており、要はconfigファイルschemaの項目にglobを使うとダメらしい。
なので、今はこの書き方をしても補完はされない。
リポジトリのREADMEには

Note: The primary maintainer @acao is on hiatus until December 2020

とあるので、解決まではもう少し待つことになりそう。
一応回避策として、

scehma:
  - functions/graphql/queries/userProfiles.gql
  - functions/graphql/schemas/userProfile.gql

というように列挙すれば補完が効くようになるが、これはすごーくめんどくさいので待った方がよさそう。
それかgraphql-for-vscodeを使うか。

5. 所感

流石にサンプルコードレベルじゃ勉強の意味が薄いなと思ってファイル分割とか開発の利便性あたりまで踏み込んでみたけど、ここまで手こずるとは思わなかった。
特にVSCodeの拡張の謎の不具合は本当に罠だったー。
でもまあ最終的に原因が分かったからよかった。
また、今回はリクエストが来るたびに全てのスキーマファイルを読み込むようにしてるけど、ここにあるように、本番では事前にファイルに書き出したスキーマをつかうようにしてもいいのかもなあ、と思った。
まあ、この辺は追々やって行こう。

次はいよいよGraphQLの踏み込んだ使い方とFirestoreのデータ設計に入って行こうと思う。

6. その他参考にしたサイト等

GraphQL | A query language for your API
Documentation Home - Apollo Basics - Apollo GraphQL Docs
Welcome | GraphQL Tools
Firestore エミュレータのデータをローカル環境で import/export する - Qiita

Next.js + Vercel + TypeScript + Firebaseでアプリを作る。環境構築編

Next.jsの勉強をしようと思い、せっかくなので自分の知らないところややったことないことのキャッチアップをしつつプリを作ってみることにした。
構成としては、VercelとFirebaseあたりを使う想定。
今回は環境構築だが、フロントとバックの疎通などはまだ未確認なのであしからず。
今回の環境構築はとりあえずDockerに載せて動かすというところがゴールなので、このままだと疎通できないかも。
必要に応じて後で修正する予定。

0. 前提:firebaseにプロジェクトを作成しておく

Firebaseに飛んでプロジェクトを作成後、Firestoreなどの使いたいサービスについても作成しておく。
ここで各サービスを作成しておかないと、あとでfirebase initをしてFirebaseの初期設定をする時に「そんなサービスないよ!」と怒られる。

1. Dockerの設定

今回のディレクトリ構成は

.
├── web/
│   ├── docker-compose.yml
│   ├── Dockerfile
│   └── Next.jsのコード諸々
└── app/
    ├── Dockerfile
    └── Firebaseのコード諸々

という感じにしようと思っている。
webにはNext.jsのコードを入れて、appにはFirebase関連のコードを入れて、webappはそれぞれ別のリポジトリで管理する想定。
docker-compose.ymlは以下:

version: '3.8'

services:
  web:
    container_name: xxxx
    build:
      context: ./
    volumes:
      - ./:/usr/src
    working_dir: /usr/src
    ports:
      - '3000:3000'
    depends_on:
      - app
    tty: true
  app:
    container_name: xxxx
    build:
      context: ../app
    volumes:
      - ../app:/usr/src
    ports:
      - '9005:9005' # firebase loginのために必要
      - '4000:4000' # firebase ui これはこっちで割り当てた(公式のデフォルトポート設定では4000だったが、ウィザードには出てこなかった)
      - '5001:5001' # Cloud Functions
      - '8080:8080' # Cloud Firestore
      - '9099:9099' # Firebase Auth
    working_dir: /usr/src
    tty: true

また、Firebaseをローカルで使うためにはfirebase-toolsJavaを入れておく必要があるので、appDockerfileにそれも記述しておく。

FROM node:14.15.0-alpine3.12

RUN apk update && \
    apk upgrade

RUN apk add openjdk8

RUN npm install -g firebase-tools

webについては今のところ特筆することはないが、webDockerfileは一応こんな感じ:

FROM node:14.15.0-alpine3.12

WORKDIR /usr/src

RUN apk update && \
    apk upgrade

あとで追加するかも。

ここまでしたら

$ docker-compose build
$docker-compose up -d

2. Next.jsの設定

まずはNext.jsの設定をしていく。

$ docker exec -it <webのコンテナ名> sh

webコンテナに入った後、

# npx create-next-app --example with-typescript-eslint-jest

として、Next.jsの初期設定を行う。
なお、今回はTypeScriptとeslint、Jestがセットになった初期設定を行っている。
Blog - Introducing Create Next App | Next.js←ここを見ると、初期設定には他にも色々と用意されているので、必要であれば見てみるといいかも。

ここまできたら

# yarn dev

http://localhost:3000にアクセスして、画面が表示されたらOK。
ちなみに、webで使っているnodeイメージには初めからyarnが入ってる様子。
yarnで立ち上げたのは、Next.jsの初期設定を行った後にコンソールに「yarn devで立ち上げて」というメッセージが表示されたからだが、npm run devでも立ち上がる。
npmに揃えた方がいいかもという気がしないでもない。

3. Firebase側の環境構築

$ docker exec -it <appのコンテナ名> sh

appのコンテナに入った後、

# firebase login

で、あらかじめ作っておいたfirebaseプロジェクトのGoogleアカウントでログイン。
ログインできたら、その後、

# firebase init

で、Firebase向けの設定を行う。
今回使うものは - Cloud Firestore - Storage - Firebase Auth - Emulator - Firebase UI(ローカル用のFirebaseコンソール) いくつか質問があるが、基本的にはyesだったりデフォルトで設定されている項目で大丈夫なはず。
ただ、Firebase UIはデフォルトのポート設定がなかったので4000を入力。
ちなみに、ここに各サービスのデフォルトポートがまとまっている。

ここまできたら、次はfirebase.jsonの編集。
firebase initの後にappディレクトリ直下にfirebase.jsonが以下のような中身であるはず:

{
  "firestore": {
    "rules": "firestore.rules",
    "indexes": "firestore.indexes.json"
  },
  "functions": {
    "predeploy": [
      "npm --prefix \"$RESOURCE_DIR\" run lint",
      "npm --prefix \"$RESOURCE_DIR\" run build"
    ]
  },
  "storage": {
    "rules": "storage.rules"
  },
  "emulators": {
    "auth": {
      "port": 9099
    },
    "functions": {
      "port": 5001
    },
    "firestore": {
      "port": 8080
    },
    "ui": {
      "enabled": true,
      "port": 4000
    }
  }
}

このままだと、localhostではアクセスできないのでemulators:配下の各項目に

    "auth": {
      "host": "0.0.0.0",
      "port": 9099
    },

と言うような感じで"host": "0.0.0.0",を追加。
これでlocalhostからアクセスできる。
最後に、

# firebase emulators:start

した後、http://localhost:4000でFirebaseのコンソールにアクセスできればOK。

最後に

Docker使った環境構築ってこういうかんじでいいのかよくわからないなあ。
あと、今回Firebaseを使うのは初めてなので、Firebaseを使う環境構築ってのもこれでいいのかわからない。
ちなみに、ざっと調べた限り、Firestore Local Emulator のデータを擬似的に永続化する←こういうのもあるみたいで、必要そうなら後でやろう。

何かあれば教えて下さい。

参考

https://firebase.google.com/docs/emulator-suite/install_and_configure
https://docs.docker.com/compose/compose-file/
https://qiita.com/kyhei_0727/items/343602fa184c84d593c4
https://a2c.tech/2020/08/nextjs-container/

Jestとreact-testing-libraryを使ったTypeScript + ReactのテストをCircleCIで回す

ReactのテストをCircleCIで自動化したかったので、ネットで色々調べつつ簡単に環境を構築してみた。

やったこと

  • サンプルのテストを作成(jest + react-testing-library + TypeScript)
  • CircleCIで自動テスト
  • テスト結果をSlackに通知

やった内容

CircleCIの設定は公式のドキュメントを読みつつ、実際に動かすテストについてはこのガイドの通りにやってみた(一部改変したりしてはいる)。

.circleci/config.yml

version: 2.1
jobs:
  test:
    docker:
      - image: circleci/node:10.15.3
    steps:
      - checkout
      - run: yarn
      - run: yarn test
workflows:
  version: 2.
  test:
    jobs:
      - test

job1個だけだとworkflowにする意味ってあんまりなさそうだなと思いつつ、workflowにまとめてる。
今回は使ってないが、workflowにはこういうjobの依存関係を決められるようなオプションもあるらしくて便利そうだなと思った。

Slack通知設定

公式ドキュメントのここをみながらすればOK

テストコード

テスト対象のソース

import React from 'react'
import { render, fireEvent } from '@testing-library/react'

import LoginForm, { Props } from '../LoginForm'

const renderLoginForm = (props: Partial<Props> = {}) => {
  const defaultProps: Props = {
    onPasswordChange() {
      return
    },
    onRememberChange() {
      return
    },
    onUsernameChange() {
      return
    },
    onSubmit() {
      return
    },
    shouldRemember: true,
  }
  return render(<LoginForm {...defaultProps} {...props} />)
}

describe('<LoginForm />', () => {
  test('should display a blank login form, with remember me checked by default', async () => {
    const { findByTestId } = renderLoginForm()

    const loginForm = await findByTestId('login-form')

    expect(loginForm).toHaveFormValues({
      username: '',
      password: '',
      remember: true,
    })
  })

  test('should allow entering a username', async () => {
    const onUsernameChange = jest.fn()
    const { findByTestId } = renderLoginForm({ onUsernameChange })
    const username = await findByTestId('username')

    fireEvent.change(username, { target: { value: 'test' } })

    expect(onUsernameChange).toHaveBeenCalledWith('test')
  })

  test('should allow entering a password', async () => {
    const onPasswordChange = jest.fn()
    const { findByTestId } = renderLoginForm({ onPasswordChange })
    const username = await findByTestId('password')

    fireEvent.change(username, { target: { value: 'test' } })

    expect(onPasswordChange).toHaveBeenCalledWith('test')
  })

  test('should allow toggling remember me', async () => {
    const onRememberChange = jest.fn()
    const { findByTestId } = renderLoginForm({
      onRememberChange,
      shouldRemember: false,
    })
    const remember = await findByTestId('remember')

    fireEvent.click(remember)

    expect(onRememberChange).toHaveBeenCalledWith(true)

    fireEvent.click(remember)

    expect(onRememberChange).toHaveBeenLastCalledWith(false)
  })

  test('should submit the form with username, password and remember', async () => {
    const onSubmit = jest.fn()
    const { container, getByTestId } = renderLoginForm({
      onSubmit,
      shouldRemember: false,
    })

    // こうすれば、querySelectorを使える
    const username = container.querySelector('input[name="username"]') as Element
    const password = await getByTestId('password')
    const remember = await getByTestId('remember')
    const submit = await getByTestId('submit')

    fireEvent.change(username, { target: { value: 'test' } })
    fireEvent.change(password, { target: { value: 'password' } })
    fireEvent.click(remember)
    fireEvent.click(submit)

    expect(onSubmit).toHaveBeenCalledWith('test', 'password', true)
  })
})

テストコード

import React from 'react'
import { render, fireEvent } from '@testing-library/react'

import LoginForm, { Props } from '../LoginForm'

const renderLoginForm = (props: Partial<Props> = {}) => {
  const defaultProps: Props = {
    onPasswordChange() {
      return
    },
    onRememberChange() {
      return
    },
    onUsernameChange() {
      return
    },
    onSubmit() {
      return
    },
    shouldRemember: true,
  }
  return render(<LoginForm {...defaultProps} {...props} />)
}

describe('<LoginForm />', () => {
  test('should display a blank login form, with remember me checked by default', async () => {
    const { findByTestId } = renderLoginForm()

    const loginForm = await findByTestId('login-form')

    expect(loginForm).toHaveFormValues({
      username: '',
      password: '',
      remember: true,
    })
  })

  test('should allow entering a username', async () => {
    const onUsernameChange = jest.fn()
    const { findByTestId } = renderLoginForm({ onUsernameChange })
    const username = await findByTestId('username')

    fireEvent.change(username, { target: { value: 'test' } })

    expect(onUsernameChange).toHaveBeenCalledWith('test')
  })

  test('should allow entering a password', async () => {
    const onPasswordChange = jest.fn()
    const { findByTestId } = renderLoginForm({ onPasswordChange })
    const username = await findByTestId('password')

    fireEvent.change(username, { target: { value: 'test' } })

    expect(onPasswordChange).toHaveBeenCalledWith('test')
  })

  test('should allow toggling remember me', async () => {
    const onRememberChange = jest.fn()
    const { findByTestId } = renderLoginForm({
      onRememberChange,
      shouldRemember: false,
    })
    const remember = await findByTestId('remember')

    fireEvent.click(remember)

    expect(onRememberChange).toHaveBeenCalledWith(true)

    fireEvent.click(remember)

    expect(onRememberChange).toHaveBeenLastCalledWith(false)
  })

  test('should submit the form with username, password and remember', async () => {
    const onSubmit = jest.fn()
    const { container, getByTestId } = renderLoginForm({
      onSubmit,
      shouldRemember: false,
    })

    // こうすれば、querySelectorを使える
    const username = container.querySelector('input[name="username"]') as Element
    const password = await getByTestId('password')
    const remember = await getByTestId('remember')
    const submit = await getByTestId('submit')

    fireEvent.change(username, { target: { value: 'test' } })
    fireEvent.change(password, { target: { value: 'password' } })
    fireEvent.click(remember)
    fireEvent.click(submit)

    expect(onSubmit).toHaveBeenCalledWith('test', 'password', true)
  })
})

このガイドでは、各inputに埋め込んだdata-testidの値で各inputを特定しているが、個人的にはこれがすごくめんどくさかった。
コード書いてる時にテストのためだけの属性を書くのは絶対忘れる。
なので、代わりにcontainer を使って、各input要素のname属性をquerySelectorで引っ掛けられるようにした。
個人的にはこっちのほうが実装が楽になりそうな気がする。

所感

  • config.ymlの書き方はもっといい方法があるかもしれないのでこれから研究
  • フリープランでやってるから有料プランではもっと色々できるかもしれない
  • 以前は業務でEnzymeを使っていたが、react-testing-libraryも結構いいなあ。

【WIP】React + TypeScriptでOAuth認証【修正予定】

ブログのネタにと思って、ここ最近はずっと私生活でいろいろあったりして、なかなか進められていなかったReactでOAuth認証を行うサンプルコードをようやく仕上げた。
github.com

着手がブツ切りになったせいで、コードがぐちゃぐちゃしてたり、いたるところにデバッグ用のconsole.logがあったりしてマジでやばいので後で修正するつもり。
やっぱりこういうのは短期決戦で一気に終わらせるのが一番いいよなあ。

RailsのviewにReactを使ったのでメモ(React + TypeScript + webpacker)

最近仕事でRailsで新規に作るviewにReactを使うということをやったので、それについて書いておく。

前提

  • これまではSprockets + CoffeeScriptで画面を作成していた
  • React + TypeScriptで新規画面を作りたい
  • 実際に画面を作るのはまだまだ先の話なので、現段階ではまずfeasibilityのチェックを優先してほしい
  • 実際に僕が実装をするのではなくReactやTypeScriptに明るくない別の同僚が実装をする可能性もある

やったこと

  • webpackerの導入
  • RailsAPI用コントローラーの作成
  • React + TypeScriptで書く

webpackerの導入

Reactが動く環境をどうやって導入するかをまず悩んだ。
個人的にはRailsがフロントエンドの管理まですることがあまり好きではないため、webpackを使ってReactの動作環境をつくろうと思っていた。 だが前提に書いたように、実際には僕以外の人間が実装を行う可能性があるため、webpackの設定を一から作っていかなければならないwebpackよりも、webpackをラップしたgemであるwebpackerを使うことにした。

webpackerインストール後の手順としては以下の通り。
フロントで使うパッケージは個別にインストールしてもいいと思うが、土台作りくらいはwebpackerのみでできるっぽい。

# webpackerの初回インストール
$ bundle exec rails webpacker:install

# React向けの設定
$ bundle exec rails webpacker:install:react

# TypeScript向けの設定
$ bundle exec rails webpacker:install:typescript

# React用の型定義追加
$ yarn add @types/react @types/react-dom

ちなみに、この記事を書いてる中で気づいたが、react-railsreact_on_railsっていうgemもあるらしい。これでもよかったかも。

RailsAPI用コントローラーの作成

これはよくあるrender :jsonでOK

React + TypeScriptで書く

ここからが本題。
ここに書いたのがベストプラクティスかは分からないが、参考になれば嬉しいので書いておく。

ディレクトリ構成

- /app
  - /javascripts
    - /packs
      - /entries
        - /someController
          - SomeControllerIndexEntry.tsx
          - SomeControllerShowEntry.tsx
    - /react
      - /someController
        - /pages
          - SomeControllerIndex.tsx
          - SomeControllerShow.tsx
      - /comp

まず、app/javascripts/packs/entries/someController直下には、各ページで使うためのReactファイルを配置する。
これはエントリーファイルなので、中身的には/app/javascripts/react/somecontroller/pagesに配置したファイルを呼び出すだけ。
/app/javascripts/react/somecontroller/pages直下には、そのページを構成するためのコンポーネントを書いたファイルを置く。
ページを構成する部品となる各コンポーネント/app/javascripts/react/somecontroller/componentsに置く。

あとは、この構成でビルドなどをするためにconfig/webpacker.ymlsource_entry_pathpacks/entriesにする。
こうすれば、

<%= javascript_pack_tag `someController/SomeControllerIndexEntry`%>

みたいにして、app/javascripts/packs/entries以下のディレクトリ構成通りに呼び出せるようになる。

この構成だと、./bin/webpackをしてビルドをすればpublic/packs/js/someControllers配下にバンドルされた各ページごとのファイルが出力されるはず。
というか、開発環境でもwebpack-dev-serverを起動してない && public〜配下にバンドル済のファイルがないという場合にはwebpacker側がバンドルをするっぽいね。

csrf_tokenについて

今回はPOST処理も必要だったので、JSでよく使われてるFormDataを使ってPOSTをしようと思ったところcsrf tokenがないからPOSTを受け付けられないという旨のエラーが出た。
Railsのviewのmetaタグにはデフォルトで

<meta name="csrf-param" content="authenticity_token" /> 
<meta name="csrf-token" content="csrfToken" />

というものがあるので、

const csrfParam = (document.querySelector('meta[name="csrf-param"]') as HTMLMetaElement).content
const csrfToken = (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement).content

こういう風に取って、

const form = new FormData()
form.append(csrfParam, csrfToken)

というようにしてformに混ぜ込めば大丈夫。

LoLで出会ったプレイヤーを忘れないアプリを作った

ちょっと前にReact勉強のために作り始めて、その後、社内で勉強会のネタに使ったあとそのまま放置していたが、コードだけ整えて一旦公開することにした。

作ったもの

スラム街として有名なLeague of Legends(LoL)で出会ったステキなプレイヤーをメモとともに記録できるアプリ。

GitHub - Ryu-u/remember-that-guy

これから追加したい機能

  • 本番対応
  • 認証機能を追加して多人数対応
  • 例外処理

今回の学び

react-exhaustive-depsのwarning

副作用フックの利用法 – React useEffectについてのこのセクションの補足に

もしも副作用とそのクリーンアップを 1 度だけ(マウント時とアンマウント時にのみ)実行したいという場合、空の配列 ([]) を第 2 引数として渡すことができます。

とあるので、てっきりuseCallbackの第2引数についても同じだと思ってたから、初回以降変わる必要のない値についてはuseCallbackの第2引数に[]を渡していた。
ただ、そうすると、react-exhaustive-deps

React Hook React.useCallback has a missing dependency: 'setIsModalOpen'. Either include it or remove the dependency array  react-hooks/exhaustive-deps

というwarningを吐くのでおかしいなと思ってたんだけど、リファレンスをよく読んだら、useCallbackの補足

依存する値の配列はコールバックに引数として渡されるわけではありません。しかし概念としては、この記法はコールバックの引数が何なのかを表現しています。コールバックの内部で参照されているすべての値は依存の配列内にも現れるべきです

と書いてあった。
useEffectuseCallbackでは第2引数の意味合いが少し違うのね。

ちなみに、useCallbackを使ったコードは https://github.com/Ryu-u/remember-that-guy/blob/master/src/components/past-games/PastGames.tsx#L21 https://github.com/Ryu-u/remember-that-guy/blob/master/src/components/past-games/DescriptionModal.tsx#L22
この2箇所なんだけど、今考えてみるとどちらもuseCallbackを使う必要がなかった...。
あとで修正しておこう。

フロントエンドとバックエンドで使用パッケージを共有する

正直、これはSPAの本来の用途とはズレているはずであまりいい方法ではないと思う。 ただ、今回はcreate-react-appとExpressをTypeScriptを使って開発する際に、型をフロントエンドとバックエンドで共有したり、そもそもマイクロサービス的に複数のサーバーを組み合わせたりしないのでnode_modulesが2つに分かれるのが嫌だなと思って、使用パッケージを共有することにした。

(此処にVSCodeのキャプチャ)

この構成にするために工夫するところは3つある。 まずはpackage.json

  "scripts": {
    "frontend-start": "BROWSER=firefox react-scripts start",
    "backend-start": "nodemon",
    "start": "ts-node --project tsconfig.backend.json backend/index.ts",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject",
    "heroku-postbuild": "yarn && yarn run build "
  }

frontend-startwebpack-dev-serverの立ち上げ。 backend-startでnodemonを使って、バックエンドのソースを更新した時に自動でコンパイルを再実行させ流ような形でExpress起動(後述するがnodemonではyarn startを実行している)。 他はcreate-react-appが作るものとかHeroku向けのコマンドとか。 Heroku向けのコマンドはまだ動かしてないので間違ってるかも。

次に、nodemonの設定はこういう感じ。

{
  "watch": ["backend"],
  "ext": "ts",
  "exec": "yarn run ts-node --project tsconfig.backend.json -r dotenv/config backend/index.ts"
}

バックエンド用のファイルを格納しているbackend/ディレクトリを監視し、その中のソースが変更されたら再コンパイルする感じにしてる。

そして最後に、tsconfig.jsonの設定。
今回はルートディレクトリにフロントエンド用のtsconfig.jsonとバックエンド用のtsconfig.backend.jsonを作成した。
tsconfig.jsonを分けた理由は、フロントエンドとバックエンドでコンパイル後のJSのバージョンをフロントエンド→ES5、バックエンド→ES6という形にしたかったから。 それぞれのtsconfig.jsonはこちら

# フロントエンド用tsconfig.json
{
  "compilerOptions": {
    "target": "es5",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "module": "esnext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "preserve"
  },
  "include": ["src", "src/types"]
}
# バックエンド用tsconfig.backend.json
{
  "compilerOptions": {
    "allowSyntheticDefaultImports": true,
    "module": "commonjs",
    "esModuleInterop": true,
    "target": "es6",
    "noImplicitAny": true,
    "moduleResolution": "node",
    "sourceMap": true,
    "outDir": "dist",
    "baseUrl": ".",
    "paths": {
      "*": ["node_modules/*", "src/types/*"]
    }
  },
  "include": ["backend/**/*"]
}

正直重要なのがimportを使うためのallowSyntheticDefaultImportsesModuleInteropくらいしかわかってないので、このあたりはまたどこかのタイミングでさいきょうのtsconfig.jsonを書きたい。

所感

はじめは認証機能を追加するつもりはなく勉強だし一人用でいいやと思っていたのだが、思いのほか簡単に作れてしまったので、認証機能を追加しようと考え直した。
で、前から少し気になってたReactでのOAuth認証をやろうとしたところ、これが全くうまくいかない...
色々ググってみて紹介されているやり方を片っ端から試してみたが全然ダメ。
なので、これは後でまたチャレンジすることにして、コードだけ整えつつ今の状態で公開して一区切りつけることにした。
ReactでのOAuth認証は後でまた試して記事を書きたい。
あと、最近はLoLをやらなくなってしまった。

Expressの公式ドキュメントを読んでみた

expressjs.com

ちなみに、日本語版はこっち⇨ Express - Node.js Web アプリケーション・フレームワーク

僕の今いる会社はRailsでの開発がメインなのでバックエンドはほとんどRailsを使っている。
だが、最近はRailsは確かに便利だと思いつつも、Railsのなんでもよしなにやってくれるところがあまり好きではなくなっていってる。
Rails wayに載ってる限りは便利だが、そこから外れると一気にめんどくさくなるのも微妙。

そう思ってる時にExpressを使ってみたところ、ExpressはRailsと比べて本当にシンプルなので一気に好きになってしまった。
それに、これまでRailsが気を利かせていい感じにやってくれてたおかげであまり意識しなかったような基本的なところもExpressを使うことで学べたりするのも大きい。
なので、Expressの公式ドキュメントを読んでみた。

middleware

expressjs.com RailsからExpress入って大きく違うのは、middlewareをどれだけ意識するかってところだと思う。
Railsだとこの辺りはそんなに意識することがなくて、基本的にはビジネスロジックに集中できるけど、Expressはこの辺りも全部自分で逐一設定しないといけない。
Railsだとcookieは簡単に使えるけど、Expressだとパッケージをインストールするところから始めないといけないんだよなあ。

ルーティング

expressjs.com ルーティングはmiddlewareほどRailsと変わりはないが、ただ、逐一自分で設定しないといけないというところはRailsと違う。 ルーティングのつなぎこみは、なんとなくgrapeに似ているような気がしたけどどうなんだろう。
というか、grapeってまだ開発続いててビックリした。
Rails4時代のgemってイメージがあった。

今回はこんな感じかな。
あとはまた別でベストプラクティスやアンチパターンを読んでいこう。