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アプリケーション開発に挑戦してみた!(バックエンド実装編⑦)~大学・学部・研究室編集機能作成~

Posted at

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

0. 初めに

こんにちは!
このシリーズでは、実務未経験の僕が「研究室のレビューアプリ」を作るまでの過程をお届けしています。
初心者の方やWebエンジニアを目指している方は是非参考にしてみてください!

前回は、大学を検索する機能を作りました。
今回は、その大学を編集する機能を作りたいと思います!

過去に「首都大学東京」という大学が「東京都立大学」に変わったりなど、大学名が変わることは、たまにあるようです。
そのため、ユーザーが大学の名前を編集できるようにするとよいかなと思います。
同様に、学部と研究室にも編集機能を付けたいと思います。

また、ユーザーが好き勝手に編集ができる状態ですと、いたずら書きをされる可能性もゼロではないです。
そこで、どのユーザーがどのように編集したかを見られるようにしたいです。
編集履歴が見られてしまうとさすがに匿名とはいえ恥ずかしい編集をする人はかなり減るのではないでしょうか。

ということで、編集機能及び編集履歴閲覧機能の作成を目標にして頑張っていきましょう!

1. 大学編集機能作成

ブランチ運用について

例によって、develop ブランチを最新化してください。
できたら、今回は、feature/09-edit-university という名前で新しいブランチを切ってください。

修正: テーブル設計

まず、これまでのテーブル設計に問題があったので修正します。
すみません!

大学テーブルのリレーションに関して、現状のテーブル設計はこのような形になっていたかと思います。
image.png

このままだと、編集履歴閲覧機能を作るのには、やや適していないです。
そのため、以下のように修正しました。
image.png

中間テーブル名を履歴を登録するという用途が明確化されるように変更しました。
また、comment カラムを追加することで、編集する理由をユーザーが書き込めるようにしました。
さらに、created_at カラムを追加して、履歴の管理をよりしやすいようにしました。

このように、設計をしっかりとしておかないと後々困るのです!
僕を反面教師にして、みなさんはぜひしっかりとした設計をしてください。(≧◇≦)

多対多のリレーションについて

ここで、とうとう多対多のリレーションについてお話しするときが来ましたね!('ω')

以前一対多のリレーションについてはご紹介しました。
忘れちゃった方は、復習しておきましょう!

今回ご紹介するのは、その名の取り、データベースのリレーションにおいて、一方から見たときもう一方が複数存在でき、また逆の一方から見たときその相手も複数存在できる状態です!
よくある例としては、学生と授業です。
一人の学生に注目すると、その学生は複数の授業を選択することができます。
一方で一つの授業に対しては、複数の学生が登録できます。
このような関係を多対多の関係と呼んでいます!

一夫多妻一対多のリレーションなら、多対多のリレーション多夫多妻制ということですな!!(きわどいネタ)

中間テーブルについて

そんなハチャメチャな多対多のリレーションですが、データベース界隈ではあまり理想的な状態ではないそうです。
極力一対多のリレーションになるようにする方が良いそうです。
そこで、その問題を解決するのが、中間テーブルというものです!

中間テーブルは、Studnets テーブルと Courses テーブルのそれぞれの主キーを持つテーブルで両者の仲介をしてくれます。

こちらの、やさしい図解で学ぶ 中間テーブル 多対多 概念編という記事が分かりやすいので、詳しく学びたい方は是非読んでみてください!

では、今回の例で確認しましょう。
先ほどの画像をもう一度見てみましょう。
image.png

ここでのポイントは、間接的に一対多のリレーションの形を再現できているということです!
Universities テーブルの id を一つ決めると、University_Edit_Histories テーブルの id は確かに複数あり得ますが、逆に University_Edit_Histories テーブルの id が決まると対応する Universities テーブルの id は一つに決まります。
同様に、同じユーザーは複数の変更履歴を持つことができますが、一つの変更履歴から見ると一人のユーザーしかいません。

このように、中間テーブルを用いることで多対多の関係を仲介して、疑似的に一対多のリレーションにすることができるのです!
これで、データーベース界隈の方々もニッコリです。(≧◇≦)

マイグレーション修正

まずは、大学・ユーザー間の中間テーブルの名前を変えましょう。
現状だと例えば、以下のような名前になっているかと思います。

変更前例: 2025_05_30_072230_create_university_user_table.php

先ほどの説明を踏まえて、これを以下のように書き換えましょう。

変更後例: 2025_05_30_072230_create_university_edit_histories_table.php

変更出来たら、先ほどの修正後のER図に基づいてカラムを追加する処理を追加しましょう。

\projectroot\src\database\migrations\2025_05_30_072230_create_university_edit_histories_table.php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        // 修正: テーブル名を変更
        Schema::create('university_edit_histories', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')->constrained('users')->onDelete('cascade');
            $table->foreignId('university_id')->constrained('universities')->onDelete('cascade');
            $table->string('comment')->default(''); // 追加: 編集理由を示すコメント
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('university_user');
    }
};

ついでに、学部と研究室の編集機能用のマイグレーション修正も先にまとめてやっておきましょうか!

学部
\project-root\src\database\migrations\2025_05_30_075447_create_faculty_edit_histories_table.php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        // 修正: テーブル名を変更
        Schema::create('faculty_edit_histories', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')->constrained('users')->onDelete('cascade');
            $table->foreignId('faculty_id')->constrained('faculties')->onDelete('cascade');
            $table->string('comment')->default(''); // 追加: 編集理由を示すコメント
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('faculty_user');
    }
};

研究室
\project-root\src\database\migrations\2025_05_30_075518_create_lab_edit_histories_table.php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        // 修正: テーブル名を変更
        Schema::create('lab_edit_histories', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')->constrained('users')->onDelete('cascade');
            $table->foreignId('lab_id')->constrained('labs')->onDelete('cascade');
            $table->string('comment')->default(''); // 追加: 編集理由を示すコメント
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('lab_user');
    }
};

モデル修正

中間テーブルの名前をLaravelが推奨する自動で認識する形式の名前から変更したので、モデルのリレーションを修正する必要があります。

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

namespace App\Models;

// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;

class User extends Authenticatable
{
    use HasFactory, Notifiable;

    /**
     * The attributes that are mass assignable.
     *
     * @var array<int, string>
     */
    protected $fillable = [
        'name',
        'email',
        'password',
    ];

    /**
     * The attributes that should be hidden for serialization.
     *
     * @var array<int, string>
     */
    protected $hidden = [
        'password',
        'remember_token',
    ];

