React + MUI DataGrid 用に作成した Custom Operator を、Jest + jest-mock-extended でテストしてみた

以前、 MUI DataGrid の Custom Operator を作りました。
React + MUI のDataGridにて、ある列が複数の日付を持つデータに対し、valueFormatter・sortComparator・filterModelを使って表示・ソート・フィルタしてみた - メモ的な思考的な

 
この時に作成した Custom Operator の getApplyFilterFn に対して Jest でテストコードを書こうと思ったところ、いろいろ悩んだためメモを残します。

 
目次

 

環境

  • React.js 17.0.2
  • React Router 6.0.1
  • @mui/x-data-grid 5.0.1
  • date-fns 2.26.0
  • TypeScript 4.5.2
  • Vite.js 2.6.14
  • Jest 27.3.1
  • jest-mock-extended 2.0.4

 
なお、以前の記事後、 @mui/x-data-grid が正式バージョンの 5.0.1 になったため、バージョンアップをしておきます。

また、以前のコードで date-fns の import が

import isSameDay from 'date-fns/isSameDay'

となっている部分があったため、今回作成するテストコードを実行すると

TypeError: (0 , isSameDay_1.default) is not a function

というエラーになってしまいます。

stackoverflowなどによると

  • import方法を変える
    • import {isSameDay} from 'date-fns'
  • tsconfig.json"esModuleInterop": false から "esModuleInterop": true へ変更する

のどちらかを行えば良いようです。

そこで今回は前者の import 方法を変更しておきます。

 

Jest + ts-jest のセットアップ

前回のコードには Jest をインストールしていなかったため、追加します。

Jest まわりをインストールします。

なお、Transformer には色々ありますが、今回は ts-jest を使用します。

# インストール
% yarn add -D jest @types/jest ts-jest

# 初期設定
% jest --init                          

The following questions will help Jest to create a suitable configuration for your project

✔ Would you like to use Jest when running "test" script in "package.json"? … yes
✔ Would you like to use Typescript for the configuration file? … no
✔ Choose the test environment that will be used for testing › node
✔ Do you want Jest to add coverage reports? … yes
✔ Which provider should be used to instrument code for coverage? › v8
✔ Automatically clear mock calls and instances between every test? … no

✏️  Modified path/to/react_mui_with_vite/package.json

📝  Configuration file created at path/to/react_mui_with_vite/jest.config.js

 
生成された jest.config.js に以下を追記します。

module.exports = {
  moduleNameMapper: {
    '^@/(.+)': '<rootDir>/src/$1'
  },

  roots: ['<rootDir>/src'],

  testPathIgnorePatterns: ['/node_modules/'],

  transform: {
    '^.+\\.(ts|tsx)$': 'ts-jest'
  }
}

 

テスト方法を考える

getApplyFilterFn

getApplyFilterFn: (filterItem: GridFilterItem) => {
  if (!filterItem.columnField || !filterItem.value || !filterItem.operatorValue) {
    return null
  }

  return (params: GridCellParams): boolean => {
    if (!params.value || !Array.isArray(params.value)) {
      return false
    }

    return params.value.filter((v) => isSameDay(v, toFilterValue(filterItem))).length > 0
  }
},

と定義していました。

そのため、テストコードは

  • GridFilterItem 型の引数を渡して getApplyFilterFn() を呼ぶ
  • 関数が戻ってくるので、 GridCellParams 型の引数を渡してその関数を呼ぶ
  • 戻り値の boolean を検証する

とすれば良さそうでした。

 
型定義を見てみると、 GridFilterItem にはプロパティが4つありました。
https://github.com/mui-org/material-ui-x/blob/v5.0.1/packages/grid/modules/grid/models/gridFilterItem.ts

一方、 GridCellParams にはプロパティや関数が数多くありました。テストで使うものは GridCellParams.value だけですが、 interface の制約に従うと他にも実装が必要そうでした。
https://github.com/mui-org/material-ui-x/blob/v5.0.1/packages/grid/modules/grid/models/params/gridCellParams.ts

 
そこで今回は、モックによるテストコードを考えることにしました。

 

ダメだったモックの方法

Jest では、標準のモックがいくつも用意されています。

 
まずはモック関数 jest.fn() を使ってみることにしました。

jest.fn()value だけ値を返すように

const m = jest.fn<GridCellParams, []>().mockImplementation(() => {
  return {
    value: [new Date(2021, 10, 27)]
  }
})

と使ってみたところ

TS2345: Argument of type '() => { value: Date; }' is not assignable to parameter of type '() => GridCellParams<any, any, any>'.   Type '{ value: Date; }' is missing the following properties from type 'GridCellParams<any, any, any>': id, field, formattedValue, row, and 6 more.

とエラーになりました。 value 以外にも id などの実装が必要そうでした。

 
続いて jest.spyOn()

const n = jest.spyOn(GridCellParams, 'value').mockReturnValue('')

としたところ、

TS2693: 'GridCellParams' only refers to a type, but is being used as a value here.

というエラーになりました。

公式ドキュメントによると、第1引数は object なので interface は渡せないようです。
https://jestjs.io/ja/docs/jest-object#jestspyonobject-methodname

 
他に Jest の標準で使えそうなものを探しましたが、見当たりませんでした。

もし使えそうなものをご存じの方がいれば、教えていただけるとありがたいです。

 

jest-mock-extended でモックを実装すればOK

Jest で interface をモックする方法を調べたところ、以下の stackoverflow に出会いました。
mocking - Mock a typescript interface with jest - Stack Overflow

そこでは jest-mock-extended が紹介されていました。
https://github.com/marchaos/jest-mock-extended

READMEを読んだところ、 interface をモックしている例が記載されていため、試してみることにしました。

 
まずは jest-mock-extended をインストールします。

% yarn add -D jest-mock-extended

 
続いてテストコードを書いていきます。

まずは GridCellParams のモックを作成します。value が値を返すように定義し、他は特に定義しません。

const cellParams = mock<GridCellParams>()
cellParams.value = [new Date(2021, 10, 27), new Date(2021, 11, 28), new Date(2021, 11, 29)]

 
次に GridFilterItem のモックも作成します。 getApplyFilterFn の中で値をチェックしているプロパティについては、適当な値をセットします。

なお、フィルター用の値 value には '2021-11-27' を指定しておきます。

const filterItem = mock<GridFilterItem>()
filterItem.columnField = 'a'
filterItem.value = '2021-11-27'
filterItem.operatorValue = 'is'

 
あとは検証です。

まずは getApplyFilterFn() を実行します。

const fn = isOperator.getApplyFilterFn(filterItem)

getApplyFilterFn() の戻り値は null の可能性もありますが、このテストケースでは null にはならないはずです。

そのため、

expect(fn).not.toBeNull()

という検証を追加します。

 
次に、戻り値の関数 fn を実行しますが、

fn(cellParams)

とすると

TS2721: Cannot invoke an object which is possibly 'null'.

になってしまいます。

そこで、オプショナルチェーンを使うことでエラーとならないようにします。
オプショナルチェーン (?.) - JavaScript | MDN

const actual = fn?.(cellParams)

 
あとは検証して終わりです。

expect(actual).toBe(true)

 
テストコードの全体像は以下のとおりです。

念のため、 false となる条件も検証しています。

import {mock} from 'jest-mock-extended'
import {GridCellParams, GridFilterItem} from '@mui/x-data-grid'
import {isOperator} from '@/components/functions/datagrid/operators'

describe('is operator test', () => {
  const cellParams = mock<GridCellParams>()
  cellParams.value = [new Date(2021, 10, 27), new Date(2021, 11, 28), new Date(2021, 11, 29)]

  describe('フィルタの値がセルの値と一致する場合', () => {
    const filterItem = mock<GridFilterItem>()
    filterItem.columnField = 'a'
    filterItem.value = '2021-11-27'
    filterItem.operatorValue = 'is'

    it('trueを返す', () => {
      const fn = isOperator.getApplyFilterFn(filterItem)

      // nullの可能性があるため、
      //   ・nullではないことを確認
      //   ・オプショナルチェーン (?.) で関数を実行
      // とする
      expect(fn).not.toBeNull()

      const actual = fn?.(cellParams)
      expect(actual).toBe(true)
    })
  })

  describe('フィルタの値がセルの値と一致しない場合', () => {
    const filterItem = mock<GridFilterItem>()
    filterItem.columnField = 'a'
    filterItem.value = '2021-11-30'
    filterItem.operatorValue = 'is'

    it('falseを返す', () => {
      const fn = isOperator.getApplyFilterFn(filterItem)
      expect(fn).not.toBeNull()

      const actual = fn?.(cellParams)
      expect(actual).toBe(false)
    })
  })
})

 
テストを実行するとパスしました。

% yarn test
yarn run v1.22.11
$ jest
 PASS  src/tests/components/functions/datagrid/operators.test.ts (5.997 s)
  is operator test
    フィルタの値がセルの値と一致する場合
      ✓ trueを返す (4 ms)
    フィルタの値がセルの値と一致しない場合
      ✓ falseを返す
...
Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total

 

ソースコード

Github に上げました。
https://github.com/thinkAmi-sandbox/react_mui_with_vite-sample

今回のPRはこちらです。
https://github.com/thinkAmi-sandbox/react_mui_with_vite-sample/pull/2

Ruby の WebMock では、クエリ文字列も考慮して stub_request する

外部サイトへのアクセスが発生する、 Railsアプリがあるとします。

このRailsアプリに対するテストコードでは、外部サイトへのアクセスが発生しないよう、 WebMock を使うのが便利です。
bblimke/webmock: Library for stubbing and setting expectations on HTTP requests in Ruby.

 
ただ、

stub_request(:get, 'https://example.com').to_return(status: 200)

のようにモックしたところ

WebMock::NetConnectNotAllowedError: Real HTTP connections are disabled. Unregistered request: GET https://example.com?foo=bar

