React + React Router v6 + MUI の Breadcrumbs で、動的ルーティングを含むパンくずリストを作ってみた

前回、MUI の Breadcrumbs のサンプルコードが React Router v6 では動かなかったため、React + React Router v6 + use-react-router-breadcrumbs を使って、パンくずリストを作ってみました。
React + React Router v6 + use-react-router-breadcrumbs でパンくずリストを作ってみた - メモ的な思考的な

とはいえ、 MUI の Breadcrumbs を使ったパンくずリストも作ってみたくなりました。
Integration with react-router | React Breadcrumbs component - MUI

そこで、React Router v6 でも MUI の Breadcrumbs が動くように実装した時のメモを残します。

 
目次

 

環境

前回の記事に比べ、各ライブラリのバージョンは少しずつ上げています。

  • React 17.0.2
  • React Router 6.0.2
  • @mui/material 5.2.2

 

MUIの例が動作するように実装

ルーティングで Index Routes を実装

前回と異なる点は以下のとおりです。

 
後者については、例えば、 /mui-breadcrumbs/1st というパスの場合、前回は

<Route path="1st">
  <Route path="" element={<FirstLayer />} />
</Route>

と、ネストした Route で path="" を設定していました。

 
一方、今回は公式ドキュメントの Index Routes の形式にのっとり

<Route path="1st">
  <Route index element={<MuiFirstLayer />} />
</Route>

と、ネストした Route で path の代わりに index を設定します。

 
全体はこんな感じです。

App.tsx

function App() {
  return (
    <div className="App">
      <Routes>
        <Route path="mui-breadcrumbs" element={<MuiLayout />}>
          <Route index element={<MuiTop />} />
          <Route path="1st">
            <Route index element={<MuiFirstLayer />} />
            <Route path="2nd">
              <Route index element={<MuiSecondLayer />} />
              <Route path="3rd">
                <Route index element={<MuiThirdLayer />} />
              </Route>
            </Route>
          </Route>
        </Route>
      </Routes>
    </div>
  )
}

 

パンくずリストコンポーネントを作成

MUI 公式ドキュメントの Breadcrumbs にある Integration with react-router のコード をベースに、React Router v6 で動くように修正します。

大きな違いとしては、 location は React Router v6 の useLocation API を使っていることです。
useLocation - React Router | API Reference

あとは

くらいです。

/mui_breadcrumbs/MuiBread.tsx

import useBreadcrumbs from 'use-react-router-breadcrumbs'
import {Link as RouterLink, NavLink, useLocation} from 'react-router-dom'
import {Breadcrumbs, Link, LinkProps} from '@mui/material'

interface LinkRouterProps extends LinkProps {
  to: string
  replace?: boolean
}

// パンくずリストに表示する文言を公式サンプルと変更
const breadcrumbNameMap: {[key: string]: string} = {
  '/mui-breadcrumbs': 'mui-breadcrumbs ホーム',
  '/mui-breadcrumbs/1st': '第1階層',
  '/mui-breadcrumbs/1st/2nd': '第2階層',
  '/mui-breadcrumbs/1st/2nd/3rd': '第3階層'
}

const LinkRouter = (props: LinkRouterProps) => <Link {...props} component={RouterLink as any} />

const Component = (): JSX.Element => {
  // locationは React Router v6 の useLocation API で取得
  const location = useLocation()

  const pathNames = location.pathname.split('/').filter((x) => x)

  return (
    <Breadcrumbs>
      <LinkRouter underline="hover" color="inherit" to="/">
        Home
      </LinkRouter>
      {pathNames.map((value, index) => {
        const last = index === pathNames.length - 1
        const to = `/${pathNames.slice(0, index + 1).join('/')}`

        // リンクの色を分かりやすくするため、サンプルとは color を変更
        return last ? (
          <div key={to}>{breadcrumbNameMap[to]}</div>
        ) : (
          <LinkRouter underline="hover" color="primary" to={to} key={to}>
            {breadcrumbNameMap[to]}
          </LinkRouter>
        )
      })}
    </Breadcrumbs>
  )
}
export default Component

 

ページまわりのコンポーネントを作成

レイアウトのコンポーネント

前回の記事と異なり、今回はパスのルートで

<Route path="mui-breadcrumbs" element={<MuiLayout />}>

と、レイアウトコンポーネントである MuiLayout コンポーネントを設定しています。

この中で、

としています。

/mui_breadcrumbs/MuiLayout.tsx

import {Outlet} from 'react-router-dom'
import MuiBread from '@/components/pages/mui_breadcrumbs/MuiBread'

