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アプリケーション開発に挑戦してみた!(その11)

0. 初めに

みなさんこんにちは!
このシリーズでは、プログラミング初心者の僕が頑張って Web アプリケーションを形にするまでの記録をお届けしています。

今回は、レビューを投稿するための研究室ひいては大学・学部を新規作成できる機能を作っていきたいと思います!

1. 新規大学作成機能作成

ブランチの運用について

まず、新規大学作成機能を作っていきましょう!

前回は、feature/04-review-crud ブランチで作業し、コミット・プッシュ後、develop ブランチに対してプルリクエストを作成してマージしました。

今回は、ローカルの develop を最新の状態にしてから、feature/05-create-university という新しいブランチを切って作業していきます!

作成画面を作成する

新しい大学を作成するための画面を作りましょう。

コントローラー作成

以下のコマンドで大学のコントローラーを作成しましょう。
もう慣れてきましたか?

実行コマンド

/var/www
$ php artisan make:controller UniversityController
\project-root\src\app\Http\Controllers\UniversityController.php
<?php

namespace App\Http\Controllers;

use App\Models\University;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Request;
use Inertia\Inertia;

class UniversityController extends Controller
{
    use AuthorizesRequests;

    public function create()
    {
        $this->authorize('create', University::class);
        return Inertia::render('University/Create');
    }
}

ポリシー作成

念のため、ポリシーも先に作っておきましょう。

実行コマンド

/var/www
$ php artisan make:policy UniversityPolicy
\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;
    }
}

AppServiceProvider.php への登録をお忘れなく!

\project-root\src\app\Providers\AppServiceProvider.php
<?php

namespace App\Providers;

use App\Models\Review;
use App\Models\University; // 追加
use App\Policies\ReviewPolicy;
use App\Policies\UniversityPolicy; // 追加
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);
        Gate::policy(University::class, UniversityPolicy::class); // 追加
    }
}

React コンポーネント作成

作成ページの React コンポーネントを作りましょう。
\project-root\src\resources\js\Pages\ 配下に University フォルダと、その中に Create.jsx というファイルを作成してください。

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

export default function Create() {
    const { data, setData, post, processing, errors, reset } = useForm({
        name: '',
    });

    const handleSubmit = (e) => {
        e.preventDefault();
        post(route('universities.store'), {
            onSuccess: () => {
                reset();
            },
        });
    };

    return (
        <>
            <Head title="大学の新規作成" />
            
            <div>
                <h1>大学の新規作成</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>
                        <button type="submit" disabled={processing}>
                            {processing ? '作成中...' : '作成'}
                        </button>
                        
                        <button 
                            type="button" 
                            onClick={() => window.history.back()}
                        >
                            キャンセル
                        </button>
                    </div>
                </form>
            </div>
        </>
    );
}

ルーティング追加

ルーティングを追加しましょう。
ログインユーザーのみが作成できるようにしたいので、middleware('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'); // 追加
});

動作確認

適当なユーザーでログインして、http://localhost/universities/create にアクセスしてみましょう。

image.png

無事に作成画面が表示されました!

保存機能を作成する

コントローラー修正

先ほど作った create() メソッドの下に store() メソッドを追加しましょう。

\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();

        return redirect('/')->with('success', '大学が作成されました。'); // 一時的にトップ画面にリダイレクト
    }

大学名は必須で、かつ 50 文字以内にしました!

また、本来であればリダイレクト先は大学一覧画面などがよさそうですが、そういった画面はまだ作っていないので、一時的にトップ画面にしておきます。

バリデーション追加

では、バリデーションメッセージが日本語で表示されるようにカスタマイズしておきましょう。
validation.php に以下を追加します。

\project-root\src\resources\lang\ja\validation.php
'custom' => [
        'name' => [
            'unique' => 'この大学名は既に作成されています。',
            'required' => '大学名は必須項目です。',
            'max' => '大学名は50文字以下にしてください。',
        ],
    ],

React コンポーネント修正

変更部分を React コンポーネントにも反映させます。

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

