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?

実務未経験エンジニアがLaravel&ReactでWebアプリケーション開発に挑戦してみた!(バックエンド実装編④)~レビューCRUD機能作成その2~

Posted at

実務未経験エンジニアがLaravel&ReactでWebアプリケーション開発に挑戦してみた!(その10)

初めに

みなさん、こんにちは!

前回は、レビューのCRUD機能の作成に取り掛かりました。
しかし、前回の作業完了時点では、まだまだ改善するべき点がたくさんありましたね。

今回は、それらを修正していきたいと思います!

ブランチ運用について

前回の最後に、feature/03-review-crud という名前のブランチで作業して、コミット・プッシュし、その後リモートの develop にマージしました。

今日は、その続きということで、feature/04-review-crud という名前の新しいブランチを切って作業します!

以下のコマンドを /project-root/src に移動した状態で、実行してください。

実行コマンド

/project-root/src
$ git checkout develop
/project-root/src
$ git pull origin develop
/project-root/src
$ git checkout -b feature/04-review-crud

シーディングを修正

前回、研究室名が材料工学研究室なのに、学部名が教養学部というようなミスマッチが起きてしまっていました。

スクリーンショット 2025-06-07 160830.png

また、レビューの平均値を表示する下準備として、ログインして、投稿して、ログアウトして...の流れを4人分も行いました。
マイグレーションを実行するとこれらのデータが消えてしまい、実行のたびにレビューを手作業で一つずつ投稿するのは大変です。

よって、今回は、研究室名と学部名のミスマッチの解消と、レビューのダミーデータの追加を目指して、シーディングを修正していきたいと思います!

研究室名と学部名のミスマッチ解消

現状を確認してみましょう。

\project-root\src\database\seeders\FacultySeeder.php
<?php

namespace Database\Seeders;

use App\Models\Faculty;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;

class FacultySeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        // 学部を適当に4つ作成
        Faculty::create([
            'university_id' => 1,
            'name' => '教養学部',
        ]);

        Faculty::create([
            'university_id' => 1,
            'name' => '経済学部',
        ]);

        Faculty::create([
            'university_id' => 1,
            'name' => '理学部',
        ]);

        Faculty::create([
            'university_id' => 1,
            'name' => '工学部',
        ]);
    }
}

まず、学部のシーダークラスを見てみましょう。
シーディングは基本的には上から順に実行されるため、工学部の id は 4 になります。

\project-root\src\database\seeders\LabSeeder.php
<?php

namespace Database\Seeders;

use App\Models\Lab;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;

class LabSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        // 研究室を適当に5つ作成
        Lab::create([
            'faculty_id' => 1,
            'name' => '機械工学科 材料工学研究室',
            'description' => '材料の力学的性質を調べる研究室です。',
            'url' => 'https://example.com/lab1',
            'professor_url' => 'https://example.com/professor1',
            'gender_ratio_male' => 6,
            'gender_ratio_female' => 4,
        ]);

        Lab::create([
            'faculty_id' => 1,
            'name' => '情報工学科',
            'description' => '情報処理技術を学ぶ研究室です。',
            'url' => 'https://example.com/lab2',
            'professor_url' => 'https://example.com/professor2',
            'gender_ratio_male' => 5,
            'gender_ratio_female' => 5,
        ]);

        Lab::create([
            'faculty_id' => 1,
            'name' => '応用化学科 荒木研究室',
            'description' => '化学の応用を学ぶ研究室です。',
            'url' => 'https://example.com/lab3',
            'professor_url' => 'https://example.com/professor3',
            'gender_ratio_male' => 7,
            'gender_ratio_female' => 3,
        ]);

        Lab::create([
            'faculty_id' => 1,
            'name' => '情報工学科',
            'description' => '情報処理技術を学ぶ研究室です。',
            'url' => 'https://example.com/lab4',
            'professor_url' => 'https://example.com/professor4',
            'gender_ratio_male' => 5,
            'gender_ratio_female' => 5,
        ]);

        Lab::create([
            'faculty_id' => 1,
            'name' => '機能材料工学科 荒井研究室',
            'description' => '機能性材料の研究を行う研究室です。',
            'url' => 'https://example.com/lab5',
            'professor_url' => 'https://example.com/professor5',
            'gender_ratio_male' => 4,
            'gender_ratio_female' => 6,
        ]);
    }
}

一方で、研究室のシーダーを見ると、faculty_id がすべて 1 になっています。
これによって、工学部ではなく、教養学部と紐づいてしまっています。

したがって、研究室のシーダーを以下のように修正すればOKです!

\project-root\src\database\seeders\LabSeeder.php
<?php

namespace Database\Seeders;

use App\Models\Lab;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;

class LabSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        // 研究室を適当に5つ作成
        Lab::create([
            'faculty_id' => 4, // 修正
            'name' => '機械工学科 材料工学研究室',
            'description' => '材料の力学的性質を調べる研究室です。',
            'url' => 'https://example.com/lab1',
            'professor_url' => 'https://example.com/professor1',
            'gender_ratio_male' => 6,
            'gender_ratio_female' => 4,
        ]);

        Lab::create([
            'faculty_id' => 4, // 修正
            'name' => '情報工学科',
            'description' => '情報処理技術を学ぶ研究室です。',
            'url' => 'https://example.com/lab2',
            'professor_url' => 'https://example.com/professor2',
            'gender_ratio_male' => 5,
            'gender_ratio_female' => 5,
        ]);

        Lab::create([
            'faculty_id' => 4, // 修正
            'name' => '応用化学科 荒木研究室',
            'description' => '化学の応用を学ぶ研究室です。',
            'url' => 'https://example.com/lab3',
            'professor_url' => 'https://example.com/professor3',
            'gender_ratio_male' => 7,
            'gender_ratio_female' => 3,
        ]);

        Lab::create([
            'faculty_id' => 4, // 修正
            'name' => '情報工学科',
            'description' => '情報処理技術を学ぶ研究室です。',
            'url' => 'https://example.com/lab4',
            'professor_url' => 'https://example.com/professor4',
            'gender_ratio_male' => 5,
            'gender_ratio_female' => 5,
        ]);

        Lab::create([
            'faculty_id' => 4, // 修正
            'name' => '機能材料工学科 荒井研究室',
            'description' => '機能性材料の研究を行う研究室です。',
            'url' => 'https://example.com/lab5',
            'professor_url' => 'https://example.com/professor5',
            'gender_ratio_male' => 4,
            'gender_ratio_female' => 6,
        ]);
    }
}

レビューのダミーデータ追加

続いて、レビューのテストデータを作成します。

ファクトリを作成します。
phpのコンテナ内に入って、以下のコマンドを実行しましょう。

実行コマンド

/var/www
$ php artisan make:factory ReviewFactory

作成したら、以下のように編集してください。

\project-root\src\database\factories\ReviewFactory.php
<?php

namespace Database\Factories;

use App\Models\Lab; // 追加
use App\Models\User; // 追加
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Review>
 */
class ReviewFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            // 追加
            'user_id' => User::inRandomOrder()->first()->id,
            'lab_id' => Lab::inRandomOrder()->first()->id,
            'mentorship_style' => $this->faker->numberBetween(1, 5),
            'lab_atmosphere' => $this->faker->numberBetween(1, 5),
            'achievement_activity' => $this->faker->numberBetween(1, 5),
            'constraint_level' => $this->faker->numberBetween(1, 5),
            'facility_quality' => $this->faker->numberBetween(1, 5),
            'work_style' => $this->faker->numberBetween(1, 5),
            'student_balance' => $this->faker->numberBetween(1, 5),
        ];
    }
}

できたら、DatabaseSeeder.php に追加しましょう。

\project-root\src\database\seeders\DatabaseSeeder.php
<?php

namespace Database\Seeders;

use App\Models\Lab; // 追加
use App\Models\Review; // 追加
use App\Models\User;
// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    /**
     * Seed the application's database.
     */
    public function run(): void
    {
        // 大学・学部・研究室のSeederクラスを呼び出す
        $this->call([
            UniversitySeeder::class,
            FacultySeeder::class,
            LabSeeder::class,
        ]);

        // ユーザー30人分を作成、さらにリレーションを付与
        User::factory()->count(30)->create()->each(function ($user) {
            // 大学・学部・研究室のリレーションを付与
            $user->universities()->attach(rand(1, 3)); // university_id: 1~3
            $user->faculties()->attach(rand(1, 4)); // faculty_id: 1~4
            $user->labs()->attach(rand(1, 5)); // lab_id: 1~5
        });

        // 追加: レビューを50件作成
        // ただし、重複を回避しながら作成
        $users = User::all();
        $labs = Lab::all();
        $createdCombinations = [];
        $targetCount = 50;

        while (count($createdCombinations) < $targetCount) {
            $userId = $users->random()->id;
            $labId = $labs->random()->id;
            $combination = "{$userId}-{$labId}";

            if (!in_array($combination, $createdCombinations)) {
                Review::factory()->create([
                    'user_id' => $userId,
                    'lab_id' => $labId,
                ]);
                $createdCombinations[] = $combination;
            }
        }
    }
}