const Component = (): JSX.Element => {
  return (
    <>
      <h1>レイアウト</h1>
      <MuiBread />
      <Outlet />
    </>
  )
}
export default Component

 

各ページのコンポーネント

前回の記事と異なり、パンくずリストコンポーネントが Layout に移動したため、残ったものを配置します。

また、表示するLinkコンポーネントは MUI のものを使うため、 component prop に React Router の Link コンポーネントを指定しています。
Third-party routing library - Links - MUI

ただ、 Link の名前が重複するため、 MUI の公式ドキュメントに従い React Router の Link コンポーネントRouterLink として as import します。

/mui_breadcrumbs/MuiTop.tsx

import {Link} from '@mui/material'
import {Link as RouterLink} from 'react-router-dom'

const Component = (): JSX.Element => {
  return (
    <>
      <h1>Top Page</h1>
      <Link to="/mui-breadcrumbs/1st" component={RouterLink as any}>
        To 1st Layer
      </Link>
    </>
  )
}
export default Component

 
/mui_breadcrumbs/MuiFirstLayer.tsx

import {Link} from '@mui/material'
import {Link as RouterLink} from 'react-router-dom'

const Component = (): JSX.Element => {
  return (
    <>
      <h1>First Layer</h1>
      <Link to="/mui-breadcrumbs/1st/2nd" component={RouterLink as any}>
        To 2nd Layer
      </Link>
    </>
  )
}
export default Component

 
/mui_breadcrumbs/MuiSecondLayer.tsx

import {Link} from '@mui/material'
import {Link as RouterLink} from 'react-router-dom'

const Component = (): JSX.Element => {
  return (
    <>
      <h1>Second Layer</h1>
      <Link to="/mui-breadcrumbs/1st/2nd/3rd" component={RouterLink as any}>
        To 3rd Layer
      </Link>
    </>
  )
}
export default Component

 
/mui_breadcrumbs/MuiThirdLayer.tsx

const Component = (): JSX.Element => {
  return (
    <>
      <h1>Third Layer</h1>
    </>
  )
}
export default Component

 

動作確認

以下のようなパンくずリストができました。リンクも MUI のものになっています。

Top

 
1st Layer

 
2nd Layer

 
3rd Layer

 
ここまでのプルリクはこちらです。
https://github.com/thinkAmi-sandbox/react_mui_with_vite-sample/pull/4

 

動的ルーティングに対応

MUIの例は、静的ルーティングにのみ対応しています。

例えば、

const breadcrumbNameMap: {[key: string]: string} = {
  '/mui-breadcrumbs': 'mui-breadcrumbs ホーム',
  '/mui-breadcrumbs/1st': '第1階層',
  '/mui-breadcrumbs/1st/:firstId': 'これにはマッチしない', // 追加
}

と、動的ルーティングの設定を追加しても、パンくずリストには表示されません。

そこで、以下のstackoverflowを参考に動的ルーティングの設定も追加してみます。
reactjs - Using React Router DOM Route in Material-UI Breadcrumbs - Stack Overflow

 

ルーティングに設定を追加

静的ルーティングのみとは別のルートを用意します。

  • /parameter-breadcrumbs/root/:rootId
  • /parameter-breadcrumbs/root/:rootId/child/:childId

のように、途中や末尾に動的なパスがあるものとします。

<Route path="parameter-breadcrumbs" element={<ParamsLayout />}>
  <Route index element={<ParamsTop />} />
  <Route path="root">
    <Route index element={<ParamsRootIndex />} />
    <Route path=":rootId">
      <Route index element={<ParamsRootDynamic />} />
      <Route path="child">
        <Route index element={<ParamsChildIndex />} />
        <Route path=":childId" element={<ParamsChildDynamic />} />
      </Route>
    </Route>
  </Route>
</Route>

 

ページまわりのコンポーネントを作成

動的ルーティング時のコンポーネントは、以下のようになります。

なお、React Router の useParams API を使い、paramsの値を画面に表示しています。
useParams - React Router | API Reference

import {Link} from '@mui/material'
import {Link as RouterLink, useParams} from 'react-router-dom'

const Component = (): JSX.Element => {
  const params = useParams()

  return (
    <>
      <h1>Root Dynamic</h1>
      <h2>Parameter: {params.rootId}</h2>
      <Link to="/parameter-breadcrumbs/root/1/child" component={RouterLink as any}>
        To Child Index
      </Link>
    </>
  )
}
export default Component

 
静的ルーティング時のコンポーネントの作りは、上記で作成した静的ルーティングのコンポーネントと変わらないため、今回は省略します。

