1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

実務1年目駆け出しエンジニアがLaravel&ReactでWebアプリケーション開発に挑戦してみた!(フロントエンド実装編⑳)~ファビコン作成~

1
Posted at

実務1年目駆け出しエンジニアがLaravel&ReactでWebアプリケーション開発に挑戦してみた!(その41)

0. 初めに

見習いエンジニアの僕がWebアプリケーションの作り方をゼロから解説しているシリーズです。

フロントエンド編も終わりが見えてきました...!!

今日は、ファビコンを作りたいと思います。

1. ファビコン作成

ファビコンは、ブラウザの画面上のタブの部分に表示されるアイコンことです。

↓これ
image.png

1.1 ブランチ運用

いつも通り、developブランチを最新化させて新規ブランチを切って作業します。
ブランチ名は、feature/frontend/faviconとかにします。

1.2 ファビコン設定

以下のファイルをダウンロードしてpublic/に設置してください。
もともとあった、favicon.icoは削除してください。

favicon

そうしたら、resources/views/app.blade.phpに以下を追加してください。

クリックでコードを見る
\project-root\src\resources\views\app.blade.php
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
    <head>

        <!-- Fonts -->

        <!-- 追加: favicon -->
        <link rel="icon" href="/favicon.svg" type="image/svg+xml" />

        <!-- Scripts -->
    </head>
    <body class="font-sans antialiased">
        @inertia
    </body>
</html>

1.3 動作確認

こんな感じになっていればOK。
image.png

問題なさそうなら、コミット・プッシュ、PR作成・マージをしておきましょう。

2. (おまけその11)不具合修正: ログアウト時に作成・編集・削除依頼用ポップオーバーが表示されてしまう

これで終わり!
...だったら嬉しいのですが、おまけコーナーにて現在起きている不具合を直していきますw

今の課題として、ご覧の通り、ログアウト状態でも大学・学部・研究室・レビューの作成(編集と削除依頼も)用のモーダルを開くことができてしまいます。
image.png
image.png

解決策はいくつかあるのですが、ログアウト状態の時は、そもそもポップオーバーメニューにそれらを表示させないようにすることにします。

2.1 ブランチ運用

ということで、developブランチを最新化させて、新規ブランチを切って作業します。
不具合の修正という意味合いで、fix/frontend/popoverとかでいきましょう。

2.2 メニューポップオーバー修正

resources/js/Components/Common/MenuPopover.jsx

クリックでコードを見る
\project-root\src\resources\js\Pages\Faculty\Index.jsx
import { usePage } from '@inertiajs/react'; // 追加