レビューを50件作成します。

ここで問題なのが、一意性制約です。
すなわち、今回は一人のユーザーは、1つの研究室に対して1回までしかレビューを投稿できないように制約を持たせているので、たくさんレビューを作成しようとすると、どこかで重複が起きてエラーが発生してしまうのです...

そこで、php の in_array() 関数を使って、重複が起きないときだけデータを作成するようにしました。
php の基本文法が分かっていれば、読むのはそんなに難しくないと思います。

ただ、若干無理やりな書き方で、もしかしたら効率が悪いかもしれません。
もし、もっと良い方法がありましたら、教えてください!(>_<)

あと、「大学・学部・研究室のSeederクラスを呼び出す」のところで、$this->call() の丸かっこがなぜか一つ多かったので直しておきました。

最後に、モデルに宣言を追加しないとエラーになるので、忘れずに追加しておきましょう。

\project-root\src\app\Models\Review.php
<?php

namespace App\Models;

use Illuminate\Container\Attributes\Auth;
use Illuminate\Database\Eloquent\Factories\HasFactory; // 追加
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Auth as FacadesAuth;

class Review extends Model
{
    use HasFactory; // 追加

    protected $fillable = [
        'user_id',
        'lab_id',
        'mentorship_style',
        'lab_atmosphere',
        'achievement_activity',
        'constraint_level',
        'facility_quality',
        'work_style',
        'student_balance',
    ];

    // リレーションシップの定義
    // ユーザーとのリレーション(多対一)
    public function user()
    {
        return $this->belongsTo(User::class);
    }

    // 研究室とのリレーション(多対一)
    public function lab()
    {
        return $this->belongsTo(Lab::class);
    }
}

シーダー実行

準備ができたら、以下のコマンドでデータを入れましょう!
実行コマンド

/var/www
$ php artisan migrate:fresh --seed

エラーが起きなければ成功です!
image.png
image.png

lab テーブルに faculty_id が 4 のデータが、reviews テーブルに50件データが入っていることが確認できれば完了です。

Create(作成)機能を修正

次に、Create(作成)機能を修正していきます。
主な修正点は以下です。

  1. バリデーションを修正
  2. リダイレクト先を研究室詳細ページに変更
  3. ポリシーを作成

バリデーション修正

レビューは、ブラウザのフォームから 1 ~ 5 のいずれかの整数値を送信することになっています。

しかし、もしかしたら、悪意のあるユーザーがそれ以外の値を送信してくるかもしれません。
というのも、ブラウザの画面は誰でも簡単に書き換えられるからです。

シーダーで入れたいずれかのユーザーでログインした状態で、ID1 の研究室のレビュー作成画面へ移動してください。

※もしかしたらログインしたユーザーが、既にシーディングの際にレビューを投稿しているかもしれません。
その状態でレビューを投稿しようとするとエラーが発生してできません。
その場合は、対象の研究室にレビューを投稿していないユーザーを見つけてログインしなおすか、対象のレビューを削除するか、もしくは、新しいユーザーを新規登録してください。

ここで「F12キー」を押すと、検証ツールと呼ばれる画面が開き、実は HTML や CSS, JavaScript を見ることができます。
この中の「要素」をクリックすると、HTML を見ることができるのです!
image.png