詳細は Github に上げたソースコードを参照ください。
https://github.com/thinkAmi-sandbox/react_mui_with_vite-sample/tree/main/src/components/pages/parameter_breadcrumbs

 

パンくずリストコンポーネントを作成

MUI のみのバージョン

パンくず名を取得する関数を用意

上記の静的ルーティングの例では、 breadcrumbNameMap でパンくずに表示するものを指定していました。

const breadcrumbNameMap: {[key: string]: string} = {
  '/mui-breadcrumbs': 'mui-breadcrumbs ホーム',
  '/mui-breadcrumbs/1st': '第1階層',
  '/mui-breadcrumbs/1st/2nd': '第2階層',
  '/mui-breadcrumbs/1st/2nd/3rd': '第3階層'
}

 
ただ、動的ルーティングの場合は

const breadcrumbNameMap: {[key: string]: string} = {
  '/parameter-breadcrumbs': 'parameter-breadcrumbs Home',
  '/parameter-breadcrumbs/root': 'Root Index',
  '/parameter-breadcrumbs/root/:rootId': 'これにはマッチしない' // 追加
}

としても、マッチしません。

そのため、 breadcrumbNameMap からパンくずの名前を取得するのではなく、パンくず名を取得する関数 getBreadcrumbsName を用意します。

今後、ToDoのところを実装していきます。

import {Link as RouterLink, matchPath, useLocation, useMatch} from 'react-router-dom'
import {Breadcrumbs, Link, LinkProps} from '@mui/material'

interface LinkRouterProps extends LinkProps {
  to: string
  replace?: boolean
}

const staticRouteNameMap: {[key: string]: string} = {
  '/parameter-breadcrumbs': 'parameter-breadcrumbs Home',
  '/parameter-breadcrumbs/root': 'Root Index',
  '/parameter-breadcrumbs/root/:rootId': 'これにはマッチしない'
}

const getBreadcrumbsName = (to: string) => {
  const staticRouteName = staticRouteNameMap[to]
  if (staticRouteName) {
    return staticRouteName
  }

  // TODO: マッチしないものを個別に取得する
}

const LinkRouter = (props: LinkRouterProps) => <Link {...props} component={RouterLink as any} />

const Component = (): JSX.Element => {
  // locationは React Router v6 の useLocation API で取得
  const location = useLocation()

  const pathNames = location.pathname.split('/').filter((x) => x)

  return (
    <Breadcrumbs>
      <LinkRouter underline="hover" color="primary" to="/">
        Home
      </LinkRouter>
      {pathNames.map((value, index) => {
        const last = index === pathNames.length - 1
        const to = `/${pathNames.slice(0, index + 1).join('/')}`

        // リンクの色を分かりやすくするため、サンプルとは color を変更
        return last ? (
          <div key={to}>{getBreadcrumbsName(to)}</div>
        ) : (
          <LinkRouter underline="hover" color="primary" to={to} key={to}>
            {getBreadcrumbsName(to)}
          </LinkRouter>
        )
      })}
    </Breadcrumbs>
  )
}
export default Component

 

useMatch は使えない

続いて、ToDoの部分を実装していきます。

stackoverflowの例では useRouteMatch を使っていました。
https://stackoverflow.com/a/59079979

ただ、React Router v6 では useRouteMatch が無いため、アップグレードガイドにしたがって useMatch を使ってみます。
Replace useRouteMatch with useMatch - React Router | Upgrading from v5

if (useMatch('/parameter-breadcrumbs/root/:rootId')) {
  return 'Root Dynamic'
}

if (useMatch('/parameter-breadcrumbs/root/:rootId/child')) {
  return 'Child Index'
}

if (useMatch('/parameter-breadcrumbs/root/:rootId/child/:childId')) {
  return 'Child Dynamic'
}

 
末尾が動的に変わる場合はうまくいきます。

/parameter-breadcrumbs/root/1

一方、途中が動的に変わる場合はうまくいきません。パンくずの途中の部分まで Child Dynamic になってしまっています。

/parameter-breadcrumbs/root/1/child/2

 
原因は、公式ドキュメントにもある通り、 useMatch は現在の location と比較してマッチしているかどうかを見ているためです。

Returns match data about a route at the given path relative to the current location.

https://reactrouter.com/docs/en/v6/api#usematch

 

matchPath を使う

そこで、別のAPIである matchPath を使います。こちらはパターンとルートを渡すことで、そのルートがマッチするかどうかを判定してくれます。

matchPath matches a route path pattern against a URL pathname and returns information about the match. This is useful whenever you need to manually run the router's matching algorithm to determine if a route path matches or not. It returns null if the pattern does not match the given pathname.