export default function Create() {
    const { data, setData, post, processing, errors, reset } = useForm({
        name: '',
    });

    const handleSubmit = (e) => {
        e.preventDefault();
        post(route('university.store'), {
            onSuccess: () => {
                reset();
                // 成功メッセージはコントローラーから返される
            },
            onError: (errors) => {
                // エラーは自動的にerrorsオブジェクトに設定される
                console.log('バリデーションエラー:', errors);
            }
        });
    };

    return (
        <>
            <Head title="大学の新規作成" />
            
            <div>
                <h1>大学の新規作成</h1>
                
                <form onSubmit={handleSubmit}>
                    <div>
                        <label htmlFor="name">大学名 *</label>
                        <input
                            id="name"
                            type="text"
                            value={data.name}
                            onChange={(e) => setData('name', e.target.value)}
                            placeholder="大学名を入力してください(最大50文字)"
                            required
                        />
                        {errors.name && (
                            <div style={{ color: 'red', fontSize: '14px', marginTop: '4px' }}>
                                {errors.name}
                            </div>
                        )}
                    </div>

                    <div>
                        <button 
                            type="submit" 
                            disabled={processing || !data.name.trim()}
                            style={{
                                padding: '10px 20px',
                                marginRight: '10px',
                                backgroundColor: processing || !data.name.trim() ? '#ccc' : '#007bff',
                                color: 'white',
                                border: 'none',
                                borderRadius: '4px',
                                cursor: processing || !data.name.trim() ? 'not-allowed' : 'pointer'
                            }}
                        >
                            {processing ? '作成中...' : '大学を作成'}
                        </button>
                        
                        <button 
                            type="button" 
                            onClick={() => window.history.back()}
                            disabled={processing}
                            style={{
                                padding: '10px 20px',
                                backgroundColor: '#6c757d',
                                color: 'white',
                                border: 'none',
                                borderRadius: '4px',
                                cursor: processing ? 'not-allowed' : 'pointer'
                            }}
                        >
                            キャンセル
                        </button>
                    </div>
                </form>
            </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'); // 追加
});

動作確認

現在、大学は3つしかない状態です。
image.png

保存されるか確認してみましょう!
image.png

「大学を作成」をクリックすると...
image.png
トップページにリダイレクトされました。
データベースの方を見てみると...
image.png
無事に保存されているみたいですね!

続いて、バリデーションが機能しているかを確認してみましょう。
最大値に設定している 50 文字以上を入力して送信してみます。
image.png
バリデーションメッセージが日本語で表示されましたね!

既に登録済みの「テスト大学」も試してみましょう。
image.png
OK です!

何も入力しないとそもそも作成ボタンが押せないようになっています。
バリデーションも問題なさそうです!

ここまでできたら、いったんコミット・プッシュ、プルリクエスト、マージをいつものようにやっておきましょう。
コミットメッセージは分かりやすければ何でも OK です!

2. 新規学部作成機能作成

ブランチの運用について

次に新しく学部を作れるようにしましょう!

先ほどリモートの develop ブランチにマージをしたので、ローカルの develop ブランチでプルしましょう。

そうしてから、新しい作業ブランチを切ります。
名前は、feature/06-create-faculty とかにしておきましょう!

作成画面を作成する

コントローラー作成

実行コマンド

/var/www
$ php artisan make:controller FacultyController
\project-root\src\app\Http\Controllers\FacultyController.php
<?php

namespace App\Http\Controllers;

use App\Models\Faculty;
use App\Models\University;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Request;
use Inertia\Inertia;

class FacultyController extends Controller
{
    use AuthorizesRequests;
    
    public function create(University $university)
    {
        $this->authorize('create', Faculty::class);
        return Inertia::render('Faculty/Create', [
            'university' => $university
        ]);
    }
}

ポリシー作成

実行コマンド

/var/www
$ php artisan make:policy FacultyPolicy
\project-root\src\app\Policies\FacultyPolicy.php
<?php

namespace App\Policies;

use App\Models\User;

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

\project-root\src\app\Providers\AppServiceProvider.php
<?php

namespace App\Providers;

use App\Models\Faculty; // 追加
use App\Models\Review;
use App\Models\University;
use App\Policies\FacultyPolicy; // 追加
use App\Policies\ReviewPolicy;
use App\Policies\UniversityPolicy;
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);
        Gate::policy(University::class, UniversityPolicy::class);
        Gate::policy(Faculty::class, FacultyPolicy::class); // 追加
    }
}

React コンポーネント作成

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