さらに、書き換えもできます!
試しに、「指導スタイル」の最大値を 5 から 6 に書き換えてみると...
image.png
ああ......!!
本来 1 ~ 5 までの数字しか選べないはずのに、6 の状態で送信できるようになってしまいました...!! (´;ω;`)ウッ…

万が一、このまま送信されて、もしデータベースに 6 という値が入ってしまったら、システムが期待通りに機能しなくなり、最悪の場合システムの根幹が崩れてしまいます。

幸い、今のところコントローラーの方でバリデーションをかけているのでエラーが発生して、実際に不正なデータが入ることはないので安心してください!

ただし、修正するべき個所はあります!

先ほどの状態で「レビューを投稿」をクリックすると、以下のようなエラーメッセージが表示されて処理がはじかれます。
image.png

ここで、バリエーションの設定を変更することで、このメッセージを変えたいと思います。

普通の人は検証ツールを使って最大値を書き換えたりしませんが、これが数字を直接入力するスタイルだったらどうでしょう?
例えば、とあるサービスで、新規会員登録の際に、許されない形式の名前をユーザーが間違って登録しようとした際に、「ニックネームは平仮名もしくはカタカナ3文字以上8文字以内で入力してください」みたいにメッセージを出してくれると、どこがだめなのかが分かって助かりますよね。
本来バリデーションのメッセージはこういう使い方をするためのものなのですが、今回だとバリエーションの説明の具体例としてはあまりよくなかったかもしれませんね。(笑)
一応公式ドキュメントを載せておきます。

今のエラーメッセージが表示されているのは、以下の箇所です。

\project-root\src\resources\js\Pages\Review\Create.jsx
{errors.mentorship_style && <div>{errors.mentorship_style}</div>}

今は、バックエンド実装編なので、Reactコンポーネントの中身はほとんど解説していませんでしたが、この部分だけ軽く説明しておきます。

これは、JavaScriptの論理積と呼ばれているもので、&& の左が true なら右を表示することができます。
つまり、mentorship_style の本来の最大値 5 よりも大きな値が送信されたことで、errors.mentorship_styletrue になり、<div> 要素として画面に表示されるというわけです。

論理演算子について、意外と誤解している人も少なくないみたいです。
怪しい方は、以下を読んでみるとよいかもしれません。
JavaScriptの「&&」「||」について盛大に勘違いをしていた件

まあ、よくわからない人は飛ばしていただいて、肝心のエラーメッセージの内容を変えましょう!
コントローラーのバリエーション部分に直接書き込んでいってもよいのですが、コントローラーごとに同じような内容を少しずつ変えて書いていくととても大変なので、resources/lang/ja/validation.php に日本語の設定を書いて管理します。

\project-root\src\resources\lang ディレクトリを作成してください。
VS Code画面左のエクスプローラーで構いません。

作成したら、その下にさらに \ja という名前のフォルダを作ります。

その中に、validation.php という名前で新しいファイルを作成してください。

\project-root\src\resources\lang\ja\validation.php
<?php

return [

    'max' => [
        'numeric' => ':attribute は :max 以下の値にしてください。',
        'file'    => ':attribute は :max KB以下のファイルにしてください。',
        'string'  => ':attribute は :max 文字以下にしてください。',
        'array'   => ':attribute は :max 個以下にしてください。',
    ],

    'custom' => [
        'mentorship_style' => [
            'max' => '指導スタイルは5以下の値にしてください。',
            'min' => '指導スタイルは1以上の値にしてください。',
        ],
        'lab_atmosphere' => [
            'max' => '雰囲気・文化は5以下の値にしてください。',
            'min' => '雰囲気・文化は1以上の値にしてください。',
        ],
        'achievement_activity' => [
            'max' => '成果・活動は5以下の値にしてください。',
            'min' => '成果・活動は1以上の値にしてください。',
        ],
        'constraint_level' => [
            'max' => '拘束度は5以下の値にしてください。',
            'min' => '拘束度は1以上の値にしてください。',
        ],
        'facility_quality' => [
            'max' => '設備は5以下の値にしてください。',
            'min' => '設備は1以上の値にしてください。',
        ],
        'work_style' => [
            'max' => '働き方は5以下の値にしてください。',
            'min' => '働き方は1以上の値にしてください。',
        ],
        'student_balance' => [
            'max' => '人数バランスは5以下の値にしてください。',
            'min' => '人数バランスは1以上の値にしてください。',
        ],
    ],

    'attributes' => [
        'mentorship_style' => '指導スタイル',
        'lab_atmosphere' => '雰囲気・文化',
        'achievement_activity' => '成果・活動',
        'constraint_level' => '拘束度',
        'facility_quality' => '設備',
        'work_style' => '働き方',
        'student_balance' => '人数バランス',
    ],

];

こんな感じで、return だけ書けばよいっぽいです。

もう一度、「指導スタイル」を 6 にして投稿してみると...
image.png
日本語になっていますね!

本当だったら、赤字にしたりとかした方が良いかもしれませんが、それはフロントエンド実装編の時にでも直しましょう!(忘れていなければw)

リダイレクト先変更

次は、レビューを投稿した後のリダイレクト先を変更します。

現状は、レビューの投稿に成功すると研究室一覧画面にリダイレクトされるようになっています。
しかし、研究室詳細画面に遷移したほうがユーザーもわかりやすいと思うので、変更します。

コントローラーの store() メソッドの return 文を以下のように修正します。

\project-root\src\app\Http\Controllers\ReviewController.php
    public function store(Request $request, Lab $lab) {
        // バリデーション
        $validated = $request->validate([
            'mentorship_style' => 'required|integer|min:1|max:5',
            'lab_atmosphere' => 'required|integer|min:1|max:5',
            'achievement_activity' => 'required|integer|min:1|max:5',
            'constraint_level' => 'required|integer|min:1|max:5',
            'facility_quality' => 'required|integer|min:1|max:5',
            'work_style' => 'required|integer|min:1|max:5',
            'student_balance' => 'required|integer|min:1|max:5',
        ]);

        // バリデーション済みのデータを保存
        $review = new Review();
        $review->user_id = Auth::id();
        $review->lab_id = $lab->id;
        $review->mentorship_style = $validated['mentorship_style'];
        $review->lab_atmosphere = $validated['lab_atmosphere'];
        $review->achievement_activity = $validated['achievement_activity'];
        $review->constraint_level = $validated['constraint_level'];
        $review->facility_quality = $validated['facility_quality'];
        $review->work_style = $validated['work_style'];
        $review->student_balance = $validated['student_balance'];
        $review->save();

        // 修正: リダイレクト先を labs.show に変更
        return redirect()->route('labs.show', ['lab' => $lab])->with('success', 'レビューが保存されました。');
    }

当然ながら、どの研究室に遷移すればよいのかという情報が必要なので、引数で受け取っている研究室IDに対応するオブジェクトの $labroute() メソッドでルーティングに渡します。

つまり、普段これまでやってきたルートモデルバインディングの逆をしている感じです。

今までは、例えば、URL に研究室の ID が入力されてルーティングがコントローラーにその値(1 とか 2 とか)を引数として渡していました。
一方、今回は、逆にコントローラーが研究室の ID をルーティングに渡して、それが URL に入ります。
この一連の流れによって、投稿する対象の研究室詳細ページにリダイレクトできます。

以下のように、ID1 の研究室に対して、すべて 3 にして投稿します。
image.png

すると、確かに ID1 の研究室の詳細ページに移動しました!
成功です。(≧◇≦)
image.png

ポリシー作成

先ほど、「同じユーザーは同じ研究室に対して一度までしかレビューを投稿できない」と言いました。

確かに、投稿済みのユーザーが作成画面にアクセスして、「レビューを投稿」をクリックするとエラーが発生してできません。
image.png

でも、こう思いませんか...?

いや、そもそも投稿済みユーザーが作成画面にアクセスできるのがおかしくね??

その通りです!
そこで、URL へのアクセスの権限を設定できるものとしてLaravelが用意しているものが、ポリシーです!
ポリシーは、認可と呼ばれるものの一つで、アプリケーション内におけるユーザーの行動(投稿したり、削除したりなど)に対する許可・不許可を決め、管理するためのルールを作ることができます。

ポリシーを作成する

php のコンテナ内で、以下のコマンドを実行するとポリシークラスを作成出来ます。

実行コマンド

/var/www
$ php artisan make:policy ReviewPolicy

作成出来たら、以下のようにします。

\project-root\src\app\Policies\ReviewPolicy.php
<?php

namespace App\Policies;

use App\Models\Lab;
use App\Models\Review;
use App\Models\User;

class ReviewPolicy
{
    // ユーザーがレビューを作成できるかどうかを判定
    public function create(User $user, Lab $lab)
    {
        // 既にレビューがある場合は作成不可
        return !Review::where('user_id', $user->id)
                    ->where('lab_id', $lab->id)
                    ->exists();
    }
}

また、\project-root\src\app\Providers\AppServiceProvider.php で作成したポリシーを登録することができます。

<?php

namespace App\Providers;

use App\Models\Review; // 追加
use App\Policies\ReviewPolicy; // 追加
use Illuminate\Support\Facades\Gate; // 追加
use Illuminate\Support\Facades\Vite;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     */
    public function register(): void
    {
        //
    }

    /**
     * Bootstrap any application services.
     */
    public function boot(): void
    {
        Vite::prefetch(concurrency: 3);

        // 追加: ポリシーを登録
        Gate::policy(Review::class, ReviewPolicy::class);
    }
}

動作確認をする

今、ユーザーID1 のユーザーが研究室ID1 の研究室にレビューを投稿済みの状態です。
ユーザーID1 のユーザーでログインした状態で http://localhost/labs/1/reviews/create にアクセスしようとしたとき、画面が見られないようになっていればよいはずです。

スクリーンショット 2025-06-13 064249.png

この行動は許可されていないと言われて、何も見ることができなくなりました!

念のため、まだレビューを投稿していない ID の研究室作成ページにアクセスしてみると...
http://localhost/labs/2/reviews/create
image.png
今度は、作成ページが見られますね!
成功です!(*^^)v

※動作確認用にアクセスするべき URL は人によって異なる可能性がありますので、データベースのレビューテーブルを見て適切な ID の研究室作成画面を開いてください。
なお、その際に Database Client のソート機能を使うと、より速く適切なデータを見つけることができると思います。

この上下の ▲▽ ボタンの話です。
image.png

これにて、Create(作成)機能の修正はいったん完了です!

Read(読み取り)機能を修正

次に、読み取り機能の修正を行います。
修正したい箇所は以下の通りです。

  1. レビュー済みの場合、自分のレビューを表示する
  2. レビュー済みでない場合、レビューを投稿するボタンを表示する

自分のレビュー表示

ログインして、レビューを投稿している研究室の詳細ページを開きましょう。
image.png
今のところ、このように自分が投稿したレビューは見られません。

コントローラーを修正する

さっそく修正していきましょう!
まずは、ビューに自分が投稿したレビューのデータを渡すようにコントローラーを修正していきましょう。

\project-root\src\app\Http\Controllers\LabController.php
<?php

namespace App\Http\Controllers;

use App\Models\Lab;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; // 追加
use Inertia\Inertia;

class LabController extends Controller
{
    public function index()
    {
        $labs = Lab::all();
        return Inertia::render('Lab/Index', [
            'labs' => $labs,
        ]);
    }

    public function show(Lab $lab)
    {
        // 大学・学部、レビューのデータも一緒に渡す
        // universityはfacultyを経由して取得
        $lab->load(['faculty.university', 'reviews']);

        // 平均値を計算するために、評価項目のカラム名を定義
        $ratingColumns = [
            'mentorship_style',
            'lab_atmosphere',
            'achievement_activity',
            'constraint_level',
            'facility_quality',
            'work_style',
            'student_balance',
        ];

        // 1. 各評価項目のユーザー間の平均値 (Average per Item) を計算
        $averagePerItem = collect($ratingColumns)->mapWithKeys(function ($column) use ($lab) {
            // 各評価項目の平均を計算(全レビューを対象)
            return [$column => $lab->reviews->avg($column)];
        });

        // 2. 新しい「総合評価」:各項目の平均値のさらに平均を計算
        // $averagePerItem の値(平均点)をコレクションとして取り出し、その平均を求める
        $overallAverage = $averagePerItem->avg();

        // 追加: 3. 現在のユーザーのレビューを取得
        $userReview = null;
        $userOverallAverage = null;
        
        if (Auth::check()) {
            $userReview = $lab->reviews->where('user_id', Auth::id())->first();
            
            // ユーザーのレビューが存在する場合、個別の総合評価を計算
            if ($userReview) {
                $userRatings = collect($ratingColumns)->map(function ($column) use ($userReview) {
                    return $userReview->$column;
                })->filter(function ($value) {
                    return $value !== null;
                });
                
                $userOverallAverage = $userRatings->avg();
            }
        }

        // 修正: 研究室のデータに加えて、求めたレビューの平均値とユーザーのレビューも一緒に渡す
        return Inertia::render('Lab/Show', [
            'lab' => $lab,
            'overallAverage' => $overallAverage,
            'averagePerItem' => $averagePerItem,
            'userReview' => $userReview,
            'userOverallAverage' => $userOverallAverage,
            'ratingData' => [
                'columns' => $ratingColumns,
            ],
        ]);
    }
}

修正箇所の解説です!

\project-root\src\app\Http\Controllers\LabController.php
if (Auth::check()) {
            $userReview = $lab->reviews->where('user_id', Auth::id())->first();
            }

ここでは、非常に便利な Auth ファサードを使用しています。
Auth::check() は、現在ログインしているかどうかを判定して結果を真偽値で返してくれます。
よって、ログインしている場合のみの処理がここには書かれています。

$lab に紐づく $reviews の中から、->where('user_id', Auth::id()) とすることで、'user_id' がログインしているユーザーの ID と一致しているものだけに絞り込み、->first() でそれを1件取得します。
その結果を、$userReview という変数に代入しています。

\project-root\src\app\Http\Controllers\LabController.php
// ユーザーのレビューが存在する場合、個別の総合評価を計算
            if ($userReview) {
                $userRatings = collect($ratingColumns)->map(function ($column) use ($userReview) {
                    return $userReview->$column;
                })->filter(function ($value) {
                    return $value !== null;
                });
                
                $userOverallAverage = $userRatings->avg();
            }

さらに、$userReview が正しく入っている場合にレビューの各評価項目の値及びそれらの平均値を取得します。

前回も登場した、コレクションですね!
filter() メソッドの引数にコールバック関数を代入することで、null ではないものだけに絞り込んでいます。
まあ、レビューは全評価項目が必須なので null になることはたぶんないと思うので要らないっちゃ要らないのですが一応念のため付けました。

$userOverallAverage = $userRatings->avg(); で、平均値を求めて変数に代入しています。

\project-root\src\app\Http\Controllers\LabController.php
// 修正: 研究室のデータに加えて、求めたレビューの平均値とユーザーのレビューも一緒に渡す
        return Inertia::render('Lab/Show', [
            'lab' => $lab,
            'overallAverage' => $overallAverage,
            'averagePerItem' => $averagePerItem,
            'userReview' => $userReview,
            'userOverallAverage' => $userOverallAverage,
            'ratingData' => [
                'columns' => $ratingColumns,
            ],
        ]);

最後の部分ですが、今の $userReview$userOverallAverageShow.jsx に渡すために追加しています。
各評価項目ごとのデータを表示するためにそれらを配列に格納する必要があり、ratingData というものも追加していますが、これは React コンポーネント側の問題で追加したものなので、今のところは気にしなくて OK です。

React コンポーネントを修正する

修正後の Show.jsx 配下の通りですので、コピペしてみてください。

\project-root\src\resources\js\Pages\Lab\Show.jsx
import React from 'react';
import { Head, router } from '@inertiajs/react';

// propsとして新しく追加されたプロパティも受け取る
export default function Show({ 
    lab, 
    overallAverage, 
    averagePerItem, 
    userReview, 
    userOverallAverage, 
    ratingData
}) {
    const reviewCount = lab.reviews ? lab.reviews.length : 0;

    // ratingColumnsが空の場合、フォールバック用の配列を使用
    const fallbackRatingColumns = [
        'mentorship_style',
        'lab_atmosphere',
        'achievement_activity',
        'constraint_level',
        'facility_quality',
        'work_style',
        'student_balance',
    ];
    
    const ratingColumns = ratingData?.columns || fallbackRatingColumns;
    const actualRatingColumns = ratingColumns.length > 0 ? ratingColumns : fallbackRatingColumns;

    const itemLabels = {
        mentorship_style: '指導スタイル',
        lab_atmosphere: '雰囲気・文化',
        achievement_activity: '成果・活動',
        constraint_level: '拘束度',
        facility_quality: '設備',
        work_style: '働き方',
        student_balance: '人数バランス',
    };

    const formatAverage = (value) => {
        return value !== null && value !== undefined ? value.toFixed(2) : 'データなし';
    };

    const handleDeleteReview = (reviewId) => {
        if (confirm('本当に削除してもよろしいですか?')) {
            router.delete(route('review.destroy', { review: reviewId }), {
                onSuccess: () => {
                    // 成功時の処理
                    alert('レビューが削除されました。');
                },
                onError: (error) => {
                    // エラー時の処理
                    alert('レビューの削除に失敗しました。');
                }
            });
        }
    };

    return (
        <div>
            <Head title={`${lab.name}の詳細`} />
            <h1>{lab.name} の詳細ページ</h1>
            <p>大学: {lab.faculty?.university?.name}</p>
            <p>学部: {lab.faculty?.name}</p>
            <p>研究室の説明: {lab.description}</p>
            <p>研究室のURL: <a href={lab.url} target="_blank" rel="noopener noreferrer">{lab.url}</a></p>
            <p>教授のURL: <a href={lab.professor_url} target="_blank" rel="noopener noreferrer">{lab.professor_url}</a></p>
            <p>男女比(男): {lab.gender_ratio_male}</p>
            <p>男女比(女): {lab.gender_ratio_female}</p>

            <hr />

            <h2>レビュー</h2>
            <p>レビュー数: {reviewCount}</p>
            
            {/* 全体の平均評価を表示 */}
            <h3>全体の評価(平均)</h3>
            <p><strong>総合評価: </strong>{formatAverage(overallAverage)}</p>

            <h4>各評価項目の平均:</h4>
            {averagePerItem && Object.keys(averagePerItem).length > 0 ? (
                <ul>
                    {Object.entries(averagePerItem).map(([itemKey, averageValue]) => (
                        <li key={itemKey}>
                            <p>
                                <strong>{itemLabels[itemKey] || itemKey}:</strong>
                                {formatAverage(averageValue)}
                            </p>
                        </li>
                    ))}
                </ul>
            ) : (
                <p>まだ評価データがありません。</p>
            )}

            {/* ユーザーのレビューが存在する場合に表示 */}
            {userReview && (
                <div>
                    <h3>あなたの投稿したレビュー</h3>
                    <p><strong>総合評価: </strong>{formatAverage(userOverallAverage)}</p>
                    
                    <h4>各評価項目:</h4>
                    <ul>
                        {actualRatingColumns && actualRatingColumns.length > 0 ? (
                            actualRatingColumns.map((column) => {
                                const value = userReview[column];
                                return (
                                    <li key={column}>
                                        <p>
                                            <strong>{itemLabels[column] || column}:</strong>
                                            {value !== null && value !== undefined 
                                                ? (typeof value === 'number' ? value.toFixed(2) : value)
                                                : '未評価'}
                                        </p>
                                    </li>
                                )
                            })
                        ) : (
                            <li>評価項目データがありません</li>
                        )}
                    </ul>
                    
                    {/* ユーザーのレビューにコメントがある場合表示 */}
                    {userReview.comment && (
                        <div>
                            <h4>コメント:</h4>
                            <p>{userReview.comment}</p>
                        </div>
                    )}
                    
                    <div>
                        <button onClick={() => handleDeleteReview(userReview.id)}>
                            このレビューを削除
                        </button>
                    </div>
                </div>
            )}

            {/* 他のレビューの削除ボタン(開発用) */}
            {lab.reviews && lab.reviews.length > 0 && (
                <div>
                    <h3>開発用:全レビュー削除</h3>
                    {lab.reviews.map((review) => (
                        <button
                            key={review.id}
                            onClick={() => handleDeleteReview(review.id)}
                        >
                            レビューID {review.id}を削除
                        </button>
                    ))}
                </div>
            )}
        </div>
    );
}

動作確認をする

では、動作確認です。
全体評価の下に、自分のレビューが表示されていれば OK です!
image.png

また、レビューを投稿していない研究室の詳細ページを開くと...
image.png
自分のレビューがないので、全体のレビューしか表示されません!
期待通りの動作ですね。(*^^)v

レビュー投稿ボタン表示

お次は、レビュー済みでない場合にレビューを投稿するボタンを表示するようにしましょう。

といっても、Show.jsx を修正するだけで OK です。

\project-root\src\resources\js\Pages\Lab\Show.jsx
import React from 'react';
import { Head, router } from '@inertiajs/react';

// propsとして新しく追加されたプロパティも受け取る
export default function Show({ 
    lab, 
    overallAverage, 
    averagePerItem, 
    userReview, 
    userOverallAverage, 
    ratingData
}) {
    const reviewCount = lab.reviews ? lab.reviews.length : 0;

    // ratingColumnsが空の場合、フォールバック用の配列を使用
    const fallbackRatingColumns = [
        'mentorship_style',
        'lab_atmosphere',
        'achievement_activity',
        'constraint_level',
        'facility_quality',
        'work_style',
        'student_balance',
    ];
    
    const ratingColumns = ratingData?.columns || fallbackRatingColumns;
    const actualRatingColumns = ratingColumns.length > 0 ? ratingColumns : fallbackRatingColumns;

    const itemLabels = {
        mentorship_style: '指導スタイル',
        lab_atmosphere: '雰囲気・文化',
        achievement_activity: '成果・活動',
        constraint_level: '拘束度',
        facility_quality: '設備',
        work_style: '働き方',
        student_balance: '人数バランス',
    };

    const formatAverage = (value) => {
        return value !== null && value !== undefined ? value.toFixed(2) : 'データなし';
    };

    const handleDeleteReview = (reviewId) => {
        if (confirm('本当に削除してもよろしいですか?')) {
            router.delete(route('review.destroy', { review: reviewId }), {
                onSuccess: () => {
                    // 成功時の処理
                    alert('レビューが削除されました。');
                },
                onError: (error) => {
                    // エラー時の処理
                    alert('レビューの削除に失敗しました。');
                }
            });
        }
    };

    const handleCreateReview = () => {
        router.get(route('review.create', { lab: lab.id }));
    };

    return (
        <div>
            <Head title={`${lab.name}の詳細`} />
            <h1>{lab.name} の詳細ページ</h1>
            <p>大学: {lab.faculty?.university?.name}</p>
            <p>学部: {lab.faculty?.name}</p>
            <p>研究室の説明: {lab.description}</p>
            <p>研究室のURL: <a href={lab.url} target="_blank" rel="noopener noreferrer">{lab.url}</a></p>
            <p>教授のURL: <a href={lab.professor_url} target="_blank" rel="noopener noreferrer">{lab.professor_url}</a></p>
            <p>男女比(男): {lab.gender_ratio_male}</p>
            <p>男女比(女): {lab.gender_ratio_female}</p>

            <hr />

            <h2>レビュー</h2>
            <p>レビュー数: {reviewCount}</p>
            
            {/* 全体の平均評価を表示 */}
            <h3>全体の評価(平均)</h3>
            <p><strong>総合評価: </strong>{formatAverage(overallAverage)}</p>

            <h4>各評価項目の平均:</h4>
            {averagePerItem && Object.keys(averagePerItem).length > 0 ? (
                <ul>
                    {Object.entries(averagePerItem).map(([itemKey, averageValue]) => (
                        <li key={itemKey}>
                            <p>
                                <strong>{itemLabels[itemKey] || itemKey}:</strong>
                                {formatAverage(averageValue)}
                            </p>
                        </li>
                    ))}
                </ul>
            ) : (
                <p>まだ評価データがありません。</p>
            )}

            {/* ユーザーのレビューが存在する場合に表示 */}
            {userReview ? (
                <div>
                    <h3>あなたの投稿したレビュー</h3>
                    <p><strong>総合評価: </strong>{formatAverage(userOverallAverage)}</p>
                    
                    <h4>各評価項目:</h4>
                    <ul>
                        {actualRatingColumns && actualRatingColumns.length > 0 ? (
                            actualRatingColumns.map((column) => {
                                const value = userReview[column];
                                return (
                                    <li key={column}>
                                        <p>
                                            <strong>{itemLabels[column] || column}:</strong>
                                            {value !== null && value !== undefined 
                                                ? (typeof value === 'number' ? value.toFixed(2) : value)
                                                : '未評価'}
                                        </p>
                                    </li>
                                )
                            })
                        ) : (
                            <li>評価項目データがありません</li>
                        )}
                    </ul>
                    
                    {/* ユーザーのレビューにコメントがある場合表示 */}
                    {userReview.comment && (
                        <div>
                            <h4>コメント:</h4>
                            <p>{userReview.comment}</p>
                        </div>
                    )}
                    
                    <div>
                        <button onClick={() => handleDeleteReview(userReview.id)}>
                            このレビューを削除
                        </button>
                    </div>
                </div>
            ) : (
                // レビューが存在しない場合はレビュー投稿ボタンを表示
                <div>
                    <h3>レビューを投稿</h3>
                    <p>まだこの研究室のレビューを投稿していません。</p>
                    <button onClick={handleCreateReview}>
                        レビューを投稿する
                    </button>
                </div>
            )}

            {/* 他のレビューの削除ボタン(開発用) */}
            {lab.reviews && lab.reviews.length > 0 && (
                <div>
                    <h3>開発用:全レビュー削除</h3>
                    {lab.reviews.map((review) => (
                        <button
                            key={review.id}
                            onClick={() => handleDeleteReview(review.id)}
                        >
                            レビューID {review.id}を削除
                        </button>
                    ))}
                </div>
            )}
        </div>
    );
}

さきほどのページを見てみましょう。
image.png
レビューを投稿するボタンができていますね。
試しにクリックしてみると...
image.png
しっかりと、作成画面に遷移しました。
ついでに、戻るボタンも付けておきましょうか。

\project-root\src\resources\js\Pages\Lab\Show.jsx
import React from 'react';
import { Head, router } from '@inertiajs/react';

// propsとして新しく追加されたプロパティも受け取る
export default function Show({ 
    lab, 
    overallAverage, 
    averagePerItem, 
    userReview, 
    userOverallAverage, 
    ratingData
}) {
    const reviewCount = lab.reviews ? lab.reviews.length : 0;

    // ratingColumnsが空の場合、フォールバック用の配列を使用
    const fallbackRatingColumns = [
        'mentorship_style',
        'lab_atmosphere',
        'achievement_activity',
        'constraint_level',
        'facility_quality',
        'work_style',
        'student_balance',
    ];
    
    const ratingColumns = ratingData?.columns || fallbackRatingColumns;
    const actualRatingColumns = ratingColumns.length > 0 ? ratingColumns : fallbackRatingColumns;

    const itemLabels = {
        mentorship_style: '指導スタイル',
        lab_atmosphere: '雰囲気・文化',
        achievement_activity: '成果・活動',
        constraint_level: '拘束度',
        facility_quality: '設備',
        work_style: '働き方',
        student_balance: '人数バランス',
    };

    const formatAverage = (value) => {
        return value !== null && value !== undefined ? value.toFixed(2) : 'データなし';
    };

    const handleDeleteReview = (reviewId) => {
        if (confirm('本当に削除してもよろしいですか?')) {
            router.delete(route('review.destroy', { review: reviewId }), {
                onSuccess: () => {
                    // 成功時の処理
                    alert('レビューが削除されました。');
                },
                onError: (error) => {
                    // エラー時の処理
                    alert('レビューの削除に失敗しました。');
                }
            });
        }
    };

    const handleCreateReview = () => {
        router.get(route('review.create', { lab: lab.id }));
    };

    return (
        <div>
            <Head title={`${lab.name}の詳細`} />
            <h1>{lab.name} の詳細ページ</h1>
            <p>大学: {lab.faculty?.university?.name}</p>
            <p>学部: {lab.faculty?.name}</p>
            <p>研究室の説明: {lab.description}</p>
            <p>研究室のURL: <a href={lab.url} target="_blank" rel="noopener noreferrer">{lab.url}</a></p>
            <p>教授のURL: <a href={lab.professor_url} target="_blank" rel="noopener noreferrer">{lab.professor_url}</a></p>
            <p>男女比: {lab.gender_ratio_male}</p>
            <p>男女比: {lab.gender_ratio_female}</p>

            <hr />

            <h2>レビュー</h2>
            <p>レビュー数: {reviewCount}</p>
            
            {/* 全体の平均評価を表示 */}
            <h3>全体の評価平均</h3>
            <p><strong>総合評価: </strong>{formatAverage(overallAverage)}</p>

            <h4>各評価項目の平均:</h4>
            {averagePerItem && Object.keys(averagePerItem).length > 0 ? (
                <ul>
                    {Object.entries(averagePerItem).map(([itemKey, averageValue]) => (
                        <li key={itemKey}>
                            <p>
                                <strong>{itemLabels[itemKey] || itemKey}:</strong>
                                {formatAverage(averageValue)}
                            </p>
                        </li>
                    ))}
                </ul>
            ) : (
                <p>まだ評価データがありません</p>
            )}

            {/* ユーザーのレビューが存在する場合に表示 */}
            {userReview ? (
                <div>
                    <h3>あなたの投稿したレビュー</h3>
                    <p><strong>総合評価: </strong>{formatAverage(userOverallAverage)}</p>
                    
                    <h4>各評価項目:</h4>
                    <ul>
                        {actualRatingColumns && actualRatingColumns.length > 0 ? (
                            actualRatingColumns.map((column) => {
                                const value = userReview[column];
                                return (
                                    <li key={column}>
                                        <p>
                                            <strong>{itemLabels[column] || column}:</strong>
                                            {value !== null && value !== undefined 
                                                ? (typeof value === 'number' ? value.toFixed(2) : value)
                                                : '未評価'}
                                        </p>
                                    </li>
                                )
                            })
                        ) : (
                            <li>評価項目データがありません</li>
                        )}
                    </ul>
                    
                    {/* ユーザーのレビューにコメントがある場合表示 */}
                    {userReview.comment && (
                        <div>
                            <h4>コメント:</h4>
                            <p>{userReview.comment}</p>
                        </div>
                    )}
                    
                    <div>
                        <button onClick={() => handleDeleteReview(userReview.id)}>
                            このレビューを削除
                        </button>
                    </div>
                </div>
            ) : (
                // レビューが存在しない場合はレビュー投稿ボタンを表示
                <div>
                    <h3>レビューを投稿</h3>
                    <p>まだこの研究室のレビューを投稿していません</p>
                    <button onClick={handleCreateReview}>
                        レビューを投稿する
                    </button>
                </div>
            )}

            {/* 他のレビューの削除ボタン(開発用) */}
            {lab.reviews && lab.reviews.length > 0 && (
                <div>
                    <h3>開発用全レビュー削除</h3>
                    {lab.reviews.map((review) => (
                        <button
                            key={review.id}
                            onClick={() => handleDeleteReview(review.id)}
                        >
                            レビューID {review.id}を削除
                        </button>
                    ))}
                </div>
            )}
        </div>
    );
}
w\project-root\src\resources\js\Pages\Review\Create.jsx
import React, { useState } from 'react';
import { Head, router } from '@inertiajs/react';

export default function Create({ lab }) {
    const [data, setData] = useState({
        lab_id: lab.id,
        mentorship_style: 3,
        lab_atmosphere: 3,
        achievement_activity: 3,
        constraint_level: 3,
        facility_quality: 3,
        work_style: 3,
        student_balance: 3,
    });

    const [errors, setErrors] = useState({});
    const [processing, setProcessing] = useState(false);

    const handleSubmit = (e) => {
        e.preventDefault();
        setProcessing(true);

        router.post(`/labs/${data.lab_id}/reviews`, data, {
            onSuccess: () => {
                // 成功時の処理
            },
            onError: (errors) => {
                setErrors(errors);
                setProcessing(false);
            },
            onFinish: () => {
                setProcessing(false);
            }
        });
    };

    const handleChange = (field, value) => {
        setData(prev => ({
            ...prev,
            [field]: value
        }));

        if (errors[field]) {
            setErrors(prev => ({
                ...prev,
                [field]: undefined
            }));
        }
    };

    const handleBack = () => {
        router.get(route('labs.show', { lab: lab.id }));
    };

    return (
        <div>
            <Head title={`${lab.name}のレビュー作成`} />
            <button onClick={handleBack}>{lab.name}の詳細ページに戻る
            </button>
            <h1>{lab.name} のレビュー作成画面</h1>

            <form onSubmit={handleSubmit}>
                {/* 指導スタイル */}
                <div>
                    <label>指導スタイル: {data.mentorship_style}</label>
                    <input
                        type="range"
                        min="1"
                        max="5"
                        value={data.mentorship_style}
                        onChange={(e) => handleChange('mentorship_style', parseInt(e.target.value))}
                    />
                    {errors.mentorship_style && <div>{errors.mentorship_style}</div>}
                </div>

                {/* 雰囲気・文化 */}
                <div>
                    <label>雰囲気・文化: {data.lab_atmosphere}</label>
                    <input
                        type="range"
                        min="1"
                        max="5"
                        value={data.lab_atmosphere}
                        onChange={(e) => handleChange('lab_atmosphere', parseInt(e.target.value))}
                    />
                    {errors.lab_atmosphere && <div>{errors.lab_atmosphere}</div>}
                </div>

                {/* 成果・活動 */}
                <div>
                    <label>成果・活動: {data.achievement_activity}</label>
                    <input
                        type="range"
                        min="1"
                        max="5"
                        value={data.achievement_activity}
                        onChange={(e) => handleChange('achievement_activity', parseInt(e.target.value))}
                    />
                    {errors.achievement_activity && <div>{errors.achievement_activity}</div>}
                </div>

                {/* 拘束度 */}
                <div>
                    <label>拘束度: {data.constraint_level}</label>
                    <input
                        type="range"
                        min="1"
                        max="5"
                        value={data.constraint_level}
                        onChange={(e) => handleChange('constraint_level', parseInt(e.target.value))}
                    />
                    {errors.constraint_level && <div>{errors.constraint_level}</div>}
                </div>

                {/* 設備 */}
                <div>
                    <label>設備: {data.facility_quality}</label>
                    <input
                        type="range"
                        min="1"
                        max="5"
                        value={data.facility_quality}
                        onChange={(e) => handleChange('facility_quality', parseInt(e.target.value))}
                    />
                    {errors.facility_quality && <div>{errors.facility_quality}</div>}
                </div>

                {/* 働き方 */}
                <div>
                    <label>働き方: {data.work_style}</label>
                    <input
                        type="range"
                        min="1"
                        max="5"
                        value={data.work_style}
                        onChange={(e) => handleChange('work_style', parseInt(e.target.value))}
                    />
                    {errors.work_style && <div>{errors.work_style}</div>}
                </div>

                {/* 人数バランス */}
                <div>
                    <label>人数バランス: {data.student_balance}</label>
                    <input
                        type="range"
                        min="1"
                        max="5"
                        value={data.student_balance}
                        onChange={(e) => handleChange('student_balance', parseInt(e.target.value))}
                    />
                    {errors.student_balance && <div>{errors.student_balance}</div>}
                </div>

                {/* 送信ボタン */}
                <div>
                    <button type="submit" disabled={processing}>
                        {processing ? '投稿中...' : 'レビューを投稿'}
                    </button>
                </div>
            </form>
        </div>
    );
}

image.png
クリックすると...
image.png
戻れました!
念のため、レビュー済みの研究室の詳細ページを開いてみると...
image.png
投稿ボタンが表示されないので正しい動作ですね!

Update(更新)機能を修正

続いて、更新機能の修正です。
修正したい箇所は以下の通りです。

  1. レビュー済みの場合、レビューを編集するボタンを表示する
  2. 自分以外のレビューを編集できないようにする

レビュー編集ボタン表示

Show.jsx を修正します。

\project-root\src\resources\js\Pages\Lab\Show.jsx
import React from 'react';
import { Head, router } from '@inertiajs/react';

// propsとして新しく追加されたプロパティも受け取る
export default function Show({ 
    lab, 
    overallAverage, 
    averagePerItem, 
    userReview, 
    userOverallAverage, 
    ratingData
}) {
    const reviewCount = lab.reviews ? lab.reviews.length : 0;

    // ratingColumnsが空の場合、フォールバック用の配列を使用
    const fallbackRatingColumns = [
        'mentorship_style',
        'lab_atmosphere',
        'achievement_activity',
        'constraint_level',
        'facility_quality',
        'work_style',
        'student_balance',
    ];
    
    const ratingColumns = ratingData?.columns || fallbackRatingColumns;
    const actualRatingColumns = ratingColumns.length > 0 ? ratingColumns : fallbackRatingColumns;

    const itemLabels = {
        mentorship_style: '指導スタイル',
        lab_atmosphere: '雰囲気・文化',
        achievement_activity: '成果・活動',
        constraint_level: '拘束度',
        facility_quality: '設備',
        work_style: '働き方',
        student_balance: '人数バランス',
    };

    const formatAverage = (value) => {
        return value !== null && value !== undefined ? value.toFixed(2) : 'データなし';
    };

    const handleDeleteReview = (reviewId) => {
        if (confirm('本当に削除してもよろしいですか?')) {
            router.delete(route('review.destroy', { review: reviewId }), {
                onSuccess: () => {
                    // 成功時の処理
                    alert('レビューが削除されました。');
                },
                onError: (error) => {
                    // エラー時の処理
                    alert('レビューの削除に失敗しました。');
                }
            });
        }
    };

    const handleCreateReview = () => {
        router.get(route('review.create', { lab: lab.id }));
    };

    const handleEditReview = () => {
        router.get(route('review.edit', { review: userReview.id }));
    };

    return (
        <div>
            <Head title={`${lab.name}の詳細`} />
            <h1>{lab.name} の詳細ページ</h1>
            <p>大学: {lab.faculty?.university?.name}</p>
            <p>学部: {lab.faculty?.name}</p>
            <p>研究室の説明: {lab.description}</p>
            <p>研究室のURL: <a href={lab.url} target="_blank" rel="noopener noreferrer">{lab.url}</a></p>
            <p>教授のURL: <a href={lab.professor_url} target="_blank" rel="noopener noreferrer">{lab.professor_url}</a></p>
            <p>男女比(男): {lab.gender_ratio_male}</p>
            <p>男女比(女): {lab.gender_ratio_female}</p>

            <hr />

            <h2>レビュー</h2>
            <p>レビュー数: {reviewCount}</p>
            
            {/* 全体の平均評価を表示 */}
            <h3>全体の評価(平均)</h3>
            <p><strong>総合評価: </strong>{formatAverage(overallAverage)}</p>

            <h4>各評価項目の平均:</h4>
            {averagePerItem && Object.keys(averagePerItem).length > 0 ? (
                <ul>
                    {Object.entries(averagePerItem).map(([itemKey, averageValue]) => (
                        <li key={itemKey}>
                            <p>
                                <strong>{itemLabels[itemKey] || itemKey}:</strong>
                                {formatAverage(averageValue)}
                            </p>
                        </li>
                    ))}
                </ul>
            ) : (
                <p>まだ評価データがありません。</p>
            )}

            {/* ユーザーのレビューが存在する場合に表示 */}
            {userReview ? (
                <div>
                    <h3>あなたの投稿したレビュー</h3>
                    <p><strong>総合評価: </strong>{formatAverage(userOverallAverage)}</p>
                    
                    <h4>各評価項目:</h4>
                    <ul>
                        {actualRatingColumns && actualRatingColumns.length > 0 ? (
                            actualRatingColumns.map((column) => {
                                const value = userReview[column];
                                return (
                                    <li key={column}>
                                        <p>
                                            <strong>{itemLabels[column] || column}:</strong>
                                            {value !== null && value !== undefined 
                                                ? (typeof value === 'number' ? value.toFixed(2) : value)
                                                : '未評価'}
                                        </p>
                                    </li>
                                )
                            })
                        ) : (
                            <li>評価項目データがありません</li>
                        )}
                    </ul>
                    
                    {/* ユーザーのレビューにコメントがある場合表示 */}
                    {userReview.comment && (
                        <div>
                            <h4>コメント:</h4>
                            <p>{userReview.comment}</p>
                        </div>
                    )}
                    
                    <div>
                        <button onClick={() => handleDeleteReview(userReview.id)}>
                            このレビューを削除
                        </button>
                        <button onClick={handleEditReview}>
                            レビューを編集する
                        </button>
                    </div>
                </div>
            ) : (
                // レビューが存在しない場合はレビュー投稿ボタンを表示
                <div>
                    <h3>レビューを投稿</h3>
                    <p>まだこの研究室のレビューを投稿していません。</p>
                    <button onClick={handleCreateReview}>
                        レビューを投稿する
                    </button>
                </div>
            )}

            {/* 他のレビューの削除ボタン(開発用) */}
            {lab.reviews && lab.reviews.length > 0 && (
                <div>
                    <h3>開発用:全レビュー削除</h3>
                    {lab.reviews.map((review) => (
                        <button
                            key={review.id}
                            onClick={() => handleDeleteReview(review.id)}
                        >
                            レビューID {review.id}を削除
                        </button>
                    ))}
                </div>
            )}
        </div>
    );
}

では、ログインしている状態で、そのユーザーがレビューを投稿済みの研究室のページを開きましょう。
image.png
「レビューを編集する」ボタンができました!
クリックしてみましょう。
image.png
編集画面に移動できました。
一応戻るボタンも付けておきましょうか!

\project-root\src\resources\js\Pages\Review\Edit.jsx
import React, { useState } from 'react';
import { Head, router } from '@inertiajs/react';

export default function Edit({ review }) {
    const [data, setData] = useState({
        lab_id: review.mentorship_style,
        mentorship_style: review.mentorship_style,
        lab_atmosphere: review.lab_atmosphere,
        achievement_activity: review.achievement_activity,
        constraint_level: review.constraint_level,
        facility_quality: review.facility_quality,
        work_style: review.work_style,
        student_balance: review.student_balance,
    });

    const [errors, setErrors] = useState({});
    const [processing, setProcessing] = useState(false);

    const handleSubmit = (e) => {
        e.preventDefault();
        setProcessing(true);

        router.put(route('review.update', { review: review.id }), data, {
            onSuccess: () => {
                // 成功時の処理
            },
            onError: (errors) => {
                setErrors(errors);
                setProcessing(false);
            },
            onFinish: () => {
                setProcessing(false);
            }
        });
    };

    const handleChange = (field, value) => {
        setData(prev => ({
            ...prev,
            [field]: value
        }));

        if (errors[field]) {
            setErrors(prev => ({
                ...prev,
                [field]: undefined
            }));
        }
    };

    const handleBack = () => {
        router.get(route('labs.show', { lab: review.lab.id }));
    };

    return (
        <div>
            <Head title={`${review.lab.name}のレビュー編集`} />
            <button onClick={handleBack}>{review.lab.name}の詳細ページに戻る
            </button>
            <h1>{review.lab.name} のレビュー編集画面</h1>

            <form onSubmit={handleSubmit}>
                {/* 指導スタイル */}
                <div>
                    <label>指導スタイル: {data.mentorship_style}</label>
                    <input
                        type="range"
                        min="1"
                        max="5"
                        value={data.mentorship_style}
                        onChange={(e) => handleChange('mentorship_style', parseInt(e.target.value))}
                    />
                    {errors.mentorship_style && <div>{errors.mentorship_style}</div>}
                </div>

                {/* 雰囲気・文化 */}
                <div>
                    <label>雰囲気・文化: {data.lab_atmosphere}</label>
                    <input
                        type="range"
                        min="1"
                        max="5"
                        value={data.lab_atmosphere}
                        onChange={(e) => handleChange('lab_atmosphere', parseInt(e.target.value))}
                    />
                    {errors.lab_atmosphere && <div>{errors.lab_atmosphere}</div>}
                </div>

                {/* 成果・活動 */}
                <div>
                    <label>成果・活動: {data.achievement_activity}</label>
                    <input
                        type="range"
                        min="1"
                        max="5"
                        value={data.achievement_activity}
                        onChange={(e) => handleChange('achievement_activity', parseInt(e.target.value))}
                    />
                    {errors.achievement_activity && <div>{errors.achievement_activity}</div>}
                </div>

                {/* 拘束度 */}
                <div>
                    <label>拘束度: {data.constraint_level}</label>
                    <input
                        type="range"
                        min="1"
                        max="5"
                        value={data.constraint_level}
                        onChange={(e) => handleChange('constraint_level', parseInt(e.target.value))}
                    />
                    {errors.constraint_level && <div>{errors.constraint_level}</div>}
                </div>

                {/* 設備 */}
                <div>
                    <label>設備: {data.facility_quality}</label>
                    <input
                        type="range"
                        min="1"
                        max="5"
                        value={data.facility_quality}
                        onChange={(e) => handleChange('facility_quality', parseInt(e.target.value))}
                    />
                    {errors.facility_quality && <div>{errors.facility_quality}</div>}
                </div>

                {/* 働き方 */}
                <div>
                    <label>働き方: {data.work_style}</label>
                    <input
                        type="range"
                        min="1"
                        max="5"
                        value={data.work_style}
                        onChange={(e) => handleChange('work_style', parseInt(e.target.value))}
                    />
                    {errors.work_style && <div>{errors.work_style}</div>}
                </div>

                {/* 人数バランス */}
                <div>
                    <label>人数バランス: {data.student_balance}</label>
                    <input
                        type="range"
                        min="1"
                        max="5"
                        value={data.student_balance}
                        onChange={(e) => handleChange('student_balance', parseInt(e.target.value))}
                    />
                    {errors.student_balance && <div>{errors.student_balance}</div>}
                </div>

                {/* 送信ボタン */}
                <div>
                    <button type="submit" disabled={processing}>
                        {processing ? '更新中...' : 'レビューを更新'}
                    </button>
                </div>
            </form>
        </div>
    );
}

image.png
クリックすると...
image.png
戻れました!
念のため、レビューを投稿していない研究室を開いてみると...
image.png
編集ボタンは出なくなりました!OK!!

自分以外のレビュー編集不可

ボタンを普通にクリックして操作する分には問題はないのですが、URL を直接入力すると他の人が投稿したレビューの編集ページにアクセス出来てしまいます。

例えば、user_id が 1 のユーザーは今 id が 15 または 53 のレビューを投稿しているとします。
この時、このユーザーでログインしている状態で、user_id が 2 のユーザーすなわち別のユーザーが投稿した id 17 のレビューの編集画面にアクセスしてみます。
http://localhost/reviews/17/edit
image.png
デデン!
なんと普通に編集できてしまいます。
そうです。
edit の前の数字をいじるだけで好きなレビューを編集できる状態です。
自分の投稿が知らないうちに他のユーザーに編集されてしまったら、当然「ふざけんな!」となります。

何か良い方法はないでしょうか?
ポリシーを使ってみるのはどうでしょうか?

\project-root\src\app\Policies\ReviewPolicy.php
<?php

namespace App\Policies;

use App\Models\Lab;
use App\Models\Review;
use App\Models\User;

class ReviewPolicy
{
    // ユーザーがレビューを作成できるかどうかを判定
    public function create(User $user, Lab $lab)
    {
        // 既にレビューがある場合は作成不可
        return !Review::where('user_id', $user->id)
                    ->where('lab_id', $lab->id)
                    ->exists();
    }

    // 追加: ユーザーがレビューを編集できるかどうかを判定
    public function update(User $user, Review $review)
    {
        // 自分が投稿したレビューのみ編集可能
        return $user->id === $review->user_id;
    }
}

ReviewContoller.phpedit() メソッドにも一行追加です。

\project-root\src\app\Http\Controllers\ReviewController.php
public function edit(Review $review) {
        // 追加: ポリシーで認可をチェック
        $this->authorize('update', $review);
        $review->load('lab');
        return Inertia::render('Review/Edit', ['review' => $review,]);
    }

これで編集できなくなります。
image.png
自分の投稿したレビューは今まで通り編集できます。
image.png

バリデーション修正

実は、作成機能を修正した時の修正が編集機能にも反映されていいるので特になりもしなくても大丈夫です!
image.png
先ほどと同様に最大値を 6 に変えて更新ボタンを押すと、日本語でバリデーションメッセージが表示されます。

Delete(削除)機能を修正

最後は、削除機能の編集です!
あともう少しですので、頑張ってください。(*´Д`)
以下を修正しましょう。

  1. レビュー済みの場合、レビューを削除するボタンを表示する
  2. 自分以外のレビューを削除できないようにする
  3. リダイレクト時のエラーを解消する

