Refineを使って爆速で管理画面を作ってみた。Next.js + Material UI + Firestore

# 始めに
## 経緯
最近、社内ツールの為にちょっとした管理画面を作る必要がありました。
しかし、わざわざ画面のデザインを考えたりログインの認証機能を作ったりするのは面倒です。
そこで、前から気になっていた管理画面作成のためのフレームワークであるRefineに入門しました。
この記事では例としてECサイトを想定した商品管理画面を作ってみます。

## 対象読者
- 管理画面を爆速で作りたい
- Next.jsとFirestoreを触ったことがある

## [Refine](https://refine.dev/)とは
Reactベースで管理画面を作るためのフレームワークです。
テンプレートが豊富で、初心者でもとっつきやすいです。
また、ドキュメントも豊富ですし、開発も活発なようです。

基本のコマンドでプロジェクトを立ち上げるだけでしっかりした見た目の管理画面が出来上がるのでテンションが上がります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/413932/571f96eb-6d6b-b0a8-6122-3d0201c6c7b2.png)

コンセプトは[こちら](https://refine.dev/docs/guides-concepts/general-concepts/)に書かれています。
UI, ルーティング, データソースへの接続, 認証などのレイヤーが抽象化されていて、それぞれ複数のライブラリから選んで使えることが特徴です。
Refineを使えば各レイヤーを繋ぐためのグルーコードを高速に書けるということです。

特定のライブラリだけに依存することが無いため、堅牢であると言えます。
例えば、データソースにFirestoreを使っていたけど会社の方針でREST APIに置き換える必要が出た場合、Refineを使っていれば、データソースへの接続部分のコードの置き換えがとても簡単です。

[公式リポジトリ](https://github.com/refinedev/refine)の以下の画像がこの思想を的確に表しています。

![image](https://camo.githubusercontent.com/be5832cb1be6b4aac079afc8a3111cd1108de1dccfd2900c8b194d713926b82f/68747470733a2f2f726566696e652e616d73332e63646e2e6469676974616c6f6365616e7370616365732e636f6d2f776562736974652f7374617469632f696d672f6469616772616d2d332e706e67)

## プロジェクト構成
今回は以下の技術構成で管理画面を作りました。

- ベース: Next.js(App Router)
  - 使ったことがある
  - 弊社でよく使われている
  - 適度にモダンっぽい
- UI: Material UI
  - 選択できるものの中で1番リッチな見た目と触り心地だった
  - Googleが好きなのでMaterial Designも好き
- ログイン認証: Google Auth
  - Googleが好き
- DB: Firestore
  - Googleが好き
  - 普段の開発でよく使う
  - ダッシュボードが使いやすい

## 各種バージョン情報
- node.js: 18.20.3
- Next.js: 14.1.0
- refinedev/core: 4.47.1

# 手順
## 1. プロジェクト生成
プロジェクトディレクトリを置きたいディレクトリで以下のコマンドを実行します。
```
npm create refine-app@latest
```
すると、使用するフレームワークをどれにするかなど聞かれるので順番に選んで行きます。

今回は以下の様な選択をしました。
バックエンドはFirestoreを使いますが、選択肢に無いのでひとまずREST APIを選択します。
```
√ Choose a project template · refine-nextjs
√ What would you like to name your project?: · admin-panel-test       
√ Choose your backend service to connect: · data-provider-custom-json-rest
√ Do you want to use a UI Framework?: · mui
√ Do you want to add example pages?: · mui-example
√ Do you need any Authentication logic?: · auth-provider-google 
```

暫し待つとプロジェクトの生成が完了します。

## 2. 起動確認
作成されたプロジェクトディレクトリに移動して以下のコマンドを実行するとローカルサーバーでアプリが立ち上がります。
```
npm run dev
```

コマンドラインに`✓ Ready in 〇〇s`と表示されたらブラウザで以下のアドレスに
アクセスすることでアプリの初期状態を確認することが出来ます
http://localhost:3000/

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/413932/2a8fad3a-8260-e064-1fc2-7a7638c93c5c.png)

いきなりGoogleログイン機能からブログ記事管理機能まで出来ていて驚きます。

## 3. Firebaseとの接続
今回はDBとしてFirestoreを使用します。
[Firebaseコンソール](https://console.firebase.google.com/?hl=ja)から新規のFirebaseプロジェクトを作成しておきます。

### 3.1 Firestoreの準備
プロダクト一覧から「Cloud Firestore」を選択して、データベースの選択ボタンを押します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/413932/46bd04ef-7a7f-88ca-379d-98d1f03850b0.png)

今回はリージョンを東京、セキュリティルールはテストモードとしました。

しばらくするとFirestoreが有効化されます。
管理画面から確認、編集するための初期データとして`products`コレクションを作成しておきます。商品マスタのつもりです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/413932/c9d29723-f551-5954-b371-32a254b1f2ca.png)

### 3.2 認証情報の取得
作成したFirebaseプロジェクトにウェブアプリを追加します。
すると、以下の様なサンプルコードが得られます。
このコードの`firebaseConfig`部分を控えておきます。後の手順で使用します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/413932/02acf4cb-72e6-4a6c-9fd3-bf65c6ed07cb.png)