というエラーが発生したため、メモを残します。

 
目次

 

環境

 

原因

エラーメッセージにもある通り、WebMockではクエリ文字列も考慮して stub_request する必要があるようです。

 
例えば、RailsアプリのControllerに外部APIを呼ぶ処理があるとします*1

ここで、外部APIは以下のFake APIを使うとします。
JSONPlaceholder - Free Fake REST API

module Api
  class PostsController < ApplicationController
    def index
      # 無意味なリクエストだけど、本来は受け取ったデータを元にあれこれする
      # See https://jsonplaceholder.typicode.com/
      connection = Faraday.new(url: 'https://jsonplaceholder.typicode.com')
      _ = connection.get '/comments?postId=1'

      render json: { data: 'ok' }
    end
  end
end

 
このControllerに対し、routesにて

Rails.application.routes.draw do
  namespace 'api' do
    get 'posts', to: 'posts#index'
  end
end

と設定されていたとします。

 
そんな中、テストコードで WebMock + RSpec を使うため、

% bin/rails generate rspec:install

で生成した spec/rails_helper.rb

require 'webmock/rspec'

と設定し、 spec/request/api/posts_spec.rb

require 'rails_helper'

RSpec.describe 'Post API', type: :request do
  describe 'GET /api/posts' do
    context 'stub失敗' do
      before do
        stub_request(:get, 'https://jsonplaceholder.typicode.com/comments').to_return(status: 200)
      end

      it 'リクエストが成功すること' do
        get '/api/posts'
        expect(response).to have_http_status(200)
      end
    end
  end
end

というテストコードを書くと、冒頭のようなエラーメッセージが表示されます。

WebMock::NetConnectNotAllowedError: Real HTTP connections are disabled. Unregistered request: GET https://jsonplaceholder.typicode.com/comments?postId=1 with headers {'Accept'=>'*/*', 'Accept-Encoding'=>'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', 'User-Agent'=>'Faraday v1.8.0'}

You can stub this request with the following snippet:

stub_request(:get, "https://jsonplaceholder.typicode.com/comments?postId=1").
  with(
    headers: {
      'Accept'=>'*/*',
      'Accept-Encoding'=>'gzip;q=1.0,deflate;q=0.6,identity;q=0.3',
      'User-Agent'=>'Faraday v1.8.0'
    }).
  to_return(status: 200, body: "", headers: {})

registered request stubs:

stub_request(:get, "https://jsonplaceholder.typicode.com/comments")

 

対応

stub_requestのURLにクエリ文字列を含める

エラーメッセージの

You can stub this request with the following snippet

の snippet をそのまま使ってもよいのですが、テストをパスさせるだけならば、 URL にクエリ文字列を含めるだけでも大丈夫です。

context 'URLにクエリ文字列を含める' do
  before do
    stub_request(:get, 'https://jsonplaceholder.typicode.com/comments?postId=1').to_return(status: 200)
  end

  it 'リクエストが成功すること' do
    get '/api/posts'
    expect(response).to have_http_status(200)
  end
end

 

stub_request + with でクエリ文字列用のハッシュを渡す

上記のように、クエリ文字列を含んだURLを stub_request に渡すのもよいのですが、クエリ文字列の整形が大変です。

公式ドキュメントによると、 with(query: {}) のような形式でクエリ文字列を渡すこともできるようです。
https://github.com/bblimke/webmock#matching-query-params-using-hash

context 'withでクエリ文字列を指定' do
  before do
    stub_request(:get, 'https://jsonplaceholder.typicode.com/comments')
      .with(
        query: {
          'postId' => 1
        }
      )
      .to_return(status: 200)
  end

  it 'リクエストが成功すること' do
    get '/api/posts'
    expect(response).to have_http_status(200)
  end
end

 

stub_request に正規表現を渡す

今回の Controller では直接Faradayを使っていました。

ただ、場合によっては、Faraday などをラップしたクライアントが存在し、そのクライアントの中で動的にクエリ文字列を生成することもあるかもしれません。

WebMockでは、 stub_request()正規表現を渡すことで、正規表現にマッチしたURLにリクエストが飛ぶ時はモックしてくれるようです。
https://github.com/bblimke/webmock#matching-uris-using-regular-expressions

context '正規表現でstub' do
  before do
    stub_request(:get, %r{jsonplaceholder.typicode.com})
      .to_return(status: 200)
  end

  it 'リクエストが成功すること' do
    get '/api/posts'
    expect(response).to have_http_status(200)
  end
end

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/rails_webmock-sample

*1:「Controllerには外部APIを呼ぶ処理を書くべきでない」などがあるかもしれませんが、今回は説明を簡単にするために書いています

React + MUI のDataGridにて、ある列が複数の日付を持つデータに対し、valueFormatter・sortComparator・filterModelを使って表示・ソート・フィルタしてみた

React の MUI (旧 Material UI) では、コンポーネントとして DataGrid が提供されていることから、グリッド表示したい時に便利です。
React Data Grid component - MUI

 
そんな中

const rows = [
  {
    id: 1,
    name: 'シナノドルチェ',
    purchaseDate: [new Date(2021, 8, 20)]
  },
  {
    id: 2,
    name: '秋映',
    purchaseDate: [new Date(2021, 9, 20), new Date(2021, 10, 10)]
  },
  {
    id: 3,
    name: 'シナノゴールド',
    purchaseDate: [new Date(2021, 9, 15), new Date(2021, 10, 10), new Date(2021, 10, 20)]
  },
  {
    id: 4,
    name: 'ふじ',
    purchaseDate: [null]
  }
]

な感じで、 purchaseDate に複数の日付を持つデータを MUI のDataGridに表示させたいことがありました。

そこで、色々試したときのメモを残します。

 
目次

   

環境

  • React.js 17.0.2
  • React Router 6.0.1
  • @mui/core 5.0.0-alpha.53
  • @mui/x-data-grid 5.0.0-beta.7
  • date-fns 2.25.0
  • TypeScript 4.4.4
  • Vite.js 2.6.13

 

やることやらないこと

やること

 

やらないこと

  • Reactでの適切なディレクトリ構造で作ること
  • CSSをいい感じに調整すること

 

Vite.jsでReact環境を構築

今回は react_mui_with_vite という名前のprojectで、TypeScriptを使って実装してきます。

% yarn create vite
yarn create v1.22.11
[1/4] 🔍  Resolving packages...
[2/4] 🚚  Fetching packages...
[3/4] 🔗  Linking dependencies...
[4/4] 🔨  Building fresh packages...
success Installed "create-vite@2.6.6" with binaries:
      - create-vite
      - cva
✔ Project name: … react_mui_with_vite
✔ Select a framework: › react
✔ Select a variant: › react-ts

Scaffolding project in path/to/dir

Done. Now run:

  cd react_mui_with_vite
  yarn
  yarn dev

✨  Done in 23.09s.

 
この記事では段階を追って動作の確認をしていきます。

そのため、各段階は別ページとして用意しておいたほうが分かりやすいことから、React Routerで各段階のページへ遷移できるようにします。
React Router | Installation

% yarn add history@5 react-router-dom@6

 

開発環境で path alias (@) を使えるようにする

このままDataGridの実装へ進んでもよいのですが、開発でよく使う機能を入れておきます。

まずはVite.js環境でも path alias を使えるようにします。今回は src 以下を @ で表せるようにします。

 

tsconfig.json

baseUrlpaths を指定します。

{
  "compilerOptions": {
    // ...
    "baseUrl": "./",
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}

 

vite.config.ts

Node.jsの path を import しつつ、 resolve.alias を指定します。

まず、 path を使うために Node.jsの型情報を入れます。

% yarn add -D @types/node

 
続いて、 vite.config.ts に設定を行います。

なお、import は import * as path from 'path' とします。
TypeScript のモジュールのインポートには import を使う|まくろぐ

合わせて、開発サーバの起動ポートも 7110 に変更しておきます。
server.port - Configuring Vite | Vite

import {defineConfig} from 'vite'
import react from '@vitejs/plugin-react'
import * as path from 'path'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react()],
  server: {
    port: 7110
  },
  resolve: {
    alias: [{find: '@', replacement: path.resolve(__dirname, 'src')}]
  }
})

 
これで path alias が使えるようになりました。

 

単数の値で DataGrid を表示

まずは、単数の値を持つデータをDataGridの列に表示してみます。

公式ドキュメントに従い、DataGridの本体と依存しているものをインストールをします。
Data Grid - Getting started - MUI

# 本体
% yarn add @mui/x-data-grid@next

# 依存
% yarn add @mui/material @mui/styles

 
続いて実装していきます。

まずは DataGrid を表示するページのコンポーネントを用意します。

src/components/pages/datagrid/DataGridBasic.tsx

import {Link} from 'react-router-dom'
import {DataGrid, GridColDef} from '@mui/x-data-grid'

const columns: GridColDef[] = [
  {field: 'id', headerName: 'ID', width: 90, type: 'number'},
  {field: 'name', headerName: '名前', width: 150, type: 'string'},
  {field: 'purchaseDate', headerName: '購入日', width: 300, type: 'date'}
]

const rows = [
  {
    id: 1,
    name: 'シナノドルチェ',
    purchaseDate: new Date(2021, 8, 20)
  },
  {
    id: 2,
    name: '秋映',
    purchaseDate: new Date(2021, 9, 15)
  },
  {
    id: 3,
    name: 'シナノゴールド',
    purchaseDate: new Date(2021, 10, 1)
  },
  {
    id: 4,
    name: 'ふじ',
    purchaseDate: null
  }
]

const Component = (): JSX.Element => {
  return (
    <>
      <nav>
        <Link to="/">Home</Link>
      </nav>
      <div style={{height: 400, width: '100%'}}>
        <DataGrid rows={rows} columns={columns} />
      </div>
    </>
  )
}
export default Component

 
続いて、このページへのルーティングを用意します。

src/main.tsx