レビュー削除ボタン表示

レビューを投稿済みの場合、自分が投稿したレビューを削除するボタンを用意します。
...と言っても、実は先ほどこっそり追加しておきました。

「このレビューを削除」を試しにクリックしてみましょう。
エラーが起きると思いますが、F5 キーを押してリロードしてみてください。
image.png
image.png
自分のレビューが消え、削除ボタンと編集ボタンも表示されなくなりましたね!

自分以外のレビュー削除不可

今、他のユーザーが投稿したレビューの削除ボタンがすべて表示されているので、自分のもの以外は消しておきましょう。

Show.jsx から以下の部分を削除してください。

\project-root\src\resources\js\Pages\Lab\Show.jsx
{/* 他のレビューの削除ボタン(開発用) */}
            {lab.reviews && lab.reviews.length > 0 && (
                <div>
                    <h3>開発用:全レビュー削除</h3>
                    {lab.reviews.map((review) => (
                        <button
                            key={review.id}
                            onClick={() => handleDeleteReview(review.id)}
                        >
                            レビューID {review.id}を削除
                        </button>
                    ))}
                </div>
            )}

image.png
これでだいぶすっきりしましたね。

さて、ここで質問です。
この削除機能にはポリシーで認可を付けるべきか?
もしかしたら、「編集と違って、削除ページがあるわけではないから URL を直接入力してページにアクセスするわけではないし、要らないんじゃね?」と思った方もいるかもしれません。
しかし、残念ながら答えは、Yes!! ポリシーは必要です!
なぜかと言いますと、Web ページのフォームからの送信以外でもリクエストを送信することができるからです!
今まで僕は単に「URL にアクセス」という表現を使ってきましたが、正確には「クライアントとして GET メソッドでサーバーに HTTP リクエストを送信」していました。
リクエストの送信方法には種類があり、GETPOST, DELETE などがあります。
URL を入力してブラウザで Web ページを開くのは基本的に GET メソッドで、開いた画面で削除ボタンを押すなどすると DELETE メソッドでリクエストを送信していたというわけです。
ところが、ブラウザのフォームから正規の方法でリクエストを送信する以外にも、ユーザーがブラウザの開発者ツールや Postman などのツールで直接 DELETE メソッドでリクエストを送信する方法もあります。
本来これらは、開発者が効率よく開発できるようにするために作られた便利なツールですが、悪意のあるユーザーに悪用される可能性もゼロではありません。
よって、削除機能にもポリシーを付けるべきということになります!
そうすると、自然と浮かび上がる疑問がありますよね。
あれ?そうしたら、update() メソッドとかにもポリシーを設定するべきだった?そうすると、store() メソッドも?
それ、正しいです!
URL パターンがある限り認可は常に必要ということです!
本当なら、設計の段階でルート設計書を作って、どの URL パターンにどのような認可が必要かなどまで細かく決めるべきだったのですが、今回は個人開発ということでそこまではやっていませんでした。
それに、Laravel には CSRF トークンによって、外部からの不正なアクセスをはじく機能も備わっています。
しかし、実際の現場では重要な考え方だと思ったので紹介しました。