    /**
     * Get the attributes that should be cast.
     *
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            'password' => 'hashed',
        ];
    }

    // リレーションの定義
    // 大学とのリレーション(多対多)
    // 修正: 中間テーブル名を明示的に指定
    public function universities()
    {
        return $this->belongsToMany(University::class, 'university_edit_histories')->withTimestamps();
    }

    // 学部とのリレーション(多対多)
    // 修正: 中間テーブル名を明示的に指定
    public function faculties()
    {
        return $this->belongsToMany(Faculty::class, 'faculty_edit_histories')->withTimestamps();
    }

    // 研究室とのリレーション(多対多)
    // 修正: 中間テーブル名を明示的に指定
    public function labs()
    {
        return $this->belongsToMany(Lab::class, 'lab_edit_histories')->withTimestamps();
    }

    // レビューとのリレーション(一対多)
    public function reviews()
    {
        return $this->hasMany(Review::class);
    }
}

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

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class University extends Model
{
    protected $fillable = ['name'];

    // リレーションの定義
    // ユーザーとのリレーション(多対多)
    // 修正: 中間テーブル名を明示的に指定
    public function users()
    {
        return $this->belongsToMany(User::class, 'university_edit_histories')->withTimestamps();
    }

    // 学部とのリレーション(一対多)
    public function faculties()
    {
        return $this->hasMany(Faculty::class);
    }
}

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

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Faculty extends Model
{
    protected $fillable = [
        'name', 'university_id'
    ];

    // リレーションの定義
    // ユーザーとのリレーション(多対多)
    // 修正: 中間テーブル名を明示的に指定
    public function users()
    {
        return $this->belongsToMany(User::class, 'faculty_edit_histories')->withTimestamps();
    }

    // 大学とのリレーション(多対一)
    public function university()
    {
        return $this->belongsTo(University::class);
    }

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

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

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Lab extends Model
{
    protected $fillable = [
        'name',
        'faculty_id',
    ];

    // リレーションの定義
    // ユーザーとのリレーション(多対多)
    // 修正: 中間テーブル名を明示的に指定
    public function users()
    {
        return $this->belongsToMany(User::class, 'lab_edit_histories')->withTimestamps();
    }

    // 学部とのリレーション(多対一)
    public function faculty()
    {
        return $this->belongsTo(Faculty::class);
    }

    // レビューとのリレーション(一対多)
    public function reviews()
    {
        return $this->hasMany(Review::class);
    }
}

これまで、解説を後回しにしてきましたが、多対多のリレーションでは、belongsToMany() を使います。
復習ですが、Laravelでは多対多のリレーションを組む際にそれぞれのモデルの単数形をアルファベット順にスネークケースでつなげることで、自動的に中間テーブルモデルを介したリレーションを実現してくれていました。
しかし、belongsToMany() の第二引数で中間テーブル名を明示することでそのルールを上書きすることができます。
余裕があったら、公式のドキュメントもご覧になってみてください。

シーダー実行

できたら、以下のコマンドでマイグレーションを実行しなおしましょう!

$ php artisan migrate:fresh --seed

例によって、これはデータ全消しをするので、本番環境では実行してはいけないのでしたね。

作成機能修正

編集機能を実装る前に下準備として、作成機能を修正しましょう!
修正したい点は以下の二つです。

  • 作成したユーザーの情報が中間テーブルに保存されない
  • リダイレクト先のページを用意していない

前々回課題として残していた部分ですね!
早速直していきましょう!

コントローラー修正

新規に大学を作成した時に、作成ユーザーのIDが中間テーブルに保存されていませんでした。
以下のようにコントローラーの store() メソッドで、ユーザーのIDを attach() メソッドで関連付けておきましょう!

また、リダイレクト先として想定しいる大学詳細ページがまだ作成されていないため、暫定的にホーム画面をリダイレクト先としていました。
今回は大学の詳細ページにその大学の学部の一覧を表示したいので、'faculty.index' としました。
こちらは、この後作成します。

\project-root\src\app\Http\Controllers\UniversityController.php
public function store(Request $request)
    {
        $this->authorize('create', University::class);
        
        $validated = $request->validate([
            'name' => 'required|string|max:50|unique:universities,name',
        ]);

        $university = new University();
        $university->name = $validated['name'];
        $university->save();

        // 追加: 現在ログイン中のユーザーと関連付ける
        $userId = $request->user()->id;
        $university->users()->attach($userId);

        return redirect()->route('faculties.index', ['university' => $university])->with('success', '大学が作成されました。'); // 修正: リダイレクト先を変更
    }

また、学部コントローラーの方に index() メソッドを追加する必要がありますね。
ついでに、store() メソッドで指定していたリダイレクト先も同様に変更しておいたので確認してください。

\project-root\src\app\Http\Controllers\FacultyController.php
public function store(Request $request, University $university)
    {
        $this->authorize('create', Faculty::class);

        $validated = $request->validate([
            'name' => 'required|string|max:50|unique:faculties,name,NULL,id,university_id,' . $university->id,
        ]);

        $faculty = new Faculty();
        $faculty->name = $validated['name'];
        $faculty->university_id = $university->id;
        $faculty->save();

        return redirect()->route('labs.index')->with('success', '学部が作成されました。'); // 修正: リダイレクト先を変更
    }

    // 追加
    public function index(University $university)
    {
        $faculties = $university->faculties()::all();
        return Inertia::render('Faculty/Index', [
            'faculties' => $faculties,
            'university' => $university
        ]);
    }

大学詳細ページ(学部一覧ページを作成)作成

以下のReactコンポーネントを作成してください!

\project-root\src\resources\js\Pages\Faculty\Index.jsx
import React from 'react';
import { Head, Link, usePage } from '@inertiajs/react';

export default function Index() {
    const { faculties, university } = usePage().props;

    return (
        <>
            <Head title={`${university.name} - 学部一覧`} />
            
            <div>
                <h1>{university.name}</h1>
                
                {/* 学部一覧 */}
                <div>
                    {faculties.length > 0 ? (
                        <div>
                            {faculties.map((faculty) => (
                                <div key={faculty.id}>
                                    <Link href={route('labs.index', { university: university.id, faculty: faculty.id })}>
                                        <h3>{faculty.name}</h3>
                                    </Link>
                                </div>
                            ))}
                        </div>
                    ) : (
                        <p>学部がまだありません。</p>
                    )}
                </div>
            </div>
        </>
    );
}

ルーティング追加

最後にルーティングを追加しましょう。
ログインしなくても学部一覧は見られるようにしたいので、ミドルウェアの外側に追加してください。

\project-root\src\routes\web.php
Route::get('/universities/{university}/faculties', [FacultyController::class, 'index'])->name('faculties.index');

動作確認

適当なユーザーでログインして以下のURLにアクセスして、大学を作成しましょう!
http://localhost/universities/create

image.png

学部一覧ページに遷移できました!(まだ、学部は作成していないので表示されていませんが...)
image.png

中間テーブルを見てみると、ログイン中のユーザーのidが user_id に、新しく作成された大学のidが university_id として登録されていることが分かると思います!(この場合だと、user_id = 1, university_id = 4)
image.png

無事にできているようなので、これにて新規大学作成機能の修正は以上です。

編集機能を作成する

お待たせしました!
いよいよ大学の編集機能を実装していきましょう。

コントローラー修正

まずは、コントローラーに編集画面を表示するための edit() メソッドと更新処理のための update() メソッドを追加しましょう!

\project-root\src\app\Http\Controllers\UniversityController.php
    // 追加
    public function edit(University $university)
    {
        $this->authorize('update', University::class);
        return Inertia::render('University/Edit', [
            'university' => $university,
        ]);
    }

    // 追加
    public function update(Request $request, University $university)
    {
        $this->authorize('update', University::class);

        $validated = $request->validate([
            'name' => 'required|string|max:50|unique:universities,name,' . $university->id,
            'comment' => 'required|string|max:255',
        ]);

        $university->name = $validated['name'];
        $university->save();

        $userId = $request->user()->id;
        $university->users()->attach($userId, [
            'comment' => $validated['comment'],
            'created_at' => now(),
            'updated_at' => now(),
        ]);

        return redirect()->route('faculties.index')->with('success', '大学情報が更新されました。');
    }

編集ページ作成

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

export default function Edit({ university }) {
    const { data, setData, put, processing, errors } = useForm({
        name: university.name || '',
        comment: '',
    });

    const handleSubmit = (e) => {
        e.preventDefault();
        put(route('universities.update', university.id));
    };

    return (
        <>
            <Head title={`${university.name} - 編集`} />
            
            <div>
                <h1>{university.name} - 編集</h1>
                
                <form onSubmit={handleSubmit}>
                    <div>
                        <label htmlFor="name">大学名:</label>
                        <input
                            id="name"
                            type="text"
                            value={data.name}
                            onChange={(e) => setData('name', e.target.value)}
                            required
                        />
                        {errors.name && <div>{errors.name}</div>}
                    </div>
                    
                    <div>
                        <label htmlFor="comment">編集理由:</label>
                        <textarea
                            id="comment"
                            value={data.comment}
                            onChange={(e) => setData('comment', e.target.value)}
                            placeholder="編集理由を入力してください"
                            required
                        />
                        {errors.comment && <div>{errors.comment}</div>}
                    </div>
                    
                    <div>
                        <button type="submit" disabled={processing}>
                            {processing ? '更新中...' : '更新'}
                        </button>
                        
                        <Link href={route('faculties.index', university.id)}>
                            <button type="button">キャンセル</button>
                        </Link>
                    </div>
                </form>
            </div>
        </>
    );
}

