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
Last updated at Posted at 2026-03-05

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

0. 初めに

こんにちは!
見習いエンジニアの僕が一からWebアプリケーションを開発する様子をお届けしているシリーズです。

正直、シリーズが長くなってきて飽き始めていますが、どうにか最後まで走りぬきたいと思います...w

今日は、前回に引き続き、学部の作成・編集用モーダルを作成したいと思います。

ぶっちゃけ、前回のやり方を踏襲すれば、ほとんど同じはずなので、すぐ終わると思います。(笑)

1. 作成モーダル作成

1.1 ブランチ運用

いつものごとく、developブランチを最新にして、新規ブランチを切って作業します。
ブランチ名は、feature/frontend/faculty-create-modalでいきます。

1.2 送信ボタン作成

resources\js\Components\Faculty\FacultySubmitButton.jsx

クリックでコードを表示
\project-root\src\resources\js\Components\Faculty\FacultySubmitButton.jsx
/**
 * 学部作成・編集ボタンコンポーネント
 * @param {Object} props
 * @param {string} props.mode - 'create' | 'edit'
 * @param {boolean} [props.disabled=false] - 無効状態
 * @param {string} [props.type='submit'] - ボタンタイプ
 * @returns {JSX.Element} コンポーネントのJSX
 */
const FacultySubmitButton = ({ mode, disabled = false, type = 'submit' }) => {
  const label = mode === 'create' ? '作成する' : '編集する';

  return (
    <button
      type={type}
      disabled={disabled}
      className={`
        p-1 bg-[#33E1ED] shadow-md rounded-md 
        hover:shadow-lg transition-shadow 
        ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
      `}
    >
        <span className="px-14 py-1 block rounded text-white font-bold">
        {label}
      </span>
    </button>
  );
};

export default FacultySubmitButton;

1.3 作成モーダル作成

resources/js/Components/Faculty/CreateFacultyModal.jsx

クリックでコードを見る
\project-root\src\resources\js\Components\Faculty\CreateFacultyModal.jsx
import { useForm } from '@inertiajs/react';
import Modal from '../Common/Modal';
import FacultySubmitButton from './FacultySubmitButton';
import InputField from '../Common/InputField';

/**
 * 学部作成モーダル
 * @param {Object} props
 * @param {boolean} props.isOpen - モーダルの開閉状態
 * @param {Function} props.onClose - モーダルを閉じる
 * @param {Object} props.university - 学部が所属する大学オブジェクト
 * @returns {JSX.Element} コンポーネントのJSX
 */

const CreateFacultyModal = ({ isOpen, onClose, university }) => {

  return (
    <Modal
      isOpen={isOpen}
      onClose={onClose}
      title="学部を作成する"
      size='sm'
    >
      <div className="h-[300px] flex flex-col">
        {/* フォーム領域 */}
        <div className="flex-1">
          <CreateFacultyForm onClose={onClose} university={university} />
        </div>
      </div>
    </Modal>
  );
};

/**
 * 学部作成フォーム
 * @param {Object} props
 * @param {Function} props.onClose - モーダルを閉じる
 * @returns {JSX.Element} コンポーネントのJSX
 */
const CreateFacultyForm = ({ onClose, university }) => {
  const { data, setData, post, processing, errors, reset } = useForm({
    name: '',
  });

  const submit = e => {
    e.preventDefault();
    post(route('faculty.store', university.id), {
      onSuccess: () => onClose(),
      preserveScroll: true,
    });
  };

  return (
    <form onSubmit={submit} className="h-full flex flex-col">
      <div className="flex-1">
        {/* 入力欄 */}
        <ErrorSlot message={errors.name} />
        <InputField
          type="text"
          value={data.name}
          onChange={e => setData('name', e.target.value)}
          placeholder="学部名(正式名称)"
          size="sm"
          className="mb-2 w-full"
        />
      </div>

      {/* 送信ボタン */}
      <div className="mt-6 flex justify-center">
        <FacultySubmitButton mode="create" disabled={processing} />
      </div>
    </form>
  );
};