export default function Create({ university }) {
    const { data, setData, post, processing, errors, reset } = useForm({
        name: '',
        university_id: university.id,
    });

    const handleSubmit = (e) => {
        e.preventDefault();
        post(route('faculties.store'), {
            onSuccess: () => {
                reset('name'); // university_idはリセットしない
            },
            onError: (errors) => {
                console.log('バリデーションエラー:', errors);
            }
        });
    };

    return (
        <>
            <Head title={`${university.name} - 学部の新規作成`} />
            
            <div>
                <h1>学部の新規作成</h1>
                <p>所属大学: <strong>{university.name}</strong></p>
                
                <form onSubmit={handleSubmit}>
                    <div>
                        <label htmlFor="name">学部名 *</label>
                        <input
                            id="name"
                            type="text"
                            value={data.name}
                            onChange={(e) => setData('name', e.target.value)}
                            placeholder="学部名を入力してください"
                            required
                        />
                        {errors.name && (
                            <div style={{ color: 'red', fontSize: '14px', marginTop: '4px' }}>
                                {errors.name}
                            </div>
                        )}
                    </div>

                    <div>
                        <button 
                            type="submit" 
                            disabled={processing || !data.name.trim()}
                            style={{
                                padding: '10px 20px',
                                marginRight: '10px',
                                backgroundColor: processing || !data.name.trim() ? '#ccc' : '#007bff',
                                color: 'white',
                                border: 'none',
                                borderRadius: '4px',
                                cursor: processing || !data.name.trim() ? 'not-allowed' : 'pointer'
                            }}
                        >
                            {processing ? '作成中...' : '学部を作成'}
                        </button>
                        
                        <button 
                            type="button" 
                            onClick={() => window.history.back()}
                            disabled={processing}
                            style={{
                                padding: '10px 20px',
                                backgroundColor: '#6c757d',
                                color: 'white',
                                border: 'none',
                                borderRadius: '4px',
                                cursor: processing ? 'not-allowed' : 'pointer'
                            }}
                        >
                            キャンセル
                        </button>
                    </div>
                </form>
            </div>
        </>
    );
}

ルーティング追加

ログインしているユーザーに制限するためにミドルウェアのグループの中に入れます。
FacultyController の use 宣言もお忘れなく!

\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}/faculties/create', [FacultyController::class, 'create'])->name('faculty.create'); // 追加
});

動作確認

確認してみましょう!
今回は、先ほど作った id4 のテスト大学の作成画面を開いてみましょう。
よって、適当なユーザーでログインしている状態で、以下の URL にアクセスしてみましょう。
http://localhost/universities/4/faculties/create

image.png
無事に表示されました!

URL の 4 の部分を 3 に変えると「所属大学」のところが id3 の「荒木市立大学」に変わります。
ルートモデルバインディングで URL に埋め込まれた ID についてのモデル情報を取得するやり方にはだいぶ慣れてきましたか?

保存機能を作成する

コントローラー修正

create() メソッドの下に以下のメソッドを追加してください。

\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('/')->with('success', '学部が作成されました。');
    }

注目は、バリデーションの部分で、同じ大学内では学部名に重複を許さないようにしています。
一方で、全体としては重複は可能なので、例えば以下のようなケースでは作成可能です。

既に東京大学に工学部があるとき、京都大学にも工学部を作成したい。

しかし、東京大学内で、既に存在する工学部を新しく作ることはできないようになっています。

バリデーション追加

では、今作ったバリデーションについてメッセージを日本語で表示できるように設定しておきましょう。

バリエーションの項目が増えてきたので、整理しておきました!

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