import * as React from 'react'
import ReactDOM from 'react-dom'
import {BrowserRouter} from 'react-router-dom'
import App from './App'

ReactDOM.render(
  <React.StrictMode>
    <BrowserRouter>
      <App />
    </BrowserRouter>
  </React.StrictMode>,
  document.getElementById('root')
)

 
App.tsxRoutesRoute を実装します。

なお、以後はルーティングについては記載しませんが、適宜追加されていくものとします。  
src/App.tsx

import * as React from 'react'
import {Route, Routes} from 'react-router-dom'
import DataGridBasic from '@/components/pages/datagrid/DataGridBasic'

function App() {
  return (
    <div className="App">
      <h1>Welcome to React Router!</h1>
      <Routes>
        <Route path="datagrid">
          <Route path="basic" element={<DataGridBasic />} />
        </Route>
      </Routes>
    </div>
  )
}

export default App

 
実装が終わったので起動してみたところ、エラーになりました。

% yarn run dev 
...
node_modules/@mui/styled-engine/GlobalStyles/GlobalStyles.js:3:23: error: Could not resolve "@emotion/react" (mark it as external to exclude it from the bundle)
node_modules/@mui/styled-engine/StyledEngineProvider/StyledEngineProvider.js:3:30: error: Could not resolve "@emotion/react" (mark it as external to exclude it from the bundle)
node_modules/@mui/styled-engine/index.js:6:21: error: Could not resolve "@emotion/styled" (mark it as external to exclude it from the bundle)
node_modules/@mui/styled-engine/index.js:26:45: error: Could not resolve "@emotion/react" (mark it as external to exclude it from the bundle)

 

エラー対応のため、 package.json へ追記

 
MUIまわりで不足しているものがあるようなので、追加でインストールします。
Installation - MUI

今回はスタイリングはしないものの、 styled-component で実装することとします。

% yarn add @mui/material @mui/styled-engine-sc styled-components

 
styled-componentの場合、 @mui/styled-engine まわりの修正も必要そうなため、 package.jsondependenciesresolution を編集します。 @mui/styled-engine - MUI

なお、styled-componentのドキュメントによると、 styled-component に関しても resolution 設定をしておくようです。
Installation - styled-components: Basics

  "dependencies": {
    "@mui/styled-engine": "npm:@mui/styled-engine-sc@latest",
  // ...
  },
  "resolutions": {
    "@mui/styled-engine": "npm:@mui/styled-engine-sc@latest",
    "styled-components": "^5"
  }

 
もう一度 yarn してから yarn run dev で起動し、 http://localhost:7110/datagrid/basic へアクセスするとDataGridが表示されました。

 

列の type を変更し、Date 型がどのように表示されるか確認

先ほどの列定義は

{field: 'purchaseDate', headerName: '購入日', width: 300, type: 'date'}

でした。

ただ、DataGridでは列の type はいくつか指定できるようです。
Column types - Data Grid - Columns - MUI

そこで

  • date
  • datetime
  • string

の違いを試してみます。

 
列定義を

const columns: GridColDef[] = [
  {field: 'id', headerName: 'ID', width: 90, type: 'number'},
  {field: 'name', headerName: '名前', width: 150, type: 'string'},
  {field: 'purchaseDate1', headerName: '購入日(date型)', width: 150, type: 'date'},
  {field: 'purchaseDate2', headerName: '購入日(dateTime型)', width: 200, type: 'dateTime'},
  {field: 'purchaseDate3', headerName: '購入日(string型)', width: 400, type: 'string'}
]

と変更した DataGrid のコンポーネント DataGridColumnTypes.tsx を用意し、表示してみます。

 
type ごとに表示形式が変わりました。

 

1列に複数の日付を持ったデータへ変更

ここまで purchaseDate は単数の日付でした。

次は本題の、複数の日付を入れた DataGridArrayInColumn.tsx を試してみます。

const rows = [
  {
    id: 1,
    name: 'シナノドルチェ',
    purchaseDate1: [new Date(2021, 8, 20)],
    purchaseDate2: [new Date(2021, 8, 20)],
    purchaseDate3: [new Date(2021, 8, 20)]
  },
  {
    id: 2,
    name: '秋映',
    purchaseDate1: [new Date(2021, 9, 15), new Date(2021, 10, 10)],
    purchaseDate2: [new Date(2021, 9, 15), new Date(2021, 10, 10)],
    purchaseDate3: [new Date(2021, 9, 15), new Date(2021, 10, 10)]
  },
  {
    id: 3,
    name: 'シナノゴールド',
    purchaseDate1: [new Date(2021, 10, 1), new Date(2021, 10, 10), new Date(2021, 10, 20)],
    purchaseDate2: [new Date(2021, 10, 1), new Date(2021, 10, 10), new Date(2021, 10, 20)],
    purchaseDate3: [new Date(2021, 10, 1), new Date(2021, 10, 10), new Date(2021, 10, 20)]
  },
  {
    id: 4,
    name: 'ふじ',
    purchaseDate1: [null],
    purchaseDate2: [null],
    purchaseDate3: [null]
  }
]

 
すると、date型

 
datetime型

 
string型

と、いずれもstring型として表示されました。

 

DataGridの valueFormatter と date-fns による書式設定

DataGridの Columns 設定によると、

The value formatter allows you to convert the value before displaying it

Value formatter にて表示書式を変更できるようです。
Value formatter - Data Grid - Columns - MUI

そこで、件数に応じて表示を変えるよう、書式を

  • 1件
    • 2021/11/07(日)
  • 2件
    • 2021/11/07(日)・2021/11/08(月)
  • 3件以上
    • 2021/11/07(日)〜2021/11/08(月)

としてみます。

また、今回は date-fns を使って書式を設定することとします。
date-fns - modern JavaScript date utility library

 
まず、date-fns をインストールします。
date-fns - modern JavaScript date utility library

% yarn add date-fns

 
続いて DataGridArrayInColumnWithValueFormatter.tsx を作り、 valueFormatter 用の関数を用意します。

const lastDay = (days: (null | string)[]): string | null => {
  return days.slice(-1)[0]
}

const formatDays = (params: GridValueFormatterParams) => {
  if (!Array.isArray(params.value)) {
    return null
  }

  const formattedDays = params.value
    .filter((v) => v)
    .map((v) => {
      if (v === null) {
        return null
      }
      return format(v, 'yyyy/MM/dd(eee)', {locale: ja})
    })

  switch (formattedDays.length) {
    case 0:
      return ''
    case 1:
      return formattedDays[0]
    case 2:
      return `${formattedDays[0]} & ${formattedDays[1]}`
    default:
      return `${formattedDays[0]} ~ ${lastDay(formattedDays)}`
  }
}

 
最後に GridColDefvalueFormatter へ設定します。

const columns: GridColDef[] = [
  {field: 'id', headerName: 'ID', width: 90, type: 'number'},
  {field: 'name', headerName: '名前', width: 150, type: 'string'},
  {field: 'purchaseDate', headerName: '購入日( date型)', width: 900, type: 'date', 
    valueFormatter: formatDays  // 追加
  }
]

 
表示すると、書式が設定されていました。

 

DataGridの sortComparator にてソート機能を設定

ここで、書式を設定したコンポーネントで昇順ソートを試すと、

と、正しいソートとなっていませんでした。

正しくソートを設定するには Custom comparator を使えば良いようです。
Custom comparator - Data Grid - Sorting - MUI

 

Custom comparator の仕様確認

公式ドキュメントにあるソースコードより、Custom comparatorで返す値は

  • 等しい

のいずれかを取れば良さそうでした。

ただ、どの値を返すのが良いのか分からなかったため、試してみることにします。

 
まずは

const compareReturnPlus = (v1: GridCellValue, v2: GridCellValue) => {
  console.log('=============>')
  console.log(v1)
  console.log(v2)
  console.log('<=============')
  return 1
}

const compareReturnMinus = (v1: GridCellValue, v2: GridCellValue) => {
  console.log('=============>')
  console.log(v1)
  console.log(v2)
  console.log('<=============')
  return -1
}

const compareReturnZero = (v1: GridCellValue, v2: GridCellValue) => {
  console.log('=============>')
  console.log(v1)
  console.log(v2)
  console.log('<=============')
  return 0
}

と、常に正・負・等しい値を返す各関数を用意します。

 
次に、GridColDefの sortComparator

const columns: GridColDef[] = [
  // ...
  {field: 'plus', headerName: 'Plus', width: 90, type: 'number', sortComparator: compareReturnPlus},
  {field: 'minus', headerName: 'Minus', width: 90, type: 'number', sortComparator: compareReturnMinus},
  {field: 'zero', headerName: 'Zero', width: 90, type: 'number', sortComparator: compareReturnZero}
]

と、それぞれの関数を指定します。

 
最後にデータとして

