実務1年目駆け出しエンジニアがLaravel&ReactでWebアプリケーション開発に挑戦してみた!(その28)
0. 初めに
こんにちは!
見習いエンジニアがWebアプリケーションを一から作るシリーズです。
前回は、学部一覧ページを作りました。
今回は、学部一覧ページから学部カードをクリックすると表示される研究室一覧ページを作成したいと思います!
1. ブランチ運用
いつも通りにdevelopブランチを最新化して、新規ブランチを切ります。
ブランチ名は、feature/frontend/labs-index-pageとかにしましょう。
作業が終わったら、コミット・プッシュをして、リモートのdevelopブランチにマージしましょう。
2. 画面デザイン
例によって、本日のお手本をお示ししますので、極力これに近づけられるように頑張って行きましょう!
現状では、まったく使いたくならない見た目をしていますねw
大変身させちゃいましょう!♪
3. テーブル定義修正
professor_nameというカラムを追加しました。
教授のURLだけあっても、誰だよ?って感じになりそうだったので...
前から直したいと思っていたので、よい機会かなと思って直します。
4. マイグレーション修正
それに伴って、マイグレーションファイルを修正しましょう!
<?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('labs', function (Blueprint $table) {
$table->id();
$table->foreignId('faculty_id')->constrained('faculties')->onDelete('cascade');
$table->string('name');
$table->text('description')->nullable();
$table->text('url')->nullable();
$table->string('professor_name')->nullable(); // 追加
$table->text('professor_url')->nullable();
$table->unsignedTinyInteger('gender_ratio_male');
$table->unsignedTinyInteger('gender_ratio_female');
$table->timestamps();
$table->softDeletes();
$table->unsignedBigInteger('version')->default(1);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('labs');
}
};
5. シーディング修正
研究室のシーディングですが、それに合わせて修正します。
同時にテストデータっぽく修正してみたいと思います。
以下のようにしてみてください。
<?php
namespace Database\Seeders;
use App\Models\Lab;
use App\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class LabSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
$adminId = User::where('email', 'admin@example.com')->value('id')
?? User::first()->id;
Lab::create([
'faculty_id' => 5,
'name' => '機械工学科 テストA研究室',
'description' => '材料の力学的性質を調べる研究室です。',
'url' => 'https://example.com/lab1',
'professor_name' => 'テストA太郎',
'professor_url' => 'https://example.com/professor1',
'gender_ratio_male' => 7,
'gender_ratio_female' => 3,
'created_by' => $adminId,
]);
Lab::create([
'faculty_id' => 5,
'name' => '機械工学科 テストB研究室',
'description' => '材料の力学的性質を調べる研究室です。',
'professor_name' => 'テストB太郎',
'url' => 'https://example.com/lab2',
'professor_url' => 'https://example.com/professor2',
'gender_ratio_male' => 7,
'gender_ratio_female' => 3,
'created_by' => $adminId,
]);
Lab::create([
'faculty_id' => 5,
'name' => '情報工学科 テストC研究室',
'description' => '情報処理技術を学ぶ研究室です。',
'url' => 'https://example.com/lab3',
'professor_name' => 'テストC太郎',
'professor_url' => 'https://example.com/professor3',
'gender_ratio_male' => 8,
'gender_ratio_female' => 2,
'created_by' => $adminId,
]);
Lab::create([
'faculty_id' => 5,
'name' => '情報工学科 テストD研究室',
'description' => '情報処理技術を学ぶ研究室です。',
'professor_name' => 'テストD太郎',
'url' => 'https://example.com/lab4',
'professor_url' => 'https://example.com/professor4',
'gender_ratio_male' => 8,
'gender_ratio_female' => 2,
'created_by' => $adminId,
]);
Lab::create([
'faculty_id' => 5,
'name' => '応用化学科 テストE研究室',
'description' => '化学の応用を学ぶ研究室です。',
'url' => 'https://example.com/lab5',
'professor_name' => 'テストE太郎',
'professor_url' => 'https://example.com/professor5',
'gender_ratio_male' => 6,
'gender_ratio_female' => 4,
'created_by' => $adminId,
]);
Lab::create([
'faculty_id' => 5,
'name' => '応用化学科 テストF研究室',
'description' => '化学の応用を学ぶ研究室です。',
'url' => 'https://example.com/lab6',
'professor_name' => 'テストF太郎',
'professor_url' => 'https://example.com/professor6',
'gender_ratio_male' => 6,
'gender_ratio_female' => 4,
'created_by' => $adminId,
]);
Lab::create([
'faculty_id' => 5,
'name' => '機能材料工学科 テストG研究室',
'description' => '機能性材料の研究を行う研究室です。',
'url' => 'https://example.com/lab7',
'professor_name' => 'テストG太郎',
'professor_url' => 'https://example.com/professor7',
'gender_ratio_male' => 9,
'gender_ratio_female' => 1,
'created_by' => $adminId,
]);
Lab::create([
'faculty_id' => 5,
'name' => '機能材料工学科 テストH研究室',
'description' => '機能性材料の研究を行う研究室です。',
'url' => 'https://example.com/lab8',
'professor_name' => 'テストH太郎',
'professor_url' => 'https://example.com/professor8',
'gender_ratio_male' => 9,
'gender_ratio_female' => 1,
'created_by' => $adminId,
]);
}
}
ここまでできたら、以下のコマンドをDockerコンテナの中で実行しましょう!
実行コマンド
$ php artisan migrate:fresh --seed
これで、データの準備は完了です!
6. コントローラー修正
画面デザインを見ていただいたら分かる通り、各研究室に対して投稿済みのレビューの数も表示するようにしたいです。
現状のLabConrtoller.phpのindexメソッドには、レビューの情報をReact側に渡す処理がないので、追加しましょう。
計算のロジックもここに書きます。
public function index(Faculty $faculty, Request $request)
{
// 評価項目のカラム名を定義
$ratingColumns = [
'mentorship_style',
'lab_atmosphere',
'achievement_activity',
'constraint_level',
'facility_quality',
'work_style',
'student_balance',
];
// UIからソート条件を取得
$sort = $request->query('sort', 'overall');
// 各評価項目の平均値を計算するためのSQL断片を作成
$avgSum = implode(' + ', array_map(fn($c) => "AVG($c)", $ratingColumns));
$count = count($ratingColumns);
$query = $faculty->labs()
->select('labs.*')
->withCount('reviews');
// 各項目の平均を追加
foreach ($ratingColumns as $column) {
$query->withAvg("reviews as avg_{$column}", $column);
}
// 総合評価を追加
$query->addSelect([
'overall_avg' => Review::query()
->selectRaw("($avgSum) / $count")
->whereColumn('reviews.lab_id', 'labs.id'),
]);
// ソートマップも $ratingColumns から生成
$sortMap = ['overall' => 'overall_avg', 'reviews_count' => 'reviews_count'];
foreach ($ratingColumns as $column) {
$sortMap[$column] = "avg_{$column}";
}
$sortColumn = $sortMap[$sort] ?? 'overall_avg';
// 検索クエリを取得
$searchQuery = $request->input('query', '');
$labs = $query
->orderByRaw("$sortColumn IS NULL")
->orderByDesc($sortColumn)
->paginate(5)
->withQueryString();
// 各ラボにランク(順位)を追加
$labs->getCollection()->transform(function ($lab, $index) use ($labs) {
$lab->rank = ($labs->currentPage() - 1) * $labs->perPage() + $index + 1;
return $lab;
});
return Inertia::render('Lab/Index', [
'labs' => $labs,
'faculty' => $faculty->load('university'),
'sort' => $sort,
'query' => $searchQuery,
]);
}
ここが重要だと思いますので、ガッツリと解説していきます!
(復習)クエリビルダとは?
バックエンド編から時間が空いてしまったので、「やべ、クエリビルダって何だっけ...」っていう人もいると思うので、復習しておきましょう。
今回使用しているLaravel バージョン11の公式ドキュメントのイントロダクションを読んでみます。
https://readouble.com/laravel/11.x/ja/queries.html
Laravelのデータベースクエリビルダは、データベースクエリを作成、実行するための便利で流暢(fluent)なインターフェイスを提供します。ほとんどのデータベース操作をアプリケーションで実行するために使用でき、Laravelがサポートするすべてのデータベースシステムで完全に機能します。
要するに、Laravelが提供してくれている、PHPのコードを書くだけでSQL文を自動で作成・実行までしてくれる流暢(fluent)(←流暢って何だよw)な便利機能です。
Laravelクエリビルダは、PDOパラメータバインディングを使用して、SQLインジェクション攻撃からアプリケーションを保護します。クエリバインディングとしてクエリビルダに渡す文字列をクリーンアップやサニタイズする必要はありません。
また、何も意識せずに普通にSQL文を書くだけだと、それをアプリケーションに組み込んだ際にSQLインジェクション攻撃(悪意のあるユーザーが入力フォームなどから、特殊なSQL文を送信して、アプリケーションの機能に悪影響を与えること)が発生する恐れがあります。
それを防ぐために、いわばデータベースからのデータを取得するという主目的以外の目的でSQL文を加工する必要があります。
このことを、サニタイズすると言います。
Laravelのクエリビルダは、そういった攻撃に対して安全性が担保されているので、こういったサニタイズのことを気にせずに使うことができるということですね。
ただし、本シリーズでは、PHPとJavaScriptの基本ができている方を想定して進んできたため、SQLについてはほとんど触れてきませんでした。
今回も、極力SQLの知識を使わずに説明をしようと思いますが、せっかくなので向上心のある方は、SQLについても勉強しましょう!
以下の動画が分かりやすいと思います。
https://www.youtube.com/watch?v=v-Mb2voyTbc
また、書籍だと以下の本が分かりやすいと思います。
お金に余裕があれば、ぜひ購入して勉強してみてください。
https://www.shoeisha.co.jp/book/detail/9784798144450
(向上心のある方のみ)SQLについて勉強してみましょう。
クエリ作成の下準備
クエリを作成する前に準備をします。
$ratingColumns = [
'mentorship_style',
'lab_atmosphere',
'achievement_activity',
'constraint_level',
'facility_quality',
'work_style',
'student_balance',
];
レビューの計算に使うカラムの一覧を定義しておきます。
今更ですけど、reviewとratingって二種類の単語があるのがビミョーですね。
後で気が向いたら統一するように修正したいと思います...💦
$sort = $request->query('sort', 'overall');
こちらは後で実装する予定ですが、研究室一覧において表示する順番を変えられるようにしたいと思っています。
そのソートの情報を画面からqueryで受け取れるようにしたいと思います。
$avgSum = implode(' + ', array_map(fn($c) => "AVG($c)", $ratingColumns));
ここが少し難しいですね。
implode()メソッドは、第二引数に指定した配列を第一引数に指定した文字列区切りで文字列として連結するメソッドです。
連結する文字列は'+'です。
連結される配列は、これです。
array_map(fn($c) => "AVG($c)", $ratingColumns)
array_map()は、第二引数に指定した配列に対して、第一引数で指定したコールバック関数を適用した後の配列を返すメソッドです。
今回は、先ほど作った配列の$ratingColumnsの中身一つ一つに対して、平均値を求めるようにしています。
厳密には、"AVG(〇〇)"という文字列にすることで、後でこの文字列を埋め込むことでいわゆる素のSQLのAVG(SQLの平均を求める関数)を実行できるように準備しています。
$avgSumの結果は以下のようになる感じです!(この段階では、あくまで文字列です)
AVG(mentorship_style) + AVG(lab_atmosphere) + AVG(achievement_activity) + AVG(constraint_level) + AVG(facility_quality) + AVG(work_style) + AVG(student_balance)
次に、以下の部分です。
$count = count($ratingColumns);
評価指標の定義の個数を計算しています。
7ですね。
これは後で使うので覚えておきましょう!
メインクエリ作成
こちらも一行ずつ詳しく解説していきます!
まずは、リレーションの部分です。
$query = $faculty->labs()
モデルにリレーションを定義しているため、これで学部に紐づいている研究室を取得することができます。
次に以下の部分です。
->select('labs.*')
クエリビルダのselect()メソッドです。
指定したカラムを取得することができ、*はすべてを表すワイルドカードです。
つまり、labs.〇〇という形式のカラムをすべて取得できます。
SQLのSELECT文に相当します。
続いて、以下の部分です。
->withCount('reviews')
withCount()は、現在のモデルに対して、リレーションを持つカラムにおける個数を数えて〇〇_countという名前のカラムとして追加するメソッドです。
SQLのCOUNTに相当します。
生成されるSQLは、以下のような感じです。
(SELECT COUNT(*) FROM reviews WHERE reviews.lab_id = labs.id) AS reviews_count
お次はこちらです。
foreach ($ratingColumns as $column) {
$query->withAvg("reviews as avg_{$column}", $column);
}
withAvg()メソッドもwithCount()メソッドと同様に計算結果を新しいカラムとして追加します。
withAvg()は、第一引数の'as'の前に指定されたリレーションモデル中の第二引数に指定されたカラムの平均値を計算して、第一引数の'as'以降の名前としてカラムを追加します。
先ほど定義した評価項目に対して、平均値を計算して、例えばavg_mentorship_styleという名前のカラムとして追加します。
SQLのAVGに相当するものです。
この時点で生成されるSQL例は以下の通りです。
SELECT
labs.*,
(SELECT COUNT(*) FROM reviews WHERE reviews.lab_id = labs.id) AS reviews_count,
(SELECT AVG(mentorship_style) FROM reviews WHERE reviews.lab_id = labs.id) AS avg_mentorship_style,
(SELECT AVG(lab_atmosphere) FROM reviews WHERE reviews.lab_id = labs.id) AS avg_lab_atmosphere,
(SELECT AVG(achievement_activity) FROM reviews WHERE reviews.lab_id = labs.id) AS avg_achievement_activity,
(SELECT AVG(constraint_level) FROM reviews WHERE reviews.lab_id = labs.id) AS avg_constraint_level,
(SELECT AVG(facility_quality) FROM reviews WHERE reviews.lab_id = labs.id) AS avg_facility_quality,
(SELECT AVG(work_style) FROM reviews WHERE reviews.lab_id = labs.id) AS avg_work_style,
(SELECT AVG(student_balance) FROM reviews WHERE reviews.lab_id = labs.id) AS avg_student_balance
FROM labs
WHERE labs.faculty_id = ?
最後に、メインクエリの最後の部分です。
$query->addSelect([
'overall_avg' => Review::query()
->selectRaw("($avgSum) / $count")
->whereColumn('reviews.lab_id', 'labs.id'),
]);
実は、ここが一番難しいところなのでしっかりと解説していきます...!!
まず、addSelect()メソッドは、これまで登場したwithCount()やwithAvg()などと同様に計算結果を新しいカラムとしてクエリに追加します。
では、何を計算するのかと言いますと、それはSQLの実行結果です。
SQLのサブクエリを作成することに相当します。
addSelect()の引数には、連想配列を指定しますが、この連想配列のキーにエイリアス名(実際に追加するカラム名)を、値に実行したいサブクエリを書きます。
つまり、この場合ですと、おおもとの'labs.*'カラムたちに加えて、'overall_avg'というカラム名として、以下のクエリビルダの作成・実行結果を追加するという意味になります。
Review::query()
->selectRaw("($avgSum) / $count")
->whereColumn('reviews.lab_id', 'labs.id'),
連想配列なので、複数のサブクエリを追加実行できますが、ここでは1つだけということですね。
それでは、このサブクエリの中身を見てみましょう。
まず、レビューモデルをもとにクエリビルダを作成します。
Review::query()
次に、レビューの平均値を計算します。
->selectRaw("($avgSum) / $count")
selectRaw()メソッドは、クエリビルダを用いないいわゆる素のSQLの実行結果を取得できます。
ここでは、先ほど定義した$avgSumという文字列が格納された変数を$countで割り算しています。
$avgSumも$countもそれ自体ではただの文字列もしくは数値ですが、selectRaw()の引数に指定することで、SQL文として認識され、実行されます。
「あれ?でも平均は先ほどの求めたのでは?」と思ったかもしれません。
確かに$avgSumにおいては、既に平均を求める計算が含まれています。
しかし、これはあくまで各評価項目ごとの平均値を求める計算です。
そのため、それらのさらに平均を求めることで総合評価値としたいので、もう一度平均値を求める必要があります。
難しいですよね...
実は、バックエンド編で作ったshow()メソッドと同じロジックにしています。
その際の説明をもう一度見ていただくとよい復習になるかもしれません。
最後の部分です。
->whereColumn('reviews.lab_id', 'labs.id'),
この部分は相関サブクエリの条件を表しています。
正直SQLが得意じゃない方は気にしなくても大丈夫だと思いますw
仕上げ
こちらは、ソート機能の部分なので後から直します。
説明もその時にしましょうか。
$sortMap = ['overall' => 'overall_avg', 'reviews_count' => 'reviews_count'];
foreach ($ratingColumns as $column) {
$sortMap[$column] = "avg_{$column}";
}
$sortColumn = $sortMap[$sort] ?? 'overall_avg';
// 検索クエリを取得
$searchQuery = $request->input('query', '');
以下の部分ですは、ソートを適用して、さらにページネーションも付けています。
$labs = $query
->orderByRaw("$sortColumn IS NULL")
->orderByDesc($sortColumn)
->paginate(10)
->withQueryString();
$sortColumnには先ほど作った、'overall_avg'も含まれます。
これで、平均値が高い順に並べることができます!
ページネーションは、10件にしていますが、動作確認のために一時的に5件などに設定してもよいかもしれません。
以下の部分は、画面側でよりランキング感を出すためにランク付けを行う処理です。
$labs->getCollection()->transform(function ($lab, $index) use ($labs) {
$lab->rank = ($labs->currentPage() - 1) * $labs->perPage() + $index + 1;
return $lab;
});
これもちょっと難しいと思うので、アツイ解説をしていきます!🔥
既にページネーションオブジェクトから実際に格納されている研究室一覧のコレクション部分を取得します。
$labs->getCollection()
取得したコレクションについてのランキング付与のロジック部分です。
->transform(function ($lab, $index) use ($labs) {
$lab->rank = ($labs->currentPage() - 1) * $labs->perPage() + $index + 1;
return $lab;
});
transform()メソッドは、コレクションの要素一つ一つに対して、コールバック関数を適用した後のコレクションを返します。
map()に似ていますが、map()とはことなり、元のコレクション自体を変えます。
まとめ記事があったので載せておきます。
https://www.larajapan.com/2022/04/04/laravel-collection%EF%BC%88%EF%BC%91%EF%BC%90%EF%BC%89map%E3%81%A8transform%E3%81%AE%E9%81%95%E3%81%84/
中身の関数を見てみましょう。
function ($lab, $index) use ($labs) {
$lab->rank = ($labs->currentPage() - 1) * $labs->perPage() + $index + 1;
return $lab;
}
useは、例のスコープ外の変数を使うときに用いる表現でした。
$labのrankプロパティを用意して返します。
ランク計算式の中身を見てみましょう。
($labs->currentPage() - 1) * $labs->perPage() + $index + 1;
$labs->currentPage()は、現在のページ、$labs->perPage()は一ページ当たりの件数、$indexは0から始まる現在のページ内の順番です。
具体例でみていきましょう。
1ページ目(page=1)の場合:
// (1 - 1) * 10 = 0
$index = 0 → rank = 0 + 0 + 1 = 1
$index = 1 → rank = 0 + 1 + 1 = 2
$index = 2 → rank = 0 + 2 + 1 = 3
$index = 3 → rank = 0 + 3 + 1 = 4
$index = 4 → rank = 0 + 4 + 1 = 5
$index = 5 → rank = 0 + 5 + 1 = 6
$index = 6 → rank = 0 + 6 + 1 = 7
$index = 7 → rank = 0 + 7 + 1 = 8
$index = 8 → rank = 0 + 8 + 1 = 9
$index = 9 → rank = 0 + 9 + 1 = 10
2ページ目(page=2)の場合:
// (2 - 1) * 10 = 10
$index = 0 → rank = 10 + 0 + 1 = 11
$index = 1 → rank = 10 + 1 + 1 = 12
$index = 2 → rank = 10 + 2 + 1 = 13
$index = 3 → rank = 10 + 3 + 1 = 14
$index = 4 → rank = 10 + 4 + 1 = 15
$index = 5 → rank = 10 + 5 + 1 = 16
$index = 6 → rank = 10 + 6 + 1 = 17
$index = 7 → rank = 10 + 7 + 1 = 18
$index = 8 → rank = 10 + 8 + 1 = 19
$index = 9 → rank = 10 + 9 + 1 = 20
要するに、ページが変わる度にランキングが切り替わってしまったら、2ページ目で本来11位なのに1位だと表示されてしまうことがあるので、このような一見面倒なことをしているというわけです。
return文
最後に、return文にフロントに渡すのに必要な変数を追加して終わりです!
return Inertia::render('Lab/Index', [
'labs' => $labs,
'faculty' => $faculty->load('university'),
'sort' => $sort,
'query' => $searchQuery,
]);
7. 研究室一覧画面作成(UI作成)
コントローラーの修正で疲れたかもしれませんが、フロントエンド編なので、一応ここからが今日のメインです!
Lab/Index.jsxを作成する
こちらは、動作確認用にバックエンド編で作成済みですが、いったん全部消して作り直します。
大学一覧ページを参考にするとよいでしょう!
import { Head } from "@inertiajs/react";
import AppLayout from '@/Layouts/AppLayout';
import LabCard from '../../Components/Lab/LabCard';
import BackButton from '../../Components/Common/BackButton';
import Pagination from "../../Components/Common/Pagination";
/**
* 研究室一覧ページコンポーネント
* @param {Object} props - コンポーネントのprops
* @param {Object} props.labs - ページネーション付き研究室データ
* @param {Object} props.faculty - 学部オブジェクト
* @param {string} props.query - 検索クエリ文字列
* @returns {JSX.Element} コンポーネントのJSX
*/
const Index = ({ labs, faculty, query }) => {
const hasResults = labs.data.length > 0;
return (
<AppLayout title={`${faculty.university.name} ${faculty.name}`}>
<Head title={`${faculty.university.name} ${faculty.name}`} />
{hasResults ? (
// 1件以上の場合:コンテンツが少なければ戻るボタンは画面下部、多ければスクロール後に表示
<div className="flex flex-col items-center min-h-full">
<div className="w-full flex justify-end">
<p className="text-[#747D8C]">{labs.total}件の研究室</p>
</div>
<div className="w-full max-w-xl space-y-6 mt-8">
{labs.data.map(lab => (
<LabCard key={lab.id} lab={lab} query={query} />
))}
</div>
{/* ページネーション */}
<Pagination paginator={labs} />
<div className="mt-auto pt-8 pb-12">
<BackButton routerName="faculties.index" params={{ query, university: faculty.university }} />
</div>
</div>
) : (
// 0件の場合:メッセージを画面中央に、戻るボタンは下部に固定
<div className="flex flex-col items-center min-h-full">
<div className="flex-1 flex items-center justify-center">
<p className="text-[#747D8C]">0件の研究室</p>
</div>
<div className="pt-8 pb-12">
<BackButton routerName="faculties.index" params={{ query, university: faculty.university }} />
</div>
</div>
)}
</AppLayout>
)
}
export default Index;
LabCardはこの後すぐ作りますので、現段階ではエラーが出ても気にしないでください!
LabCard.jsxを作成する
import { router } from "@inertiajs/react";
import RankingBadge from '../../Components/Lab/RankingBadge';
import StarRating from "./Star/StarRating";
/**
* 研究室カードコンポーネント
* @param {Object} props - コンポーネントのprops
* @param {Object} props.lab - 研究室オブジェクト
* @param {string} props.query - 検索クエリ文字列
* @returns {JSX.Element} コンポーネントのJSX
*/
const LabCard = ({ lab, query }) => {
// 総合評価を小数第2位までフォーマット
const formattedReview = lab.overall_avg != null
? Number(lab.overall_avg).toFixed(2)
: null;
// レビュー数を取得(reviews_countまたはreviewsの配列長)
const reviewCount = lab.reviews_count ?? lab.reviews?.length ?? 0;
return (
<div
className="bg-[#EEF7FB] rounded-lg shadow-md px-4 py-3 hover:shadow-lg transition-shadow cursor-pointer flex items-start gap-4 relative"
onClick={() => router.get(route("labs.show", { lab: lab.id, query }))}
>
<div className="absolute top-1 left-1">
<RankingBadge rank={lab.rank} className="flex-shrink-0 text-3xl" />
</div>
<div className="ml-6">
<span className="text-2xl font-bold text-[#747D8C]">
{lab.name}
</span>
{formattedReview && (
<div className="flex items-center gap-2 mt-1">
<StarRating rating={Number(lab.overall_avg)} />
<span className="text-sm text-[#F4BB42]">
{formattedReview}
</span>
</div>
)}
</div>
{/* 右下にレビュー数を表示 */}
<div className="absolute bottom-2 right-3">
<span className="text-sm text-[#747D8C]">
{reviewCount}件のレビュー
</span>
</div>
</div>
);
}
export default LabCard;
小数点以下2位までを表示するように統一したいので、toFixed(2)を用いています。
RankingBadgeとStarRatingは、この後作成します!
RankingBadgeを作成する
import FirstPlaceIcon from '../../Assets/icons/lab/first.svg';
import SecondPlaceIcon from '../../Assets/icons/lab/second.svg';
import ThirdPlaceIcon from '../../Assets/icons/lab/third.svg';
const BADGE_MAP = {
1: FirstPlaceIcon,
2: SecondPlaceIcon,
3: ThirdPlaceIcon,
};
const RankingBadge = ({ rank }) => {
const Icon = BADGE_MAP[rank];
if (!Icon) {
return <div className='w-7 h-7' />;
}
return (
<img
src={Icon}
alt={`Rank ${rank}`}
className='w-7 h-7 object-contain'
/>
);
}
export default RankingBadge;
例によって、React IconsとFigmaで作成した.svg画像を配布するので、適宜ダウンロードして指定されたフォルダに格納してください。
StarRatingを作成する
\Lab\Starというフォルダをあらかじめ作成しておく必要があるのでお気を付けください。
import StarIcon from "./StarIcon";
/**
* 星評価コンポーネント(5つ星表示、小数対応)
* @param {Object} props - コンポーネントのprops
* @param {number} props.rating - 評価値(0〜5の小数)
* @returns {JSX.Element} コンポーネントのJSX
*/
const StarRating = ({ rating }) => {
const maxStars = 5;
// 各星の塗りつぶし割合を計算(0〜100%)
const getStarFillPercentage = (index) => {
if (rating >= index + 1) {
return 100; // 完全に塗りつぶし
} else if (rating > index) {
return (rating - index) * 100; // 部分的に塗りつぶし
}
return 0; // 塗りつぶしなし
};
return (
<div className="flex items-center gap-0.5">
{[...Array(maxStars)].map((_, index) => (
<StarIcon
key={index}
fillColor="#F4BB42"
emptyColor="#E2EDF6"
fillPercentage={getStarFillPercentage(index)}
/>
))}
</div>
);
};
export default StarRating;
評価数に応じて、星マークの色を割合で変えられるようにするためにgetStarFillPercentage()関数を用意しました!
StarIconを作成する
import { useId } from "react";
/**
* 星アイコンコンポーネント(グラデーション塗りつぶし対応)
* @param {Object} props - コンポーネントのprops
* @param {string} props.fillColor - 塗りつぶし色
* @param {string} [props.emptyColor="#E2EDF6"] - 空の部分の色
* @param {number} [props.fillPercentage=100] - 塗りつぶし割合(0〜100)
* @returns {JSX.Element} コンポーネントのJSX
*/
const StarIcon = ({ fillColor, emptyColor = "#E2EDF6", fillPercentage = 100 }) => {
const id = useId();
const gradientId = `starGradient-${id}`;
return (
<svg width="16" height="16" viewBox="0 0 42 42" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset={`${fillPercentage}%`} stopColor={fillColor} />
<stop offset={`${fillPercentage}%`} stopColor={emptyColor} />
</linearGradient>
</defs>
<path
d="M20.4125 1.29884L15.6511 10.953L4.99795 12.5061C3.08753 12.7832 2.32191 15.1384 3.70733 16.4874L11.4146 23.9978L9.5917 34.6072C9.26358 36.5249 11.2834 37.9613 12.975 37.0645L22.5052 32.0551L32.0355 37.0645C33.7271 37.9541 35.7469 36.5249 35.4188 34.6072L33.5959 23.9978L41.3032 16.4874C42.6886 15.1384 41.923 12.7832 40.0125 12.5061L29.3594 10.953L24.598 1.29884C23.7448 -0.421992 21.273 -0.443867 20.4125 1.29884Z"
fill={`url(#${gradientId})`}
/>
</svg>
);
};
export default StarIcon;
svgタグを用いて、グラデーションを実現しています。
詳しくは、リファレンスを確認してみてください!
https://developer.mozilla.org/ja/docs/Web/SVG/Guides/SVG_in_HTML
https://developer.mozilla.org/ja/docs/Web/CSS/Reference/Values/gradient/linear-gradient
offsetを二つ置くことで、一般的なグラデーションではなく、境界をはっきりさせることができます。
また、pathタグは、.svgの描写を座標で行うものです。
このような複雑なものは僕には当然作れなかったので、Claude AIに作ってもらったというわけですw
fillに、url(#${gradientId})を指定することで、指定されたIDに対してグラデーションを適用できます。
ここでは、ReactのフックであるuseIdを用いて、レンダリングごとに一のIDを生成しています。
ちょっと難しいので、よくわからなければ、理解は後回しでも大丈夫です!
8. ソート機能追加(UI作成)
ここまでできたら、ほぼ完成なのですが、さらにバリューアップを目指しましょう!
評価指標ごとに高い順にソートできる機能を作りたいと思います!
想定しているユーザーである「研究室を探している大学生」にとって使う価値の高いものになると思ったからです。
コントローラーには、既にソート処理が書かれているので、修正はUI部分だけでです!
LabCardを修正する
現状だと、総合評価とレビューの多い順にしか対応していないので、他の7つの評価指標にも対応できるように関数を定義します。
import { router } from "@inertiajs/react";
import RankingBadge from '../../Components/Lab/RankingBadge';
import StarRating from "./Star/StarRating";
/**
* ソート条件に対応する評価値を取得
* @param {Object} lab - 研究室オブジェクト
* @param {string} sort - ソート条件のキー
* @returns {number|null} 評価値
*/
const getRatingValue = (lab, sort) => {
if (sort === 'reviews_count') {
return null; // レビュー数の場合は星評価を表示しない
}
const ratingMap = {
overall: lab.overall_avg,
mentorship_style: lab.avg_mentorship_style,
lab_atmosphere: lab.avg_lab_atmosphere,
achievement_activity: lab.avg_achievement_activity,
constraint_level: lab.avg_constraint_level,
facility_quality: lab.avg_facility_quality,
work_style: lab.avg_work_style,
student_balance: lab.avg_student_balance,
};
return ratingMap[sort] ?? lab.overall_avg;
};
/**
* 評価値をフォーマット
* @param {number|null} value - 評価値
* @returns {string|null} フォーマットされた評価値
*/
const formatRating = (value) => {
return value != null ? Number(value).toFixed(2) : null;
};
/**
* 研究室カードコンポーネント
* @param {Object} props - コンポーネントのprops
* @param {Object} props.lab - 研究室オブジェクト
* @param {string} props.query - 検索クエリ文字列
* @param {string} props.sort - ソート条件
* @returns {JSX.Element} コンポーネントのJSX
*/
const LabCard = ({ lab, query, sort = 'overall' }) => {
// ソート条件に応じた評価値を取得
const ratingValue = getRatingValue(lab, sort);
const formattedReview = formatRating(ratingValue);
// レビュー数を取得(reviews_countまたはreviewsの配列長)
const reviewCount = lab.reviews_count ?? lab.reviews?.length ?? 0;
return (
<div
className="bg-[#EEF7FB] rounded-lg shadow-md px-4 py-3 hover:shadow-lg transition-shadow cursor-pointer flex items-start gap-4 relative"
onClick={() => router.get(route("labs.show", { lab: lab.id, query }))}
>
<div className="absolute top-1 left-1">
<RankingBadge rank={lab.rank} className="flex-shrink-0 text-3xl" />
</div>
<div className="ml-6">
<span className="text-2xl font-bold text-[#747D8C]">
{lab.name}
</span>
{formattedReview && (
<div className="flex items-center gap-2 mt-1">
<StarRating rating={ratingValue} />
<span className="text-sm text-[#F4BB42]">
{formattedReview}
</span>
</div>
)}
</div>
{/* 右下にレビュー数を表示 */}
<div className="absolute bottom-2 right-3">
<span className="text-sm text-[#747D8C]">
{reviewCount}件のレビュー
</span>
</div>
</div>
);
}
export default LabCard;
Lab/Indexを修正する
同様に、Indexも修正しましょう。
import { Head, router } from "@inertiajs/react";
import AppLayout from '@/Layouts/AppLayout';
import LabCard from '../../Components/Lab/LabCard';
import BackButton from '../../Components/Common/BackButton';
import Pagination from "../../Components/Common/Pagination";
/**
* ソートオプションの定義
*/
const sortOptions = [
{ value: 'overall', label: '総合評価の高い順' },
{ value: 'reviews_count', label: 'レビュー数の多い順' },
{ value: 'mentorship_style', label: '指導スタイルの高い順' },
{ value: 'lab_atmosphere', label: '雰囲気・文化の高い順' },
{ value: 'achievement_activity', label: '成果・活動の高い順' },
{ value: 'constraint_level', label: '拘束度の高い順' },
{ value: 'facility_quality', label: '設備の高い順' },
{ value: 'work_style', label: '働き方の高い順' },
{ value: 'student_balance', label: '人数バランスの高い順' },
];
/**
* 研究室一覧ページコンポーネント
* @param {Object} props - コンポーネントのprops
* @param {Object} props.labs - ページネーション付き研究室データ
* @param {Object} props.faculty - 学部オブジェクト
* @param {string} props.query - 検索クエリ文字列
* @param {string} props.sort - ソート条件
* @returns {JSX.Element} コンポーネントのJSX
*/
const Index = ({ labs, faculty, query, sort = 'overall' }) => {
const hasResults = labs.data.length > 0;
/**
* ソート条件変更時のハンドラ
* @param {Event} e - イベントオブジェクト
*/
const handleSortChange = (e) => {
const newSort = e.target.value;
router.get(route('labs.index', { faculty: faculty.id }), {
query,
sort: newSort,
}, {
preserveState: true,
preserveScroll: true,
});
};
return (
<AppLayout title={`${faculty.university.name} ${faculty.name}`}>
<Head title={`${faculty.university.name} ${faculty.name}`} />
{hasResults ? (
// 1件以上の場合:コンテンツが少なければ戻るボタンは画面下部、多ければスクロール後に表示
<div className="flex flex-col items-center min-h-full">
<div className="w-full flex flex-col items-end gap-2">
<p className="text-[#747D8C]">{labs.total}件の研究室</p>
<select
value={sort}
onChange={handleSortChange}
className="text-sm text-[#747D8C] bg-[#EEF5F9] border border-[#747D8C] rounded px-3 py-1 pr-8 outline-none focus:outline-none focus:ring-0 focus:border-[#747D8C]"
>
{sortOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div className="w-full max-w-xl space-y-6 mt-8">
{labs.data.map(lab => (
<LabCard key={lab.id} lab={lab} query={query} sort={sort} />
))}
</div>
{/* ページネーション */}
<Pagination paginator={labs} />
<div className="mt-auto pt-8 pb-12">
<BackButton routerName="faculties.index" params={{ query, university: faculty.university }} />
</div>
</div>
) : (
// 0件の場合:メッセージを画面中央に、戻るボタンは下部に固定
<div className="flex flex-col items-center min-h-full">
<div className="flex-1 flex items-center justify-center">
<p className="text-[#747D8C]">0件の研究室</p>
</div>
<div className="pt-8 pb-12">
<BackButton routerName="faculties.index" params={{ query, university: faculty.university }} />
</div>
</div>
)}
</AppLayout>
)
}
export default Index;
ソート条件の選択は、selectタグで選べるようにしました。
8. (おまけその5)修正: 前回のミス
今回は記事が長くなってしまったので、軽い修正にとどめます。(笑)
前回作成した学部一覧画面において、学部が1件以上あるときに右上に表示される文字が「〇〇件の検索結果」となっていました。

適切な文言に修正しましょう。
import { Head } from "@inertiajs/react";
import AppLayout from '@/Layouts/AppLayout';
import FacultyCard from '../../Components/Faculty/FacultyCard';
import BackButton from '../../Components/Common/BackButton';
/**
* 学部一覧ページコンポーネント
* @param {Object} props - コンポーネントのprops
* @param {Array} props.faculties - 学部データの配列
* @param {Object} props.university - 大学オブジェクト
* @param {string} [props.query=''] - 検索クエリ文字列
* @returns {JSX.Element} コンポーネントのJSX
*/
const Index = ({ faculties, university, query = '' }) => {
const hasResults = faculties.length > 0;
return (
<AppLayout title={`${university.name}の学部一覧`}>
<Head title={`${university.name}の学部一覧`} />
{hasResults ? (
<div className="flex flex-col items-center min-h-full">
<div className="w-full flex justify-end">
{/* ↓ココを直す */}
<p className="text-[#747D8C]">{faculties.length}件の学部</p>
</div>
<div className="w-full grid grid-cols-3 gap-6 mt-8 justify-items-center">
{faculties.map(faculty => (
<FacultyCard key={faculty.id} faculty={faculty} query={query} />
))}
</div>
<div className="mt-auto pt-8 pb-12">
<BackButton routerName="universities.index" params={{ query }} />
</div>
</div>
) : (
<div className="flex flex-col items-center min-h-full">
<div className="flex-1 flex items-center justify-center">
<p className="text-[#747D8C]">0件の学部</p>
</div>
<div className="pt-8 pb-12">
<BackButton routerName="universities.index" params={{ query }}/>
</div>
</div>
)}
</AppLayout>
)
}
export default Index;
本当は、別のブランチを切って作業するべきなのですが、まあ良いでしょう!
9. まとめ・次回予告
今回は、研究室一覧ページを作成しました。
ランキング表示にするために、コントローラーを修正しましたね。
また、クエリビルダについても復習できました。
SQLに興味を持った方はぜひ勉強してみてください!
じかいは、いよいよ研究室詳細画面を作成したいと思います!
よろしくお願いいたします。
これまでの記事一覧
☆要件定義・設計編
☆環境構築編
- その2: 環境構築編① ~WSL, Ubuntuインストール~
- その3: 環境構築編② ~Docker Desktopインストール~
- その4: 環境構築編③ ~Dockerコンテナ立ち上げ~
- その5: 環境構築編④ ~Laravelインストール~
- その6: 環境構築編⑤ ~Gitリポジトリ接続~
☆バックエンド実装編
- その7: バックエンド実装編① ~認証機能作成~
- その8: バックエンド実装編②前編 ~レビュー投稿機能作成~
- その8.5: バックエンド実装編②後編 ~レビュー投稿機能作成~
- その9: バックエンド実装編③ ~レビューCRUD機能作成~
- その10: バックエンド実装編④ ~レビューCRUD機能作成その2~
- その11: バックエンド実装編⑤ ~新規大学・学部・研究室作成機能作成~
- その12: バックエンド実装編⑥ ~大学検索機能作成~
- その13: バックエンド実装編⑦ ~大学・学部・研究室編集機能作成~
- その14: バックエンド実装編⑧ ~コメント投稿機能~
- その15: バックエンド実装編⑨ ~コメント編集・削除機能~
- その16: バックエンド実装編⑩ ~ブックマーク機能~
- その17: バックエンド実装編⑪ ~排他制御・トランザクション処理~
- その18: バックエンド実装編⑫ ~マイページ機能作成~
- その19: バックエンド実装編⑬ ~管理者アカウント機能作成~
- その20: バックエンド実装編⑭ ~通知機能作成~
- その21: バックエンド実装編⑮ ~ソーシャルログイン機能作成~
☆フロントエンド実装編
- その22: フロントエンド実装編① ~メインコンテンツ領域作成~
- その23: フロントエンド実装編② ~ヘッダー作成~
- その24: フロントエンド実装編③ ~サイドバー作成~
- その25: フロントエンド実装編④ ~ホームページ作成~
- その26: フロントエンド実装編⑤ ~大学検索結果画面作成~
- その27: フロントエンド実装編⑥ ~大学詳細・学部一覧画面作成~
参考
軽く宣伝
YouTubeを始めました(というか始めてました)。
内容としては、Webエンジニアの生活や稼げるようになるまでの成長記録などを発信していく予定です。
現在、まったく再生されておらず、落ち込みそうなので、見てくださる方はぜひ高評価を教えもらえると励みになると思います。"(-""-)"