### 3.3 [refine-firebase](https://github.com/resulturan/refine-firebase)のインストール
今回はRefineにFirebaseを統合するためにサードパーティー製のデータプロバイダーを使用します。
以下のコマンドでインストールします
```
npm install refine-firebase
```

### 3.4 Firestoreをデータソースとして設定
`/src/providers/data-provider/firebaseConfig.ts`を作成し、以下の様に記述します。firebaseConfig変数には手順3.2で控えていたものを設定します。

```firebaseConfig.ts
import { initializeFirebase, FirestoreDatabase } from "refine-firebase";

const firebaseConfig = {
  // 手順3.2で控えていたやつ
};

export const firebaseApp = initializeFirebase(firebaseConfig);
export const firestoreDatabase = new FirestoreDatabase();
```

最後に、`/src/app/_refine_context.tsx`を以下の様に修正します。
```diff_tsx
+ import { firestoreDatabase } from "@providers/data-provider/firebaseConfig";

- dataProvider={dataProvider}
+ dataProvider={firestoreDatabase.getDataProvider()}
```

これで、データソースとしてFirestoreを使用する設定は完了です。

ちなみに、デフォルトではrefineのエンドポイントからダミーのブログ記事情報を取得するデータプロバイダーが設定されています。
そのため、この変更によって既存のBlog postsとCategories画面にはレコードが表示されなくなります。

## 4. 商品一覧、編集機能を追加
### 4.1 productsリソースを追加
Refineにはデータのまとまりを表すリソース(resource)という概念があります。RDBでいうテーブル、Firestoreだとコレクションと対応します。

リソースをRefineに登録してやることで、その中のデータをCRUDするための画面が生成されます。
リソースの登録には以下のコマンドを使用します。
```
npx refine add resource
```

コマンドを実行するとリソース名を聞かれるので、手順3.1で準備しておいた商品マスタのコレクション名である`products`を指定します。
すると、以下の4つのアクションをできるようにするかと聞かれます。
今回はひとまず全て選択しました。

- list: 複数を一覧する
- create: 1つ作成する
- edit: 1つ編集する
- show: 1つを閲覧する

```
? Resource Name (users, products, orders etc.) products
? Select Actions list, create, edit, show
🎉 Resource (src/components/products) generated successfully!
```

すると、`/src/app/_refine_context.tsx`に以下の記述が追加されます。
```diff_tsx
+ {
+   name: "products",
+   list: "/products",
+   create: "/products/create",
+   edit: "/products/edit/:id",
+   show: "/products/show/:id"
+ }
```
これらの記述により、CRUDそれぞれのページへのルーティングが定義されます。

### 4.2 商品マスタのCRUD用ページの生成
#### 4.2.1 page.tsxの作成
手順4.1のリソースの追加コマンド実行により、`/src/components/products`以下に商品マスタのCRUD用ページも生成されました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/413932/95cf7d66-dab6-2863-9d78-19578fed307f.png)
しかし、このままだと適切にルーティングが行われません。

サイドメニューの「Products」タブをクリックしてhttp://localhost:3000/products にアクセスしても404エラーが返されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/413932/c4456f54-259f-7abc-d3c9-26746cbf031c.png)

そこで生成された上記のファイルを`/src/app`以下に移動し、以下のように配置することで適切にルーティングされるようになります。

- `index.ts`を削除
- 全てのtsxファイルの名前を`page.tsx`に変更
- `layout.tsx`を`/src/app/blog-posts`からそのままコピーしてくる

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/413932/96c5f070-40cd-7730-28d1-ffeb628e135f.png)


しかし、満を持してhttp://localhost:3000/products にアクセスすると以下のエラーが表示されます。
`Error: The default export is not a React Component in page: "/products"`
以降の手順で修正していきます。