ついでに、学部一覧ページに大学の編集ボタンを付けておきましょう。

\project-root\src\resources\js\Pages\Faculty\Index.jsx
import React from 'react';
import { Head, Link, usePage } from '@inertiajs/react';

export default function Index() {
    const { faculties, university } = usePage().props;

    return (
        <>
            <Head title={`${university.name} - 学部一覧`} />
            
            <div>
                <h1>{university.name}</h1>
                
                {/* 大学編集ボタン */}
                <div>
                    <Link href={route('university.edit', university.id)}>
                        <button>大学を編集</button>
                    </Link>
                </div>
                
                {/* 学部一覧 */}
                <div>
                    {faculties.length > 0 ? (
                        <div>
                            {faculties.map((faculty) => (
                                <div key={faculty.id}>
                                    <Link href={route('labs.index', { university: university.id, faculty: faculty.id })}>
                                        <h3>{faculty.name}</h3>
                                    </Link>
                                </div>
                            ))}
                        </div>
                    ) : (
                        <p>学部がまだありません</p>
                    )}
                </div>
            </div>
        </>
    );
}

ルーティング追加

ログイン中のユーザーのみにしたいので、Auth ミドルウェアの中に入れましょう。
行が長くなってきたので、コメントアウトで整理しておきました。

\project-root\src\routes\web.php
Route::middleware('auth')->group(function () {
    Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
    Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
    Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');

    // レビュー関連
    Route::get(('/labs/{lab}/reviews/create'), [ReviewController::class, 'create'])->name('review.create');
    Route::post(('/labs/{lab}/reviews'), [ReviewController::class, 'store'])->name('review.store');
    Route::get('/reviews/{review}/edit', [ReviewController::class, 'edit'])->name('review.edit');
    Route::put('/reviews/{review}', [ReviewController::class, 'update'])->name('review.update');
    Route::delete('/reviews/{review}', [ReviewController::class, 'destroy'])->name('review.destroy');

    // 大学関連
    Route::get('/universities/create', [UniversityController::class, 'create'])->name('university.create');
    Route::post('/universities', [UniversityController::class, 'store'])->name('university.store');
    Route::get('/universities/{university}/edit', [UniversityController::class, 'edit'])->name('university.edit'); // 追加
    Route::put('/universities/{university}', [UniversityController::class, 'update'])->name('university.update'); // 追加

    // 学部関連
    Route::get('/universities/{university}/faculties/create', [FacultyController::class, 'create'])->name('faculty.create');
    Route::post('/universities/{university}/faculties', [FacultyController::class, 'store'])->name('faculty.store');
    
    // 研究室関連
    Route::get('/faculties/{faculty}/labs/create', [LabController::class, 'create'])->name('lab.create');
    Route::post('/faculties/{faculty}/labs', [LabController::class, 'store'])->name('lab.store');
});

ポリシー追加

大学の編集はログイン中ユーザーのみにしたいと思います。
まあ、ルーティングでミドルウェアを使っているので、要らないっちゃ要らないのですが、一応。

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

namespace App\Policies;

use App\Models\User;

class UniversityPolicy
{
    // ユーザーが大学を作成できるかどうかを判定
    public function create(User $user)
    {
        // 大学を作成できるのはログインユーザーのみ
        return $user->exists;
    }

    // 追加: ユーザーが大学を更新できるかどうかを判定
    public function update(User $user)
    {
        // 大学を更新できるのはログインユーザーのみ
        return $user->exists;
    }
}

動作確認

ここまでできたら、ログインしている状態で、先ほどのページに新しく作った編集ボタンをクリックしてみましょう。
image.png

編集画面に遷移できました。
大学名を変えて、編集理由を入力して「更新」をクリックしてみましょう。
image.png

リダイレクトできました!
image.png

中間テーブルを見てみると...
image.png

無事にデータが入っていますね!
これにて、編集機能は完成です!

編集履歴閲覧機能を作成する

お次は、編集履歴が見られるようにしたいです。

コントローラー修正

以下のメソッドを追加しましょう。

\project-root\src\app\Http\Controllers\UniversityController.php
 // 追加
    public function update(Request $request, University $university)
    {
        $this->authorize('update', University::class);

        $validated = $request->validate([
            'name' => 'required|string|max:50|unique:universities,name,' . $university->id,
            'comment' => 'required|string|max:255',
        ]);

        $university->name = $validated['name'];
        $university->save();

        $userId = $request->user()->id;
        $university->users()->attach($userId, [
            'comment' => $validated['comment'],
            'created_at' => now(),
            'updated_at' => now(),
        ]);

        return redirect()->route('faculties.index', ['university' => $university])->with('success', '大学情報が更新されました。');
    }

    // 追加
    public function history(University $university)
    {
        $editHistory = $university->users()
            ->withPivot('comment', 'created_at', 'updated_at')
            ->get()
        ->sortByDesc(fn($user) => $user->pivot->updated_at)
        ->values()
        ->map(function ($user) {
            return [
                'user' => $user->name,
                'comment' => $user->pivot->comment,
                'created_at' => $user->pivot->created_at,
                'updated_at' => $user->pivot->updated_at,
            ];
    });

        return Inertia::render('University/History', [
            'university' => $university,
            'editHistory' => $editHistory,
        ]);
    }

history() メソッド中に登場する withPivot() メソッドは初めて登場しましたね。
こちらは、引数に指定した中間テーブルにあるカラムを取得できる便利なメソッドです。
pivot->comenet のようにするだけで値を取得できます。

編集履歴閲覧ページ作成

\project-root\src\resources\js\Pages\University\History.jsx
import React from "react";
import { Head, Link, usePage } from "@inertiajs/react";

export default function History() {
    const { university, editHistory } = usePage().props;

    return (
        <>
            <Head title={`${university.name} - 編集履歴`} />

            <div>
                <h1>{university.name} - 編集履歴</h1>

                {/* 編集履歴一覧 */}
                <div>
                    {editHistory.length > 0 ? (
                        <div>
                            {editHistory.map((history, index) => (
                                <div key={index}>
                                    <h3>編集 #{editHistory.length - index}</h3>
                                    <p>
                                        <strong>編集者:</strong> {history.user}
                                    </p>
                                    <p>
                                        <strong>編集日時:</strong>{" "}
                                        {new Date(
                                            history.updated_at
                                        ).toLocaleString("ja-JP")}
                                    </p>
                                    <p>
                                        <strong>編集理由:</strong>{" "}
                                        {history.comment || "作成しました。"}
                                    </p>
                                </div>
                            ))}
                        </div>
                    ) : (
                        <p>編集履歴がありません。</p>
                    )}
                </div>

                {/* 戻るリンク */}
                <div>
                    <Link href={route("faculties.index", university.id)}>
                        <button>学部一覧に戻る</button>
                    </Link>
                </div>
            </div>
        </>
    );
}

ついでに、学部一覧ページに遷移ボタンを付けておきましょう。

\project-root\src\resources\js\Pages\Faculty\Index.jsx
import React from 'react';
import { Head, Link, usePage } from '@inertiajs/react';