const MenuPopover = ({ addLabel, onAddClick, onEditClick, onViewHistoryClick, onDeletionRequestClick }) => {
  const user = usePage().props.auth.user;

  const menuItems = [
    ...(addLabel && user ? [{ label: addLabel, onClick: onAddClick }] : []),
    ...(user ? [{ label: '編集する', onClick: onEditClick }] : []),
    { label: '編集履歴を見る', onClick: onViewHistoryClick },
    ...(user ? [{ label: '削除依頼をする', onClick: onDeletionRequestClick }] : []),
  ];
  

2.3 動作確認

ログアウト時に「編集履歴を見る」の一つだけが表示されていればOK!
image.png

できたら、コミット・プッシュ、PR作成・マージを忘れずに行いましょう!

3. (おまけその12)バリューアップ: ヘッダー固定

おまけコーナーは続きます。
続いては、ヘッダーの固定です。

これまでは、スクロールをするとヘッダーが見えなくなってしまっていました。
image.png

バグというわけではないのですが、より使いやすいように修正することをバリューアップと呼んだりします。

ヘッダーが常に触れる状態にすれば、ホームページに戻れたり、サイドバーを開いたりするのが容易になるので、ユーザーにとってのUXの向上につながるでしょう!

3.1 ブランチ運用

developブランチを最新化させて、feature/frontend/fix-headerという新規ブランチを切って作業します。

3.2 ヘッダーコンポーネント修正

resources/js/Components/Header.jsx

クリックでコードを見る
\project-root\src\resources\js\Components\Header.jsx
    <header
      className="
        fixed
        top-0
        left-0
        w-full
        z-50

3.3 共通レイアウトコンポーネント修正

resources/js/Layouts/AppLayout.jsx

クリックでコードを見る
\project-root\src\resources\js\Layouts\AppLayout.jsx
      {/* メインコンテンツ領域 */}
      <main className={`flex-1 bg-transparent flex${isHome ? '' : ' pt-14'}`}>

ヘッダーと同じ高さのパディングを上に付けることで、常にヘッダーが表示されます(ホームページモードではヘッダーを表示しないのでそのままにします)。

3.4 動作確認

こんな感じになっていればOK!
image.png

問題なければ、コミット・プッシュ、PR作成・マージをしておきましょう。

4. (おまけその13)バリューアップ: プログレスバー色変更

4.1 ブランチ運用

developブランチを最新化させて、feature/frontend/progress-bar-colorという新規ブランチを切って作業します。

4.2 プログレスバー色変更

画面のロード中に表示される画面上部のバーの色を、現在の黒から、青い色に変更します(その方がアプリのイメージに合うと思ったから)。

こちらもバリューアップ対応です。
resources/js/app.jsx

クリックでコードを見る
\project-root\src\resources\js\app.jsx
  progress: {
    color: '#297FF0',
  },

できたら、コミット・プッシュ、PR作成・マージをお忘れなく!

5.(おまけその14)バリューアップ: レビュー未投稿時のボタン追加

現在、ログイン状態でレビューを投稿していないと「まだ、レビューを投稿していません。」と表示されます。

これをクリックすると作成モーダルが開くようにすると、初めての方にとって使いやすくなるでしょう。

また、ログアウト状態ではそもそもこの文言を表示させないようにします。
image.png

5.1 ブランチ運用

developブランチを最新化させて、新規ブランチfeature/frontend/no-review-postedを切って作業をします。

5.2 レビュー未投稿時モーダル開閉可能

resources/js/Pages/Lab/Show.jsx

クリックでコードを見る
\project-root\src\resources\js\Pages\Lab\Show.jsx
        {/* レビュー投稿状態 + ケバブメニュー 右寄せ */}
        <div className="flex items-center gap-2">
          {auth?.user && userReview ? (
            <button
              type="button"
              onClick={() => setIsReviewEditModalOpen(true)}
              className="text-[#747D8C] hover:underline cursor-pointer"
            >
              レビューを投稿済みです。
            </button>
          ) : auth?.user ? (
            <button
              type="button"
              onClick={() => setIsCreateModalOpen(true)}
              className="text-[#747D8C] hover:underline cursor-pointer"
            >
              まだ、レビューを投稿していません。
            </button>
          ) : (
            <p className="text-[#747D8C]">まだ、レビューを投稿していません。</p>
          )}

image.png
ログイン状態で、「まだ、レビューを投稿していません。」をクリックするとレビューが作成用のモーダルが開くことが確認できました!

問題なければ、コミットしましょう。

2.3 ログアウト時非表示

次に、ログアウト時には非表示にするようにしましょう。
resources/js/Pages/Lab/Show.jsx

クリックでコードを見る
\project-root\src\resources\js\Pages\Lab\Show.jsx
        {/* レビュー投稿状態 + ケバブメニュー 右寄せ */}
        <div className="flex items-center gap-2">
          {auth?.user && userReview ? (
            <button
              type="button"
              onClick={() => setIsReviewEditModalOpen(true)}
              className="text-[#747D8C] hover:underline cursor-pointer"
            >
              レビューを投稿済みです。
            </button>
          ) : auth?.user ? (
            <button
              type="button"
              onClick={() => setIsCreateModalOpen(true)}
              className="text-[#747D8C] hover:underline cursor-pointer"
            >
              まだ、レビューを投稿していません。
            </button>
          ) : null}

ログアウト時にはすっきりとした感じになりました。
image.png

できたら、コミット・プッシュ、PR作成・マージをお忘れなく。

6.(おまけその15)修正: ヘッダーのオーバーレイ

気が付いたかもしれませんが、先ほどヘッダーを修正したせいでサイドバーやモーダルを開いたときに他の部分と同様な「暗くなる」「クリックできない」というしようが適用されなくなってしまいました。(笑)

直しましょう。

6.1 ブランチ運用

最新化されたdevelopからfix/frontend/header-overlayを切ります。

z-indexとは

原因は、z-indexがおかしいことです。

z-indexとは、いわば、x軸、y軸に次ぐ3つ目の軸です。
Webの画面は平面ですが、要素を重ねたときにどれを上に(もしくは下に)表示するかを決めるものです。

コンポーネント 現在のz-index 問題
Header z-50
Sidebar オーバーレイ(背景暗転) z-40 ヘッダーより低いため暗転が届かない
Sidebar パネル z-50 ヘッダーと同じで重なる
Modal オーバーレイ(背景暗転) なし z-indexが未指定のためヘッダーの下になる
Modal コンテンツ z-[101] コンテンツ自体は問題なし

こうすれば解決!(整頓)

レイヤー 修正後のz-index 対象
z-40 Header / ホームのハンバーガー 通常コンテンツの上
z-50 Sidebar・Modalのオーバーレイ(暗転背景) ヘッダーを覆う
z-[60] Sidebarパネル・Modalコンテンツ オーバーレイの上

コンポーネント修正

  • resources/js/Layouts/AppLayout.jsx
クリックでコードを見る
\project-root\src\resources\js\Layouts\AppLayout.jsx
      {/* ホームモード: ハンバーガーアイコンのみを固定表示(サイドバー非表示時のみ) */}
      {isHome ? (
        !isSidebarOpen && (
          <div className="fixed top-4 right-6 z-40">
            <HamburgerMenu onOpenSidebar={() => setSidebarOpen(true)} />
          </div>
  • resources/js/Components/Header.jsx
クリックでコードを見る
\project-root\src\resources\js\Components\Header.jsx
        z-40
  • resources/js/Components/Sidebar.jsx
クリックでコードを見る
\project-root\src\resources\js\Components\Sidebar.jsx
      {/* オーバーレイ */}
      <div
        onClick={onClose}
        className={`
          fixed inset-0 bg-black/40 transition-opacity duration-300 z-50
          ${isOpen ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'}
        `}
        aria-hidden="true"
      />

      {/* 本体 */}
      <aside
        role="dialog"
        aria-modal="true"
        className={`
          fixed right-0 top-0 h-dvh w-[270px] max-w-[90vw] bg-[#EEF5F9] shadow-2xl z-[60]
          transform transition-transform duration-300
          ${isOpen ? 'translate-x-0' : 'translate-x-full'}
          flex flex-col
        `}
      >
  • resources/js/Components/Common/Modal.jsx
クリックでコードを見る
\project-root\src\resources\js\Components\Common\Modal.jsx
      {/* 本体 */}
      <div
       role="dialog"
       aria-modal="true"
       aria-activedescendant={title ? 'modal-title' : undefined}
       className={`
          fixed inset-0 z-[60] flex items-center justify-center p-4
          transition-all duration-300
          ${isOpen ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'}
      `}
      >
        <div
         className={`
            w-full ${sizeClasses[size]}
            bg-[#EEF7FB] rounded-lg shadow-xl
            transform transition-all duration-300
            ${isOpen ? 'scale-100 translate-y-0' : 'scale-95 -translate-y-4'}
          `}
        >

動作確認

ホームページは問題なさそうです。
image.png

モーダルも問題なさそうです。
image.png

サイドバーも問題なさそうですね!
image.png

コミット・プッシュ、PR作成・マージをしましょう!

7.(おまけその16)リファクタリング: レビューの平均値計算

これも前々からやろうと思っていたことなのですが、ここでやってみたいと思います。というのも、今各評価指標ごとの平均値やレビューの総合値を計算するロジックが、LabController.phpshow()メソッドやindex()メソッド、MyPageController.phpshowUser()メソッドに散らばってしまっています。

共通しているこの処理を切り出してみたいと思います
(同じ処理が散らばっていると仕様変更などで修正する必要が出たときに、すべての箇所を修正する必要が出てくるため修正漏れが発生しやすい微妙な状態であるため、一般的に好まれません。また、長すぎるコントローラーはファットコントローラーなどと呼ばれ、一般的に好まれません)。

7.1 ブランチ運用

developブランチを最新化させて、refactor/review-averageブランチを切って作業します。

7.2 Labモデルメソッド追加

具体的には、Labモデルにメソッドを追加して、コントローラー側からはこれを呼び出すようにします。

Labモデルには、Reviewモデルとのリレーションが定義されているため、書きやすいと思います。

app/Models/Lab.php

クリックでコードを見る
\project-root\src\app\Models\Lab.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Builder; // 追加
use Illuminate\Support\Collection; // 追加

class Lab extends Model
{
    use SoftDeletes;

    // 追加
    public const RATING_COLUMNS = [
        'mentorship_style',
        'lab_atmosphere',
        'achievement_activity',
        'constraint_level',
        'facility_quality',
        'work_style',
        'student_balance',
    ];

    protected $fillable = [
        'name',
        'faculty_id',
    ];

    // リレーションの定義...

    /**
     * 各評価項目のユーザー間の平均値を取得する
     * reviews リレーションがロード済みである前提
     */
    public function getAveragePerItem(): Collection
    {
        return collect(self::RATING_COLUMNS)->mapWithKeys(function ($column) {
            return [$column => $this->reviews->avg($column)];
        });
    }

    /**
     * 全評価項目の総合平均値を取得する
     */
    public function getOverallAverage(): ?float
    {
        return $this->getAveragePerItem()->avg();
    }

    /**
     * 特定レビューの全評価項目の平均値を取得する
     */
    public static function getUserReviewAverage(Review $review): ?float
    {
        return collect(self::RATING_COLUMNS)
            ->map(fn($column) => $review->$column)
            ->filter(fn($value) => $value !== null)
            ->avg();
    }

    /**
     * overall_avg, avg_{col}, reviews_count を動的属性としてセットする
     * MyPageController 等でコレクション内の各 Lab に付与する用途
     */
    public function appendRatingAverages(): self
    {
        $averagePerItem = $this->getAveragePerItem();

        $this->overall_avg = $averagePerItem->avg();
        foreach (self::RATING_COLUMNS as $column) {
            $this->{"avg_{$column}"} = $averagePerItem[$column];
        }
        $this->reviews_count = $this->reviews->count();

        return $this;
    }

    /**
     * 一覧表示用: 各評価項目の平均・総合評価・レビュー数をクエリに付与するスコープ
     */
    public function scopeWithRatingAverages(Builder $query): Builder
    {
        $query->select('labs.*')->withCount('reviews');

        foreach (self::RATING_COLUMNS as $column) {
            $query->withAvg("reviews as avg_{$column}", $column);
        }

        $avgSum = implode(' + ', array_map(fn($c) => "AVG($c)", self::RATING_COLUMNS));
        $count = count(self::RATING_COLUMNS);

        $query->addSelect([
            'overall_avg' => Review::query()
                ->selectRaw("($avgSum) / $count")
                ->whereColumn('reviews.lab_id', 'labs.id'),
        ]);

        return $query;
    }
}

7.3 LabControllerで呼び出す

さて、今定義したメソッドたちをコントローラー側で呼び出しましょう。

app/Http/Controllers/LabController.php

クリックでコードを見る
\project-root\src\app\Http\Controllers\LabController.php
// use App\Models\Review; 不要になったので削除

<?php

namespace App\Http\Controllers;

// use 宣言...

class LabController extends Controller
{
    use AuthorizesRequests;

    /**
     * 研究室の詳細を表示する
     */
    public function show(Lab $lab, Request $request): Response
    {
        // 大学・学部、レビューのデータも一緒に渡す
        // universityはfacultyを経由して取得
        $lab->load(['faculty.university', 'reviews']);

        // コメントデータを取得(投稿者情報も含む)
        $comments = $lab->comments()->with('user')->latest()->get();

        // 各評価項目の平均値と総合評価を計算
        $averagePerItem = $lab->getAveragePerItem();
        $overallAverage = $lab->getOverallAverage();

        // 現在のユーザーのレビューを取得
        $userReview = null;
        $userOverallAverage = null;
        
        if (Auth::check()) {
            $userReview = $lab->reviews->where('user_id', Auth::id())->first();
            
            if ($userReview) {
                $userOverallAverage = Lab::getUserReviewAverage($userReview);
            }
        }

        // ユーザーのブックマーク状態を取得
        $userBookmark = $lab->bookmarks()->where('user_id', Auth::id())->first();
        $bookmarkCount = $lab->bookmarks()->count();

        // 検索クエリを取得
        $searchQuery = $request->input('query', '');

        // 研究室のデータに加えて、求めたレビューの平均値とユーザーのレビュー、コメント、ブックマーク、認証情報も一緒に渡す
        return Inertia::render('Lab/Show', [
            'lab' => $lab,
            'overallAverage' => $overallAverage,
            'averagePerItem' => $averagePerItem,
            'userReview' => $userReview,
            'userOverallAverage' => $userOverallAverage,
            'userBookmark' => $userBookmark,
            'bookmarkCount' => $bookmarkCount,
            'query' => $searchQuery,
            'ratingData' => [
                'columns' => Lab::RATING_COLUMNS,
            ],
            'comments' => $comments,
            'auth' => [
                'user' => Auth::user(),
            ],
        ]);
    }

    store()...
    
    /**
     * 研究室の一覧を表示する
     */
    public function index(Faculty $faculty, Request $request): Response
    {
        // UIからソート条件を取得
        $sort = $request->query('sort', 'overall');

        $query = $faculty->labs()->withRatingAverages();

        // ソートマップを生成
        $sortMap = ['overall' => 'overall_avg', 'reviews_count' => 'reviews_count'];
        foreach (Lab::RATING_COLUMNS 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();

        // 各ラボにランク(順位)を追加
        $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,
        ]);
    }

    // update()...

    /**
     * 研究室の編集履歴を表示する
     */
    public function history(Lab $lab): Response
    {
        $query = request('query', '');

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

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

一つずつ見ていきましょう。

まず、show()メソッド内で各評価項目の平均値と総合評価の計算が長ったらしく書かれていた部分は以下のように2行であっさり書くことができるようになりました。

\project-root\src\app\Http\Controllers\LabController.php
        // 各評価項目の平均値と総合評価を計算
        $averagePerItem = $lab->getAveragePerItem();
        $overallAverage = $lab->getOverallAverage();

これは、これまで書いてきたものをモデルに切り出したからでした。

\project-root\src\app\Models\Lab.php
    /**
     * 各評価項目のユーザー間の平均値を取得する
     * reviews リレーションがロード済みである前提
     */
    public function getAveragePerItem(): Collection
    {
        return collect(self::RATING_COLUMNS)->mapWithKeys(function ($column) {
            return [$column => $this->reviews->avg($column)];
        });
    }

    /**
     * 全評価項目の総合平均値を取得する
     */
    public function getOverallAverage(): ?float
    {
        return $this->getAveragePerItem()->avg();
    }

ちなみに、RATING_COLUMNSは、定数です。
定数は、静的メソッドと同じく::で呼び出すことができ、定義されているクラス内ではself::で呼び出すことができます。

このRATING_COLUMNSも毎回コントローラーで再定義していたものが、ここに集約されるようになりました。

\project-root\src\app\Models\Lab.php
    public const RATING_COLUMNS = [
        'mentorship_style',
        'lab_atmosphere',
        'achievement_activity',
        'constraint_level',
        'facility_quality',
        'work_style',
        'student_balance',
    ];

次に、ユーザーごとのレビューの平均値も以下のように1行に収まるようになりました。

\project-root\src\app\Http\Controllers\LabController.php
            if ($userReview) {
                $userOverallAverage = Lab::getUserReviewAverage($userReview);
            }

これもモデルに関数を切り出したからです。
レビューを引数にしています。

ちなみに、戻り値の型の?は、nullable な型と呼ばれていて、PHPの機能としては比較的新しいものです。
これは、型がnullでもよいことを許す機能です。

レビューが0件の時は、結果はnullになるためです。

\project-root\src\app\Models\Lab.php
    public static function getUserReviewAverage(Review $review): ?float
    {
        return collect(self::RATING_COLUMNS)
            ->map(fn($column) => $review->$column)
            ->filter(fn($value) => $value !== null)
            ->avg();
    }

続いて、index()メソッドを見てみましょう。

クエリを作っていた部分は以下のように1行にまとまりました。

\project-root\src\app\Http\Controllers\LabController.php
        $query = $faculty->labs()->withRatingAverages();

これは、モデルの方にスコープと呼ばれるものを定義したからです。

\project-root\src\app\Models\Lab.php
    /**
     * 一覧表示用: 各評価項目の平均・総合評価・レビュー数をクエリに付与するスコープ
     */
    public function scopeWithRatingAverages(Builder $query): Builder
    {
        $query->select('labs.*')->withCount('reviews');

        foreach (self::RATING_COLUMNS as $column) {
            $query->withAvg("reviews as avg_{$column}", $column);
        }

        $avgSum = implode(' + ', array_map(fn($c) => "AVG($c)", self::RATING_COLUMNS));
        $count = count(self::RATING_COLUMNS);

        $query->addSelect([
            'overall_avg' => Review::query()
                ->selectRaw("($avgSum) / $count")
                ->whereColumn('reviews.lab_id', 'labs.id'),
        ]);

        return $query;
    }

scopeという言葉を先頭に付けることでLaravelのスコープの機能を使うことができます。

スコープとは

今回の例はそこまでわかりやすいとは自分でも思っていないので、Claude AIに他の分かりやすい例を作ってもらいましたので、紹介します。

スコープの利点が分かる具体例:ECサイトの注文管理

ECサイトで「注文(Order)」を管理するシステムを例にします。

スコープなしの場合

コントローラーのあちこちに、似たような where が散らばります。

// 管理画面:未払いの注文一覧
public function unpaidOrders() {
    return Order::where('status', 'unpaid')
                ->where('deleted_at', null)
                ->orderBy('created_at', 'desc')
                ->get();
}

// 管理画面:今月の未払い注文
public function unpaidThisMonth() {
    return Order::where('status', 'unpaid')
                ->where('deleted_at', null)
                ->whereMonth('created_at', now()->month)
                ->orderBy('created_at', 'desc')
                ->get();
}

// ユーザー画面:自分の未払い注文
public function myUnpaidOrders($userId) {
    return Order::where('status', 'unpaid')
                ->where('deleted_at', null)
                ->where('user_id', $userId)
                ->orderBy('created_at', 'desc')
                ->get();
}

問題点: 「未払い」の定義(status = 'unpaid')が3箇所に書かれています。もし仕様変更で「未払い」の条件が status IN ('unpaid', 'pending') になったら、全部の箇所を探して直す必要があります。

スコープありの場合

条件の「意味」をモデルに集約します。

// app/Models/Order.php
class Order extends Model
{
    // 「未払い」の定義をここ一箇所にまとめる
    public function scopeUnpaid($query)
    {
        return $query->whereIn('status', ['unpaid', 'pending']);
    }

    // 「新しい順」もスコープにできる
    public function scopeLatest($query)
    {
        return $query->orderBy('created_at', 'desc');
    }

    // 特定ユーザーの注文
    public function scopeForUser($query, $userId)
    {
        return $query->where('user_id', $userId);
    }
}

コントローラーがこんなにスッキリします。

// 管理画面:未払いの注文一覧
public function unpaidOrders() {
    return Order::unpaid()->latest()->get();
}

// 管理画面:今月の未払い注文
public function unpaidThisMonth() {
    return Order::unpaid()->latest()->whereMonth('created_at', now()->month)->get();
}

// ユーザー画面:自分の未払い注文
public function myUnpaidOrders($userId) {
    return Order::unpaid()->forUser($userId)->latest()->get();
}

仕様変更があっても scopeUnpaid を1箇所直すだけで全部に反映されます。

まとめ:スコープの3つの利点
利点 説明
① 再利用性 同じ条件を何度も書かなくていい
② 保守性 条件変更が1箇所で済む(バグを防げる)
③ 可読性 ->where('status', 'unpaid') より ->unpaid() のほうが意味が伝わる

特に②が一番重要で、「ビジネスルールをモデルに閉じ込める」という設計の考え方(ファットモデル・スリムコントローラー)につながります。コントローラーはDBの詳細を知らなくていい、という思想です。

...らしいです。(笑)

ということで、今回の場合ですと「各評価項目の平均・総合評価・レビュー数をクエリに付与する」という処理が1行にまとまっています。
あいにく、今のソースコードですと呼び出す箇所は1か所だけですが、他のページでもこれらを表示したい場合が出てきたときに、これを呼び出すだけでOKということになります!

続いて、MyPageControllerの方も見てみましょう。

7.4 MyPageControllerで呼び出す

app/Http/Controllers/MyPageController.php

クリックでコードを見る
\project-root\src\app\Http\Controllers\MyPageController.php
    /**
     * ユーザー情報を表示する
     */
    public function showUser(): Response
    {
        $user = Auth::user();

        // 管理者・一般ユーザー問わず通知を取得
        $notifications = $user->notifications()->latest()->get();

        // ブックマーク済み研究室とそのレビュー情報・総合評価を取得
        $bookmarks = $user->bookmarks()->with(['lab.reviews'])->get()->map(function($bookmark) {
            $lab = $bookmark->lab;
            if (!$lab) return null;

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

            return $lab;
        })->filter();

        // ユーザーが作成した大学・学部・研究室を取得
        $universities = University::where('created_by', $user->id)->get();
        $faculties = Faculty::where('created_by', $user->id)->with('university')->get();
        $createdLabs = Lab::where('created_by', $user->id)->with(['faculty.university', 'reviews'])->get()->map(function ($lab) {
            return $lab->appendRatingAverages();
        });

        return Inertia::render('MyPage/Index', [
            'title' => "{$user->name}さんのマイページ",
            'user' => $user,
            'notifications' => $notifications,
            'bookmarks' => $bookmarks,
            'universities' => $universities,
            'faculties' => $faculties,
            'createdLabs' => $createdLabs,
        ]);
    }

ここでは、「ブックマーク済み研究室」と「作成済み研究室」の二つに対してレビューの計算結果を付与する処理をモデルに追加したappendRatingAverages()メソッドを呼び出すことで、それぞれ1行で書くことに成功しています。

\project-root\src\app\Models\Lab.php
    /**
     * overall_avg, avg_{col}, reviews_count を動的属性としてセットする
     * MyPageController 等でコレクション内の各 Lab に付与する用途
     */
    public function appendRatingAverages(): self
    {
        $averagePerItem = $this->getAveragePerItem();

        $this->overall_avg = $averagePerItem->avg();
        foreach (self::RATING_COLUMNS as $column) {
            $this->{"avg_{$column}"} = $averagePerItem[$column];
        }
        $this->reviews_count = $this->reviews->count();

        return $this;
    }

これまでに追加してきた他のメソッドと違う点は、Labモデルそのものを返すという点です。

7.5 動作確認

研究室一覧ページ、研究室詳細ページ、マイページのブックマーク済み一覧、作成済み一覧にレビューの平均値が表示されていればOK!!
image.png
image.png
image.png
image.png

コミット・プッシュ、PR作成・マージをお忘れなく!

8. まとめ・次回予告

今日は、ファビコンを作りました!
アプリのイメージを決める大切な物でした。

それ以外にもいろいろと修正しましたねw

次回は、いよいよフロントエンド編最終回、レスポンシブデザイン対応です...!!

最後までよろしくお願いします!

これまでの記事一覧

☆要件定義・設計編

☆環境構築編

☆バックエンド実装編

☆フロントエンド実装編

軽く宣伝

YouTubeを始めました(というか始めてました)。
内容としては、Webエンジニアの生活や稼げるようになるまでの成長記録などを発信していく予定です。

現在、まったく再生されておらず、落ち込みそうなので、見てくださる方はぜひ高評価を教えもらえると励みになると思います。"(-""-)"

1
1
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
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?