【Next.js + TypeScript + Firebase】CSVのインポートとエクスポート機能を作成する


今回はNext.jsとFirestoreを使ってCSVのインポート機能とエクスポート機能の作り方を解説していきます。

また、ただCSVをインポートして保存するだけではなく、インポートする際にCSVのデータにバリデーションをかけるやり方についても、この記事で説明していきますね。

デザインに関してはChakra UIを使って整えます。

## 開発環境

* macOS Ventura 13.2.1
* Next.js 13.4.9
* Chakra UI 2.7.1
* Firebase 10.0.0

## 必要なライブラリをインストールする

ターミナルに以下のコマンドを入力して、必要なライブラリをNext.jsのプロジェクトにインストールしてください。

・Firebase
```
yarn add firebase @types/firebase
```

・react-dropzone
```
yarn add react-dropzone
```

・react-papaparse
```
yarn add react-papaparse
```

・react-csv
```
yarn add react-csv
```
```
yarn add @types/react-csv
```

・Chakra UI
```
yarn add @chakra-ui/react @emotion/react @emotion/styled framer-motion
```

## 今回使うCSVデータ

今回は以下のようなCSVデータを使います。

|科目ID  |科目名  |
|---|---|
|456789  |数学  |
|123456  |英語  |
|789123  |世界史  |
|654321  |日本史  |
|789456  |地理  |
|321654  |化学  |
|234567  |物理  |
|876543  |生物  |
|908172  |古典  |
|908172  |現代文  |

このCSVの科目IDがドキュメントIDがなり、科目名がドキュメントのnameフィールドとして保存されるように処理を書いていきます。


## ページコンポーネントを作成する

まずは、ページコンポーネントから作成していきます。pagesディレクトリにcsv_test.tsxというファイルを作成してください。

csv_test.tsxの中身は一旦、以下のようにしておいてください。

```pages/csv_test.tsx
const Csv_test = () => {
    return (
        <div>
            CSV
        </div>
    );
}

export default Csv_test;
```

これからCSVをインポートするコンポーネントとエクスポートするコンポーネントを作成して、その2つのコンポーネントをcsv_test.tsxのページに表示させるようにしていきます。

## CSVをインポートするコンポーネントを作成する

CSVインポートの機能から先に作成していきます。

プロジェクトにcomponentsディレクトリを作成して、その中にCsvImport.tsxというファイルを作成します。

CsvImport.tsxの中身は以下のようにしてください。

```components/CsvImport.tsx
import { useCallback } from 'react';
import React, { useState } from "react";
import { useDropzone } from 'react-dropzone';
import { readString } from 'react-papaparse';
import {
    Box,
    Button,
} from '@chakra-ui/react';

const CsvImport = () => {
    // CSVをドロップしたときに呼び出される処理
    const onDrop = useCallback((acceptedFiles: any) => {
        acceptedFiles.forEach((file: any) => {
            const reader = new FileReader();
            reader.onabort = () => console.log('file reading was aborted');
            reader.onerror = () => console.log('file reading has failed');
            reader.onload = () => {
                const binaryStr = reader.result;
                // CSVのデータをコンソールに表示する
                console.log(binaryStr);
            }
            reader.readAsText(file);
        });
    }, []);

    const {getRootProps, getInputProps, isDragActive} = useDropzone({onDrop});

    return (
        <div className="App">
            <div {...getRootProps()}>
                <input {...getInputProps()} />
                { isDragActive ? <p>Drop the files here ...</p>
                    : <Button>ファイルを選択</Button>
                }
            </div>
        </div>
    );
}

export default CsvImport;
```

ここまでできたら、CsvImport.tsxに作成したCSVをインポートするコンポーネントをページコンポーネントで読み込みます。

csv_test.tsxを以下のように編集してください。


```pages/csv_test.tsx
import {
    Box,
} from '@chakra-ui/react';
import CsvImport from '@/components/CsvImport';


const Csv_test = () => {
    return (
        <Box>
            <Box p={3}>
                <Box mb={2}>
                    <p>CSVインポート</p>
                </Box>
                <CsvImport/>
            </Box>
        </Box>
    );
}

export default Csv_test;
```

実際に画面を開いてCSVを選択すると、コンソールにCSVのデータが出力されていると思います。
![スクリーンショット 2023-07-14 13.23.49.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3089293/c9df51e0-34ba-1120-65a7-ec5562a8e9b2.png)

## CSVのデータをFirestoreに保存する

では、CsvImport.tsxにCSVとして読み込んだデータをFirestoreに保存するための処理を追加していきます。

Firestoreにsubjectというコレクションを作成して、先ほどのCSVの科目IDをドキュメントID、科目名をドキュメントのnameフィールドとして保存されるようにします。

まず、CsvImport.tsxに以下のような関数を追記してください。