export default function Index() {
    const { faculties, university } = usePage().props;

    return (
        <>
            <Head title={`${university.name} - 学部一覧`} />
            
            <div>
                <h1>{university.name}</h1>
                
                {/* 大学編集ボタン */}
                <div>
                    <Link href={route('university.edit', university.id)}>
                        <button>大学を編集</button>
                    </Link>
                </div>
                
                {/* 編集履歴ボタン */}
                <div>
                    <Link href={route('university.history', university.id)}>
                        <button>編集履歴を見る</button>
                    </Link>
                </div>
                
                {/* 学部一覧 */}
                <div>
                    {faculties.length > 0 ? (
                        <div>
                            {faculties.map((faculty) => (
                                <div key={faculty.id}>
                                    <Link href={route('labs.index', { university: university.id, faculty: faculty.id })}>
                                        <h3>{faculty.name}</h3>
                                    </Link>
                                    {/* <div>
                                        <Link href={route('faculties.edit', { university: university.id, faculty: faculty.id })}>
                                            <button>編集</button>
                                        </Link>
                                    </div> */}
                                </div>
                            ))}
                        </div>
                    ) : (
                        <p>学部がまだありません</p>
                    )}
                </div>
            </div>
        </>
    );
}

ルーティング追加

\project-root\src\routes\web.php
Route::get('/universities/{university}/history', [UniversityController::class, 'history'])->name('universities.history'); // 追加

動作確認

「編集履歴を見る」をクリックしてみましょう。
http://localhost/universities/4/faculties
image.png

履歴が表示されましたね!
image.png

検索結果から学部一覧ページに移動できるようにする

前回作った大学の検索結果画面から、クリック操作で今日作った学部一覧ページに移動できるようにしましょう!

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

export default function Index({ universities, query }) {
  const [search, setSearch] = useState(query || '');

  const handleSubmit = (e) => {
    e.preventDefault();
    router.get('/universities', { query: search });
  };

  // ページネーション用の関数
  const goToPage = (page) => {
    const params = { page };
    if (query) {
      params.query = query;
    }
    router.get('/universities', params);
  };

  const goToPreviousPage = () => {
    if (universities.current_page > 1) {
      goToPage(universities.current_page - 1);
    }
  };

  const goToNextPage = () => {
    if (universities.current_page < universities.last_page) {
      goToPage(universities.current_page + 1);
    }
  };

  const goToFirstPage = () => {
    goToPage(1);
  };

  const goToLastPage = () => {
    goToPage(universities.last_page);
  };

  // ページ番号の配列を生成(現在のページ前後2ページずつ表示)
  const getPageNumbers = () => {
    const current = universities.current_page;
    const last = universities.last_page;
    const pages = [];

    let start = Math.max(1, current - 2);
    let end = Math.min(last, current + 2);

    // 最初の方のページの場合、後ろを多めに表示
    if (current <= 3) {
      end = Math.min(last, 5);
    }
    
    // 最後の方のページの場合、前を多めに表示
    if (current >= last - 2) {
      start = Math.max(1, last - 4);
    }

    for (let i = start; i <= end; i++) {
      pages.push(i);
    }

    return pages;
  };

  const pageNumbers = getPageNumbers();

  return (
    <div>
      <Head title="大学検索結果" />

      <h1>大学検索</h1>

      <div>
        <input
          type="text"
          value={search}
          onChange={(e) => setSearch(e.target.value)}
          onKeyPress={(e) => {
            if (e.key === 'Enter') {
              handleSubmit(e);
            }
          }}
          placeholder="大学名で検索"
        />
        <button onClick={handleSubmit}>
          検索
        </button>
      </div>

      {query && (
        <p>
          <strong>{query}</strong>の検索結果{universities.total}
        </p>
      )}

      {universities.data.length === 0 ? (
        <p>該当する大学は見つかりませんでした</p>
      ) : (
        <ul>
          {universities.data.map((university) => (
            <li key={university.id}>
              <Link href={route('faculties.index', university.id)}>
                {university.name}
              </Link>
            </li>
          ))}
        </ul>
      )}

      {/* ページネーション */}
      {universities.last_page > 1 && (
        <div>
          <p>
            ページ {universities.current_page} / {universities.last_page} 
             {universities.total} 件中 {universities.from} - {universities.to} 件目
          </p>
          
          <div>
            {/* 最初のページボタン */}
            <button
              onClick={goToFirstPage}
              disabled={universities.current_page === 1}
            >
              
            </button>

            {/* 前のページボタン */}
            <button
              onClick={goToPreviousPage}
              disabled={universities.current_page === 1}
            >
              
            </button>

            {/* 最初のページ番号より前に省略がある場合 */}
            {pageNumbers[0] > 1 && (
              <>
                <button onClick={() => goToPage(1)}>1</button>
                {pageNumbers[0] > 2 && <span>...</span>}
              </>
            )}

            {/* ページ番号ボタン */}
            {pageNumbers.map((pageNum) => (
              <button
                key={pageNum}
                onClick={() => goToPage(pageNum)}
                disabled={pageNum === universities.current_page}
              >
                {pageNum}
              </button>
            ))}

            {/* 最後のページ番号より後に省略がある場合 */}
            {pageNumbers[pageNumbers.length - 1] < universities.last_page && (
              <>
                {pageNumbers[pageNumbers.length - 1] < universities.last_page - 1 && (
                  <span>...</span>
                )}
                <button onClick={() => goToPage(universities.last_page)}>
                  {universities.last_page}
                </button>
              </>
            )}

            {/* 次のページボタン */}
            <button
              onClick={goToNextPage}
              disabled={universities.current_page === universities.last_page}
            >
              
            </button>

            {/* 最後のページボタン */}
            <button
              onClick={goToLastPage}
              disabled={universities.current_page === universities.last_page}
            >
              
            </button>
          </div>
        </div>
      )}
    </div>
  );
}

http://localhost/labs
image.png
image.png
image.png

これで、大学の編集機能はできました!
コミット・プッシュ、プルリクエスト作成・マージを忘れずにしておきましょう!

2. 学部編集機能作成

この調子で、学部の編集機能を作成しましょう!
といっても、大学編集機能と同じ部分も多いと思いますので、ビビらずやってきましょう。('ω')

ブランチ運用について

先ほどの変更をマージしたリモートの develop ブランチからローカルの develop ブランチにプルして最新化しましょう。
そうしたら次は、feature/10-edit-faculty という新しいブランチを切って作業開始です(ちなみに僕はブランチ名を間違えて切ってしまいました。プルリクエスト作ってマージした後に気が付きました。みなさんは気を付けてくださいw)。

修正: テーブル設計

先ほどと同様ですが、学部とユーザー間のテーブル設計を見直しておきましょう。
以下のように、comment カラムと created_at カラムを追加しました。
まあ、マイグレーションで timestamp() とすると updated_at カラムも同時に作成されるんですけどね。
image.png

マイグレーションファイルとモデルの修正は先ほどすでに行っているので、確認だけでした!

下準備: ホーム画面修正

さて、先ほどの大学編集機能を実装していた時と同様に学部編集画面を作って、編集後は研究室一覧ページにリダイレクトするようにすればよさそうですよね!
先ほどは、University/Edit.jsx => Faculty/Index.jsx という流れなので、これに従って、Faculty/Edit.jsx => Lab/Index.jsx にすればよさそうだ!

...ここで気が付くことがあると思います。
Lab/Index.jsx 既にあるやん
そうです。
このアプリケーションのトップ画面用に既に Lab/Index.jsx を作成していました。

どうしようかと思ったのですが、この既にある方の名前を変更して、これから作るリダイレクト先の学部詳細画面としての研究室一覧ページの名前を Lab/Index.jsx にしたいと思います。

コントローラー修正

既にあった index() メソッドを以下のように修正しましょう。

\project-root\src\app\Http\Controllers\LabController.php
    // 修正: 名前変更、大学・学部のデータも渡す
    public function home() // 名前変更: 'index' => 'home'
    {
        $labs = Lab::with(['faculty.university'])->get(); // 大学・学部のデータも渡す
        return Inertia::render('Lab/Home', [ // レンダリング先も変更
            'labs' => $labs,
        ]);
    }