長くなりましたが、念のために、destroy(), update(), store() メソッドにも認可を付けておきましょう!

\project-root\src\app\Policies\ReviewPolicy.php
<?php

namespace App\Policies;

use App\Models\Lab;
use App\Models\Review;
use App\Models\User;

class ReviewPolicy
{
    // ユーザーがレビューを作成できるかどうかを判定
    public function create(User $user, Lab $lab)
    {
        // 既にレビューがある場合は作成不可
        return !Review::where('user_id', $user->id)
                    ->where('lab_id', $lab->id)
                    ->exists();
    }

    // ユーザーがレビューを編集できるかどうかを判定
    public function update(User $user, Review $review)
    {
        // 自分が投稿したレビューのみ編集可能
        return $user->id === $review->user_id;
    }

    // ユーザーがレビューを削除できるかどうかを判定
    public function delete(User $user, Review $review)
    {
        // 自分が投稿したレビューのみ削除可能
        return $user->id === $review->user_id;
    }
}

\project-root\src\app\Http\Controllers\ReviewController.php
<?php

namespace App\Http\Controllers;

use App\Models\Lab;
use App\Models\Review;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Inertia\Inertia;

class ReviewController extends Controller
{
    use AuthorizesRequests;
    
    public function create(Lab $lab) {
        // ポリシーで認可をチェック
        $this->authorize('create', [Review::class, $lab]);
        return Inertia::render('Review/Create', ['lab' => $lab]);
    }