const ErrorSlot = ({ message }) => (
  <p className="h-5 text-sm leading-5 text-red-500 overflow-hidden">
    {message ?? '\u00A0'}
  </p>
);

export default CreateFacultyModal;

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

resources/js/Components/University/MenuPopover.jsx

クリックでコードを表示
\project-root\src\resources\js\Components\University\MenuPopover.jsx
/**
 * メニューポップオーバーコンポーネント
 * @param {Object} props - コンポーネントのプロパティ
 * @pram {function} props.onAddFacultyClick - 「学部を追加する」クリック時のハンドラ
 * @param {Function} props.onEditClick - 「編集する」クリック時のハンドラ
 * @returns {JSX.Element} コンポーネントのJSX
 */
const MenuPopover = ({ onAddFacultyClick, onEditClick }) => {
  const menuItems = [
    { label: '学部を追加する', onClick: onAddFacultyClick }, // 修正
    { label: '編集する', onClick: onEditClick },
    { label: '編集履歴を見る', onClick: () => {} },
    { label: '削除依頼をする', onClick: () => {} },
  ];

  return (
    ...
  );
};

export default MenuPopover;

1.5 学部一覧画面修正

resources/js/Pages/Faculty/Index.jsx

クリックでコードを表示
\project-root\src\resources\js\Pages\Faculty\Index.jsx
// その他のインポート...
import CreateFacultyModal from "@/Components/Faculty/CreateFacultyModal"; // 追加

const Index = ({ faculties, university, query = '' }) => {
  const [isMenuOpen, setIsMenuOpen] = useState(false);
  const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); // 追加
  const [isEditModalOpen, setIsEditModalOpen] = useState(false);

  // ...

  // ↓追加
  /**
   * メニューポップオーバーの「学部を追加する」クリック時の処理
   * @returns {void}
   */
  const handleAddFacultyClick = () => {
    setIsMenuOpen(false);
    setIsCreateModalOpen(true);
  }

  /**
   * メニューポップオーバーの「編集する」クリック時の処理
   * @returns {void}
   */
  const handleEditClick = () => {
    setIsMenuOpen(false);
    setIsEditModalOpen(true);
  }

  return (
    <AppLayout title={`${university.name}の学部一覧`}>
      <Head title={`${university.name}の学部一覧`} />
      <div className="flex flex-col items-center min-h-full">
        {/* ヘッダー部分(共通) */}
        <div className="w-full flex flex-row items-center justify-between">
          {/* パンくずリスト 左寄せ */}
          <div>
            <Breadcrumb university={university} query={query} />
          </div>
          {/* 学部件数 + ケバブメニュー 右寄せ */}
          <div className="flex items-center gap-2">
            <p className="text-[#747D8C]">{faculties.length}件の学部</p>
            {/* ケバブメニュー */}
            <div className="relative" ref={menuRef}>
              <button
                onClick={() => setIsMenuOpen(!isMenuOpen)}
                className="p-2 rounded-full"
              >
                <KebabIcon />
              </button>
              {/* ↓修正 */}
              {isMenuOpen && <MenuPopover onAddFacultyClick={handleAddFacultyClick} onEditClick={handleEditClick} />}
            </div>
          </div>
        </div>

        {/* コンテンツ部分 */}
        {hasResults ? (
          <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="flex-1 flex items-center justify-center">
            <p className="text-[#747D8C]">まだ学部が登録されていません。</p>
          </div>
        )}
      </div>
      {/* 追加: 学部作成モーダル */}
      <CreateFacultyModal
        isOpen={isCreateModalOpen}
        onClose={() => setIsCreateModalOpen(false)}
        university={university}
      />
      {/* 学部編集モーダル */}
      <EditUniversityModal
        isOpen={isEditModalOpen}
        onClose={() => setIsEditModalOpen(false)}
        university={university}
      />
    </AppLayout>
  );
};

export default Index;

1.6 動作確認