```components/CsvImport.tsx
// CSVのデータをFirestoreに保存する関数
const HandleFileRead = (binaryStr: any) => {
    readString(binaryStr, {
        worker: true,
        complete: async (results: any) => {
            // FirestoreにCSVデータを保存する処理 (results.dataは配列になったCSVデータ)
            for (let i = 1; i < results.data.length; i++) {
                // CSVデータの1列目をドキュメントIDとして指定
                const docRef = doc(db, "subject", results.data[i][0]);
                // CSVデータの2列目をドキュメントのnameフィールドに指定してFirestoreに保存
                await setDoc(docRef, {
                    name: results.data[i][1],
                });
            }
        }
    });
}
```

HandleFileRead関数はCSVのデータをFirestoreに保存するための関数です。Firestoreのsubjectコレクションにデータを保存しています。

Firestoreに保存するsetDocメソッドを使うときにawaitを使うため、繰り返しはmap関数ではなくfor文を使っています。

また、CSVデータの最初の1行目はヘッダーになっているので、for文の繰り返しは0ではなく1からスタートするようにしていますね。

このHandleFileRead関数を先ほどのonDrop関数の中で呼び出します。

```components/CsvImport.tsx
    // CSVをドロップしたときに呼び出される処理
    const onDrop = useCallback((acceptedFiles: any) => {
        acceptedFiles.forEach((file: any) => {
            const reader = new FileReader();
            reader.onabort = () => console.log('file reading was aborted');
            reader.onerror = () => console.log('file reading has failed');
            reader.onload = () => {
                const binaryStr = reader.result;
                // CSVのデータをFirestoreに保存する処理
                HandleFileRead(binaryStr);
            }
            reader.readAsText(file);
        });
    }, []);
```

ここまでできたら、実際に画面を開き、CSVをインポートしてみてください。CSVのデータがFirestoreに保存されているはずです。
![スクリーンショット 2023-07-14 15.37.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3089293/5c302745-fd85-e1ff-dbb2-796d178e3088.png)

## CSVデータのバリデーション

続いては、先ほどのCSVデータをFirestoreに保存する処理に、CSVデータのバリデーションをつけていきます。

まずは、CsvImport.tsxのコンポーネントにエラーメッセージを表示するためのタグを追加していきます。

```components/CsvImport.tsx
    return (
        <div className="App">
            <div {...getRootProps()}>
                <input {...getInputProps()} />
                { isDragActive ? <p>Drop the files here ...</p>
                    : <Button>ファイルを選択</Button>
                }
            </div>
            {/* CSVバリデーションのエラーメッセージ */}
            <Box id="csv_import_error_message" color={'red'}></Box>
        </div>
    );
```

もしインポートしたCSVがバリデーションで弾かれた場合は、このBoxタグでエラーメッセージを表示させます。

次は、バリデーションの処理を書いていきます。

今回は科目IDと科目名に必須のバリデーションをつけていきますね。インポートしたCSVの科目IDと科目名に1つでも値が入っていないセルがあった場合にバリデーションで弾かれるようにしていきます。

バリデーションの処理も先ほど作成したFirestoreにデータを保存するための処理と同様に、HandleFileRead関数のcompleteの中に書いていきます。

HandleFileRead関数を以下のように変更してください。

```components/CsvImport.tsx
// CSVのデータをFirestoreに保存する関数
const HandleFileRead = (binaryStr: any) => {
    readString(binaryStr, {
        worker: true,
        complete: async (results: any) => {
            // エラーメッセージの初期化
            (document.getElementById('csv_import_error_message') as HTMLElement).innerHTML = '';
            // バリデーションの処理
            for (let i=0; i<results.data.length; i++) {
                // 科目IDもしくは科目名が入力されていないときのバリデーション
                if (!results.data[i][0] || !results.data[i][1]) {
                    (document.getElementById('csv_import_error_message') as HTMLElement).innerHTML = '科目IDもしくは科目名が入力されていないセルがあります。';
                    return false;
                }
            }
            // FirestoreにCSVデータを保存する処理 (results.dataは配列になったCSVデータ)
            for (let i = 1; i < results.data.length; i++) {
                // CSVデータの1列目をドキュメントIDとして指定
                const docRef = doc(db, "subject", results.data[i][0]);
                // CSVデータの2列目をドキュメントのnameフィールドに指定してFirestoreに保存
                await setDoc(docRef, {
                    name: results.data[i][1],
                });
            }
        }
    });
}
```

このバリデーションは科目IDか科目名のどちらか一方が空文字になっているときに、エラーメッセージのBoxタグのテキストを「科目IDもしくは科目名が入力されていないセルがあります。」に変更するような処理になっています。

また、一度インポートして失敗したときにエラーメッセージが出て、再度インポートして成功したにもかかわらずエラーメッセージが表示され続けるということがないように、CSVのインポートが行われるたびにエラーメッセージは初期化されるようにしておきます。

これでバリデーションの実装は完了です。

実際に、CSVのデータを

|科目ID  |科目名  |
|---|---|
|456789  |数学  |
|123456  |英語  |
|789123  |世界史  |
|654321  |日本史  |
|789456  |地理  |
|321654  |化学  |
|234567  |物理  |
|876543  |  |
|908172  |古典  |
|908172  |現代文  |


