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