変更点

  • メソッド名: index() => home()
  • 大学・学部のデータも一緒に渡す
  • レンダリングするReactコンポーネント: Lab/Index => Lab/Home

研究室一覧ページ修正

それに合わせて、研究室一覧ページのReactコンポーネントを修正しましょう。

今までは、研究室の名前だけで、どの大学・学部の所属なのかが分かりませんでした。
当然トップページを開いただけの段階で、それらが表示されていないとわかりにくいです。
よって、以下のように修正しました。

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

export default function Home({ labs, query }) {
  const [search, setSearch] = useState(query || '');

  const handleSearch = (e) => {
    e.preventDefault();
    router.get(route('universities.index'), { query: search }); // クエリパラメータ付きでGETリクエスト
  };

  return (
    <div>
      <Head title="研究室一覧" />
      <h1>研究室一覧</h1>
      <form onSubmit={handleSearch}>
        <input
          type="text"
          value={search}
          onChange={(e) => setSearch(e.target.value)}
          placeholder="大学名で検索"
        />
        <button type="submit">検索</button>
      </form>
      <div>
        {labs.map((lab) => (
          <div
            key={lab.id}
          >
            <p>{lab.faculty.university.name} {lab.faculty.name} {lab.name}</p>
          </div>
        ))}
      </div>
    </div>
  );
}

変更点

  • ファイル名: Lab/Index.jsx => Lab/Home.jsx
  • 所属大学・学部も表示

ルーティング修正

今作ったReactコンポーネントをトップページとして表示したいので、以下のようにルーティングを追加します。

\project-root\src\routes\web.php
Route::get('/', [LabController::class, 'home'])->name('labs.home'); // 追加

また、これだと '/' にGETメソッドでアクセスするルーティングが2つある状態になってしまうので、以下のようにもともと '/' について設定されていたルーティングのURLパターンを変更しておきましょう。

\project-root\src\routes\web.php
// 修正: URLを'/auth'に変更
Route::get('/auth', function () {
    return Inertia::render('Welcome', [
        'canLogin' => Route::has('login'),
        'canRegister' => Route::has('register'),
        'laravelVersion' => Application::VERSION,
        'phpVersion' => PHP_VERSION,
    ]);
});

動作確認

まずは、トップページにアクセスしてみます。
http://localhost/
image.png

次に、もともとトップページに設定されていた Laravel のページを開いてみます。
image.png

問題なさそうです!

編集・履歴閲覧機能を作成する

コントローラー修正

下準備ができたので、編集機能の作成に移ります。
といっても、大学の時とほとんど同じです。

まずは、コントローラーを修正します。
以下の3つのメソッドを追加してください。

\project-root\src\app\Http\Controllers\FacultyController.php
    // 追加
    public function edit(Faculty $faculty)
    {
        $this->authorize('update', Faculty::class);
        return Inertia::render('Faculty/Edit', [
            'faculty' => $faculty,
        ]);
    }

    // 追加
    public function update(Request $request, Faculty $faculty)
    {
        $this->authorize('update', Faculty::class);

        $validated = $request->validate([
            'name' => 'required|string|max:50|unique:universities,name,' . $faculty->id,
            'comment' => 'required|string|max:255',
        ]);

        $faculty->name = $validated['name'];
        $faculty->save();

        $userId = $request->user()->id;
        $faculty->users()->attach($userId, [
            'comment' => $validated['comment'],
            'created_at' => now(),
            'updated_at' => now(),
        ]);

        return redirect()->route('labs.index', ['faculty' => $faculty])->with('success', '大学情報が更新されました。');
    }

    // 追加
    public function history(Faculty $faculty)
    {
        $editHistory = $faculty->users()
            ->withPivot('comment', 'created_at', 'updated_at')
            ->get()
        ->sortByDesc(fn($user) => $user->pivot->updated_at)
        ->values()
        ->map(function ($user) {
            return [
                'user' => $user->name,
                'comment' => $user->pivot->comment,
                'created_at' => $user->pivot->created_at,
                'updated_at' => $user->pivot->updated_at,
            ];
    });

        return Inertia::render('Faculty/History', [
            'faculty' => $faculty,
            'editHistory' => $editHistory,
        ]);
    }

また、研究室コントローラーの方にも1つ追加です。
先ほど、もともとあった index() メソッドを home() メソッドに名称変更したので、問題ないですね!

\project-root\src\app\Http\Controllers\LabController.php
    // 追加
    public function index(Faculty $faculty)
    {
        $labs = $faculty->labs()->get();
        
        return Inertia::render('Lab/Index', [
            'labs' => $labs,
            'faculty' => $faculty->load('university'),
        ]);
    }

さらに、変更に伴い、作成機能の部分の修正も必要になります。
今までは、作成後のリダイレクト先として、トップページとしての研究室一覧ページを設定していました。
しかし、今後は学部に紐づく研究室一覧ページにリダイレクトしたいため、どの学部なのかという情報をパラメータとして渡す必要があります。
また、中間テーブルにもデータが入るようにします。
よって、store() メソッドを以下のように修正する必要があります。

\project-root\src\app\Http\Controllers\FacultyController.php
    public function store(Request $request, University $university)
    {
        $this->authorize('create', Faculty::class);

        $validated = $request->validate([
            'name' => 'required|string|max:50|unique:faculties,name,NULL,id,university_id,' . $university->id,
        ]);

        $faculty = new Faculty();
        $faculty->name = $validated['name'];
        $faculty->university_id = $university->id;
        $faculty->save();

        // 追加: 現在ログイン中のユーザーと関連付ける
        $userId = $request->user()->id;
        $faculty->users()->attach($userId);

        return redirect()->route('labs.index', ['faculty' => $faculty])->with('success', '学部が作成されました。'); // 修正: 学部IDを渡す
    }

ポリシー追加

念のため、ポリシーも一応つけておきますか。

\project-root\src\app\Policies\FacultyPolicy.php
// 追加: ユーザーが学部を更新できるかどうかを判定
    public function update(User $user)
    {
        // 学部を作成できるのはログインユーザーのみ
        return $user->exists;
    }

Reactコンポーネント作成

まず、編集ページを作成しましょう。

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

export default function Edit({ faculty }) {
    const { data, setData, put, processing, errors } = useForm({
        name: faculty.name || '',
        comment: '',
    });

    const handleSubmit = (e) => {
        e.preventDefault();
        put(route('faculty.update', faculty.id));
    };

    return (
        <>
            <Head title={`${faculty.name} - 編集`} />
            
            <div>
                <h1>{faculty.name} - 編集</h1>
                
                <form onSubmit={handleSubmit}>
                    <div>
                        <label htmlFor="name">学部名:</label>
                        <input
                            id="name"
                            type="text"
                            value={data.name}
                            onChange={(e) => setData('name', e.target.value)}
                            required
                        />
                        {errors.name && <div>{errors.name}</div>}
                    </div>
                    
                    <div>
                        <label htmlFor="comment">編集理由:</label>
                        <textarea
                            id="comment"
                            value={data.comment}
                            onChange={(e) => setData('comment', e.target.value)}
                            placeholder="編集理由を入力してください"
                            required
                        />
                        {errors.comment && <div>{errors.comment}</div>}
                    </div>
                    
                    <div>
                        <button type="submit" disabled={processing}>
                            {processing ? '更新中...' : '更新'}
                        </button>
                        
                        <Link href={route('labs.index', faculty.id)}>
                            <button type="button">キャンセル</button>
                        </Link>
                    </div>
                </form>
            </div>
        </>
    );
}

次に、編集履歴閲覧ページです。