という感じに変更します。あえて科目IDが876543のところだけ科目名を空欄にしています。このCSVをインポートしてみるとバリデーションでインポートできないようになっていることが確認できると思います。
![スクリーンショット 2023-07-14 16.15.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3089293/fc5ecb83-9ba8-ea2f-3d2e-d142fae12e54.png)

これでCSVのインポート機能は完成です。

## CSVをエクスポートするコンポーネントを作成する

続いては、Firestoreに保存されているデータをCSVとして出力する機能ですね。

まずは、CSV出力を行うためのコンポーネントを作成していきます。componentsディレクトリにCsvExport.tsxというファイルを作成してください。

このファイルの中身は以下のように書きます。

```components/CsvExport.tsx
import React, { useEffect, useState } from 'react';
import { CSVLink, CSVDownload } from "react-csv";
import {
    Box,
    Button,
} from '@chakra-ui/react';


const CsvExport = () => {
    // CSVLinkをクライアントサイドでのみレンダリングするためのuseState
    const [isClient, setIsClient] = useState(false);

    // CSVとしてエクスポートするデータのヘッダー
    const headers = [
        { label: "科目ID", key: "subjectId" },
        { label: "科目名", key: "subjectName" }
    ];

    // CSVとしてエクスポートするデータ
    const csvExportData = [
        { subjectId: "456789", subjectName: "数学" },
        { subjectId: "123456", subjectName: "英語" },
        { subjectId: "789123", subjectName: "世界史" },
        { subjectId: "654321", subjectName: "日本史" },
        { subjectId: "789456", subjectName: "地理" },
        { subjectId: "321654", subjectName: "化学" },
        { subjectId: "234567", subjectName: "物理" },
        { subjectId: "876543", subjectName: "生物" },
        { subjectId: "908172", subjectName: "現代文" },
    ];

    useEffect(() => {
        // isClientをtrueにする
        setIsClient(true);
    }, []);

    return (
        <Box>
            {
                isClient &&
                <CSVLink data={csvExportData} headers={headers} filename={"subject.csv"}>
                    <Button>
                        CSVエクスポート
                    </Button>
                </CSVLink>
            }
        </Box>
    );
}

export default CsvExport;
```

ユーザーがCSVLinkのタグをクリックすると、CSVがダウンロードされるようになっています。ダウンロードされるCSVのヘッダーはheadersという定数になっていて、データの部分はcsvExportDataという定数になっています。

現時点ではCSVとして出力するデータはハードコーディングしていますが、このあとFirestoreからデータを取得して、そのデータを出力するようにしていきます。

また、CSVLinkはサーバーサイドとクライアントサイドでのレンダリング結果が一致しないといったエラーが出る場合があります。

その場合は、useEffectを使ってCSVLinkのコンポーネントがクライアントサイドでのみレンダリングされるようにすれば解決できます。今回の実装でもそのようにしています。

## FirestoreのデータをCSVとして出力する

では、CsvExport.tsxにFirestoreからデータを取得する処理を追加していきます。

まずはFirestoreから取得したデータを格納するためのuseStateを定義します。

```components/CsvExport.tsx
// csvエクスポート用のデータを格納するuseState
const [ csvExportData, setCsvExportData ] = useState<any>([]);
```

※ハードコーディングしたcsvExportDataの配列は消しておきます。

次は、Firestoreからデータを取得する関数を追記します。

```components/CsvExport.tsx
    // CSVとしてエクスポートするデータを作成する処理
    const getCsvExportData = async () => {
        const subjects: any = [];
        const subjectQuerySnapshot = await getDocs(collection(db, "subject"));
        subjectQuerySnapshot.docs.map((doc)=>{
            subjects.push({
                subjectId: doc.id,
                subjectName: doc.data().name
            });
        });
        setCsvExportData(subjects);
    }

```
Firestoreからsubjectコレクションのドキュメントのスナップショットを取得し、ドキュメントIDとnameフィールドを持つオブジェクトとして、配列に1つ1つ入れていき、その配列をuseStateに格納するというのが、この関数でやっていることですね。

また、この関数はuseEffectを使って実行します。

```components/CsvExport.tsx
    useEffect(() => {
        // CSVとしてエクスポートするためのデータを取得する処理を実行
        getCsvExportData();
        // isClientをtrueにする
        setIsClient(true);
    }, []);
```

CSVをエクスポートするコンポーネントがレンダリングされるときに、Firestoreからデータを取得するという感じです。

ここまでできたら、実際に画面を開いてCSVがエクスポートできるかどうかを確認してみてください。

## まとめ

react-dropzoneやreact-csvなどのライブラリを使うことでNext.jsでCSVのインポートやエクスポート機能をつくることができる。

今回の実装のgithubのリポジトリはこちらです。

https://github.com/masakiwakabayashi/next_csv_import_and_expor