    public function store(Request $request, Lab $lab) {
        // 追加: ポリシーで認可をチェック
        $this->authorize('create', [Review::class, $lab]);
        // バリデーション
        $validated = $request->validate([
            'mentorship_style' => 'required|integer|min:1|max:5',
            'lab_atmosphere' => 'required|integer|min:1|max:5',
            'achievement_activity' => 'required|integer|min:1|max:5',
            'constraint_level' => 'required|integer|min:1|max:5',
            'facility_quality' => 'required|integer|min:1|max:5',
            'work_style' => 'required|integer|min:1|max:5',
            'student_balance' => 'required|integer|min:1|max:5',
        ]);

        // バリデーション済みのデータを保存
        $review = new Review();
        $review->user_id = Auth::id();
        $review->lab_id = $lab->id;
        $review->mentorship_style = $validated['mentorship_style'];
        $review->lab_atmosphere = $validated['lab_atmosphere'];
        $review->achievement_activity = $validated['achievement_activity'];
        $review->constraint_level = $validated['constraint_level'];
        $review->facility_quality = $validated['facility_quality'];
        $review->work_style = $validated['work_style'];
        $review->student_balance = $validated['student_balance'];
        $review->save();

        return redirect()->route('labs.show', ['lab' => $lab])->with('success', 'レビューが保存されました。');
    }