\project-root\src\resources\js\Pages\Faculty\History.jsx
import React from "react";
import { Head, Link, usePage } from "@inertiajs/react";

export default function History() {
    const { faculty, editHistory } = usePage().props;

    return (
        <>
            <Head title={`${faculty.name} - 編集履歴`} />

            <div>
                <h1>{faculty.name} - 編集履歴</h1>

                {/* 編集履歴一覧 */}
                <div>
                    {editHistory.length > 0 ? (
                        <div>
                            {editHistory.map((history, index) => (
                                <div key={index}>
                                    <h3>編集 #{editHistory.length - index}</h3>
                                    <p>
                                        <strong>編集者:</strong> {history.user}
                                    </p>
                                    <p>
                                        <strong>編集日時:</strong>{" "}
                                        {new Date(
                                            history.updated_at
                                        ).toLocaleString("ja-JP")}
                                    </p>
                                    <p>
                                        <strong>編集理由:</strong>{" "}
                                        {history.comment || "作成しました。"}
                                    </p>
                                </div>
                            ))}
                        </div>
                    ) : (
                        <p>編集履歴がありません。</p>
                    )}
                </div>

                {/* 戻るリンク */}
                <div>
                    <Link href={route("labs.index", faculty.id)}>
                        <button>研究室一覧に戻る</button>
                    </Link>
                </div>
            </div>
        </>
    );
}

最後に、研究室一覧ページです。
先ほど、もともとの Lab/Index.jsx の名前を変えたので、名前被りが起きませんね!

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

export default function Index({ labs, faculty }) {
  return (
    <div>
      <Head title={`${faculty.name} - 研究室一覧`} />
      <h1>{faculty.university.name} {faculty.name} - 研究室一覧</h1>
      
      {/* 学部一覧に戻るボタン */}
      <div>
        <Link href={route('faculties.index', faculty.university.id)}>
          <button>学部一覧に戻る</button>
        </Link>
      </div>
      
      {/* 学部編集ボタン */}
      <div>
        <Link href={route('faculty.edit', faculty.id)}>
          <button>学部を編集</button>
        </Link>
      </div>
      
      {/* 編集履歴ボタン */}
      <div>
        <Link href={route('faculty.history', faculty.id)}>
          <button>編集履歴を見る</button>
        </Link>
      </div>
      <div>
        {labs.length > 0 ? (
          labs.map((lab) => (
            <div key={lab.id}>
              <p>{lab.name}</p>
            </div>
          ))
        ) : (
          <p>研究室がありません。</p>
        )}
      </div>
    </div>
  );
}

ルーティング追加

auth ミドルウェアの中に追加です!

\project-root\src\routes\web.php
Route::middleware('auth')->group(function () {
    Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
    Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
    Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');

    // レビュー関連
    Route::get(('/labs/{lab}/reviews/create'), [ReviewController::class, 'create'])->name('review.create');
    Route::post(('/labs/{lab}/reviews'), [ReviewController::class, 'store'])->name('review.store');
    Route::get('/reviews/{review}/edit', [ReviewController::class, 'edit'])->name('review.edit');
    Route::put('/reviews/{review}', [ReviewController::class, 'update'])->name('review.update');
    Route::delete('/reviews/{review}', [ReviewController::class, 'destroy'])->name('review.destroy');

    // 大学関連
    Route::get('/universities/create', [UniversityController::class, 'create'])->name('university.create');
    Route::post('/universities', [UniversityController::class, 'store'])->name('university.store');
    Route::get('/universities/{university}/edit', [UniversityController::class, 'edit'])->name('university.edit');
    Route::put('/universities/{university}', [UniversityController::class, 'update'])->name('university.update');

    // 学部関連
    Route::get('/universities/{university}/faculties/create', [FacultyController::class, 'create'])->name('faculty.create');
    Route::post('/universities/{university}/faculties', [FacultyController::class, 'store'])->name('faculty.store');
    Route::get('/faculties/{faculty}/edit', [FacultyController::class, 'edit'])->name('faculty.edit'); // 追加
    Route::put('/faculties/{faculty}', [FacultyController::class, 'update'])->name('faculty.update'); // 追加
    
    // 研究室関連
    Route::get('/faculties/{faculty}/labs/create', [LabController::class, 'create'])->name('lab.create');
    Route::post('/faculties/{faculty}/labs', [LabController::class, 'store'])->name('lab.store');
});

さらに、もともとあった '/lab' の部分を学部のIDを入れられるように修正します。

\project-root\src\routes\web.php
Route::get('/faculty/{faculty}/labs', [LabController::class, 'index'])->name('labs.index'); // 修正

また、以下も追加ですね!

\project-root\src\routes\web.php
Route::get('/faculties/{faculty}/history', [FacultyController::class, 'history'])->name('faculty.history'); // 追加

動作確認

では、適当なユーザーでログインして以下にアクセスしてください。
作成機能の確認から行います。
http://localhost/universities/4/faculties
「学部を作成」をクリック。
image.png

適当に入力して作成。
image.png

研究室一覧ページに移動できました!
image.png

「学部一覧に戻る」をクリック。
学部が追加されていることが確認できます。
image.png

「テスト学部」をクリックすると戻ってこられます。(開発経過とスクショのタイミングの都合上「編集履歴を見る」ボタンがありませんが気にしないでください。)
image.png

「学部を編集」をクリック。
適当な名前とコメントを残して更新。
image.png

名前が変わりました!
image.png

「編集履歴を見る」ボタンをクリックすると履歴が見られます。
image.png

中間テーブルにもデータが入っているみたいです!
image.png

以上で、学部の編集機能は完了とします!
コミット・プッシュ、プルリクエスト作成・マージをしておきましょう。

3. 研究室編集機能作成

もうすでに記事がかなり長くなっていますが、頑張りましょう。
最後は、研究室を編集できるようにしましょう!!

ブランチ運用について

develop ブランチを最新化し、そこから新規ブランチ feature/11-edit-lab を切ってください。

修正: テーブル設計

以下のように中間テーブルを修正しました。
image.png

編集・履歴閲覧機能を作成する

コントローラー修正

store() メソッドのリダイレクト先設定及び中間テーブルへのデータ挿入部分を修正しておきましょう。

\project-root\src\app\Http\Controllers\LabController.php
    public function store(Request $request, Faculty $faculty)
    {
        // 認可
        $this->authorize('create', Lab::class);

        // バリデーション
        $validated = $request->validate([
            'name' => 'required|string|max:50|unique:labs,name,NULL,id,faculty_id,' . $faculty->id,
            'description' => 'nullable|string|max:500',
            'url' => 'nullable|url|max:255',
            'professor_url' => 'nullable|url|max:255',
            'gender_ratio_male' => 'required|integer|min:0|max:10',
            'gender_ratio_female' => [
                'required',
                'integer',
                'min:0',
                'max:10',
                function ($attribute, $value, $fail) use ($request) {
                    $male = (int) $request->input('gender_ratio_male', 0);
                    $female = (int) $value;
                    if ($male + $female > 10) {
                        $fail('男女比の合計は10である必要があります。');
                    }
                },
            ],
        ]);
        
        $lab = new Lab();
        $lab->name = $validated['name'];
        $lab->description = $validated['description'];
        $lab->url = $validated['url'];
        $lab->professor_url = $validated['professor_url'];
        $lab->gender_ratio_male = $validated['gender_ratio_male'];
        $lab->gender_ratio_female = $validated['gender_ratio_female'];
        $lab->faculty_id = $faculty->id;
        $lab->save();

        // 追加: 現在ログイン中のユーザーと関連付ける
        $userId = $request->user()->id;
        $lab->users()->attach($userId);

        return redirect()->route('labs.show', ['lab' => $lab])->with('success', '研究室が作成されました。'); // 修正: リダイレクト先を変更
    }

さらに、以下の3つのメソッドを追加してください。