const rows = [
  {
    id: 1,
    name: 'シナノドルチェ',
    purchaseDate: [new Date(2021, 8, 20)],
    plus: 1,
    minus: 1,
    zero: 1
  },
// ...

のように id 列と同じ値を plusminuszero として用意します。

画面で plus 列を昇順ソートしたところ、コンソールには

=============>
2
1
<=============
=============>
3
2
<=============
=============>
4
3
<=============

と出力されました。

画面は昇順でソートされています。

もう一度 plus 列をクリックしたところ降順でソートされました。

 
次に、minus列をクリックすると

と、画面は昇順ソートされましたが、実際には降順でソートされました。

もう一度 minus 列をクリックしたところ昇順でソートされました。

なお、Consoleの出力値は plus 列のときと同様でした。

 
最後にzero列をクリックすると

と、昇順・降順ともに変化はありませんでした。

 
これらより、

  • Custom comparatorの第1引数には次の行、第2に引数には現在の行の値がそれぞれ渡されてくる
  • Custom comparatorの第1引数の方が大きいときは正の値を、第2引数の方が大きいときは負の値を返せば良さそう

と考えて良さそうでした。

 

Custom comparator の実装

まずは今回使うコンポーネント DataGridArrayInColumnWithSortComparator.tsx に、Custom comparator用の関数を定義します。

今回は複数の日付のうち先頭の日付をソートの基準日とし、date-fns の differenceInSeconds で差分を取るようにします。

また、想定外の型が入っていたときは、ひとまず固定値を返しています。

const compareDays = (v1: GridCellValue, v2: GridCellValue) => {
  if (!Array.isArray(v1)) {
    return 1
  }
  if (!Array.isArray(v2)) {
    return -1
  }

  if (!(v1[0] instanceof Date)) {
    return 1
  }
  if (!(v2[0] instanceof Date)) {
    return -1
  }
  return differenceInSeconds(v1[0], v2[0])
}

 
続いて、 GridColDefsortComparator に作成した関数を指定します。

const columns: GridColDef[] = [
  // ...
  {
    field: 'purchaseDate',
    headerName: '購入日(date型)',
    width: 900,
    type: 'date',
    valueFormatter: formatDays,
    sortComparator: compareDays  // 追加
  }
]

 
再度表示してみると

と、期待通りのソートとなりました。

 

DataGridのfilterModelとonFilterModelChangeにより、フィルタ機能を設定

ソートができるようになったコンポーネントでフィルタの機能を使ったところ

と、 2021/10/20 のデータが存在するにも関わらず、1件もデータが表示されなくなりました。

Arrayなデータのせいか、フィルタがうまくできていないようです。

 
公式ドキュメントを見たところ、 Create a custom operator 機能を使うことで、独自のフィルタ機能を追加できそうでした。
Create a custom operator - Data Grid - Filtering - MUI

Create a custom operator を使うためには、 DataGrid に対して

  • filterModel
  • onFilterModelChange

を定義するとともに、新しく operator を用意してフィルタが必要な列の filterOperators に設定すれば良さそうでした。

 
今回は type が date の時のフィルタ機能を実装するため、既存の date 型の operator を見ると

  • is
  • is not
  • is after
  • is on or after
  • is before
  • is on or before
  • is empty
  • is not empty

がありました。

これらを実装してみます。

ただ、実装する中で失敗例と成功例があったため、それぞれメモを残しておきます。

 

[失敗] フィルタの入力値用コンポーネントを自作し operator で使う

まずは失敗例からです。

 

実装

最初に、一番簡単そうな is operator を公式ドキュメントのサンプルに従って実装してみます。
https://mui.com/components/data-grid/filtering/#create-a-custom-operator

 
まずは src/components/functions/datagrid/operatorsWithError.tsx に必要なものを実装していきます。

ここではMUIのDatePickerコンポーネントを使い、フィルタの入力値用コンポーネントを実装します。

const DateInputValue = (props: GridFilterInputValueProps) => {
  const {item, applyValue} = props

  const handleFilterChange = (event: any) => {
    // フィルタの入力値には時間が含まれるが、比較するときには時間が不要なため、削除しておく
    // すべて消すと event に null が入ってくる
    const value =
      event == null ? null : isValidDate(event) ? new Date(event.getFullYear(), event.getMonth(), event.getDate()) : ''
    applyValue({...item, value: value})
  }

  return (
    <LocalizationProvider dateAdapter={AdapterDateFns} locale={ja}>
      <DatePicker
        label="value"
        inputFormat="yyyy/MM/dd"
        mask="____/__/__"
        value={item.value}
        onChange={handleFilterChange}
        renderInput={(params: TextFieldProps) => {
          params.variant = 'standard'
          return <TextField {...params} />
        }}
      />
    </LocalizationProvider>
  )
}

 
続いて、フィルタの入力値用コンポーネントを指定した operator を用意します。

export const isOperator = {
  label: 'Is',
  value: 'is',
  getApplyFilterFn: (filterItem: GridFilterItem) => {
    if (!filterItem.columnField || !filterItem.value || !filterItem.operatorValue) {
      return null
    }

    return (params: any): boolean => {
      return params.value.filter((v: any) => isSameDay(v, filterItem.value)).length > 0
    }
  },
  InputComponent: DateInputValue,
  InputComponentProps: {type: 'date'}
}

 
これで operator 関係は実装できたため、DataGrid コンポーネント ( DataGridArrayInColumnWithFilterError.tsx ) に組み込みます。

なお、以下のコードでは示していない関数などは、ここまで作ってきたものと同様なので省略しています。

const Component = (): JSX.Element => {
  const [filterModel, setFilterModel] = React.useState<GridFilterModel>({
    items: [{columnField: 'purchaseDate', value: null, operatorValue: 'is'}]
  })

  if (columns.length > 0) {
    const ratingColumn = columns.find((col) => col.field === 'purchaseDate')
    const newRatingColumn = {
      ...ratingColumn!,
      filterOperators: [isOperator]
    }
    const ratingColIndex = columns.findIndex((col) => col.field === 'purchaseDate')
    columns[ratingColIndex] = newRatingColumn
  }

  return (
    <>
      <nav>
        <Link to="/">Home</Link>
      </nav>
      <div style={{height: 400, width: '100%'}}>
        <DataGrid
          rows={rows}
          columns={columns}
          filterModel={filterModel}
          onFilterModelChange={(model) => setFilterModel(model)}
        />
      </div>
    </>
  )
}
export default Component

 

動作確認

実行してみると、自作した operator が表示されました。

 
また、データの絞り込みも成功しているようです。

 
ただ、気になるところがいくつかあります。

まずは、DataGridの標準フィルタには存在しなかった、フィルタの下にスクロールバーが表示されています。

 
さらに厳しいのは、直接年月日を入力しようとしたところ、入力を受け付けずエラーになってしまうことです。

 

[成功] フィルタの入力値用コンポーネントとして標準の GridFilterInputValue を operator で使う

続いて成功例です。

DatePickerであっても、DataGrid標準の operator では動作していました。

そこで、DataGrid標準の operator を探したところ、以下にありました。
https://github.com/mui-org/material-ui-x/blob/v5.0.0-beta.7/packages/grid/modules/grid/models/colDef/gridDateOperators.ts

ソースコードの53行目あたりで、operator のコンポーネントInputComponent: GridFilterInputValue と指定していました。

GridFilterInputValue の定義はこちらでした。
https://github.com/mui-org/material-ui-x/blob/v5.0.0-beta.7/packages/grid/modules/grid/components/panel/filterPanel/GridFilterInputValue.tsx#L34

そこで、 GridFilterInputValue を使い operator を作ってみたところ、うまくいきました。

 

実装

まずは、src/components/functions/datagrid/operators.tsx を用意し、 operator および operator で必要なコンポーネントを実装します。

なお、画面で入力したフィルタの値は 2021-11-08 のような - 区切りの書式で filterItem.value に設定されるため、実際の値と比較できるよう Date 型へと変換します。

const toFilterValue = (filterItem: GridFilterItem) => {
  // 日付は '-' 区切りの文字列で渡されてくるので、フィルタで使えるように整形
  const values = filterItem.value.split('-')
  return new Date(values[0], values[1] - 1, values[2])
}

export const isOperator = {
  label: 'is',
  value: 'is',
  getApplyFilterFn: (filterItem: GridFilterItem) => {
    if (!filterItem.columnField || !filterItem.value || !filterItem.operatorValue) {
      return null
    }

    return (params: GridCellParams): boolean => {
      if (!params.value || !Array.isArray(params.value)) {
        return false
      }

      return params.value.filter((v) => isSameDay(v, toFilterValue(filterItem))).length > 0
    }
  },
  InputComponent: GridFilterInputValue,
  InputComponentProps: {type: 'date'}
}

 
続いて、コンポーネント DataGridArrayInColumnWithFilter.tsx に、作成した operator を設定します。

先ほどの公式ドキュメント版では、コンポーネントの中で列定義を書き換えて operator を設定していました。

ただ、 GridColDeffilterOperators があるため、 GridColDef の方で設定します。

const columns: GridColDef[] = [
  // ...
  {
    field: 'purchaseDate',
    headerName: '購入日(date型)',
    // ...
    filterOperators: [
      isOperator,
    ]
  }

 
コンポーネントの方では operator の設定が不要となったため、スッキリしました。

const Component = (): JSX.Element => {
  const [filterModel, setFilterModel] = React.useState<GridFilterModel>({
    items: []
  })

  return (
    <>
      <nav>
        <Link to="/">Home</Link>
      </nav>
      <div style={{height: 400, width: '100%'}}>
        <DataGrid
          rows={rows}
          columns={columns}
          filterModel={filterModel}
          onFilterModelChange={(model) => setFilterModel(model)}
        />
      </div>
    </>
  )
}
export default Component

 

動作確認

フィルタを使ってみると、スクロールバーやエラーは表示されません。

 
フィルタによる絞り込みも問題なくできました。

 
また、

const rows = [
  {
    id: 1,
    name: 'シナノドルチェ',
    purchaseDate: [new Date(2021, 8, 20)]
  },
  {
    id: 2,
    name: '秋映',
    purchaseDate: [new Date(2021, 9, 20), new Date(2021, 10, 10)]
  },
  {
    id: 3,
    name: 'シナノゴールド',
    purchaseDate: [new Date(2021, 9, 15), new Date(2021, 10, 10), new Date(2021, 10, 20)]
  },
  {
    id: 4,
    name: 'ふじ',
    purchaseDate: [null]
  }
]

というデータに対し、 2021/11/10 と等しいものというフィルタ

を入力すると、正しく絞り込みが行われました。

 
あとは、date-fns の関数を使いながら他の operator も実装し、問題ないことを確認しました。

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/react_mui_with_vite-sample

 
今回のPRはこちらです。
https://github.com/thinkAmi-sandbox/react_mui_with_vite-sample/pull/1

JetBrains IDEの2021.2系から Code completion の背景色設定が変わってたので、修正してみた

JetBrains IDE (PyCharmやRubyMine、IntelliJ IDEAなど) を2021.2系へバージョンアップしたところ、Code completion の背景色設定が変更されたようで、白っぽくなってしまいました。

 
これでは目がつらいので、設定変更したときのメモを残します。

 
目次

 

環境

  • JetBrains IDE 2021.1系 から2022.1系へアップデート
    • どのバージョンから変更されたのか不明なため、系という書き方にしています

 

今までの設定箇所

youtrackにある通り、

Color Scheme > General > Popups and Hints > Documentation

にて変更していました。
Code completion popup uses light theme when the IDE is set to use the dark theme after update to 2021.1 EAP : IDEA-260663

 

2021.2からの設定箇所

stackoverflowにある通り、2021.2 系では Completion という設定が増えていました。
jetbrains ide - How can I change the color of the code completion background in PyCharm? - Stack Overflow

 
手元の環境で

Color Scheme > General > Popups and Hints > Completion

と設定してみたところ、Code completion の背景色が元に戻りました。

React + Rails + Vite.js なSPAアプリをモノレポで作ってみた

RailsでWebpacker以外の方法で React のSPAアプリが作れないかを調べたところ、 vite_rails がありました。
Rails Integration | Vite Ruby

webpackやRailsのWebpackerに詳しくないため、React と Rails と Vite.js を組み合わせてSPAが作れそうなのは魅力的でした。

そこで、 vite_rails を試すため、React + Rails + Vite.js なSPAアプリをモノレポで作ってみました。

 
目次

 

環境

  • バックエンド
  • フロントエンド
    • TypeScript 4.4.4
    • React.js 17.0.2
    • React Router 5.2.1
    • Vite.js 2.6.13

 

実装することとしないこと

実装すること

  • React + Rails + Vite.js なSPAアプリをモノレポで作る
  • vite_rails により、 bin/viteconfig/vite.json を作成する
  • Railsから切り離しやすくするため、ReactのディレクトリはRailsのアプリディレクトリ (app) の外側に置く

 

実装しないこと

  • Reactのディレクトリ構造をきちんと考えること
    • いろいろな構成が考えられますが、今回は適当に作ります

 

実装

セットアップ

Rails

rails new します。

今回のフロントエンドは Vite.js でセットアップするため、JavaScript まわりの生成はスキップします。

(JavaScriptとは関係ないものもスキップしていますが、今回試す範囲には影響ないはず)

% rails new react_with_vite_rails --skip-action-mailbox --skip-action-text --skip-action-cable --skip-sprockets --skip-javascript --skip-turbolinks --skip-jbuilder --skip-system-test

 
ちなみに、Rails 6.1 から最小限にするフラグ --minimal が追加されたようです。
Rails 6.1でrails newの生成を最小限にするフラグが追加(翻訳)|TechRacho by BPS株式会社

   

vite_rails

公式ドキュメントに従い、セットアップを行います。
Getting Started | Vite Ruby

 
Gemfileに追加します。

gem 'vite_rails'

 
bundle installします。

% bundle install

If you upgraded the gem manually, please run:
        bundle exec vite upgrade

 
vite_railsinstall を行います。

% bundle exec vite install
Creating binstub
Check that your vite.json configuration file is available in the load path:

        No such file or directory @ rb_sysopen - path/to/react_with_vite_rails/config/vite.json

Creating configuration files
Installing sample files
Installing js dependencies

> esbuild@0.13.11 postinstall path/to/react_with_vite_rails/node_modules/esbuild
> node install.js

+ vite@2.6.13
+ vite-plugin-ruby@3.0.3
added 35 packages from 83 contributors and audited 51 packages in 7.872s

6 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities

Adding files to .gitignore

Vite ⚡️ Ruby successfully installed! 🎉

 
bundle exec vite install 後に、Railsのアプリの中の app/frontend/entrypoints ディレクトリに application.js ファイルが作成されました。

ただ、今回はRailsのアプリの外に React 用のディレクトリを作るため、今は放置しておきます。

 

Vite.js のインストール

React + TypeScript な構成で作ります。

今回は、 ルートディレクトリの下に frontend ディレクトリを作成し、その中で React まわりを作成します。

% yarn create vite
yarn create v1.22.11
warning package.json: No license field
[1/4] 🔍  Resolving packages...
[2/4] 🚚  Fetching packages...
[3/4] 🔗  Linking dependencies...
[4/4] 🔨  Building fresh packages...
success Installed "create-vite@2.6.6" with binaries:
      - create-vite
      - cva
✔ Project name: … frontend
✔ Select a framework: › react
✔ Select a variant: › react-ts

Scaffolding project in path/to/react_with_vite_rails/frontend...

Done. Now run:

  cd frontend
  yarn
  yarn dev

✨  Done in 10.76s.

 

vite_rails と Vite.js の間で設定を調整

ここまでで vite_rails と Vite.js により、

が複数作成されています。

また、 tsconfig.json が Vite.js で作成された frontend ディレクトリの中にあります。

これらのファイルを調整するとともに、調整後の設定を vite_rails に伝えるため config/vite.json へ反映します。

 

package.json

2つの設定をマージします。

なお、 Vite.js のインストールバージョンが双方で異なっていたため、新しいバージョンの方を採用しました。

{
  "name": "frontend",
  "version": "0.0.0",
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "serve": "vite preview"
  },
  "dependencies": {
    "react": "^17.0.0",
    "react-dom": "^17.0.0"
  },
  "devDependencies": {
    "@types/react": "^17.0.0",
    "@types/react-dom": "^17.0.0",
    "@vitejs/plugin-react": "^1.0.0",
    "typescript": "^4.3.2",
    "vite": "^2.6.13",
    "vite-plugin-ruby": "^3.0.3"
  }

 

vite.config.ts

こちらもマージします。

vite_rails が生成した vite.config.ts に、 react プラグインを追加します。

import { defineConfig } from 'vite'
import RubyPlugin from 'vite-plugin-ruby'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [
    RubyPlugin(),
    react(),  // 追加
  ],
})

 

