Loading...

2023-09-25(月) 15:00

📊 Next.js で CSVインポート機能を実装する

Next.jsCSV
Next.jsでreact-papaparseというライブラリを使用してCSVインポート機能を実装する手順を解説します。

目次

前提

この記事では以下を前提としています。

  • Next.js のバージョンは 13.4.19 (App router 使用)
  • Typescript 使用
  • Tailwind を使用
  • 本番環境に必要なエラーハンドリングやバリデーションなどは実装していない

この記事のゴール

以下の動画ように、ファイルをアップロードするためのドラッグ&ドロップ可能なフィールドに実際にファイルを CSV ファイルをドラッグ&ドロップすることで CSV ファイルを読み込みます。
動画ではドラッグ&ドロップによってファイルをアップロードしていますが、ファイル選択ダイアログからファイルを選択してアップロードすることも可能です。 ファイルをアップロードして読み込んだ内容を表示するところまでをゴールとしています。

上記の動画で使用している CSV データはダミーデータになります。

react-papaparse をインストールする

CSV ファイルをアップロードして読み込む処理をするためにreact-papaparseというライブラリを使用します。
以下でインストールします。

ターミナル
# npmを使う場合
npm install --save react-papaparse
 
# yarnを使う場合
yarn add react-papaparse

react-papaparseの公式リポジトリは以下です。

Bunlong/react-papaparse

react-papaparseのGitHubリポジトリ

github.com

CSV ファイルアップロード用のコンポーネントを作成する

CSV ファイルをドラッグ&ドロップ、もしくはファイル選択ダイアログから選択してアップロードして CSV の中身を受け取るためのコンポーネントを以下のように作成します。 なお、以下はreact-papaparseの公式リポジトリに記載されているサンプルコードをほぼそのまま使用しています。

CSVReader.tsx
'use client';
import React, { useState, useEffect, CSSProperties } from 'react';
 
import {
  useCSVReader,
  lightenDarkenColor,
  formatFileSize,
} from 'react-papaparse';
 
const GREY = '#CCC';
const GREY_LIGHT = 'rgba(255, 255, 255, 0.4)';
const DEFAULT_REMOVE_HOVER_COLOR = '#A01919';
const REMOVE_HOVER_COLOR_LIGHT = lightenDarkenColor(
  DEFAULT_REMOVE_HOVER_COLOR,
  40,
);
const GREY_DIM = '#686868';
 
const styles = {
  zone: {
    alignItems: 'center',
    border: `2px dashed ${GREY}`,
    borderRadius: 3,
    display: 'flex',
    flexDirection: 'column',
    height: '100%',
    justifyContent: 'center',
    padding: 20,
  } as CSSProperties,
  file: {
    background: 'linear-gradient(to bottom, #EEE, #DDD)',
    border: `2px dashed ${GREY}`,
    borderRadius: 20,
    display: 'flex',
    height: 120,
    width: 120,
    position: 'relative',
    zIndex: 10,
    flexDirection: 'column',
    justifyContent: 'center',
  } as CSSProperties,
  info: {
    alignItems: 'center',
    display: 'flex',
    flexDirection: 'column',
    paddingLeft: 10,
    paddingRight: 10,
    fontSize: 12,
    zIndex: 20,
  } as CSSProperties,
  size: {
    backgroundColor: GREY_LIGHT,
    border: `2px dashed ${GREY}`,
    borderRadius: 3,
    marginBottom: '0.5em',
    justifyContent: 'center',
    display: 'flex',
    fontSize: 12,
    zIndex: 20,
  } as CSSProperties,
  name: {
    backgroundColor: GREY_LIGHT,
    border: `2px dashed ${GREY}`,
    borderRadius: 3,
    fontSize: 12,
    marginBottom: '0.5em',
    zIndex: 20,
  } as CSSProperties,
  progressBar: {
    bottom: 14,
    position: 'absolute',
    width: '100%',
    paddingLeft: 10,
    paddingRight: 10,
  } as CSSProperties,
  zoneHover: {
    borderColor: GREY_DIM,
    border: `2px dashed ${GREY_DIM}`,
  } as CSSProperties,
  default: {
    border: `2px dashed ${GREY}`,
  } as CSSProperties,
  remove: {
    height: 23,
    position: 'absolute',
    right: 6,
    top: 6,
    width: 23,
  } as CSSProperties,
};
 