    public function edit(Review $review) {
        // ポリシーで認可をチェック
        $this->authorize('update', $review);
        $review->load('lab');
        return Inertia::render('Review/Edit', ['review' => $review,]);
    }

    public function update(Request $request, Review $review) {
        // 追加: ポリシーで認可をチェック
        $this->authorize('update', $review);
        // バリデーション
        $validated = $request->validate([
            'mentorship_style' => 'required|integer|min:1|max:5',
            'lab_atmosphere' => 'required|integer|min:1|max:5',
            'achievement_activity' => 'required|integer|min:1|max:5',
            'constraint_level' => 'required|integer|min:1|max:5',
            'facility_quality' => 'required|integer|min:1|max:5',
            'work_style' => 'required|integer|min:1|max:5',
            'student_balance' => 'required|integer|min:1|max:5',
        ]);

        // バリデーション済みのデータを更新
        $review->mentorship_style = $validated['mentorship_style'];
        $review->lab_atmosphere = $validated['lab_atmosphere'];
        $review->achievement_activity = $validated['achievement_activity'];
        $review->constraint_level = $validated['constraint_level'];
        $review->facility_quality = $validated['facility_quality'];
        $review->work_style = $validated['work_style'];
        $review->student_balance = $validated['student_balance'];
        $review->save();

        return redirect()->route('labs.show', ['lab' => $review->lab_id])->with('success', 'レビューが更新されました。');
    }