メニューポップアップの「学部を追加する」をクリックして、適当な名前で学部を追加してみましょう。
image.png

できました!
image.png

コミット・プッシュ、PR作成、developブランチへのマージを忘れずに!

2. 編集モーダル作成

2.1 ブランチ運用

先ほどの変更履歴をローカルのdevelopブランチでプルして、新しいブランチを切って作業します。
ブランチ名は、feature/frontend/faculty-edit-modalとかにします。

2.2 編集モーダル作成

resources/js/Components/Faculty/EditFacultyModal.jsx

クリックでコードを表示
\project-root\src\resources\js\Components\Faculty\EditFacultyModal.jsx
import { useForm } from '@inertiajs/react';
import Modal from '../Common/Modal';
import FacultySubmitButton from './FacultySubmitButton';
import InputField from '../Common/InputField';
import TextareaField from '../Common/TextareaField';

/**
 * 学部編集モーダル
 * @param {Object} props
 * @param {boolean} props.isOpen - モーダルの開閉状態
 * @param {Function} props.onClose - モーダルを閉じる
 * @param {Object} props.faculty - 編集対象の学部オブジェクト
 * @returns {JSX.Element} コンポーネントのJSX
 */

const EditFacultyModal = ({ isOpen, onClose, faculty }) => {
  return (
    <Modal
      isOpen={isOpen}
      onClose={onClose}
      title="学部を編集する"
      size='sm'
    >
      <div className="h-[300px] flex flex-col">
        {/* フォーム領域 */}
        <div className="flex-1">
          <EditFacultyForm onClose={onClose} faculty={faculty} />
        </div>
      </div>
    </Modal>
  );
};

/**
 * 学部編集フォーム
 * @param {Object} props
 * @param {Function} props.onClose - モーダルを閉じる
 * @returns {JSX.Element} コンポーネントのJSX
 */
const EditFacultyForm = ({ onClose, faculty }) => {
  const { data, setData, put, processing, errors, reset } = useForm({
    name: faculty?.name ?? '',
    comment: '',
    version: faculty?.version ?? 1,
  });

  const submit = e => {
    e.preventDefault();
    put(route('faculty.update', faculty.id), {
      onSuccess: () => onClose(),
      preserveScroll: true,
    });
  };

  return (
    <form onSubmit={submit} className="h-full flex flex-col">
      <div className="flex-1">
        {/* 入力欄 */}
        <ErrorSlot message={errors.name} />

        {/* 大学名 */}
        <InputField
          type="text"
          value={data.name}
          onChange={e => setData('name', e.target.value)}
          placeholder="大学名(正式名称)"
          size="sm"
          className="mb-2 w-full"
        />

        {/* 編集理由 */}
        <ErrorSlot message={errors.comment} />
        <TextareaField
          value={data.comment}
          onChange={e => setData('comment', e.target.value)}
          placeholder="編集理由を入力してください"
          size="sm"
          rows={4}
          className="w-full"
        />
      </div>

      {/* 送信ボタン */}
      <div className="mt-6 flex justify-center">
        <FacultySubmitButton mode="edit" disabled={processing} />
      </div>
    </form>
  );
};

const ErrorSlot = ({ message }) => (
  <p className="h-5 text-sm leading-5 text-red-500 overflow-hidden">
    {message ?? '\u00A0'}
  </p>
);

export default EditFacultyModal;

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

これまでのメニューポップオーバーは、学部一覧ページで使用するのを前提に作られてきました。

しかし、学部を編集するメニューは研究室一覧ページに表示する必要があります。

そのため、両場面で使用できるように汎用性を高めます。

まず、ファイルの場所を移動させましょう。

  • 移動前: resources/js/Components/University/MenuPopover.jsx
  • 移動後: resources/js/Components/Common/MenuPopover.jsx

「切り取り」→「貼り付け」をすると、VS Codeが勝手に「インポートを更新しますか?」と出してくれるので、「はい」を押せば大丈夫です。

もし、少し待っても表示がされない場合は、手動でインポート文を修正しましょう。
学部一覧画面コンポーネントでインポートしていたはずです。