\project-root\src\app\Http\Controllers\LabController.php
    // 追加
    public function edit(Lab $lab)
    {
        // 認可
        $this->authorize('update', $lab);

        $lab->load('faculty.university');

        // Labの編集ページを表示
        return Inertia::render('Lab/Edit', [
            'lab' => $lab->load('faculty.university'),
            'faculty' => $lab->faculty,
            'university' => $lab->faculty->university,
        ]);
    }

    // 追加
    public function update(Request $request, Lab $lab)
    {
        $this->authorize('update', Lab::class);

        $validated = $request->validate([
            'name' => 'required|string|max:50|unique:labs,name,' . $lab->id . ',id,faculty_id,' . $lab->faculty_id,
            'description' => 'nullable|string|max:500',
            'url' => 'nullable|url|max:255',
            'professor_url' => 'nullable|url|max:255',
            'gender_ratio_male' => 'required|integer|min:0|max:10',
            'gender_ratio_female' => [
                'required',
                'integer',
                'min:0',
                'max:10',
                function ($attribute, $value, $fail) use ($request) {
                    $male = (int) $request->input('gender_ratio_male', 0);
                    $female = (int) $value;
                    if ($male + $female !== 10) {
                        $fail('男女比の合計は10である必要があります。');
                    }
                },
            ],
            'comment' => 'required|string|max:255',
        ]);
        
        $lab->name = $validated['name'];
        $lab->description = $validated['description'];
        $lab->url = $validated['url'];
        $lab->professor_url = $validated['professor_url'];
        $lab->gender_ratio_male = $validated['gender_ratio_male'];
        $lab->gender_ratio_female = $validated['gender_ratio_female'];
        $lab->faculty_id = $lab->faculty_id;
        $lab->save();

        $userId = $request->user()->id;
        $lab->users()->attach($userId, [
            'comment' => $validated['comment'],
            'created_at' => now(),
            'updated_at' => now(),
        ]);

        return redirect()->route('labs.show', ['lab' => $lab])->with('success', '研究室が更新されました。');
    }

    // 追加
    public function history(Lab $lab)
    {
        $editHistory = $lab->users()
            ->withPivot('comment', 'created_at', 'updated_at')
            ->get()
        ->sortByDesc(fn($user) => $user->pivot->updated_at)
        ->values()
        ->map(function ($user) {
            return [
                'user' => $user->name,
                'comment' => $user->pivot->comment,
                'created_at' => $user->pivot->created_at,
                'updated_at' => $user->pivot->updated_at,
            ];
    });

        return Inertia::render('Lab/History', [
            'lab' => $lab,
            'editHistory' => $editHistory,
        ]);
    }

ポリシー追加

\project-root\src\app\Policies\LabPolicy.php
    // ユーザーが研究室を更新できるかどうかを判定
    public function update(User $user)
    {
        // 研究室を作成できるのはログインユーザーのみ
        return $user->exists;
    }

Reactコンポーネント作成

まず、編集ページを作成しましょう。

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

export default function Edit({ lab, faculty, university }) {
    const { data, setData, put, processing, errors } = useForm({
        name: lab.name || '',
        description: lab.description || '',
        url: lab.url || '',
        professor_url: lab.professor_url || '',
        gender_ratio_male: lab.gender_ratio_male || 5,
        gender_ratio_female: lab.gender_ratio_female || 5,
        comment: '', // 編集理由
    });

    const handleSubmit = (e) => {
        e.preventDefault();
        put(route('lab.update', lab.id), {
            onSuccess: () => {
                console.log('研究室が正常に更新されました');
            },
            onError: (errors) => {
                console.log('バリデーションエラー:', errors);
            }
        });
    };

    const handleGenderRatioChange = (type, value) => {
        const numValue = parseInt(value) || 0;
        const clampedValue = Math.max(0, Math.min(10, numValue));
        
        if (type === 'male') {
            setData({
                ...data,
                gender_ratio_male: clampedValue,
                gender_ratio_female: 10 - clampedValue
            });
        } else {
            setData({
                ...data,
                gender_ratio_female: clampedValue,
                gender_ratio_male: 10 - clampedValue
            });
        }
    };

    return (
        <>
            <Head title={`${lab.name} - 編集`} />
            
            <div className="container mx-auto px-4 py-8">
                <div className="max-w-2xl mx-auto">
                    <div className="mb-6">
                        <h1 className="text-2xl font-bold">研究室編集</h1>
                        <p className="text-gray-600 mt-2">
                            {university.name} / {faculty.name} / {lab.name}
                        </p>
                    </div>

                    <form onSubmit={handleSubmit} className="space-y-6">
                        <div>
                            <label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-2">
                                研究室名 <span className="text-red-500">*</span>
                            </label>
                            <input
                                type="text"
                                id="name"
                                value={data.name}
                                onChange={(e) => setData('name', e.target.value)}
                                className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
                                required
                                maxLength={50}
                            />
                            {errors.name && (
                                <p className="text-red-500 text-sm mt-1">{errors.name}</p>
                            )}
                        </div>

                        <div>
                            <label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-2">
                                説明
                            </label>
                            <textarea
                                id="description"
                                value={data.description}
                                onChange={(e) => setData('description', e.target.value)}
                                rows={4}
                                className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
                                placeholder="研究室の概要や研究内容について説明してください"
                                maxLength={500}
                            />
                            {errors.description && (
                                <p className="text-red-500 text-sm mt-1">{errors.description}</p>
                            )}
                        </div>

                        <div>
                            <label htmlFor="url" className="block text-sm font-medium text-gray-700 mb-2">
                                研究室URL
                            </label>
                            <input
                                type="url"
                                id="url"
                                value={data.url}
                                onChange={(e) => setData('url', e.target.value)}
                                className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
                                placeholder="https://example.com"
                                maxLength={255}
                            />
                            {errors.url && (
                                <p className="text-red-500 text-sm mt-1">{errors.url}</p>
                            )}
                        </div>

                        <div>
                            <label htmlFor="professor_url" className="block text-sm font-medium text-gray-700 mb-2">
                                教授URL
                            </label>
                            <input
                                type="url"
                                id="professor_url"
                                value={data.professor_url}
                                onChange={(e) => setData('professor_url', e.target.value)}
                                className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
                                placeholder="https://example.com/professor"
                                maxLength={255}
                            />
                            {errors.professor_url && (
                                <p className="text-red-500 text-sm mt-1">{errors.professor_url}</p>
                            )}
                        </div>

                        <div>
                            <label className="block text-sm font-medium text-gray-700 mb-4">
                                性別比率 <span className="text-red-500">*</span>
                            </label>
                            <div className="grid grid-cols-2 gap-4">
                                <div>
                                    <label htmlFor="gender_ratio_male" className="block text-sm text-gray-600 mb-1">
                                        男性比率 (0-10)
                                    </label>
                                    <input
                                        type="number"
                                        id="gender_ratio_male"
                                        min="0"
                                        max="10"
                                        value={data.gender_ratio_male}
                                        onChange={(e) => handleGenderRatioChange('male', e.target.value)}
                                        className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
                                        required
                                    />
                                </div>
                                <div>
                                    <label htmlFor="gender_ratio_female" className="block text-sm text-gray-600 mb-1">
                                        女性比率 (0-10)
                                    </label>
                                    <input
                                        type="number"
                                        id="gender_ratio_female"
                                        min="0"
                                        max="10"
                                        value={data.gender_ratio_female}
                                        onChange={(e) => handleGenderRatioChange('female', e.target.value)}
                                        className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
                                        required
                                    />
                                </div>
                            </div>
                            <div className="mt-2">
                                <p className="text-sm text-gray-500">
                                    合計: {data.gender_ratio_male + data.gender_ratio_female} / 10
                                </p>
                                {(data.gender_ratio_male + data.gender_ratio_female) !== 10 && (
                                    <p className="text-orange-500 text-sm">
                                        注意: 男女比の合計は10である必要があります
                                    </p>
                                )}
                            </div>
                            {errors.gender_ratio_male && (
                                <p className="text-red-500 text-sm mt-1">{errors.gender_ratio_male}</p>
                            )}
                            {errors.gender_ratio_female && (
                                <p className="text-red-500 text-sm mt-1">{errors.gender_ratio_female}</p>
                            )}
                        </div>

                        <div>
                            <label htmlFor="comment" className="block text-sm font-medium text-gray-700 mb-2">
                                編集理由 <span className="text-red-500">*</span>
                            </label>
                            <textarea
                                id="comment"
                                value={data.comment}
                                onChange={(e) => setData('comment', e.target.value)}
                                rows={3}
                                className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
                                placeholder="編集理由を入力してください"
                                required
                                maxLength={255}
                            />
                            {errors.comment && (
                                <p className="text-red-500 text-sm mt-1">{errors.comment}</p>
                            )}
                        </div>

                        <div className="flex gap-4">
                            <button
                                type="submit"
                                disabled={processing || (data.gender_ratio_male + data.gender_ratio_female) !== 10}
                                className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
                            >
                                {processing ? '更新中...' : '研究室を更新'}
                            </button>
                            
                            <Link href={route('labs.index', faculty.id)}>
                                <button 
                                    type="button" 
                                    className="px-4 py-2 bg-gray-500 text-white rounded-md hover:bg-gray-600"
                                >
                                    研究室一覧に戻る
                                </button>
                            </Link>
                        </div>
                    </form>
                </div>
            </div>
        </>
    );
}