return [
    // 基本的なバリデーションメッセージ
    'required' => ':attribute は必須項目です。',
    'string' => ':attribute は文字列である必要があります。',
    'unique' => 'この:attribute は既に登録されています。',

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

    'custom' => [
        // 大学関連
        'name' => [
            'unique' => 'この大学名は既に作成されています。',
            'required' => '大学名は必須項目です。',
            'max' => '大学名は50文字以下にしてください。',
        ],
        
        // 学部関連(学部作成時のname)
        'faculty.name' => [
            'unique' => 'この学部名は既にこの大学に存在します。',
            'required' => '学部名は必須項目です。',
            'max' => '学部名は50文字以下にしてください。',
        ],

        // 既存の評価関連
        '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' => [
        'name' => '大学名',
        'faculty.name' => '学部名',
        'mentorship_style' => '指導スタイル',
        'lab_atmosphere' => '雰囲気・文化',
        'achievement_activity' => '成果・活動',
        'constraint_level' => '拘束度',
        'facility_quality' => '設備',
        'work_style' => '働き方',
        'student_balance' => '人数バランス',
    ],

];

実は、先ほど 'custom' が二つあったので片方が上書きされてしまう状態でしたが、これで解決です!

React コンポーネント修正

変更店を反映させます。

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

export default function Create({ university }) {
    const { data, setData, post, processing, errors, reset } = useForm({
        name: '',
    });

    const handleSubmit = (e) => {
        e.preventDefault();
        post(route('faculty.store', university.id), {
            onSuccess: () => {
                reset('name'); // university_idはリセットしない
                // 成功メッセージはコントローラーから返される
            },
            onError: (errors) => {
                console.log('バリデーションエラー:', errors);
            }
        });
    };

    return (
        <>
            <Head title={`${university.name} - 学部の新規作成`} />
            
            <div>
                <h1>学部の新規作成</h1>
                <p>所属大学: <strong>{university.name}</strong></p>
                
                <form onSubmit={handleSubmit}>
                    <div>
                        <label htmlFor="name">学部名 *</label>
                        <input
                            id="name"
                            type="text"
                            value={data.name}
                            onChange={(e) => setData('name', e.target.value)}
                            placeholder="学部名を入力してください"
                            required
                        />
                        {errors.name && (
                            <div style={{ color: 'red', fontSize: '14px', marginTop: '4px' }}>
                                {errors.name}
                            </div>
                        )}
                    </div>

                    <div>
                        <button 
                            type="submit" 
                            disabled={processing || !data.name.trim()}
                            style={{
                                padding: '10px 20px',
                                marginRight: '10px',
                                backgroundColor: processing || !data.name.trim() ? '#ccc' : '#007bff',
                                color: 'white',
                                border: 'none',
                                borderRadius: '4px',
                                cursor: processing || !data.name.trim() ? 'not-allowed' : 'pointer'
                            }}
                        >
                            {processing ? '作成中...' : '学部を作成'}
                        </button>
                        
                        <button 
                            type="button" 
                            onClick={() => window.history.back()}
                            disabled={processing}
                            style={{
                                padding: '10px 20px',
                                backgroundColor: '#6c757d',
                                color: 'white',
                                border: 'none',
                                borderRadius: '4px',
                                cursor: processing ? 'not-allowed' : 'pointer'
                            }}
                        >
                            キャンセル
                        </button>
                    </div>
                </form>
            </div>
        </>
    );
}

ルーティング追加

ミドルウェア内に追加です。

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}/faculties/create', [FacultyController::class, 'create'])->name('faculty.create');
    Route::post('/universities/{university}/faculties', [FacultyController::class, 'store'])->name('faculty.store'); // 追加
});

動作確認

それでは、動作確認です!
現状、学部は 4 つだけです。
image.png

適当な名前で作成します...
image.png
image.png
正しく、リダイレクトされました...!!
DB の方はどうかな...!?
image.png
YES!!!!!!

ついでにバリデーションのチェックもしておきましょう。

試しに 51 文字を入力して送信すると...
image.png

登録済みの名前で送信すると...
image.png

http://localhost/universities/3/faculties/create にアクセスして、id 3 の「荒木市立大学」で「テスト学部」を作ってみると...
image.png
image.png
university_id が異なれば、同じ名前でも学部を作成できますね!
全て問題なさそうです。(*^^)v

ここまで確認出来たら、例のごとくコミット・プッシュ、プルリクエスト作成、develop にマージまでを忘れずにしておきましょう!

3. 新規研究室作成機能作成

ブランチの運用について

最後に新しく研究室を作成できる機能を作って終わりにしたいと思います!
ローカルの develop ブランチを最新化してから、今度は、feature/07-create-lab という新規ブランチを切って作業します。

作成画面を作成する

コントローラー修正

例によって、作成画面を作ります。
まずは、コントローラーを作成します。
研究室コントローラーは既にあるので、作成画面を表示するためのメソッドを追加します。

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

namespace App\Http\Controllers;

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

class LabController extends Controller
{
    use AuthorizesRequests; // 追加
    
    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,
            ],
        ]);
    }

    // 追加するメソッド
    public function create(Faculty $faculty)
    {
        // 認可
        $this->authorize('create', Lab::class);

        // 学部に紐づく大学の情報を取得
        $university = $faculty->university;

        // Inertiaを使ってLabの作成ページを表示
        return Inertia::render('Lab/Create', [
            'faculty' => $faculty,
            'university' => $university,
        ]);
    }
}

ポリシー作成

ポリシーはないので以下のコマンドで作成します。
実行コマンド

/var/www
$ php artisan make:policy LabPolicy
\project-root\src\app\Policies\LabPolicy.php
<?php

namespace App\Policies;

use App\Models\User;

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

登録もお忘れなく!

\project-root\src\app\Providers\AppServiceProvider.php
<?php

namespace App\Providers;

