0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

RemixでCSVimport

Posted at

import type { LoaderFunctionArgs, ActionFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { Link, useLoaderData, useActionData } from "@remix-run/react";
import { AwardService } from "~/lib/services/award.service";
import { z } from "zod";
import { parse } from "csv-parse/sync";
import { prisma } from "~/lib/db.server";


export const loader = async (_args: LoaderFunctionArgs) => {
  console.log("loader!!!!!!!!!!!!!!!!!!!!!!!!!!!");
  const practices = await (prisma as any).awardPractice.findMany({
    orderBy: { createdAt: "desc" },
  });
  // プロパティ名は既存の描画を壊さないために awards のまま返す
  return json({ awards: practices });
};

export const AwardCsvRowSchema = z.object({
  日時: z.string().refine((val) => !isNaN(Date.parse(val)), {
    message: "有効な日付形式ではありません",
  }),
  タイトル: z.string().min(1, { message: "タイトルは必須です" }),
  評価: z.coerce.number().min(1).max(5, { message: "評価は1から5の間で入力してください" }),
  社員: z.string().min(1, { message: "社員は必須です" }),
  テーマ: z.string().min(1, { message: "テーマは必須です" }),
});

export const action = async ({ request }: ActionFunctionArgs) => {
  console.log("action!!!!!!!!!!!!!!!!!!!!!!!!!!!");
  const formData = await request.formData();
  const file = formData.get("file");

  if (!file || typeof file === "string") {
    return json({ ok: false, error: "CSVファイルを選択してください" }, { status: 400 });
  }
  try {
    const csvText = await file.text();
    const validatedData: z.infer<typeof AwardCsvRowSchema>[] = [];
    const records: unknown[] = parse(csvText, {
      columns: true, // 1行目をヘッダーとして扱う
      skip_empty_lines: true, // 空行をスキップ
    });

    for (const [index, record] of records.entries()) {
      const result = AwardCsvRowSchema.safeParse(record);
      if (!result.success) {
        const firstError = result.error.issues[0];
        const errorMessage = `${index + 2}行目 (${firstError.path.join(", ")}): ${firstError.message}`;
        return json({ ok: false, error: errorMessage }, { status: 400 });
      }
      validatedData.push(result.data);
    }
    if (validatedData.length === 0) {
      return json({ ok: false, error: "インポートするデータがありません" }, { status: 400 });
    }
    const result = await importAwardsPracticeTable(validatedData);
    return json({ ok: true, result });
    
  } catch (error) {
    return json({ ok: false, error: "CSVの解析中にエラーが発生しました" }, { status: 500 });
  }
};

// 今回は練習だからprisma に投入する部分もここで。
const importAwardsPracticeTable = async (validatedData: z.infer<typeof AwardCsvRowSchema>[]) => {
  try{

  const result = await (prisma as any).awardPractice.createMany({
    data: validatedData.map((data) => ({
      date: data.日時,
      title: data.タイトル,
      rating: data.評価,
      employee: data.社員,
      theme: data.テーマ,
    })),
  })
  return { ok: true, result };
  } catch (error) {
    console.error("Error importing awards:", error);
    throw new Error("Failed to import awards data");
  }
};


export default function AwardsIndex() {
  const { awards } = useLoaderData<typeof loader>();
  const actionData = useActionData<typeof action>();
  const errorMessage = actionData && typeof actionData === "object" && "error" in actionData ? (actionData as any).error as string : undefined;

  return (
    <div className="container mx-auto p-6">
      <div className="flex justify-between items-center mb-6">
        <h1 className="text-2xl font-bold">表彰一覧CSVインポート準備</h1>
        <div className="flex gap-3">
          <Link to="/recommendations" className="text-gray-600 hover:underline">推薦一覧へ</Link>
        </div>
      </div>

      <div className="bg-white border border-gray-200 rounded p-6 mb-6">
        <form method="post" encType="multipart/form-data" className="flex items-center gap-3">
          <input type="file" name="file" accept=".csv" className="text-sm" />
          <button
            type="submit"
            className="px-4 py-2 bg-emerald-500 hover:bg-emerald-600 text-white text-sm rounded-md"
          >
            CSVをインポート
          </button>
        </form>
        {errorMessage && (
          <p className="mt-3 text-sm text-red-600">{errorMessage}</p>
        )}
      </div>

      <div className="overflow-x-auto bg-white border border-gray-200 rounded-lg shadow">
        <table className="min-w-full divide-y divide-gray-200">
          <thead className="bg-gray-50">
            <tr>
              <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">日時</th>
              <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">タイトル</th>
              <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">評価</th>
              <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">社員</th>
              <th className="px-4 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">
            {awards.length === 0 && (
              <tr>
                <td className="px-4 py-3 text-sm text-gray-500"></td>
                <td className="px-4 py-3 text-sm text-gray-900"></td>
                <td className="px-4 py-3 text-sm text-gray-900"></td>
                <td className="px-4 py-3 text-sm text-gray-900"></td>
                <td className="px-4 py-3 text-sm text-gray-900"></td>
              </tr>
            )}
            {awards.map((a: any) => (
              <tr key={a.id} className="hover:bg-gray-50">
                <td className="px-4 py-3 text-sm text-gray-500">{a.date ? new Date(a.date).toLocaleString("ja-JP") : ""}</td>
                <td className="px-4 py-3 text-sm text-gray-900">{a.title}</td>
                <td className="px-4 py-3 text-sm text-gray-900">{a.rating ?? ""}</td>
                <td className="px-4 py-3 text-sm text-gray-900">{a.employee}</td>
                <td className="px-4 py-3 text-sm text-gray-900">{a.theme}</td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </div>
  );
}

ネットワークリクエストのエラーをテストする3つの方法


1. 開発ツールのリクエストブロックパターンを改善する

より確実に特定のURLをブロックするための設定です。

  • ブロックするパターン: */api/recommendations/export*
    • アスタリスク (*) をワイルドカードとして使うことで、ドメインやプロトコルが変わっても確実にマッチさせます。
  • 手順:
    1. Chrome開発ツールの「Network」タブを開く。
    2. 「Enable network request blocking」にチェックを入れる。
    3. 「+ Add pattern」で上記パターンを追加する。

2. コードで強制的にエラーを発生させる(最も確実)

クエリパラメータを使って、サーバー側で意図的にエラーを発生させる方法です。最も信頼性が高く、おすすめの方法です。

  • 実装例:
    app/routes/api.recommendations.export.tsloader 関数の冒頭に以下を追加します。

    if (new URL(request.url).searchParams.get('fail') === '1') {
      throw new Response('Forced Server Error for Testing', { status: 500 });
    }
    
  • 確認方法:
    ブラウザで http://localhost:3000/api/recommendations/export?fail=1 にアクセスし、500エラーが返ってくることを確認します。

  • UIテストでの使い方:
    テスト中は、<Link> やフォームのアクション先を一時的に to="/api/recommendations/export?fail=1" に変更して、UIがエラーを正しく表示できるかを確認します。


3. ネットワークを「Offline」にする

アプリケーション全体がオフラインになった場合の挙動を手軽に確認する方法です。

  • 手順:
    1. Chrome開発ツールの「Network」タブを開く。
    2. スロットリング設定のドロップダウン(デフォルトは No throttling)から Offline を選択する。
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?