https://reactrouter.com/docs/en/v6/api#matchpath

 
今回の関数 getBreadcrumbsName

/parameter-breadcrumbs
/parameter-breadcrumbs/root
/parameter-breadcrumbs/root/1
/parameter-breadcrumbs/root/1/child
/parameter-breadcrumbs/root/1/child/2

のように順を追ってルートが渡されていく形で呼ばれるため、 matchPath が使えそうです。

if (matchPath('/parameter-breadcrumbs/root/:rootId', to)) {
  return 'Root Dynamic'
}

if (matchPath('/parameter-breadcrumbs/root/:rootId/child', to)) {
  return 'Child Index'
}

if (matchPath('/parameter-breadcrumbs/root/:rootId/child/:childId', to)) {
  return 'Child Dynamic'
}

 

MUI + use-react-router-breadcrumbs のバージョン

MUI のみでは、動的ルーティングが複数ある場合に設定が複雑になりそうでした。

そこで、前々回の記事で使った use-react-router-breadcrumbs を MUI と組み合わせて使ってみます。

 
前々回の記事との違いとしては、

  • MUI と組み合わせるので、一番外側に MUI の Breadcrumbs コンポーネントを置く
  • useBreadcrumbs する時に、パスとパンくず名を含んだオブジェクト (routes) を渡す
  • 現在のパスにはリンクを張らない

です。  

import useBreadcrumbs from 'use-react-router-breadcrumbs'
import {Link as RouterLink} from 'react-router-dom'
import {Breadcrumbs, Link, LinkProps} from '@mui/material'

interface LinkRouterProps extends LinkProps {
  to: string
  replace?: boolean
}

const routes = [
  {path: '/parameter-breadcrumbs', breadcrumb: 'parameter-breadcrumbs Home'},
  {path: '/parameter-breadcrumbs/root', breadcrumb: 'Root Index'},
  {path: '/parameter-breadcrumbs/root/:rootId', breadcrumb: 'Root Dynamic'},
  {path: '/parameter-breadcrumbs/root/:rootId/child', breadcrumb: 'Child Index'},
  {path: '/parameter-breadcrumbs/root/:rootId/child/:childId', breadcrumb: 'Child Dynamic'}
]

const LinkRouter = (props: LinkRouterProps) => <Link {...props} component={RouterLink as any} />

const Component = (): JSX.Element => {
  const breadcrumbs = useBreadcrumbs(routes)
  return (
    <>
      <Breadcrumbs>
        {breadcrumbs.map(({match, breadcrumb, location}, index) => {
          // 最後のパスはリンクしないようにする
          const pathNames = location.pathname.split('/').filter((x) => x)
          const last = index === pathNames.length

          return last ? (
            <span key={match.pathname}>{breadcrumb}</span>
          ) : (
            <span key={match.pathname}>
              <LinkRouter underline="hover" color="primary" to={match.pathname}>
                {breadcrumb}
              </LinkRouter>
            </span>
          )
        })}
      </Breadcrumbs>
    </>
  )
}
export default Component

 
以上で実装が終わりました。

ここまでのプルリクはこちらです。
https://github.com/thinkAmi-sandbox/react_mui_with_vite-sample/pull/5

 

動作確認

いずれも想定通りに動作しています。

 

/parameter-breadcrumbs/root

 

/parameter-breadcrumbs/root/1

 

/parameter-breadcrumbs/root/1/child

 

/parameter-breadcrumbs/root/1/child/2

 

ソースコード

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

 
また、再掲となりますが、今回の記事で関係するプルリクは以下の2つになります。

React + React Router v6 + use-react-router-breadcrumbs でパンくずリストを作ってみた

React + MUI の Breadcrumbs では、 React Router と統合して扱えます。
Integration with react-router | React Breadcrumbs component - MUI

 
ただ、サンプルコードは React Router v5用のもののようで、 codesandbox で公開されているデモがエラーで止まっています。

 
React Router v6 向けにさくっと作れるものがないかを探したところ、 icd2k3/use-react-router-breadcrumbs がありました。
https://github.com/icd2k3/use-react-router-breadcrumbs

そこで、さくっと作れるパンくずリストはどんなものだろうと思い、試してみた時のメモを残します。

 
目次

 

環境

  • React 17.0.2
  • React Router 6.0.1
  • use-react-router-breadcrumbs 3.0.1

 

実装

ルーティング

今回は、 /router-breadcrumbs/1st/2nd/3rd のようなネストしたルーティングでパンくずリストを表示してみます。

そのため、ルーティングはこんな感じにします。

