0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

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

0. 初めに

みなさん、こんにちは!

前回の記事、読んでくださいましたか!
前回はかなり長くなってしまって、やむを得ず、前編後編に分けさせていただきました。
分かりにくかったら申し訳ございません!

何はともあれ、レビュー投稿機能を作成することができました。
今回は、さらにCRUD機能と呼ばれるものを作成していきたいと思います!
よろしくお願いいたします。(#^.^#)

1. 目次

0. 初めに
1. 目次
2. Gitリポジトリ運用について
3. CRUD機能とは?
4. 研究室詳細ページを作成
5. レビュー投稿機能を修正
6. レビュー編集機能を作成
7. レビュー削除機能を作成
8. まとめ・次回予告

2. Gitリポジトリ運用について

前回の作業は、feature/03-review-crud というブランチを develop から切って行っていました。

前回の最後に「コミット・プッシュはしなくてOK」と言いました。
そうです。このブランチ名の「crud」っていうのは今日の範囲の作業が終わるまで運用する予定で名付けました。

しかし、前回までで多くのファイルを作成したり、編集したりしました。
本当であれば、もっと細かく作業を分割して、そのたびごとに新規ブランチを切るべきでしたが、僕の想定が甘くてこのブランチでの作業がかなり多くなってしまいました。

今回の作業が終わったら、コミット・プッシュしますので、もう少しだけこのブランチで作業しましょう。
次回以降は、もっと細かく作業を分割してブランチを切っていければいいなと思っています。

もし、「一体いつになったら、コミット・プッシュするんだ??」と思った方がいらっしゃいましたら、申し訳ないです。💦
これから改善していきます!

3. CRUD機能とは?

CRUD機能について

では、CRUDとは、何なのでしょうか?
この言葉は、四つの英単語のアルファベットの頭文字からできています。

Create, Read, Update, Deleteの頭文字をとっており、それぞれ日本語にするとそのまま、生成読み取り更新削除のことです。

まず、Create(生成)は、データを新規作成・登録する機能のことです。
ブログでいえば、新しい記事を投稿する機能がこれに該当します。

次に、Read(読み取り)です。
これは、既にあるデータを表示する機能のことです。
ブログの記事一覧ページや自分の調べたいキーワードで検索した結果が表示される機能などもこれに該当します。

続いて、Update(更新)です。
既存のデータの修正や編集が行える機能です。
ブログで過去に投稿した記事内に誤りがあった場合などに修正することができる機能がこれに該当します。

最後のDelete(削除)は、文字通り既存データを消すことができます。
投稿済みのブログを削除したり、ユーザーがサービスから退会したりできるのがこれに当たります。

ほとんどのWebアプリケーションはデータベースと連携しており、多くの場合、CRUD機能を作成するということは、データベース上のデータについて、生成・読み取り・更新・削除の操作を行うことを指します。

今回は、データベース上のレビューのデータに関してCRUD機能を作成したいと思います。

今回の目標

実は、このCRUD機能のうち、Createに(生成)に関しては、前回作成しました。
すなわち、新規のレビューを投稿して、それをデータベースに保存するという処理自体はもうすでにできています。

したがって、残りのRead(読み取り)とUpdate(更新), Delete(削除)を作成するのが今日の目標です。

目標の確認が終わったところで、実際にCRUDのRから作っていきましょう!

4. 研究室詳細ページを作成

では、CRUDのR, Read(読み取り)機能を作っていきます。
SQLでいうところの SELECT に対応するのが、このReadです!
データベースに登録されているレビューのデータを画面に表示して、ユーザーが見られるようにします。

ややこしい説明

ここで、少しだけややこしい話をします。
Read(読み取り)はたいていの場合、データそのものを見られるようにする機能です。

例えば、ブログ投稿サイトだと、ブログ一覧からその記事のタイトルをクリックするとページが遷移して、本文を読むことができたり、投稿者の情報を見たりすることができるという感じです。

今回の場合だと、レビューそのもの情報(例えば、どの研究室に対して、誰が、どのような評価をしたのか)を表示する機能を作ることになります。

ところが、今回のレビューは、誰が投稿したかは非公開で投稿できるようにしたいですし、内容も各評価項目の数字だけです。
わざわざレビュー一つ一つに対して詳細を表示するページを作る必要はないと感じますね。

そこで、研究室の詳細ページにレビューの情報を載せるというのはどうでしょうか?
具体的には、研究室ごとに詳細ページがあって、そこで複数のユーザーのレビューの平均値が見られるような形にすればレビューの読み込みもできていると言えるのではないでしょうか?

つまり、本当は研究室についてのRead(読み込み)機能の作成をしているのですが、それと同時にレビューについてのRead(読み込み)機能もそれと一緒にしてしまおうということです。
このようにすることで、どの研究室に対してのレビューなのかという情報もわかります。

ちょっとよくわからない説明だったかもしれませんが、実際に以下のように作業をしていくことで、少しずつ僕の言いたいことが伝わってくれれば幸いです。

下準備

では、作業に入りましょう!
要は、研究室詳細ページを作成して、そこにその研究室に対するレビューの情報をデータベースから取得して表示すればよいということです!

まずは、下準備としていくつかレビューを投稿しておきましょう。
複数のユーザーのレビューの平均値を表示したいのですが、一つの研究室に対して、一人のユーザーが一度までしか投稿できないため、面倒かもしれませんが複数ユーザーでログインしてレビューを一つずつ投稿します。

image.png
前回IDが1のユーザーでログインしたので、今回はID2~4のユーザーで順番にログインしてみます。

phpのコンテナ内で以下のコマンドも忘れずに!

/var/www
$ npm run dev

ログイン出来たら、http://localhost/labs/1/reviews/create にアクセスしましょう。
アクセス出来たら、適当に値をばらつかせて投稿しましょう。
image.png

要するにログイン → 投稿 → ログアウト これを3セット行います。
なお、ログアウトは、http://localhost/dashboard で行ってください。

最終的に四人分のレビューのデータが入っていればOKです!
image.png

コントローラー修正

show()メソッドを追加する

下準備ができたので、次はコントローラーを修正します。
LabControllerを開いて見てください。

今のところ、index() メソッドしかないと思います。
この「index」という言葉は、一覧を表示するときによく使う言葉なので覚えておくとよいかもしれません!

この下に詳細ページを表示するためのメソッドを追加します。
詳細を「見せる」ということで、「show」というメソッド名が慣習的によく使われますので、今回も show() メソッドを追加します!

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

namespace App\Http\Controllers;

use App\Models\Lab;
use Illuminate\Http\Request;
use Inertia\Inertia;

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

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

前回レビューを作ったとき同様に、Laravelのルートモデルバインディングを利用したいと思います。

まず、ユーザーが研究室のIDを含むURLにアクセスする(例えば、labs/1 のように)。

次に、ルーティングがそのIDを変数にしてコントローラーに渡します(例えば、{lab} のようにします)。

最後に、コントローラーはそのIDが入った変数を引数(この場合だと、$lab)として受け取り、そのIDと等しいモデルを取得することができます。

ここで、なぜ Lab $lab のように2つもlabを書くのかという話です。
これは、前回説明を割愛した部分です。

$lab には、どの研究室なのかを識別するための研究室IDが入ります。
よって、これは単に1とか2とかの数字でしかないため、$lab だけだとなんのIDかが分かりません。

そこで、その隣の Lab がLabモデルを自動でインスタンス化して、そのLabモデルインスタンスに対して $lab で指定されたIDを取得することができます。
つまり、Laravelが、「App\Models\Lab に関するIDだ!」と認識して自動で処理をしてくれるわけです。

なお、前回すでにuse宣言はしているので、完全修飾クラス名を書く必要はありません。

また、これにより、$lab の方が何でもよいことが分かります。
もし、分かりにくければ、例えば以下のようにしても構いません!
コントローラー

public function show(Lab $labId){ }

ルーティング

Route::get('/labs/{labId}', [LabController::class, 'show']);

ここでは、labId にIDとなる数字が入ります。
つまり、コントローラーとルーティングで名前をそろえていただければ、Laravelが自動で認識してIDを渡してくれます。
$hoge とかでもよいです。

そして、中身の処理の部分ですが、load() メソッドを使用することで、リレーションで紐づいている大学・学部及びレビューの情報も取ってくることができます。

show()メソッド中にレビューの平均値を計算する処理を追加する

今回は、一つ一つのレビューの投稿が見られるようにするのではなく、投稿したユーザーのすべての平均値を見られるようにしたいです。

そのために必要な処理を show() メソッドの中に追加しました。
return のところも少し変更したので気を付けてください!

\project-root\src\app\Http\Controllers\LabController.php
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();

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

ここからの説明がやや大変ですが、頑張ってついてきてください!

最初は、全ユーザーの総合評価を求めたいです。
流れとしては、各項目のユーザー間の平均を求め、その後、それらの平均を計算することで、その研究室の総合評価としたいです。

伝わってますかね...??( ;∀;)
まず、$ratingColumns には、評価項目の文字列を配列として代入しています。

次に、以下の部分についてです。

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

Laravelには、コレクションというものがあります。
見た目はphpの配列にとても似ていますが、より効率的にデータを処理することができるようになっています。
phpの配列の拡張版みたいな感じです。

コレクションは、collect() 関数を使うことで生成できます!
ここでは、$ratingColumns という配列をもとにコレクションを生成しています。
「配列をコレクションに変換して扱いやすくした」と考えてもよいでしょう。

$averagePerItem = collect($ratingColumns);

試しに、中身を見てみます。
実行結果
image.png
ぱっと見 php の配列のように思われますが、確かに7つの項目がデータとして格納されていることが分かりますね!
一応一番上に青文字で、コレクションクラスのインスタンスが生成されていることが示されているのが分かりますね。

では、続きを見てみましょう。
コレクションには、専用のメソッドがいくつか用意されていて、それらを -> でつないでいくことで、欲しい形のコレクションに変化させて取得できます。
詳しくは、公式ドキュメントもしくは、こちらのLaravel初心者向け、よく使うCollectionのフィルタリング系メソッドを読んでいただけるとやや丁寧に書かれているのでお勧めです。

$averagePerItem = collect($ratingColumns)->mapWithKeys()

このように現状のコレクションに対して、mapWithKeys() メソッドを使うことで、新しい中身のコレクションを生成することができます。

では、mapWithKeys() メソッドについて見てみましょう。
これは、コレクションの各要素に対して、コールバック関数を実行し、その結果となる新しいコレクションを返します。

コールバック関数とは、メソッドの中で呼ばれる関数のことです。
メソッドの引数として渡されます。

mapWithKeys() メソッドの引数として書かれている、以下の部分がコールバック関数です。

function ($column) use ($lab) {
    return [$column => $lab->reviews->avg($column)];
}

そして、このmapWithKeys() メソッドは、キーのペアが格納されるいわゆる php の連想配列を配列に入れたような形のコレクションを返します。

よって、今回は、コールバック関数が各評価項目キーに、それらのレビューの投稿者間の平均とする連想配列の1ペアを作るようにし、その結果(戻り値)をmapWithKeys() メソッドで1評価項目ずつコレクション形式にして入れていくという感じの流れにします!

うーん、難しいかも...!!💦

最後に、コールバック関数の中身を見てみましょう。
これは、php の無名関数と呼ばれるものです。

その名の通り、function の後ろに名前がない関数です。
以下の記事が簡潔で分かりやすいと思いますので、良かったらどうぞ。
一目でわかるPHP無名関数!使い方を簡単解説

今回引数は、$column というものを定義しており、これは、$ratingColumns の一つ一つの中身だと思ってください。

そして、use ($lab) とすることで、親の関数スコープ(show() メソッド)内の変数 $lab を子であるコールバック関数内でも使用できるようにしています。

return [$column => $lab->reviews->avg($column)];

このようにすることで、評価項目をキーに、それについてのレビューの平均値を値とする連想配列の1ペアを返します。

これを、全項目分繰り返します!
foreach とかに似ていますね!

なので、最初のループでは、

return ['mentorship_style' => $lab->reviews->avg('mentorship_style')];

のように代入され、次のループでは

return ['lab_atmosphere' => $lab->reviews->avg('lab_atmosphere')];

のように $colmns の中に2つ目の項目が入った形で return されます。

最終的には、全項目分の連想配列を集めて、コレクションを返します。
実行結果
image.png
確かに、すべての評価項目に対して、レビューの平均値が設定されていることが分かりますね!!

ここまで来たら、さらに、今計算された各項目の平均値たちの平均を計算して、研究室の総合評価を求めます!

人によってはもしかしたら、もう何を言っているのか訳が分からない方もいるかもしれませんね。(笑)
大丈夫です!
分かるまで粘りましょう!

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

先ほどと同様に avg() を使って、$averagePerItem の平均値を計算し、$overallAverage という変数に代入しています。

実行結果
image.png
出来ました!

お疲れ様でした。
平均値の計算には、コレクションという Laravel 専用の概念を用いたため、やや複雑に感じた方も多かったと思います。
なかなか1回では完璧に理解できないという方が多いと思います。

でも、大丈夫!
分からない部分を何度も読み返したり、Googleで必要な知識を調べたり、ChatGPT に聞いてみたり、できることはたくさんあります!
分かるまであきらめずにちょとずつ理解を深めていけばOKですよ!(#^.^#)

特に、mapWithKeys() メソッドは、php の foreach や JavaScript の map などに似ているので、これらを復習するともしかしたら理解がしやすくなるかもしれません。

また、所々で出てきた「実行結果」という画面はどうやって出すかと言いますと、show() メソッド内で dd() 関数というものを使っています。
これは、引数に入れた変数の中身を見ることができ、それよりも下に書かれた処理をストップしてくれます。

例えば、dd($averagePerItem) みたいに書くと、それ以下の処理をいったんストップして、$averagePerItem の中身がブラウザの画面に表示されます。

dd() 関数は、デバッグ(バグの原因を見つけて直すこと)の時に非常によく使うので、覚えておくとよいかもしれません!

そうしたら、最後の return 文の Inertia でデータをReactコンポーネントに渡す部分の修正をしたら、コントローラーは完成です!
今計算した、$overallAverage$averagePerItem も追加します。

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

Reactコンポーネント作成

コントローラーで、Lab/show.jsx に研究室(及びそれに紐づく大学・学部、レビュー、さらにはその平均値)のデータを渡す処理を書きました!
しかし、まだ Lab/show.jsx 自体は作成していませんので、新しいファイルを作りましょう!

image.png
VS Codeで Lab の中に index.jsx に加えて、Show.jsx を追加します。

当然何も書いていないので、真っ黒ですね。
image.png

以下をコピペしてください!

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

// propsとして 'lab' だけでなく、`overallAverage` と `averagePerItem` も受け取る
export default function Show({ lab, overallAverage, averagePerItem }) {
    const reviewCount = lab.reviews ? lab.reviews.length : 0;

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

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

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

            <hr />

            <h2>レビュー</h2>
            <p>レビュー数: {reviewCount}</p>
            {/* 新しい総合評価を表示 */}
            <p><strong>総合評価(各項目の平均の平均): </strong>{formatAverage(overallAverage)}</p>

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

後でもう少し修正する箇所がありますが、とりあえずはこれで完成です!

動作確認

では、http://localhost/labs/1 にアクセスしてみてください!
一応、ユーザーIDが4のユーザーでログインしている状態でお願いします。
image.png
出来ましたね!

よく見ると、「材料工学研究室なのに教養学部なのかよ」ということで、変な感じがしますね。
おそらくシーディングの時にミスったかもしれません。
機能としては問題ないと思います。
余裕があったら、後で直しましょう...!( ;∀;)

何はともあれ、これにて、レビュー(厳密には研究室)のRead(読み取り)機能ができました!

5. レビュー編集機能を作成

お次は、CRUDのU, Update(更新)機能を作ります!
SQLの UPDATE に対応します。

更新前のデータベース
image.png

ユーザーが既に投稿したレビューの値を編集できるようにしたいです。
今回の目標として、user_id が4のユーザーの mentorship_style を5から1に変えて登録しなおしたいと思います。

レビュー編集画面作成

まずは、レビューを編集する画面を作成しましょう!

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

ReviewController.php に新しいメソッドを追加します。
編集ページを表示するメソッドにしたいので、慣例的よく使われる「edit」という名前にします。

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

namespace App\Http\Controllers;

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

class ReviewController extends Controller
{
    public function create(Lab $lab) {
        return Inertia::render('Review/Create', ['lab' => $lab]);
    }

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

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

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

    // 追加
    public function edit(Review $review) {
        $review->load('lab');
        return Inertia::render('Review/Edit', ['review' => $review,]);
    }
}

Inertiaで $lab をReactコンポーネントに渡しています。
Read(読み取り)機能を作ったときと同様に、load() メソッドを用いて、リレーションが紐づいている研究室のデータも格納します。

Reactコンポーネントを作成する

Review/Create はまだ作成していないので、resources >> js >> Pages >> Reviewのフォルダで、新しいファイルを作成してください。
ファイル名は、Edit.jsx としてください。

出来たら、以下のようにしてください。
Create.jsx と似ているので、それをもとに作成するのが良いかもです。

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

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

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

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

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

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

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

    return (
        <div>
            <Head title={`${review.lab.name}のレビュー編集`} />
            <h1>{review.lab.name} のレビュー編集画面</h1>

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

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

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

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

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

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

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

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

ルーティングを追加する

最後にルーティングを追加します。

\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'); // 追加
});

当然ながら、ログインしているユーザーしかレビューを編集できないようにしたいので、middleware('auth') のグループの中に追加します。

/reviews/{review}/edit にアクセスが来たら、ReviewControlleredit メソッドが呼ばれます。
{review} の部分で、レビューの ID が渡されます。

動作確認をする

では、動作確認です。
レビューIDが4の投稿を編集したいので、以下にアクセスしてみてください。
http://localhost/reviews/4/edit

image.png

できました!
Inertiaでレビューのデータが渡されているのが分かると思います。
また、レビューに紐づく研究室のデータも渡しているので、しっかりと研究室名も表示できていますね!

レビュー更新機能作成

さて、ここからは、今作成した編集画面から編集した内容を登録できるようにしていきます!

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

編集したデータを保存しなおすためのメソッドを追加します。
慣例に従って、「update」という名前にしますね!

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

namespace App\Http\Controllers;

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

class ReviewController extends Controller
{
    public function create(Lab $lab) {
        return Inertia::render('Review/Create', ['lab' => $lab]);
    }

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

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

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

    public function edit(Review $review) {
        $review->load('lab');
        return Inertia::render('Review/Edit', ['review' => $review,]);
    }

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

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

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

store() メソッドとほとんど同じですね。

ルーティングを追加する

今追加した、update() メソッドが呼ばれるようにルーティングを追加します。

\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'); // 追加
});

更新時のアクセス方法は PUT を使うことが多いようです。
現場によっては、GETPOST しか使わないという話も聞いたことがありますし、そもそも HTTPリクエスト のメソッドについて本シリーズではあまり深堀したことがないので、今はそんなに気にしなくても大丈夫だと思います。

動作確認をする

では、先ほどの画面で「指導スタイル」を5から1に変更して、「レビューを更新」をクリックしましょう!
image.png

更新後のデータベース
image.png
id 4のレビューの mentorship_style が5から1に変わっていることが確認できますね!

比較用: 更新前のデータベース
image.png

また、updated_at も変化し、created_at と異なっていることも確認できます!
image.png
※4行目です。

これにて、Update(更新)機能も完了です!

6. レビュー削除機能を作成

残すのは、Delete(削除)機能だけですね!
SQLの DELETE に対応するものです。

今回は、id が4のレビューを削除したいと思います。

コントローラー修正

まず、コントローラーを修正します。
削除処理をするメソッド名には、よく「destroy」が使われます!
update() メソッドの下に追加しましょう。

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

namespace App\Http\Controllers;

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

class ReviewController extends Controller
{
    public function create(Lab $lab) {
        return Inertia::render('Review/Create', ['lab' => $lab]);
    }

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

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

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

    public function edit(Review $review) {
        $review->load('lab');
        return Inertia::render('Review/Edit', ['review' => $review,]);
    }

    public function update(Request $request, Review $review) {
        // バリデーション
        $validated = $request->validate([
            'mentorship_style' => 'required|integer|min:1|max:5',
            'lab_atmosphere' => 'required|integer|min:1|max:5',
            'achievement_activity' => 'required|integer|min:1|max:5',
            'constraint_level' => 'required|integer|min:1|max:5',
            'facility_quality' => 'required|integer|min:1|max:5',
            'work_style' => 'required|integer|min:1|max:5',
            'student_balance' => 'required|integer|min:1|max:5',
        ]);

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

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

    // 追加
    public function destroy(Review $review) {
        $review->delete();
        return redirect()->route('labs.show')->with('success', 'レビューが削除されました。');
    }
}

ルートモデルバインディングを用いて、URLで渡されたIDに対応する研究室のレビューを削除し、詳細ページにリダイレクトします。
この場合は、特にReactコンポーネントに渡したいデータはないので、Inertitaは使いません。

Reactコンポーネント修正

研究室詳細ページのレビュー欄に削除ボタンのようなものがあるとよいですね。
Show.jsx に追加します。

import React from 'react';
import { Head, router } from '@inertiajs/react';

// propsとして 'lab' だけでなく、`overallAverage` と `averagePerItem` も受け取る
export default function Show({ lab, overallAverage, averagePerItem }) {
    const reviewCount = lab.reviews ? lab.reviews.length : 0;

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

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

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

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

            <hr />

            <h2>レビュー</h2>
            <p>レビュー数: {reviewCount}</p>
            {/* 新しい総合評価を表示 */}
            <p><strong>総合評価(各項目の平均の平均): </strong>{formatAverage(overallAverage)}</p>

            <h3>各評価項目の平均:</h3>
            {averagePerItem && Object.keys(averagePerItem).length > 0 ? (
                <ul>
                    {Object.entries(averagePerItem).map(([itemKey, averageValue]) => (
                        <li key={itemKey}>
                            <p>
                                <strong>{itemLabels[itemKey] || itemKey}:</strong>
                                {formatAverage(averageValue)}
                            </p>
                        </li>
                    ))}
                </ul>
            ) : (
                <p>まだ評価データがありません。</p>
            )}
            { lab.reviews && lab.reviews.length > 0 ? (
                <div>
                    {lab.reviews.map((review) => (
                        <button
                            key={review.id}
                            onClick={() => handleDeleteReview(review.id)}
                        >
                            レビューID {review.id}を削除
                        </button>))}
                </div>
            ) : (
                <p>レビューがまだありません。</p>
            )}
        </div>
    );
}

ルーティング追加

destroy() メソッドが呼び出されるようにルーティングを追加します!

\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'); // 追加
});