\project-root\src\resources\js\Pages\Faculty\Index.jsx
import MenuPopover from "@/Components/Common/MenuPopover";

次に、中身を修正します。
標示するメニューのラベル用の文字列をpropsとして、親コンポーネントから受け取れるようにしました。

resources/js/Components/Common/MenuPopover.jsx

クリックでコードを表示
\project-root\src\resources\js\Components\Common\MenuPopover.jsx
/**
 * メニューポップオーバーコンポーネント
 * @param {Object} props - コンポーネントのプロパティ
 * @param {string} props.addLabel - 追加ボタンのラベルに表示する文字列
 * @param {Function} props.onAddClick - 「〇〇を追加する」クリック時のハンドラ
 * @param {Function} props.onEditClick - 「編集する」クリック時のハンドラ
 * @returns {JSX.Element} コンポーネントのJSX
 */
const MenuPopover = ({ addLabel, onAddClick, onEditClick }) => {
  const menuItems = [
    { label: addLabel, onClick: onAddClick },
    { label: '編集する', onClick: onEditClick },
    { label: '編集履歴を見る', onClick: () => {} },
    { label: '削除依頼をする', onClick: () => {} },
  ];

  return (
    <div
      className="absolute top-full right-0 mt-2 w-48 rounded-lg shadow-xl overflow-hidden z-10"
      style={{ backgroundColor: '#EEF7FB' }}
    >
      <ul className="py-1">
        {menuItems.map((item, index) => (
          <li
            key={index}
            style={
              index !== menuItems.length - 1
                ? { borderBottom: '1px solid #747D8C' }
                : {}
            }
          >
            <button
              className="w-full px-4 py-3 text-left text-sm"
              style={{ color: '#747D8C', fontWeight: 'bold' }}
              onClick={item.onClick}
            >
              {item.label}
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
};

export default MenuPopover;

2.4 研究室一覧画面修正

resources/js/Pages/Lab/Index.jsx

クリックでコードを表示
\project-root\src\resources\js\Pages\Lab\Index.jsx
import { Head, router } from "@inertiajs/react";
import AppLayout from '@/Layouts/AppLayout';
import LabCard from '../../Components/Lab/LabCard';
import Pagination from "../../Components/Common/Pagination";
import Breadcrumb from "../../Components/Common/Breadcrumb";
import MenuPopover from "@/Components/Common/MenuPopover";
import KebabIcon from "@/Components/Common/KebabIcon";
import EditFacultyModal from "@/Components/Faculty/EditFacultyModal";
import { useState, useEffect, useRef } from "react";

/**
 * ソートオプションの定義
 */
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 [isMenuOpen, setIsMenuOpen] = useState(false);
  const [isEditModalOpen, setIsEditModalOpen] = useState(false);
  const menuRef = useRef(null);
  const hasResults = labs.data.length > 0;

  // 外側クリックでメニューポップオーバーを閉じる
  useEffect(() => {
    const handleClickOutside = (event) => {
      if (menuRef.current && !menuRef.current.contains(event.target)) {
        setIsMenuOpen(false);
      }
    };

    if (isMenuOpen) {
      document.addEventListener('mousedown', handleClickOutside);
    }

    return () => {
      document.removeEventListener('mousedown', handleClickOutside);
    };
  }, [isMenuOpen]);

  /**
   * メニューポップオーバーの「編集する」クリック時の処理
   * @returns {void}
   */
  const handleEditClick = () => {
    setIsMenuOpen(false);
    setIsEditModalOpen(true);
  }

  /**
   * ソート条件変更時のハンドラ
   * @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}`} />

      <div className="flex flex-col items-center min-h-full">
        <div className="w-full flex flex-row items-center justify-between">
          {/* パンくずリスト 左寄せ */}
          <div>
            <Breadcrumb university={faculty.university} faculty={faculty} query={query} />
          </div>
          {/* 研究室件数 + ケバブメニュー 右寄せ+ソート */}
          <div className="flex items-center 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 className="relative" ref={menuRef}>
              <button
                onClick={() => setIsMenuOpen(!isMenuOpen)}
                className="p-2 rounded-full"
              >
                <KebabIcon />
              </button>
              {isMenuOpen && <MenuPopover addLabel="研究室を追加する" onEditClick={handleEditClick} />}
            </div>
          </div>
        </div>
        {hasResults ? (
          <>
            <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="flex-1 flex items-center justify-center">
            <p className="text-[#747D8C]">まだ研究室は登録されていません。</p>
          </div>
        )}
      </div>
      {/* 学部編集モーダル */}
      <EditFacultyModal
        isOpen={isEditModalOpen}
        onClose={() => setIsEditModalOpen(false)}
        faculty={faculty}
      />
    </AppLayout>
  )
};

export default Index;

2.5 動作確認

研究室一覧ページで、ケバブアイコンをクリックしてポップオーバーが開くか確認してください。
image.png

その中の「編集する」をクリックすると以下が開けばOK。
image.png

適当に入力して、「編集する」をクリックすると...
image.png

更新できました!OKですね。(^^♪
image.png

少し、レイアウトを変更しています。

これにて、作業は完了なので、コミット・プッシュを忘れずにしておきましょう。

3. (おまけその8)バックエンドPHPDocその1

今回は、記事のボリュームに余裕がある(ないけど、いつもよりはマシというだけだが)ので、おまけコーナーを久々にやりたいと思います。

フロントエンド編なのに、バックエンドのリファクタリングを今日からやっていきたいと思いますw

やることとしては、

  • PHPDoc形式のコメントアウト付与
  • 型宣言の追加
  • 不要になったメソッドの削除

を予定しています。

PHPDocについては、実はこのシリーズでも過去に登場しています。
怪しい方は調べて復習してみてください。

まあ、JSDocと似たような感じだと思ってもらえれば、、、いつも書いているアレです。
ただし、今回はタイプヒントを積極的にしていこうかなと思っています。
タイプヒントをすると、引数や戻り値の型は見ればすぐわかるので、PHPDocの方には特別な理由がない限り省略する方針にしたいと思います。

タイプヒントについても、過去にこのシリーズで取り扱ったことがあるので「何それ?」って方は復習しておきましょう。

※補足ですが、以前このシリーズで「PHPは、他のTypeScriptやJavaなどのように型宣言ができる」と説明したかと思います。

ただし、厳密にはこれらは異なっています。

PHPはソースコードが実行時に一行一行その都度機械語に翻訳される仕組みで動いています。

インタープリタ言語などと呼ばれています。
他にも有名なものとしては、PythonやRuby, JavaScriptが該当します。

一方で、Javaはソースコードをいっぺんにすべて機械語に変換します。
コンパイラ言語などと呼ばれることが多く、Javaの他にはGoやRustなんかが有名ですかね。

コンパイル言語は、構文にミスがあった場合に、実際に実行される前にエラーが発生するので、問題を早期(リリースする前の開発段階と言い換えてもOKです)に発見できます。

「じゃあ、PHPで型宣言をしても意味ないんじゃね?」

って思うかもしれません。

しかし、PHPの実行環境の上でVS Code等の現代的なエディタを使った開発をしていると、PHPの解析ツール(詳しくは割愛します)がコードを書いている段階で、構文エラーを検知して、警告を出してくれます

例えば、InertiaのResponseを返す関数の戻り値にあえてstring型の宣言を追加してみます。

すると...
image.png

このように、「string型の戻り値を期待しているのに、違う戻り値が書かれてしまっていますよ!」と教えてくれます。

これで、型の不意一致によるバグを防ぐことができるというわけです。

伝わったかな?(´・ω・)

ちなみにTypeScriptは、一度すべてJavaScriptに変換して、その後実行します。
この辺はややこしいのでまた機会があれば、どこかで..

インタプリタ言語とコンパイラ言語のメリット・デメリットとかもあるので、興味がわいた方は調べてみてください。

とりあえず、これ以上補足で長々と語っても仕方ないので、実装に移りましょう。

まずは、コントローラーから直していくのが良いかなと思います!

3.1 ブランチ運用

developブランチを最新化させて、新規ブランチを切って作業しましょう。
ブランチ名は、refactor/php-doc/controllersにします。

3.2 大学コントローラー

記念すべき最初のコントローラーは、UniversityController.phpさんです。

レスポンスかリダイレクトありのレスポンスかの違いがあるくらいですね。

あと、createeditメソッドは、前回モーダルを作成したことで、ページそのものが必要なくなったので削除しました。

クリックでコードを表示
\project-root\src\app\Http\Controllers\UniversityController.php
<?php

namespace App\Http\Controllers;

use App\Models\University;
use App\Notifications\ModelChangedNotification;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;

class UniversityController extends Controller
{
    use AuthorizesRequests;

    /**
     * 新規大学を保存する
     */
    public function store(Request $request): RedirectResponse
    {
        $this->authorize('create', University::class);
        
        $validated = $request->validate([
            'name' => 'required|string|max:50|unique:universities,name',
            'type' => 'required|string|in:national,public,private',
        ]);

        $university = new University();
        $university->name = $validated['name'];
        $university->type = $validated['type'];
        $university->created_by = $request->user()->id;
        $university->save();

        $userId = $request->user()->id;
        $university->users()->attach($userId);

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

    /**
     * 大学一覧を表示する
     */
    public function index(Request $request): Response
    {
        $query = $request->input('query', '');

        $universities = University::query()
            ->when($query, function ($queryBuilder) use ($query) {
                $queryBuilder->where('name', 'like', '%' . $query . '%');
            })
            ->orderBy('name')
            ->paginate(10) // 10件ずつ表示に変更
            ->withQueryString();

        return Inertia::render('University/Index', [
            'universities' => $universities,
            'query' => $query,
        ]);
    }

    /**
     * 大学情報を更新する
     */
    public function update(Request $request, University $university): RedirectResponse
    {
        $this->authorize('update', University::class);

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

        // トランザクション開始
        DB::beginTransaction();

        try {
            // 他のユーザーが更新している可能性がある
            // そのため、最初に最新の university を取得
            $current = University::find($university->id);

            if ($validated['version'] !== $current->version) {
                throw ValidationException::withMessages([
                    'version' => '他のユーザーがこの大学情報を更新しました。最新の情報を確認してください。',
                ]);
            }

            // データ更新
            $current->name = $validated['name'];
            $current->type = $validated['type'];
            $current->version += 1; // バージョンを1増やす
            $current->save();

            // 履歴保存
            $userId = $request->user()->id;
            $current->users()->attach($userId, [
                'comment' => $validated['comment'],
                'created_at' => now(),
                'updated_at' => now(),
            ]);

            DB::commit(); // トランザクション処理終了

            // 作成者へ通知を送信
            if ($userId !== $current->created_by && $current->creator) {
                $changes = collect($current->getChanges())
                    ->only(['name'])
                    ->map(fn($new, $key) => [
                        'old' => $old[$key] ?? null,
                        'new' => $new,
                    ])
                    ->toArray();
    
                $current->creator->notify(
                    new ModelChangedNotification('edited', '大学', $current->name, $changes)
                );
            }

             // リダイレクト
            return redirect()->route('faculties.index', ['university' => $current])->with('success', '大学情報が更新されました。');
        } catch (\Exception $e) {
            DB::rollBack(); // エラー時はロールバック
            throw $e;
        }
    }

    /**
     * 大学編集履歴を表示する
     */
    public function history(University $university): Response
    {
        $editHistory = $university->users()
            ->withPivot('comment', 'created_at', 'updated_at')
            ->get()
        ->sortByDesc(fn($user) => $user->pivot->updated_at)
        ->values()
        ->map(function ($user) {
            return [
                'user' => $user->name,
                'comment' => $user->pivot->comment,
                'created_at' => $user->pivot->created_at,
                'updated_at' => $user->pivot->updated_at,
            ];
    });

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

`use`宣言の追加もお忘れなく!

また、ついでに、作成・編集用のページコンポーネント(\resources\js\Pages\University\Create.jsx\src\resources\js\Pages\University\Edit.jsx)も必要なくなったので削除しておきましょうか。

LinuxコマンドでもVS Codeのエクスプローラー上でのマウス操作でもどちらでも大丈夫です。

3.3 学部コントローラー

続いて、学部コントローラーさん(なぜかさきほどから急にさん付け)です。

先ほどと同様に、createeditメソッドは不要になったので削除です。

クリックでコードを表示
\project-root\src\app\Http\Controllers\FacultyController.php
<?php

namespace App\Http\Controllers;

use App\Models\Faculty;
use App\Models\University;
use App\Notifications\ModelChangedNotification;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;

class FacultyController extends Controller
{
    use AuthorizesRequests;

    public function store(Request $request, University $university): RedirectResponse
    {
        $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->created_by = $request->user()->id;
        $faculty->save();

        $userId = $request->user()->id;
        $faculty->users()->attach($userId);

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

    public function index(Request $request, University $university): Response
    {
        $query = $request->input('query', '');
        $faculties = $university->faculties()->get(); // 修正: 名前のソートを削除
        return Inertia::render('Faculty/Index', [
            'faculties' => $faculties,
            'university' => $university,
            'query' => $query,
        ]);
    }

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

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

        // トランザクション
        DB::beginTransaction();

        try {
            // 現在のバージョンを取得して比較
            $current = Faculty::find($faculty->id);
            if ($validated['version'] !== $current->version) {
                throw ValidationException::withMessages([
                    'version' => '他のユーザーがこの学部情報を更新しました。最新の情報を確認してください。',
                ]);
            }

            // 更新
            $current->name = $validated['name'];
            $current->version += 1;
            $current->save();

            // 履歴保存
            $userId = $request->user()->id;
            $current->users()->attach($userId, [
                'comment' => $validated['comment'],
                'created_at' => now(),
                'updated_at' => now(),
            ]);

            DB::commit();

            if ($userId !== $current->created_by && $current->creator) {
                $changes = collect($current->getChanges())
                    ->only(['name'])
                    ->map(fn($new, $key) => [
                        'old' => $old[$key] ?? null,
                        'new' => $new,
                    ])
                    ->toArray();

                $current->creator->notify(
                    new ModelChangedNotification('edited', '学部', $current->name, $changes)
                );
            }

            return redirect()->route('labs.index', ['faculty' => $current])->with('success', '大学情報が更新されました。');
        } catch (\Exception $e) {
            DB::rollBack();
            throw $e;
        }
    }

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

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

ついでに、不要になったページコンポーネントも同時に削除しておきます。
本当は、Gitのコミット履歴の分かりやすさの観点から、モーダルを作っているときに削除したほうがよかったと思いますが...

(追記)肝心のPHPDocが書かれていません。型宣言をしているのでなくてもよいですが、気になる場合は適切なコメント追加しておいてください。

削除するコンポーネント:

  • \resources\js\Pages\Faculty\Create.jsx
  • \resources\js\Pages\Faculty\Edit.jsx

今日のところは、ここまでにしておきます!
次回以降余裕があれば、続きをやっていきましょう。

コミット・プッシュをしておきましょう。

ただし、このブランチは引き続き次回以降も使おうと思うんで、あえてPR作成とdevelopブランチへのマージまではしないでおいてください。

4. まとめ・次回予告

今回は、学部作成・編集モーダルを作成しました!

「クリックでコードを表示」というワザを覚えたので、記事が読みやすくなったかと思います。(≧◇≦)

次回は、研究室の作成・編集用モーダルを作成したいと思います!
最後まで読んでくれありがとうございます!

これまでの記事一覧

☆要件定義・設計編

☆環境構築編

☆バックエンド実装編

☆フロントエンド実装編

軽く宣伝

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?