<Routes>
  <Route path="router-breadcrumbs">
    <Route path="1st">
      <Route path="" element={<FirstLayer />} />
      <Route path="2nd">
        <Route path="" element={<SecondLayer />} />
        <Route path="3rd">
          <Route path="" element={<ThirdLayer />} />
        </Route>
      </Route>
    </Route>
  </Route>
</Routes>

 

パンくずリストコンポーネントを作成

use-react-router-breadcrumbs のREADMEに従い、パンくずリストコンポーネントを作成します。

import useBreadcrumbs from 'use-react-router-breadcrumbs'
import {NavLink} from 'react-router-dom'

const Component = (): JSX.Element => {
  const breadcrumbs = useBreadcrumbs()
  return (
    <>
      {breadcrumbs.map(({match, breadcrumb}, index) => (
        <span key={match.pathname}>
          {index > 0 && <> / </>}
          <NavLink to={match.pathname}> {breadcrumb}</NavLink>
        </span>
      ))}
    </>
  )
}
export default Component

 

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

パンくずリストコンポーネントを組み込んだページコンポーネントを用意します。

FirstLayer.tsx

import {Link} from 'react-router-dom'
import MyBread from '@/components/pages/router_breadcrumbs/MyBread'

const Component = (): JSX.Element => {
  return (
    <>
      <MyBread />
      <h1>First Layer</h1>
      <Link to="/router-breadcrumbs/1st/2nd">To 2nd Layer</Link>
    </>
  )
}
export default Component

 
SecondLayer.tsx

import {Link} from 'react-router-dom'
import MyBread from '@/components/pages/router_breadcrumbs/MyBread'

const Component = (): JSX.Element => {
  return (
    <>
      <MyBread />
      <h1>Second Layer</h1>
      <Link to="/router-breadcrumbs/1st/2nd/3rd">To 3rd Layer</Link>
    </>
  )
}
export default Component

 

ThridLayer.tsx

import MyBread from '@/components/pages/router_breadcrumbs/MyBread'

const Component = (): JSX.Element => {
  return (
    <>
      <MyBread />
      <h1>Third Layer</h1>
    </>
  )
}
export default Component

 

動作確認

以下のようなパンくずリストができました。

 
1st Layer

 
2nd Layer

 
3rd Layer

 
実際には README の Advanced 以降のように調整する必要があるかもしれませんが、さくっと作る場合はこんな感じでできました。

 

ソースコード

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

 
今回のプルリクはこちらです。
https://github.com/thinkAmi-sandbox/react_mui_with_vite-sample/pull/3

Ruby 3.0.3 にアップデートしたところ、bootsnap の影響で Rails が起動しなくなったので対応した

先日、Rubyのセキュリティリリースが出ていました。
Ruby 3.0.3 Released

 
そのため、Rubyのバージョンを 3.0.3 に上げたところ、RailsRSpec が起動しなくなったため、対応した時のメモを残します。

 

環境

 

エラー

Ruby 3.0.3 にアップデートした後で Rails を起動したところ、以下のエラーになりました。

% bundle exec rails s
path/to/vendor/bundle/ruby/3.0.0/gems/bootsnap-1.9.1/lib/bootsnap/compile_cache/iseq.rb:13:in `to_binary': wrong argument type false (expected Symbol) (TypeError)

 
RSpecも実行してみましたが

% bundle exec rspec  

An error occurred while loading ./spec/requests/api_spec.rb.
Failure/Error: require 'rspec/rails'

TypeError:
  wrong argument type false (expected Symbol)
# ./vendor/bundle/ruby/3.0.0/gems/bootsnap-1.9.1/lib/bootsnap/compile_cache/iseq.rb:13:in `to_binary'
...
No examples found.


Finished in 0.00009 seconds (files took 1.38 seconds to load)
0 examples, 0 failures, 1 error occurred outside of examples

と、テストコードの実行前にエラーとなってしまいました。

 

対応

エラーに出ていた bootsnap のリポジトリを見たところ、issueやPRがありました。

 
issueを見たところ、bootsnap を 1.9.3 に上げると解消されるとのことでした。

 
そこで、 bootsnap をアップデートします。

% bundle update bootsnap

 
その後、 rails s したところ、無事に起動しました。

% bundle exec rails s
=> Booting Puma
=> Rails 6.1.4.1 application starting in development 
...

 
また、RSpecも実行し、テストがパスすることを確認しました。

% bundle exec rspec  

..............................

Finished in 0.61663 seconds (files took 1.74 seconds to load)
30 examples, 0 failures

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 の背景色が元に戻りました。