tsconfig.json

frontend/tsconfig.jsontsconfig.json へ移動します。

今回は中身を変更しません。

 

config/vite.json

Reactのコンポーネントなどは Vite.js で生成した場所においてゆくため、 sourceCodeDirrails_vite が生成した app/frontend から、Vite.jsで生成した frontend へと切り替えます。

{
  "all": {
    // 変更前
    //    "sourceCodeDir": "app/frontend",
    // 変更後
    "sourceCodeDir": "frontend",
    "watchAdditionalPaths": []
  },

 
以上で調整が終わりました。調整の詳細は、以下のコミットとなります。
https://github.com/thinkAmi-sandbox/react_with_vite_rails-sample/commit/ecb76c98455d32c79db02d1d6d8de50697cd7575

 

RailsのViewの上で、React のコンポーネントを動かす

RailsのControllerとViewを生成

Controller と View を生成します。

% bin/rails g controller hello index
Running via Spring preloader in process 29384
      create  app/controllers/hello_controller.rb
       route  get 'hello/index'
      invoke  erb
      create    app/views/hello
      create    app/views/hello/index.html.erb
      invoke  test_unit
      create    test/controllers/hello_controller_test.rb
      invoke  helper
      create    app/helpers/hello_helper.rb
      invoke    test_unit
      invoke  assets
      invoke    css
      create      app/assets/stylesheets/hello.css

 

Layoutの修正

vite_rails のタグを Layout に追加します。

  • vite_client_tag
    • Hot Module Reload 用
  • vite_react_refresh_tag
    • React 向けに、 追加が必要な Hot Module Reload 用
  • vite_javascript_tag 'main.tsx'
    • 今回は React のエントリポイントディレクトリにある main.tsx を指定
      • このファイルに、React Routerのルーティングを書く予定

 
また、CSSの読み込みは行わないので、 stylesheet_link_tag 'application', media: 'all'コメントアウト(削除)しておきます。

app/views/layouts/application.html.erb

<!DOCTYPE html>
<html>
  <head>
    <title>ReactWithViteRails</title>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>

    <!-- 追加 開始 --->
    <%= vite_client_tag %>
    <%= vite_react_refresh_tag %>
    <%= vite_javascript_tag 'main.tsx' %>
    <!-- 追加 終了 --->
  </head>

  <body>
    <%= yield %>
  </body>
</html>

 

Viewの修正

Controllerはそのままにします。

Viewに React 用の id を設定します。

app/views/hello/index.html.erb

<div id="root"></div> 

 

routesを修正

hello#indexroot として設定します。

config/routes.rb

Rails.application.routes.draw do
  root 'hello#index'
end

 

pumaの起動ポートを修正

他のアプリと重複するのを避けるため、適当なポートへ変更しておきます。

port ENV.fetch("PORT") { 7100 }

 

Helloコンポーネントを作成

hello と表示するだけのコンポーネントです。

frontend/src/components/Hello.tsx

const Home = (): JSX.Element => {
    return (
        <>
            <h1>hello</h1>
        </>
    )
}

export default Home 

 

main.tsx を修正

先ほど作成した Hello コンポーネントを組み込んで表示します。

(なお、importする時に名前を誤って Home にしてしまいましたが、あとで直すので気にしないこととします)

frontend/src/main.tsx

import ReactDOM from "react-dom";
import React from "react";
import Home from "./components/Hello";

ReactDOM.render(
    <React.StrictMode>
        <Home />
    </React.StrictMode>,
    document.getElementById('root')
) 

 

config/vite.json を修正

エントリポイントディレクトリを Vite.js で生成した frontend の下の src に変更します。

これで、Railsの Layouts で <%= vite_javascript_tag 'main.tsx' %> とした時に、 frontend/src/main.tsx が読み込まれるようになります。

{
  "all": {
    "sourceCodeDir": "frontend",
    "entrypointsDir": "src",  // 追加
    "watchAdditionalPaths": []
  },
...
}

 

動作確認

ここまでで準備ができたため、RailsとVite.jsの両サーバを起動して動作を確認します。

Vite.jsのサーバ

$ bin/vite dev

 
Railsのサーバ

% bin/rails s

 
http://localhost:7100 にアクセスしたところ、 hello が表示されました。

 
ここまでのコミットはこちら。
https://github.com/thinkAmi-sandbox/react_with_vite_rails-sample/commit/7da5085e782b0333c4c54325e8ef9b2b00bef5d6

 

axiosをセットアップし、RailsAPIからデータを取得して表示する

続いて、RailsAPIからデータを取得し、画面に表示することを試してみます。

 

RailsAPIを作成

今回は現在時刻をJSONで返すAPIを作成します。

このAPIJSONを返すだけなので、 --no-assets で生成します。

% rails g controller api --no-assets
Running via Spring preloader in process 47954
      create  app/controllers/api_controller.rb
      invoke  erb
      create    app/views/api
      invoke  test_unit
      create    test/controllers/api_controller_test.rb
      invoke  helper
      create    app/helpers/api_helper.rb
      invoke    test_unit

 
生成されたコントローラーを修正します。

React側でローディング中の表示を行うため、重い処理をしているつもりで sleep を入れてあります。

また、日時を画面に表示できれば良いので、日時の表示形式は特に考えず、そのままレスポンスすることにします。

app/controllers/api_controller.rb

class ApiController < ApplicationController
  def now
    # 重い処理をしているつもり
    sleep 1

    # レスポンスを返す
    render json: {
      current: Time.now
    }
  end
end

 
routesも追加します。

config/routes.rb

Rails.application.routes.draw do
  root 'hello#index'

  get 'api/now'  # 追加
end

 
Rails側の実装が終わったため、続いてReact側を実装します。

 

axiosのインストール

% yarn add axios
% yarn add -D @types/axios

 

APIからデータを取得するカスタムフックを作成

今回はAPIからデータを取得するところをカスタムフックとして作成します。

frontend/src/hooks/useFetchApi.tsx

import axios, {AxiosError} from 'axios'
import {useEffect, useState} from "react";

type ResponseData = {
    current: string
}

export interface Response {
    data: ResponseData
    error: AxiosError | null,
    loading: boolean
}

export const useFetchApi = () => {
    const [response, setResponse] = useState<Response>({ data: { current: '' }, error: null, loading: false })

    useEffect(() => {
        fetchRequest()
    }, [])

    const fetchRequest = () => {
        setResponse(prevState => ({ ...prevState, loading: true }))
        axios.get<ResponseData>('http://localhost:7100/api/now').then((response) => {
            setResponse({ data: response.data, error: null, loading: false })
        }).catch(error => {
            setResponse({ data: { current: '' }, error: error, loading: false })
        })
    }

    return response
}

 

「カスタムフックの結果を表示する」コンポーネントの作成

APIから取得した現在時刻をそのまま表示します。

frontend/src/components/CurrentTime.tsx

import {useFetchApi} from "../hooks/useFetchApi";

const CurrentTime = (): JSX.Element => {
    const { data, error, loading } = useFetchApi()

    if (loading) return <div>...loading</div>
    if (error) return <div>{error.message}</div>

    return (
        <div>current: {data.current}</div>
    )
}

export default CurrentTime

 

main.tsxコンポーネントを追加

現在時刻を表示するコンポーネントを追加します。

import ReactDOM from "react-dom";
import React from "react";
import Hello from "./components/Hello";
import CurrentTime from "./components/CurrentTime";

ReactDOM.render(
    <React.StrictMode>
        <Hello />
        <CurrentTime />
    </React.StrictMode>,
    document.getElementById('root')
)

 

動作確認

最初は loading 表示になります。

 
1秒くらい経過すると、現在時刻が表示されました。

 

React Routerでのルーティングを追加

Rails

フロントエンドでルーティングするため、マッチしないルートが来てもフロントへ渡します。

Rails.application.routes.draw do
  root 'hello#index'
  get 'api/now'

  # マッチしないルートはフロントに流す
  get '*all', to: 'hello#index'
  # For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html
end

 

React Routerのインストール

% yarn add react-router-dom
% yarn add -D @types/react-router-dom

 

各ページのコンポーネントを作成

今回は

  • HelloPage
    • 今まで作ってきた、現在時刻を表示するコンポーネントをここへ移動
    • AboutPageへのリンクあり
  • AboutPage
    • HelloPageへのリンクあり
  • NotFoundPage
    • どのルートにもマッチしない場合に表示
    • HelloPageへのリンクあり

の3つのページのコンポーネントを作成します。

 

HelloPage

今まで作ってきたコンポーネントを移動したものです。

また、フロントでルーティングするため、React Routerの Link コンポーネントを使っています。
https://reactrouter.com/web/api/Link

import Hello from "../components/Hello";
import CurrentTime from "../components/CurrentTime";
import {Link} from "react-router-dom";

const HelloPage = (): JSX.Element => {
    return (
        <>
            <Hello />
            <CurrentTime />

            <Link to="/about">Go to About page</Link>
        </>
    )
}

export default HelloPage

 

AboutPage

import { Link } from 'react-router-dom'

const AboutPage = (): JSX.Element => {
    return (
        <>
            <h1>About Page</h1>
            <Link to="/">Go to Hello page</Link>
        </>
    )
}

export default AboutPage 

 

NotFoundPage

中身はAboutPageと変わりません。

import { Link } from 'react-router-dom'

const NotFoundPage = (): JSX.Element => {
    return (
        <>
            <h1>Not Found Page</h1>
            <Link to="/">Go to Hello page</Link>
        </>
    )
}

export default NotFoundPage

 

main.tsxにReact Routerを組み込む

マッチさせたい順に設定するため、

  • /about
  • /
  • その他

の順に Route を書きます。

import ReactDOM from "react-dom";
import React from "react";
import {Switch} from "react-router"
import {BrowserRouter, Route} from "react-router-dom";
import HelloPage from "./pages/HelloPage";
import AboutPage from "./pages/AboutPage";
import NotFound from "./pages/NotFound";

ReactDOM.render(
    <React.StrictMode>
        <BrowserRouter>
            <Switch>
                <Route path="/about">
                    <AboutPage />
                </Route>

                <Route exact path="/">
                    <HelloPage />
                </Route>

                <Route>
                    <NotFound />
                </Route>
            </Switch>
        </BrowserRouter>
    </React.StrictMode>,
    document.getElementById('root')
)

 
以上で実装は終わりです。コミットはこちら。
https://github.com/thinkAmi-sandbox/react_with_vite_rails-sample/commit/bc350f88e0bc3ca4e6ec2dfc1392939e8fbff5a0

 

動作確認

/ にアクセスした時

 

/about にアクセスした時

 

/foo にアクセスした時

ルートとしては存在しないため、NotFoundPage が表示されます。

 
以上より、Webpackerを使わずに、 React + Rails + Vite.js なSPAアプリをモノレポで作っても動作することが分かりました。

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/react_with_vite_rails-sample

 
今回のPRはこちらです。
https://github.com/thinkAmi-sandbox/react_with_vite_rails-sample/pull/1

Play Framework の View に React + TypeScript を組み込み、 sbt + Vite.js ですべてコンパイルできるようにしてみた

Play Framework アプリに React を組み込んでみる機会がありました。

ただ、Reactを使って SPA にするのではなく、まずは

  • ルーティングは Play Framework (バックエンド)が担当
  • Play Framework の View上で React が動く

という、MPA (Multi Page Application) な Play Framework アプリの作成を考えました。

 
MPAで作るにはどうすれば良いかを調べたところ、以下の記事がありました。とても分かりやすく参考になりました。ありがとうございました。
Integrate Vue.js into existing Play Framework project | CyberAgent Developers Blog

 
上記の記事のまとめには

本ブログではVue.jsを既存のPlay Frameworkに統合する方法を紹介しましたが、AngularJS、Reactを統合する方法もほぼ同じです。ぜひこれを参考に、様々な Modern WebSiteを作っていきましょう。

と書かれていたことから、React であっても作れるかもしれないと考え、この記事をベースに作ってみることにしました。

ただ、いろいろ悩んだことがあったため、メモを残します。

 
目次

 

環境

実装すること

  • Play Framework の View に React を組み込むこと
    • 1つのViewで1つのReact アプリ
    • Viewでは script タグで読み込む
  • React を始めとする Assets には、フィンガープリントが付いていること
    • 73ee83e91609731acc7ecffc9854ecfb-vendor.js みたいなの
  • React は TypeScript で書くこと
  • sbt run で、Play と React の両方をコンパイルした上で動作すること

 

実装しないこと

  • SPAなアプリにすること
  • Reactから Play Framework の API にアクセスすること
  • Viteでビルドするが、バックエンドとは統合しないこと

 

構成

  • Play Frameworkまわり
    • Scala 2.13.6
    • Play Framework 2.8.8
    • sbt 1.5.5
    • sbt-digest 1.1.4
    • Java や sbt は SDKMAN! でインストール
  • Reactまわり
    • React 17.0.2
      • create-react-app ではなく、 Vite でセットアップ
    • TypeScript 4.4.3
  • ビルドツール
    • vite 2.6.2

 

作るもの

方針

ページは以下の3つを作ります。Reactの有無などは以下の通りです。

No URL 説明 Reactファイル
1 / いわゆるHome なし
2 /apple りんご一覧ページ apple.js
3 /sweet_potato さつまいも一覧ページ sweet_potato.js

 
なお、各ページのルーティングはサーバサイドで行います。

 

ディレクトリ構造

主なディレクトリとファイルは以下の通りです。

.
├── app
│   ├── controllers/    # Play の Controller
│   └── views/          # PlayのView
├── build.sbt
├── conf/
│   ├── application.conf
│   ├── logback.xml
│   ├── messages
│   └── routes
├── frontend/
│   ├── node_modules/
│   ├── package.json
│   ├── project
│   │   ├── build.properties
│   │   └── target/
│   ├── src/
│   │   ├── components/          # React の各コンポーネント
│   │   ├── pages/
│   │   │   ├── apple/           # `/apple` ページ
│   │   │   └── sweet_potato/    # `/sweet_potate` ページ
│   │   └── vite-env.d.ts
│   ├── target/
│   ├── tsconfig.json
│   ├── vite.config.ts
│   └── yarn.lock
├── logs/
├── project/
├── public/
│   └── vite_assets/             # Viteでビルドした時の出力先 兼 PlayのAssets
├── target/
└── test/

 

完成イメージ

Home

React を使わない Twirl だけのページです。各ページへのリンクを置いてあります。

 

りんご一覧ページ

Homeにある りんご一覧ページへ をクリックすると、このページへ遷移します。

 
なお、 へー ボタンを押すと、数字がカウントアップします。

カウントアップは React Hooks の useState を使い、 Twirl 上で React が動作することを確認します。

 

さつまいも一覧ページ

こちらはりんご一覧ページと同じです。

 

課題:Assets のフィンガープリントを誰が付与するか

Play Frameworkでは routes

GET  /assets/*file  controllers.Assets.versioned(path="/public", file: Asset)

と書いて、Twirlに

<link rel="stylesheet" href="@routes.Assets.versioned("assets/css/app.css")">

と書くことで、フィンガープリントが付いた Assets へのリンクが自動で作成されます。
Using reverse routing and static state | Assets Overview - 2.8.x

 
では、各ファイルにフィンガープリントを付与するのはどこで行えばよいのかを考えてみます。

Reactで付与するのも良いのですが、 React 以外の Asset を使うことも想定されます。

そこで、

なことができるかを調べてみます。
Reverse routing and fingerprinting for public assets | Assets Overview - 2.8.x

 
今回の React アプリは create-react-app を使って作成します。ただ、 create-react-app で作ったReactアプリではフィンガープリントを無効にするのは難しそうでした。
Generate build without hashed file names. · Issue #821 · facebook/create-react-app

対応するには

  • ファイルのリネーム
  • create-react-appのeject

など、少々つらい方面でした。

 
それ以外の方法を調べたところ、Vue.jsを作ったEvan You氏が開発した Vite というビルドツールがありました。
Home | Vite

Viteのドキュメントを見たところ、

と分かりました。

そこで今回は Vite を使ってみることにしました。

 

実装

Play Framework と React の初期セットアップ

まずは sbt new で Play Framework アプリを生成します。

 % sbt new playframework/play-scala-seed.g8
...
name [play-scala-seed]: mpa_app_with_play_react_vite
organization [com.example]: com.example.thinkami

 
続いて Vite を使い React をセットアップします。

# ディレクトリを移動
% cd mpa_app_with_play_react_vite


# Vite でセットアップ
% npm init vite@latest
npx: installed 6 in 2.148s
...

# React アプリを入れるディレクトリを `frontend` として設定
✔ Project name: … frontend

# React を選択
✔ Select a framework: › react

# TypeScript を使うため、 `react-ts` を選択
✔ Select a variant: › react-ts

Done. Now run:

  cd frontend
  npm install
  npm run dev


# `frontend` ディレクトリへ移動してインストール
% cd frontend
% yarn install


# Node.jsの型定義を使うため、追加でインストール
% yarn add -D @types/node

 
これで Play Framework + React の初期セットアップができました。

 

React の 各Component を作成

List コンポーネント

一覧表示するためのコンポーネントを用意します。

// src/components/List.tsx

export type Props = {
    items: string[]
}

const Component = ({items}: Props): JSX.Element => {
    return (
        <ul>
            {items.map((item) => (
                <li key={item}>
                    <p>{item}</p>
                </li>
            ))}
        </ul>
    )
}
export default Component

 

Counter コンポーネント

へー ボタンを用意し、クリックするたびに +1 するための処理を入れます。

// src/components/Counter.tsx

import {useState} from "react";

const Component = (): JSX.Element => {
    const [count, setCount] = useState(0)

    return (
        <>
            <button type="button" onClick={() => setCount((count) => count + 1)}>
                へー {count}
            </button>
        </>
    )
}
export default Component

 

ListApp コンポーネント

ListとCounterを組み合わせたコンポーネントです。

りんご一覧とさつまいも一覧のページのひな形になっています。

// src/components/ListApp.tsx

import List from "./List";
import Counter from "./Counter";

export type Props = {
    name: string
    items: string[]
}

const ListApp = ({name, items}: Props): JSX.Element => {
    return (
        <>
            <h1>{name}</h1>
            <List items={items} />
            <Counter />
            <p><a href="/">戻る</a></p>
        </>
    )
}
export default ListApp

 

Reactの各ページを作成

先ほど作成したコンポーネントReactDOM.render します。

りんご一覧ページは以下の通りです。 さつまいも一覧ページは ListApp の引数 name だけが異なるため、ここでは省略します。

// src/pages/apple/index.tsx

import React from "react";
import ReactDOM from "react-dom";
import ListApp from "../../components/ListApp";

ReactDOM.render(
    <React.StrictMode>
        <ListApp name={'りんご'} items={['シナノゴールド', '秋映', 'ふじ']} />
    </React.StrictMode>,
    document.getElementById('root')
)

 
ここまでで React の必要なページとコンポーネントができました。

 

Viteの設定ファイル frontend/vite.config.ts を編集

Viteでのビルド設定を vite.config.ts に記載します。

 

出力先ディレクトリの設定

Play Framework の Assets ディレクトリである public の中に出力すると、その先は Play Framework に任せることができそうです。

そこで、

の各設定を追加します。

なお、出力先は frontend ディレクトリの外側になるため、 resolve()__dirname.. を使って指定します。
javascript - fs: how do I locate a parent folder? - Stack Overflow

export default defineConfig({
  plugins: [react()],
  build: {
    // 再度ビルドした時に、以前のファイルを消す
    emptyOutDir: true,
    // ビルドしたファイルを変更する
    outDir: resolve(__dirname, '..', 'public', 'vite_assets'),

 

Multi-Page Appの設定を追加

今回はViewごとにReactのエントリポイントを用意する必要があります。

そこで、Multi-Page Appとなるよう、以下の記事に従い rollupOptionsInput に設定を行います。
Multi-Page App | Building for Production | Vite

今回は

  • エントリポイント src/pages/apple/index.tsx を元に apple.js を出力
  • エントリポイント src/pages/sweet_potato/index.tsx を元に sweet_potato.js を出力

となるよう、Inputのキーにはビルドしてできるファイル名を、値にはエントリポイントのファイル名を指定します。

export default defineConfig({
  build: {
    rollupOptions: {
      input: {
        apple: resolve(__dirname, 'src', 'pages', 'apple', 'index.tsx'),
        sweet_potato: resolve(__dirname, 'src', 'pages', 'sweet_potato', 'index.tsx')
      },

 

出力ファイル名の設定を追加

今回はフィンガープリントを付けない形での出力ファイル名とするため、rollupOptionsOutput に設定を行います。
build.rollupOptions | Configuring Vite | Vite

rollupOptionsについては rollup.js のドキュメントに記載があります。

今回は

を設定します。

export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        entryFileNames: '[name].js',
        chunkFileNames: '[name].js',
        assetFileNames: '[name][extname]',
      }

 
以上で Vite の設定も完了です。

 

Play Framework の実装

Controller

今回は View をそのまま表示するだけです。

ここでは AppleController だけ記載しますが、他もほぼ同じ Controller です。

package controllers
import play.api.mvc._
import javax.inject._

@Singleton
class AppleController @Inject()(val cc: ControllerComponents) extends AbstractController(cc) {
  def index(): Action[AnyContent] = Action { implicit request: Request[AnyContent] =>
    Ok(views.html.apple())
  }
}

 

View

index.scala.html

Twirl そのままです。Reactを使っているページへのリンクを付けています。

@()

@main("Welcome to Play") {
  <h1>Home</h1>
  <ul>
    <li><a href="/apple">りんご一覧へ</a></li>
    <li><a href="/sweet_potato">さつまいも一覧へ</a></li>
  </ul>
}

 

apple.scala.html

ビルドしたJavaScriptscript タグで読み込みます。

@()

@main("Welcome to Play") {
  <div id="root"></div>

  <script type="module" src="@routes.Assets.versioned("vite_assets/vendor.js")"></script>
  <script type="module" src="@routes.Assets.versioned("vite_assets/apple.js")"></script>
}

 

sweet_potato.scala.html

apple.scala.html とは、2つ目の script タグの src のみ異なります。

@()

@main("Welcome to Sweet Potato") {
  <div id="root"></div>

  <script type="module" src="@routes.Assets.versioned("vite_assets/vendor.js")"></script>
  <script type="module" src="@routes.Assets.versioned("vite_assets/sweet_potato.js")"></script>
}

 

routes

各コントローラのルーティングを追加します。

なお、Assetsのルーティングはデフォルトのままです。

GET     /                           controllers.HomeController.index()
GET     /apple                      controllers.AppleController.index()
GET     /sweet_potato               controllers.SweetPotatoController.index()

# Map static resources from the /public folder to the /assets URL path
GET     /assets/*file               controllers.Assets.versioned(path="/public", file: Asset)

 

Play Frameworkのビルド設定

sbt run でフロントエンドとバックエンドが両方コンパイルできるようにする

Play Frameworkのアプリを sbt run で起動すると、 dev mode となります。

公式ドキュメントによると、dev modeで起動する場合、以下のメソッドを使うことにより処理を差し込めそうです。

  • beforeStarted(): Unit - called before the play application is started, but after all “before run” tasks have been completed.
  • afterStarted(): Unit - called after the play application has been started.
  • afterStopped(): Unit - called after the play process has been stopped.

Hooking into Play’s dev mode | Sbt Cookbook - 2.8.x

 
そこで、冒頭でも紹介した記事を参考にしながら必要なものを実装していきます。
Integrate Vue.js into existing Play Framework project | CyberAgent Developers Blog

 

PlayKeys.playRunHooks 用のクラスを作成

PlayKeys.playRunHooks 用のクラスとして、 project/PlayDevRunHook.scala を実装します。

import java.io.PrintWriter
import play.sbt.PlayRunHook
import sbt._

import scala.io.Source
import scala.sys.process.Process
import scala.util.Using

object PlayDevRunHook {
  def apply(base: File): PlayRunHook = {
    val frontendBase = base / "frontend"
    val packageJsonPath = frontendBase / "package.json"
    // フロントエンドのビルド時に vite_assets にあるファイルが消されてしまうので、package.json と同じディレクトリに置いておく
    val packageJsonHashPath = frontendBase / "package.json.hash"

    object FrontendBuildProcess extends PlayRunHook {
      var process: Option[Process] = None

      override def beforeStarted(): Unit = {
        println("Hook to Play Framework dev run -- beforeStarted")

        val currPackageJsonHash = Using(Source.fromFile(packageJsonPath)) { source =>
          source.getLines().mkString.hashCode().toString
        }.getOrElse("")

        val oldPackageJsonHash = getStoredPackageJsonHash

        // frontend/package.json が変更されたら、もう一度 'yarn install` コマンドを実行する
        if (!oldPackageJsonHash.contains(currPackageJsonHash)) {
          println(s"Found new/changed package.json. Run yarn install ...")

          // 同期的にインストールを実行
          Process("yarn install", frontendBase).!

          updateStoredPackageJsonHash(currPackageJsonHash)
        }
      }

      override def afterStarted(): Unit = {
        println(s"> Watching frontend changes in $frontendBase")
        // フロントエンドのビルド用のプロセスを立ち上げ、非同期で実行する
        process = Option(Process("yarn build --watch", frontendBase).run)
      }

      override def afterStopped(): Unit = {
        // フロントエンドのビルド用のプロセスを停止する
        process.foreach(_.destroy)
        process = None
      }

      private def getStoredPackageJsonHash: Option[String] = {
        if (packageJsonHashPath.exists()) {
          val hash = Using(Source.fromFile(packageJsonHashPath)) { source =>
            source.getLines().mkString
          }
          Some(hash.getOrElse(""))
        } else {
          None
        }
      }

      private def updateStoredPackageJsonHash(hash: String): Unit = {
        Using(new PrintWriter(packageJsonHashPath)) { writer =>
          writer.write(hash)
        }
      }
    }

    FrontendBuildProcess
  }
}

 
修正箇所としては

  • 今回のアプリでは yarn installyarn build --watch は一箇所でしか使わないため、Shell object をインライン化
  • 元の実装ではビルドしたアプリの出力先に package.json.hash を出力していたが、Viteでビルドする時に出力先のファイルをすべて削除してしまうため、 package.json と同じディレクトリに置く
  • scala.util.Using の使用や、戻り値の型を追加するなど、IDEでの警告に対応

です。

 
なお、 Shell object の記法について、一部調べたことがあったため、メモとして残します。

object Shell {
  def execute(cmd: String, cwd: File, envs: (String, String)*): Int = {
    Process(cmd, cwd, envs: _*).!
  }

  def invoke(cmd: String, cwd: File, envs: (String, String)*): Process = {
    Process(cmd, cwd, envs: _*).run
  }
}

 
まず、可変長引数まわりです。

  • envs: (String, String)*(String, String)* は可変長引数としてのタプルです。
  • Process(cmd, cwd, envs: _*)_* は、可変長引数を Process に渡しています。

このあたりは、Pythonと比較した記法が紹介されている以下がわかりやすかったです。
syntax - What does param: _* mean in Scala? - Stack Overflow

 
次は、Process のメソッド

  • !
  • run

の違いです。

これについては、Scala Standard Library に記載があり、ざっくり run は非同期・ ! は同期での実行と理解しました。

Starting Processes

To execute all external commands associated with a ProcessBuilder, one may use one of four groups of methods. Each of these methods have various overloads and variations to enable further control over the I/O. These methods are:

  • run: the most general method, it returns a scala.sys.process.Process immediately, and the external command executes concurrently.
  • !: blocks until all external commands exit, and returns the exit code of the last one in the chain of execution.
  • !!: blocks until all external commands exit, and returns a String with the output generated.
  • lazyLines: returns immediately like run, and the output being generated is provided through a LazyList[String]. Getting the next element of that LazyList may block until it becomes available. This method will throw an exception if the return code is different than zero -- if this is not desired, use the lazyLines_! method.

 

build.sbt に追記

公式ドキュメントの記述に従い、 build.sbt に追加します。

// sbt run 時に、フロントエンドもコンパイルする
PlayKeys.playRunHooks += PlayDevRunHook(baseDirectory.value)

 

ビルド時に Assets にフィンガープリントを付与する

JavaScript などの Assets の Cache Busting として、ビルド時にフィンガープリントを付与するよう設定します。

Play Framework の場合、sbt-websbt-digest を使います。

 
sbt-digest のREADMEと以下の記事を参考に、設定を行います。
Play Frameworkでブラウザキャッシュ対策(Cache Busting) - Qiita

まず project/plugins.sbt に以下を追加します。

addSbtPlugin("com.typesafe.sbt" % "sbt-digest" % "1.1.4")

 
続いて、 build.sbt に追記します。

なお、今回は開発環境でもフィンガープリントを付与する設定を行います。

lazy val root = (project in file("."))
  .enablePlugins(PlayScala)
  .enablePlugins(SbtWeb)  // 追加

// 開発環境でもフィンガープリントを付与
Assets / pipelineStages := Seq(digest)

ちなみに、sbt 1.5 より、 pipelineStages in Assets ではなく Assets / pipelineStages となったようです。
Migrating to slash syntax | sbt Reference Manual — Migrating from sbt 0.13.x

 

dist taskにて、Play Framework に React を含めるようにする

Play Framework では dist taskを使うことでバイナリバージョンができます。

その dist task にも React を含めるよう、 build.sbt に設定を追加します。

lazy val frontEndBuild = taskKey[Unit]("Execute dashboard frontend build command")

val frontendPath = "frontend"
val frontEndFile = file(frontendPath)

frontEndBuild := {
  println(Process("yarn install", frontEndFile).!!)
  println(Process("yarn build", frontEndFile).!!)
}

dist := (dist dependsOn frontEndBuild).value
stage := (stage dependsOn dist).value

 

動作確認

sbt run 時にフロントエンドもコンパイルされること

ログを見たところ、コンパイルされていました。

% sbt run
...
[info] p.c.s.AkkaHttpServer - Listening for HTTP on /0:0:0:0:0:0:0:0:9000
> Watching frontend changes in path/to/mpa_app_with_play_react_vite/frontend

(Server started, use Enter to stop and go back to the console...)

yarn run v1.22.11
warning package.json: No license field
$ tsc && vite build --watch
vite v2.6.2 building for production...

watching for file changes...

build started...
transforming...
✓ 31 modules transformed.
rendering chunks...
../public/vite_assets/apple.js          0.26 KiB / gzip: 0.21 KiB
../public/vite_assets/sweet_potato.js   0.33 KiB / gzip: 0.23 KiB
../public/vite_assets/ListApp.js        0.48 KiB / gzip: 0.30 KiB
../public/vite_assets/vendor.js         129.47 KiB / gzip: 41.77 KiB
built in 1646ms.

 

scriptタグにフィンガープリントが付いていること

Elementを開いてみると、scriptタグにフィンガープリントが付与されていることを確認できました。

 

ソースコード

Github に上げました。
https://github.com/thinkAmi-sandbox/mpa_app_with_play_react_vite

 
今回のPRはこちらです。
https://github.com/thinkAmi-sandbox/mpa_app_with_play_react_vite/pull/1

Scala + Play Framework 2.8 系で、設定ファイル(application.conf)の内容を読み込む

Scala + Play Framework 2.8系で、設定ファイル (application.conf) の内容を読み込む機会があったため、メモを残します。

 
目次

 

環境

  • Scala 2.13.6
  • Play Framework 2.8.8

 

方法

Play Framework 2.8系では

  • Controllerで play.api.Configurationplay.api. Environment をDIする
  • ConfigFactory.load().getString を使う

で行うようです。

 

動作確認

以下の application.config・Controller・Viewを用意し、試してみます。

application.config

foo=bar

 
ConfigLoaderController.scala

今回は

  • Controllerで play.api.Configuration をDIする
  • ConfigFactory.load().getString を使う

を試してみます。

package controllers

import com.typesafe.config.ConfigFactory
import play.api.mvc._

import javax.inject.Inject

class ConfigLoaderController @Inject()(val cc: ControllerComponents, val config: play.api.Configuration) extends AbstractController(cc) {
  def diConfig() = Action { implicit request: Request[AnyContent] =>
    // injectされた config を使うパターン
    Ok(views.html.di_config(config.underlying.getString("foo")))
  }

  def load() = Action { implicit request: Request[AnyContent] =>
    // Factoryでloadする
    val conf = ConfigFactory.load()
    val value = conf.getString("foo")
    Ok(views.html.load_config(value))
  }
}

 
それぞれの方法用にViewを用意して、動作を確認してみます。

DI用のView (di_config.scala.html) を

@(name: String)

@main("Welcome to Play") {
    <h1>Configuration value: @name</h1>
}

と用意したところ、値を読み込めてViewで表示できました。

 
一方、load用のView (load_config.scala.html) を

@(name: String)

@main("Welcome to Play") {
    <h1>Load value: @name</h1>
}

と用意したところ、こちらも読み込めてViewで表示できました。

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/play_scala_hello_app

PRはこちらです。
https://github.com/thinkAmi-sandbox/play_scala_hello_app/pull/1