export default function CSVReader({
  setUploadedData,
}: {
  setUploadedData: (data: any) => void;
}) {
  const { CSVReader } = useCSVReader();
  const [zoneHover, setZoneHover] = useState(false);
  const [removeHoverColor, setRemoveHoverColor] = useState(
    DEFAULT_REMOVE_HOVER_COLOR,
  );
 
  const [uploadedList, setUploadedList] = useState<any[]>([]);
 
  useEffect(() => {}, [uploadedList]);
 
  return (
    <CSVReader
      onUploadAccepted={(results: any) => {
        setUploadedList([]);
        const _uploadedData = results.data ?? [];
 
        if (_uploadedData.length > 0) {
          setUploadedList(_uploadedData.slice(1));
          setUploadedData(_uploadedData.slice(1));
        }
        // console.log('---------------------------');
        // console.log(results);
        // console.log('---------------------------');
        setZoneHover(false);
      }}
      onDragOver={(event: DragEvent) => {
        event.preventDefault();
        setZoneHover(true);
      }}
      onDragLeave={(event: DragEvent) => {
        event.preventDefault();
        setZoneHover(false);
      }}
    >
      {({
        getRootProps,
        acceptedFile,
        ProgressBar,
        getRemoveFileProps,
        Remove,
      }: any) => (
        <>
          <div
            {...getRootProps()}
            style={Object.assign(
              {},
              styles.zone,
              zoneHover && styles.zoneHover,
            )}
            className='flex mb-4'
          >
            {acceptedFile ? (
              <>
                <div className='flex flex-col'>
                  <div className='flex flex-row items-center'>
                    <span className='text-lg'>{acceptedFile.name}</span>
                    <span className='text-lg px-4'> - </span>
                    <span className='text-lg mr-4'>
                      {formatFileSize(acceptedFile.size)}
                    </span>
                    <div
                      {...getRemoveFileProps()}
                      onMouseOver={(event: Event) => {
                        event.preventDefault();
                        setRemoveHoverColor(REMOVE_HOVER_COLOR_LIGHT);
                      }}
                      onMouseOut={(event: Event) => {
                        event.preventDefault();
                        setRemoveHoverColor(DEFAULT_REMOVE_HOVER_COLOR);
                      }}
                      onClick={(event: Event) => {
                        getRemoveFileProps().onClick(event);
                        // event.preventDefault();
 
                        // console.log('remove clicked');
                        setUploadedList([]);
                        setUploadedData([]);
                        // Your code here
                      }}
                    >
                      <Remove color={removeHoverColor} />
                    </div>
                  </div>
                  <div className='flex'>
                    <ProgressBar />
                  </div>
                </div>
              </>
            ) : (
              <div className='flex'>
                <span className='text-lg'>
                  クリックまたはドラッグ&ドロップでCSVファイルをアップロードしてください
                </span>
              </div>
            )}
          </div>
        </>
      )}
    </CSVReader>
  );
}

上記で CSV ファイルをアップロードするためのドラッグ&ドロップ部分とクリックによるファイル選択ダイアログの表示部分を含んでいます。
また、アップロードしたファイル名とそのファイルサイズの表示も含みます。上記のCSVReaderコンポーネントを使って実際にアップロードした CSV ファイルの中身をテーブル表示してみます。

CSV データ表示用のコンポーネントを作成する

上記で作成したコンポーネントCSVReaderと組み合わせて読み込んだ CSV ファイルの中身をテーブル表示するためのコンポーネントを以下のように作成します。

CSVDataTable.tsx
'use client';
import { useState } from 'react';
import CSVReader from './Uploader/CSVReader';
 