use App\Models\Faculty;
use App\Models\Lab; // 追加
use App\Models\Review;
use App\Models\University;
use App\Policies\FacultyPolicy;
use App\Policies\LabPolicy; // 追加
use App\Policies\ReviewPolicy;
use App\Policies\UniversityPolicy;
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);
        Gate::policy(University::class, UniversityPolicy::class);
        Gate::policy(Faculty::class, FacultyPolicy::class);
        Gate::policy(Lab::class, LabPolicy::class); // 追加
    }
}

React コンポーネント作成

\resources\js\Pages\Lab フォルダは既にあるので、その中に Create.jsx を以下のように作成してください。

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

export default function Create({ faculty, university }) {
    const { data, setData, post, processing, errors } = useForm({
        name: '',
        description: '',
    });

    const handleSubmit = (e) => {
        e.preventDefault();
        post(route('lab.store', faculty.id));
    };

    return (
        <>
            <Head title={`研究室作成 - ${faculty.name} - ${university.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}
                        </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
                            />
                            {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"
                            />
                            {errors.description && (
                                <p className="text-red-500 text-sm mt-1">{errors.description}</p>
                            )}
                        </div>

                        <div className="flex gap-4">
                            <button
                                type="submit"
                                disabled={processing}
                                className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
                            >
                                {processing ? '作成中...' : '作成'}
                            </button>              
                        </div>
                    </form>
                </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}/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'); // 追加
});

動作確認

http://localhost/faculties/3/labs/create
image.png

今回、Claude に作ってもらいましたが、CSS も効いていてかっこいい感じになりましたね。(笑)

保存機能を作成する

コントローラー修正

最後に、保存機能を作りましょう。
コントローラーに 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();

        return redirect('/')->with('success', '研究室が作成されました。');
    }

バリデーション追加

validation.php'custom' の中に追加してもらえればよかなと思います。

\project-root\src\resources\lang\ja\validation.php
'custom' => [
        // 大学関連
        'name' => [
            'unique' => 'この大学名は既に作成されています。',
            'required' => '大学名は必須項目です。',
            'max' => '大学名は50文字以下にしてください。',
        ],
        
        // 学部関連(学部作成時のname)
        'faculty.name' => [
            'unique' => 'この学部名は既にこの大学に存在します。',
            'required' => '学部名は必須項目です。',
            'max' => '学部名は50文字以下にしてください。',
        ],

        // 追加: 研究室関連(研究室作成時のname)
        'lab.name' => [
            'unique' => 'この研究室名は既にこの学部に存在します。',
            'required' => '研究室名は必須項目です。',
            'max' => '研究室名は50文字以下にしてください。',
        ],

        // 既存の評価関連
        '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以上の値にしてください。',
        ],
    ],

React コンポーネント修正

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

export default function Create({ faculty, university }) {
    const { data, setData, post, processing, errors } = useForm({
        name: '',
        description: '',
        url: '',
        professor_url: '',
        gender_ratio_male: 5,
        gender_ratio_female: 5,
    });

    const handleSubmit = (e) => {
        e.preventDefault();
        post(route('lab.store', faculty.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={`研究室作成 - ${faculty.name} - ${university.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}
                        </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>
                            <button
                                type="submit"
                                disabled={processing || (data.gender_ratio_male + data.gender_ratio_female) !== 10}
                                className="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
                            >
                                {processing ? '作成中...' : '研究室を作成'}
                            </button>
                        </div>
                    </form>
                </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}/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'); // 追加
});

動作確認

ログイン状態で、以下にアクセス。
http://localhost/faculties/3/labs/create

image.png
image.png

適当に入力して、作成ボタンをクリックすると、正常にリダイレクトされる。
image.png

DB を確認すると、入力した内容でデータが入っていることが確認できる。
image.png

これにて、完了です。
コミット・プッシュ、develop ブランチへのプルリクエスト作成・マージを済ませておきましょう。

4. まとめ・次回予告

お疲れ様でした。
いかがでしたか?

今回は、特に新しいことがなかったので淡々と進ませていただきました。
時にはそういう日があってもいいだろう!
むしろ、最近書く分量が多くなってきて大変なのです...(*´Д`)

ところが、お気づきの方もいらっしゃるかもしれませんが、実は直さないといけないところがあります。
今日の作業では、中間テーブルに作成したユーザーの ID が保存されるようになっていません!

その辺の修正は、おそらく次々回くらいになると思います。
少々お待ちください。

ということで、次回は、大学の検索機能を作っていきたいと思います!
ぜひ読んでみてください!

これまでの記事一覧

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

--- 環境構築編 ---

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

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?