動作確認をする

では、http://localhost/labs/1 にアクセスして、ID4のレビューを削除するボタンを押してみましょう。
image.png

「OK」で大丈夫です。
image.png

もしかしたら、リダイレクトの部分でエラーが起きたかもしれませんが、気にせずリロードしてください。(笑)
おそらく、研究室の情報を渡していないためです。
次回直します。

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

データが消えました!
一応、削除はできたということにしましょう!

7. コミット・プッシュ

お待たせいたしました。
ここまでの作業をコミット・プッシュしておきましょう。

srcディレクトリに移動して、以下のコマンドを順に実行します。

実行コマンド

~/project-root/src
$ git add .

エラーが起きたら、指示に従って直します。
image.png

対象のファイル(Index.jsx, Show.jsx, Edit.jsx)を開き、画面右下の「CRLF」をクリックします。
image.png

すると、画面上部に「改行コードの選択」が出てくるので「LF」に変更します。
image.png

できたら、再度、add します。

実行コマンド

~/project-root/src
$ git add .

次にコミット。
実行コマンド

~/project-root/src
$ git commit -m "Created the Review CURD, but some modifications are necessary"

最後にプッシュ。
実行コマンド

~/project-root/src
$ git push -u origin feature/03-review-crud

できたら、GitHubを開いて、プルリクエストを作成して、マージしましょう。
「develop」にマージするので、「base」の部分がもし、「main」なら変更するのを忘れずに!!!!
image.png

完了です。
もし、Gitの操作に不安がある場合は、バックエンド実装編①バックエンド実装編②前編をご覧ください。
結構丁寧に書いてあります!

8. まとめ・次回予告

お疲れ様でした!
今日は、既に投稿したレビューを表示したり、編集したり、削除したりできるようになりました。

しかし、お気づきかと思いますが、直すべきところがたくさんあります!(笑)

ログインしていなくても他のユーザーのレビューを削除出来てしまったり、他のユーザーが投稿したレビューの編集ページにアクセスできてしまったり、リダイレクトでエラーが発生してしまったり...とこのままではとても使い物になりません...

ということで、残念ながら、次回もレビューのCRUD機能の作成の続きをやります!w

今日は、記事が長くなってしまうため、とりあえず、作成・読みとり・更新・削除というものを作ることができるということを実感することに専念したかったので、この辺で終了したいと思います!

また、次回の記事でお会いしましょう~!

参考

これまでの記事一覧

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

--- 環境構築編 ---

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

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?