次に、編集履歴閲覧ページです。

\project-root\src\resources\js\Pages\Faculty\History.jsx
import React from "react";
import { Head, Link, usePage } from "@inertiajs/react";

export default function History() {
    const { lab, editHistory } = usePage().props;

    return (
        <>
            <Head title={`${lab.name} - 編集履歴`} />

            <div>
                <h1>{lab.name} - 編集履歴</h1>
                <p>
                    {lab.faculty?.university?.name} / {lab.faculty?.name}
                </p>

                {/* 編集履歴一覧 */}
                <div>
                    {editHistory.length > 0 ? (
                        <div>
                            {editHistory.map((history, index) => (
                                <div key={index}>
                                    <h3>編集 #{editHistory.length - index}</h3>
                                    <p>
                                        <strong>編集者:</strong> {history.user}
                                    </p>
                                    <p>
                                        <strong>編集日時:</strong>{" "}
                                        {new Date(
                                            history.updated_at
                                        ).toLocaleString("ja-JP")}
                                    </p>
                                    <p>
                                        <strong>編集理由:</strong>{" "}
                                        {history.comment || "作成しました。"}
                                    </p>
                                </div>
                            ))}
                        </div>
                    ) : (
                        <p>編集履歴がありません。</p>
                    )}
                </div>

                {/* 戻るリンク */}
                <div>
                    <Link href={route("labs.show", lab.id)}>
                        <button>研究室詳細に戻る</button>
                    </Link>
                </div>
            </div>
        </>
    );
}

最後に、研究室一覧ページです。
ボタンなどを追加しました。

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

export default function Index({ labs, faculty }) {
  return (
    <div>
      <Head title={`${faculty.name} - 研究室一覧`} />
      <h1>{faculty.university.name} {faculty.name} - 研究室一覧</h1>
      
      {/* 学部一覧に戻るボタン */}
      <div>
        <Link href={route('faculties.index', faculty.university.id)}>
          <button>学部一覧に戻る</button>
        </Link>
      </div>
      
      {/* 学部編集ボタン */}
      <div>
        <Link href={route('faculty.edit', faculty.id)}>
          <button>学部を編集</button>
        </Link>
      </div>
      
      {/* 編集履歴ボタン */}
      <div>
        <Link href={route('faculty.history', faculty.id)}>
          <button>編集履歴を見る</button>
        </Link>
      </div>
      
      {/* 研究室作成ボタン */}
      <div>
        <Link href={route('lab.create', faculty.id)}>
          <button>研究室を作成</button>
        </Link>
      </div>
      <div>
        {labs.length > 0 ? (
          labs.map((lab) => (
            <div key={lab.id}>
              <Link href={route('labs.show', lab.id)}>
                <p>{lab.name}</p>
              </Link>
            </div>
          ))
        ) : (
          <p>研究室がありません。</p>
        )}
      </div>
    </div>
  );
}

ルーティング追加

\project-root\src\routes\web.php
Route::middleware('auth')->group(function () {
    Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
    Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
    Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');

    // レビュー関連
    Route::get(('/labs/{lab}/reviews/create'), [ReviewController::class, 'create'])->name('review.create');
    Route::post(('/labs/{lab}/reviews'), [ReviewController::class, 'store'])->name('review.store');
    Route::get('/reviews/{review}/edit', [ReviewController::class, 'edit'])->name('review.edit');
    Route::put('/reviews/{review}', [ReviewController::class, 'update'])->name('review.update');
    Route::delete('/reviews/{review}', [ReviewController::class, 'destroy'])->name('review.destroy');

    // 大学関連
    Route::get('/universities/create', [UniversityController::class, 'create'])->name('university.create');
    Route::post('/universities', [UniversityController::class, 'store'])->name('university.store');
    Route::get('/universities/{university}/edit', [UniversityController::class, 'edit'])->name('university.edit');
    Route::put('/universities/{university}', [UniversityController::class, 'update'])->name('university.update');

    // 学部関連
    Route::get('/universities/{university}/faculties/create', [FacultyController::class, 'create'])->name('faculty.create');
    Route::post('/universities/{university}/faculties', [FacultyController::class, 'store'])->name('faculty.store');
    Route::get('/faculties/{faculty}/edit', [FacultyController::class, 'edit'])->name('faculty.edit');
    Route::put('/faculties/{faculty}', [FacultyController::class, 'update'])->name('faculty.update');
    
    // 研究室関連
    Route::get('/faculties/{faculty}/labs/create', [LabController::class, 'create'])->name('lab.create');
    Route::post('/faculties/{faculty}/labs', [LabController::class, 'store'])->name('lab.store');
    Route::get('/labs/{lab}/edit', [LabController::class, 'edit'])->name('lab.edit'); // 追加
    Route::put('/labs/{lab}', [LabController::class, 'update'])->name('lab.update'); // 追加
});

これもお忘れなく!

\project-root\src\routes\web.php
Route::get('/labs/{lab}/history', [LabController::class, 'history'])->name('lab.history'); // 追加

動作確認

image.png

「研究室を作成」をクリック。
適当な内容を入力して、作成。
image.png
image.png

詳細ページにリダイレクトしました。
続いて「研究室を編集」をクリック。
image.png

適当に値を変えて、更新。
image.png
image.png

研究室詳細ページにリダイレクトし、情報が更新されていることを確認。
image.png

「編集履歴を見る」をクリックして、履歴が見られることを確認(若干違うと思いますが、気にせず!)
image.png

どうにかできたっぽいですね!
結構ミスってやり直したので、データが微妙に皆さんと違うと思いますが気にしないでください。
混乱させてしまったら申し訳ないです!💦

4. まとめ・次回予告

今回もすさまじいボリュームになってしまいました。💦
前々から「この機能やるのだる~い」と思っていましたが、ついに今日がその時でした。
しかし、実は今の編集機能には問題点が残っています。
ですが、それを今回取り扱うとさすがに長すぎるので、それは次々回の次回のそのまた次の回くらいでやります。(笑)

次回は、コメント機能を作成したいと思います!
今回よりは短く終わらせるつもりですので、是非ともよろしくお願いします!

参考

これまでの記事一覧

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

--- 環境構築編 ---

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

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?