    public function destroy(Review $review) {
        // 追加: ポリシーで認可をチェック
        $this->authorize('delete', $review);
        $review->delete();
        return redirect()->route('labs.show')->with('success', 'レビューが削除されました。');
    }
}

リダイレクト時エラー解消

本日の最後でございます!
削除ボタンを押すとエラーが発生しますので、それを直しましょう!
image.png
こんな感じのモーダルが表示されると思います。

リダイレクトするときにどの研究室なのかが分からなくて正しくリダイレクトできないようですね。
実際、削除処理だけは完了しているみたいです。

ReviewController.phpdestrory() メソッドを修正しましょう。

\project-root\src\app\Http\Controllers\ReviewController.php
public function destroy(Review $review) {
        // ポリシーで認可をチェック
        $this->authorize('delete', $review);

        $labId = $review->lab_id; // 追加: 研究室IDを取得

        $review->delete();
        return redirect()->route('labs.show', ['lab' => $labId])->with('success', 'レビューが削除されました。');
    }

研究室の id をパラメータとして渡してあげます。

出来たら動作確認してみましょう。
image.png
image.png
image.png
image.png
正しくリダイレクトされて、レビューも削除されているみたいですね!

まとめ・次回予告

作業としては今日はこれでおしまいです!
お疲れ様でした。

作業が終わったら、コミット・プッシュして、develop ブランチに対してプルリクエストを作成してマージしておきましょう~!

今回もすごいボリュームになってしまいました。💦
最後まで読んでくださり本当になりがとうございます!(>_<)

僕も最後の方は、Qiita の動作が重たくなってややいらいらしながら書きました。(笑)

CRUD 機能を作るだけでもこれだけ色々なことを考えなければいけないのかということを実感してもらえたらうれしいです。
ここまで読んでくださっているあなたはきっと実力がアップしているはずです。
この調子で一緒に勉強頑張っていきましょう~!(≧◇≦)

次回は、新規研究室作成機能を作ります。
引き続きお楽しみに!(^_-)-☆

参考

これまでの記事一覧

--- 要件定義・設計編 ---

--- 環境構築編 ---

--- バックエンド実装編 ---

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?