export default function CSVDataTable() {
  // CSVファイルの内容を格納する用のstate
  const [uploadedList, setUploadedList] = useState < any > [];
 
  // CSVファイルをアップロードした時の処理
  const handleUploadCsv = (data: any) => {
    const _formattedData = data
      .map((d: any) => {
        return {
          name: d[0],
          namekana: d[1],
          age: d[2],
          birthday: d[3],
          company: d[4],
        };
      })
      .filter((d: any) => d != null);
 
    setUploadedList(_formattedData);
  };
 
  // インポート実行ボタンを押した時の処理
  const handleOnImport = async () => {
    console.log('handleOnImport');
    console.log(uploadedList);
 
    // バックエンドにデータを送信したり必要な処理を書く
  };
  return (
    <>
      <div>
        <div className='py-4 text-gray-600 dark:text-white'>
          <CSVReader setUploadedData={handleUploadCsv} />
        </div>
        <div className='py-4 text-gray-600 dark:text-white'>
          {uploadedList.length > 0 ? (
            <div className='shadow overflow-hidden border-b border-gray-200 sm:rounded-lg'>
              <table className='min-w-full divide-y divide-gray-200'>
                <thead className='bg-gray-50'>
                  <tr>
                    <th
                      scope='col'
                      className='px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider'
                    >
                      氏名
                    </th>
                    <th
                      scope='col'
                      className='px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider'
                    >
                      氏名カナ
                    </th>
                    <th
                      scope='col'
                      className='px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider'
                    >
                      年齢
                    </th>
                    <th
                      scope='col'
                      className='px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider'
                    >
                      生年月日
                    </th>
                    <th
                      scope='col'
                      className='px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider'
                    >
                      所属
                    </th>
                  </tr>
                </thead>
                <tbody className='bg-white divide-y divide-gray-200'>
                  {uploadedList.map((d: any) => (
                    <tr key={d.name + d.namekana}>
                      <td className='px-6 py-4 whitespace-nowrap'>
                        <div className='text-sm text-gray-900'>{d.name}</div>
                      </td>
                      <td className='px-6 py-4 whitespace-nowrap'>
                        <div className='text-sm text-gray-900'>
                          {d.namekana}
                        </div>
                      </td>
                      <td className='px-6 py-4 whitespace-nowrap'>
                        <div className='text-sm text-gray-900'>{d.age}</div>
                      </td>
                      <td className='px-6 py-4 whitespace-nowrap'>
                        <div className='text-sm text-gray-900'>
                          {d.birthday}
                        </div>
                      </td>
                      <td className='px-6 py-4 whitespace-nowrap'>
                        <div className='text-sm text-gray-900'>{d.company}</div>
                      </td>
                    </tr>
                  ))}
                </tbody>
              </table>
            </div>
          ) : null}
        </div>
        <div className='flex justify-end'>
          <button
            className='flex justify-center bg-blue-500 hover:bg-blue-700 text-white text-sm py-1 px-2 rounded mr-1'
            onClick={() => handleOnImport()}
          >
            インポート実行
          </button>
        </div>
      </div>
    </>
  );
}

上記で CSV ファイルをアップロードすると、その中身がテーブル表示されます。
あとは「インポート実行」ボタンをクリックするとhandleOnImportが実行されます。ここではhandleOnImportはブラウザのコンソール画面に読み込んだ CSV ファイルのデータを出力するのみですが、実際には バックエンドサーバにデータを送信して保存するような処理を書くことになると思います。

handleUploadCsvCSVReaderコンポーネントに渡すことで、アップロードした CSV ファイルのデータを処理できるようにしています。
14-18行目は、インポートする CSV ファイルに含まれるデータに合わせて、適当なキー名(name, namekanaなど)をつけて使っています。d[0]は1列目のデータになり、それ以降順番に d[1]d[2]、、、が 2 列目、3 列目、、、に該当します。

上記のCSVDataTableを適当なコンポーネントやページの中で読み込めば、冒頭の動画と同様の動きをする CSV ファイルのインポート機能を実装することができます。

まとめ

react-papaparseを使用することで、ドラッグ&ドロップやファイル選択ダイアログの表示、CSV ファイルの読み込みなど、実際にはやることが多い部分を任せることができるため、とても簡単に CSV ファイルのインポート機能を実装することができます。 この記事では CSV ファイルに含まれるデータのチェックや CSV ファイル以外のファイルがアップロードされた場合、データ数が多すぎる場合などを想定した処理は行っていませんが、実際にはそれらに対処する処理も合わせて使うことになると思います。