#### 4.2.2 page.tsxの修正
まず、以下のコマンドで必要なInferencerというコンポーネントをインストールします。
```
npm install @refinedev/inferencer
```

RefineではInferencerコンポーネントを使ってCRUD用のページのコードを生成することができます。Inferencerとは、推論者という意味です。
この機能のお陰で、画面を爆速で作ることが出来ます。

次に、`/src/app/products/page.tsx`を以下の様に修正します

```diff_tsx
- export const ProductsList = () => {
+ export default function ProductsList() {
```

すると、以下の様に商品一覧画面が表示されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/413932/dc3e07ff-cce8-600b-44a4-dd4aa703b958.png)

しかし、これで完了ではありません。
上記の画面に記載されている通り、現時点では商品一覧画面のコードがInferencerによって生成されている状態です。

そこで、「Show the auto-generated code」ボタンを押して生成されたコードを確認します。
表示されたコードを`/src/app/products/page.tsx`に貼り付けます。
ただ、そのままではまたしても動かないので以下のようにさらに修正します。

```diff_tsx
+ "use client";

- export const ProductsList = () => {
+ export default function ProductsList() {
```

これでようやく、商品一覧画面ができました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/413932/e57d7ce7-79c6-fe00-0510-1c07053efd1b.png)

`/src/app/products/page.tsx`の最終的なコードは以下の様になりました。
<details><summary>商品一覧画面のコード</summary>

```/src/app/products/page.tsx
"use client";

import React from "react";
import {
    useDataGrid,
    EditButton,
    ShowButton,
    DeleteButton,
    List,
} from "@refinedev/mui";
import { DataGrid, GridColDef } from "@mui/x-data-grid";

export default function ProductsList() {
    const { dataGridProps } = useDataGrid();

    const columns = React.useMemo<GridColDef[]>(
        () => [
            {
                field: "id",
                headerName: "Id",
                minWidth: 50,
            },
            {
                field: "name",
                flex: 1,
                headerName: "Name",
                minWidth: 200,
            },
            {
                field: "price",
                flex: 1,
                headerName: "Price",
                type: "number",
                minWidth: 200,
            },
            {
                field: "stockQuantity",
                flex: 1,
                headerName: "Stock Quantity",
                type: "number",
                minWidth: 200,
            },
            {
                field: "description",
                flex: 1,
                headerName: "Description",
                minWidth: 200,
            },
            {
                field: "actions",
                headerName: "Actions",
                sortable: false,
                renderCell: function render({ row }) {
                    return (
                        <>
                            <EditButton hideText recordItemId={row.id} />
                            <ShowButton hideText recordItemId={row.id} />
                        </>
                    );
                },
                align: "center",
                headerAlign: "center",
                minWidth: 80,
            },
        ],
        [],
    );

    return (
        <List>
            <DataGrid {...dataGridProps} columns={columns} autoHeight />
        </List>
    );
};
```
</details>

驚くべきことに、Firestoreの`products`コレクションに例として登録したレコードの通りにUIが作られています。Inferencerすごい。

同様にしてcreateとeditとshowディレクトリ以下の`page.tsx`も生成します。
最終的に、以下の様にそれぞれの画面が出来上がります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/413932/7d7cabf8-a246-1afb-e68d-92ee0afca038.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/413932/b7f05fcf-6e74-729a-6bf0-bbaf282850db.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/413932/0d584ba0-ab51-5793-7205-1c3aeeaa6998.png)

実際にCreate画面でレコードを登録してみたり、Edit画面で編集するとそれが一覧画面に反映されることが確認できます。

## おわりに
この記事ではRefineの導入部分を紹介しました。

Refineでは簡単に任意のリソースのデータを取得してきてそれをUIに反映できるので、複数のコレクションを複合した複雑な一覧画面や編集画面を作ることができます。
また、cssを書かずともきれいで気持ちいい触り心地の画面ができるのがとても良いと思います。
限られた人だけが使う管理画面といえど、見た目や触り心地は一定の水準を保っていてほしいものです。

Refineを触っていて管理画面がもりもり出来ていくのが楽しくてこの土日が溶けました。
今後も簡単な管理画面を作るときはRefineに頼ろうと思います。

## 参考リンク集
https://refine.dev/docs/
https://dev.classmethod.jp/articles/using-refine-with-nextjs/
https://qiita.com/uehaj/items/8f3d61bb700c370dc98e
https://zenn.dev/ficilcom/articles/refine_startup