1
2

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

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

0. 初めに

こんにちは!
実務1年目エンジニアが自身の勉強もかねて、Webアプリケーション開発の方法を一から解説しているシリーズです。

今回も前回に引き続き、作成・編集用モーダルを作っていきます。

今日作るのは、研究室の作成・編集モーダルです!

頑張って行きましょう~。

1. 作成モーダル作成

例によって、作成モーダルから先に作りましょう。

1.1 ブランチ運用

developブランチから新しく、feature/frontend/lab-create-modalを切って作業します。

1.2 送信ボタン作成

クリックでコードを表示
\project-root\src\resources\js\Components\Lab\LabSubmitButton.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 LabSubmitButton = ({ 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 LabSubmitButton;

1.3 作成モーダル作成

クリックでコードを表示
\project-root\src\resources\js\Components\Lab\CreateLabModal.jsx
import { useForm } from '@inertiajs/react';
import Modal from '../Common/Modal';
import LabSubmitButton from './LabSubmitButton';
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 CreateLabModal = ({ isOpen, onClose, faculty }) => {
  return (
    <Modal
      isOpen={isOpen}
      onClose={onClose}
      title="研究室を作成する"
      size='lg'
    >
      <div className="h-[300px] flex flex-col">
        {/* フォーム領域 */}
        <div className="flex-1 overflow-y-auto">
          <CreateLabForm onClose={onClose} faculty={faculty} />
        </div>
      </div>
    </Modal>
  );
};

/**
 * 研究室作成フォーム
 * @param {Object} props
 * @param {Function} props.onClose - モーダルを閉じる
 * @returns {JSX.Element} コンポーネントのJSX
 */
const CreateLabForm = ({ onClose, faculty }) => {
  const { data, setData, post, processing, errors, reset } = useForm({
    name: '',
    description: '',
    url: '',
    professor_name: '',
    professor_url: '',
    gender_ratio_male: 5,
    gender_ratio_female: 5,
  });

  const submit = e => {
    e.preventDefault();
    post(route('lab.store', 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.description} />
        <TextareaField
          value={data.description}
          onChange={e => setData('description', e.target.value)}
          placeholder="研究室の簡単な説明を入力してください(任意)"
          size="sm"
          rows={4}
          className="w-full"
        />

        {/* URL */}
        <ErrorSlot message={errors.url} />
        <InputField
          type="text"
          value={data.url}
          onChange={e => setData('url', e.target.value)}
          placeholder="研究室のURL(任意)"
          size="sm"
          className="mb-2 w-full"
        />

        {/* 教授名 */}
        <ErrorSlot message={errors.professor_name} />
        <InputField
          type="text"
          value={data.professor_name}
          onChange={e => setData('professor_name', e.target.value)}
          placeholder="教授名(任意)"
          size="sm"
          className="mb-2 w-full"
        />

        {/* 教授URL */}
        <ErrorSlot message={errors.professor_url} />
        <InputField
          type="text"
          value={data.professor_url}
          onChange={e => setData('professor_url', e.target.value)}
          placeholder="教授のURL(任意)"
          size="sm"
          className="mb-2 w-full"
        />

        {/* 男女比 */}
        <ErrorSlot message={errors.gender_ratio_male || errors.gender_ratio_female} />
        <div className="mb-2">
          <div className="flex h-8 w-full overflow-hidden rounded">
            <div
              className="flex h-full items-center justify-center text-center text-white whitespace-nowrap transition-[flex-basis] duration-300 ease-out"
              style={{ backgroundColor: '#7BB3CE', flexBasis: `${data.gender_ratio_male * 10}%` }}
            >
              {data.gender_ratio_male > 0 ? `男 ${data.gender_ratio_male}` : ''}
            </div>
            <div
              className="flex h-full items-center justify-center text-center text-white whitespace-nowrap transition-[flex-basis] duration-300 ease-out"
              style={{ backgroundColor: '#E89EB9', flexBasis: `${data.gender_ratio_female * 10}%` }}
            >
              {data.gender_ratio_female > 0 ? `女 ${data.gender_ratio_female}` : ''}
            </div>
          </div>
          <input
            type="range"
            min="0"
            max="10"
            step="1"
            value={data.gender_ratio_male}
            onChange={e => {
              const male = parseInt(e.target.value, 10);
              const female = 10 - male;
              setData('gender_ratio_male', male);
              setData('gender_ratio_female', female);
            }}
            className="mt-2 w-full"
          />
        </div>
      </div>

      {/* 送信ボタン */}
      <div className="mt-6 flex justify-center">
        <LabSubmitButton 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 CreateLabModal;

1.4 研究室一覧画面修正

クリックでコードを表示
\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 CreateLabModal from "@/Components/Lab/CreateLabModal"; // 追加
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 [isCreateModalOpen, setIsCreateModalOpen] = 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 handleAddLabClick = () => {
    setIsMenuOpen(false);
    setIsCreateModalOpen(true);
  }

  /**
   * メニューポップオーバーの「編集する」クリック時の処理
   * @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="研究室を追加する" onAddClick={handleAddLabClick} 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>
      {/* 追加: 研究室追加モーダル */}
      <CreateLabModal
        isOpen={isCreateModalOpen}
        onClose={() => setIsCreateModalOpen(false)}
        faculty={faculty}
      />
      {/* 学部編集モーダル */}
      <EditFacultyModal
        isOpen={isEditModalOpen}
        onClose={() => setIsEditModalOpen(false)}
        faculty={faculty}
      />
    </AppLayout>
  )
};

export default Index;

1.5 バリデーション修正

バックエンド編で作ったバリデーションが不完全だったので、修正します!

コントローラー修正

description(研究室の説明)についてのバリデーションがなかったので追加します。
また、文字数なども微妙に調整しています。

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

namespace App\Http\Controllers;

// use宣言...

class LabController extends Controller
{
    use AuthorizesRequests;

    // その他のメソッド定義...
    
    public function store(Request $request, Faculty $faculty)
    {
        // 認可
        $this->authorize('create', Lab::class);

        // バリデーション
        $validated = $request->validate([
            'name' => 'required|string|max:50|unique:labs,name,NULL,id,faculty_id,' . $faculty->id,
            'description' => 'nullable|string|max:150', // 修正
            'url' => 'nullable|url|max:255',
            'professor_name' => 'nullable|string|max:25', // 追加
            'professor_url' => 'nullable|url|max:255',
            'gender_ratio_male' => 'required|integer|min:0|max:10',
            'gender_ratio_female' => [
                'required',
                'integer',
                'min:0',
                'max:10',
                function ($attribute, $value, $fail) use ($request) {
                    $male = (int) $request->input('gender_ratio_male', 0);
                    $female = (int) $value;
                    if ($male + $female > 10) {
                        $fail('男女比の合計は10である必要があります。');
                    }
                },
            ],
        ]);
        
        $lab = new Lab();
        $lab->name = $validated['name'];
        $lab->description = $validated['description'];
        $lab->url = $validated['url'];
        $lab->professor_name = $validated['professor_name']; // 追加
        $lab->professor_url = $validated['professor_url'];
        $lab->gender_ratio_male = $validated['gender_ratio_male'];
        $lab->gender_ratio_female = $validated['gender_ratio_female'];
        $lab->faculty_id = $faculty->id;
        $lab->created_by = $request->user()->id;
        $lab->save();

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

        return redirect()->route('labs.show', ['lab' => $lab])->with('success', '研究室が作成されました。');
    }
}

日本語バリデーション追加

日本語のバリデーションメッセージも足りていなかったので追加します。

クリックでコードを表示
\project-root\src\resources\lang\ja\validation.php
<?php

return [
    // 基本的なバリデーションメッセージ...

    'custom' => [
        // 大学関連...
        
        // 学部関連...

        // 研究室関連
        'lab.name' => [
            'unique' => 'この研究室名は既にこの学部に存在します。',
            'required' => '研究室名は必須項目です。',
            'max' => '研究室名は50文字以下にしてください。',
        ],
        // ↓追加分
        'description' => [
            'max' => '説明は150文字以下にしてください。',
        ],
        'professor_name' => [
            'max' => '教授名は25文字以下にしてください。',
        ],
        'url' => [
            'url' => 'URLは有効なURL形式で入力してください。',
        ],
        'professor_url' => [
            'url' => '教授のURLは有効なURL形式で入力してください。',
        ],

        // レビュー関連...
    ],

    'attributes' => [
        'nickname' => 'ニックネーム',
        'email' => 'メールアドレス',
        'password' => 'パスワード',
        'password_confirmation' => 'パスワード(確認用)',
        'name' => '大学名',
        'faculty.name' => '学部名',
        'comment' => '編集理由',
        // ↓追加分
        'description' => '説明',
        'professor_name' => '教授名',
        'url' => 'URL',
        'professor_url' => '教授のURL',
        // ↑追加分
        'mentorship_style' => '指導スタイル',
        'lab_atmosphere' => '雰囲気・文化',
        'achievement_activity' => '成果・活動',
        'constraint_level' => '拘束度',
        'facility_quality' => '設備',
        'work_style' => '働き方',
        'student_balance' => '人数バランス',
    ],

];

1.6 動作確認

ポップアップオーバーメニューから「研究室を追加する」をクリック。
image.png

モーダルが開くか確認して、適当な値を入力して送信。
image.png

入力した内容で研究室が作成されるかを確認。
image.png

ここまでできればいったん完了です!
コミット・プッシュ、PR作成・マージを行って、変更をリモートのdevelopブランチに反映させましょう。

2. 編集モーダル作成

2.1 ブランチ運用

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

2.2 編集モーダル作成

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

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

const EditLabModal = ({ isOpen, onClose, lab }) => {
  return (
    <Modal
      isOpen={isOpen}
      onClose={onClose}
      title="研究室を編集する"
      size='lg'
    >
      <div className="h-[300px] flex flex-col">
        {/* フォーム領域 */}
        <div className="flex-1 overflow-y-auto">
          <EditLabForm onClose={onClose} lab={lab} />
        </div>
      </div>
    </Modal>
  );
};

/**
 * 研究室編集フォーム
 * @param {Object} props
 * @param {Function} props.onClose - モーダルを閉じる
 * @returns {JSX.Element} コンポーネントのJSX
 */
const EditLabForm = ({ onClose, lab }) => {
  const { data, setData, put, processing, errors, reset } = useForm({
    name: lab?.name ?? '',
    description: lab?.description ?? '',
    url: lab?.url ?? '',
    professor_name: lab?.professor_name ?? '',
    professor_url: lab?.professor_url ?? '',
    gender_ratio_male: lab?.gender_ratio_male ?? 5,
    gender_ratio_female: lab?.gender_ratio_female ?? 5,
    comment: '',
    version: lab?.version ?? 1,
  });

  const submit = e => {
    e.preventDefault();
    put(route('lab.update', lab.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.description} />
        <TextareaField
          value={data.description}
          onChange={e => setData('description', e.target.value)}
          placeholder="研究室の簡単な説明を入力してください(任意)"
          size="sm"
          rows={4}
          className="w-full"
        />

        {/* URL */}
        <ErrorSlot message={errors.url} />
        <InputField
          type="text"
          value={data.url}
          onChange={e => setData('url', e.target.value)}
          placeholder="研究室のURL(任意)"
          size="sm"
          className="mb-2 w-full"
        />

        {/* 教授名 */}
        <ErrorSlot message={errors.professor_name} />
        <InputField
          type="text"
          value={data.professor_name}
          onChange={e => setData('professor_name', e.target.value)}
          placeholder="教授名(任意)"
          size="sm"
          className="mb-2 w-full"
        />

        {/* 教授URL */}
        <ErrorSlot message={errors.professor_url} />
        <InputField
          type="text"
          value={data.professor_url}
          onChange={e => setData('professor_url', e.target.value)}
          placeholder="教授のURL(任意)"
          size="sm"
          className="mb-2 w-full"
        />

        {/* 男女比 */}
        <ErrorSlot message={errors.gender_ratio_male || errors.gender_ratio_female} />
        <div className="mb-2">
          <div className="flex h-8 w-full overflow-hidden rounded">
            <div
              className="flex h-full items-center justify-center text-center text-white whitespace-nowrap transition-[flex-basis] duration-300 ease-out"
              style={{ backgroundColor: '#7BB3CE', flexBasis: `${data.gender_ratio_male * 10}%` }}
            >
              {data.gender_ratio_male > 0 ? `男 ${data.gender_ratio_male}` : ''}
            </div>
            <div
              className="flex h-full items-center justify-center text-center text-white whitespace-nowrap transition-[flex-basis] duration-300 ease-out"
              style={{ backgroundColor: '#E89EB9', flexBasis: `${data.gender_ratio_female * 10}%` }}
            >
              {data.gender_ratio_female > 0 ? `女 ${data.gender_ratio_female}` : ''}
            </div>
          </div>
          <input
            type="range"
            min="0"
            max="10"
            step="1"
            value={data.gender_ratio_male}
            onChange={e => {
              const male = parseInt(e.target.value, 10);
              const female = 10 - male;
              setData('gender_ratio_male', male);
              setData('gender_ratio_female', female);
            }}
            className="mt-2 w-full"
          />
        </div>

        {/* 編集理由 */}
        <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">
        <LabSubmitButton 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 EditLabModal;

2.3 研究室詳細画面修正

クリックでコードを表示
\project-root\src\resources\js\Pages\Lab\Show.jsx
import { useState, useEffect, useRef } from "react";
import { Head, router } from "@inertiajs/react";
import AppLayout from "@/Layouts/AppLayout";
import StarRating from "@/Components/Lab/Star/StarRating";
import Breadcrumb from "@/Components/Common/Breadcrumb";
import CreateReviewButton from "@/Components/Review/CreateReviewButton";
import MenuPopover from "@/Components/Common/MenuPopover";
import KebabIcon from "@/Components/Common/KebabIcon";
import EditLabModal from "@/Components/Lab/EditLabModal";
import { formatRating } from "@/utils/formatRating";
import {
  Chart as ChartJS,
  RadialLinearScale,
  PointElement,
  LineElement,
  Filler,
  Tooltip,
  Legend,
} from "chart.js";
import { Radar } from "react-chartjs-2";

// Chart.jsのコンポーネントを登録
ChartJS.register(
  RadialLinearScale,
  PointElement,
  LineElement,
  Filler,
  Tooltip,
  Legend
);

/**
 * 研究室詳細ページコンポーネント
 * @param {Object} props - コンポーネントのprops
 * @param {Object} props.lab - 研究室オブジェクト
 * @param {Object} props.averagePerItem - 各評価項目の平均値
 * @param {number} props.overallAverage - 総合評価の平均値
 * @param {Array} props.comments - コメント一覧
 * @param {Object} props.auth - 認証情報
 * @param {Object|null} props.userReview - ログインユーザーのレビュー
 * @param {Object|null} props.userBookmark - ログインユーザーのブックマーク
 * @param {number} props.bookmarkCount - ブックマーク数
 * @param {string} props.query - 検索クエリ文字列
 * @returns {JSX.Element} コンポーネントのJSX
 */

const Show = ({ lab, averagePerItem, overallAverage, comments, auth, userReview, userBookmark, bookmarkCount, query }) => {

  const [showAllComments, setShowAllComments] = useState(false);
  const [isMenuOpen, setIsMenuOpen] = useState(false);
  const [isEditModalOpen, setIsEditModalOpen] = useState(false);
  const menuRef = useRef(null);

  // 外側クリックでメニューポップオーバーを閉じる
  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);
  };

  // ブックマーク状態とカウントをローカルstateで管理
  const [isBookmarked, setIsBookmarked] = useState(auth?.user && userBookmark);
  const [currentBookmarkCount, setCurrentBookmarkCount] = useState(bookmarkCount || 0);
  const [bookmarkId, setBookmarkId] = useState(userBookmark?.id || null);

  /**
   * ブックマークのトグル処理
   * ログインしていない場合は何もしない。
   * 既にブックマーク済みなら解除、未ブックマークなら追加する。
   * @returns {void}
   */
  const handleBookmarkClick = () => {
    // ログインしていない場合は何もしない
    if (!auth?.user) {
      return;
    }

    if (isBookmarked) {
      // ブックマーク解除
      router.delete(route('bookmark.destroy', bookmarkId), {
        preserveScroll: true,
        onSuccess: () => {
          setIsBookmarked(false);
          setCurrentBookmarkCount((prev) => Math.max(0, prev - 1));
          setBookmarkId(null);
        },
      });
    } else {
      // ブックマーク追加
      router.post(route('bookmark.store'), {
        lab_id: lab.id,
      }, {
        preserveScroll: true,
        onSuccess: (page) => {
          setIsBookmarked(true);
          setCurrentBookmarkCount((prev) => prev + 1);
          // 新しいブックマークIDを取得(ページデータから)
          if (page.props.userBookmark) {
            setBookmarkId(page.props.userBookmark.id);
          }
        },
      });
    }
  };

  // 7つの評価指標のラベル
  const labels = [
    "指導スタイル",
    "雰囲気・文化",
    "成果・活動",
    "拘束度",
    "設備",
    "働き方",
    "人数バランス",
  ];

  // LabControllerから渡された平均評価データを配列に変換
  const averageRatings = averagePerItem
    ? [
        averagePerItem.mentorship_style || 0,
        averagePerItem.lab_atmosphere || 0,
        averagePerItem.achievement_activity || 0,
        averagePerItem.constraint_level || 0,
        averagePerItem.facility_quality || 0,
        averagePerItem.work_style || 0,
        averagePerItem.student_balance || 0,
      ]
    : [0, 0, 0, 0, 0, 0, 0];

  // ログインユーザーのレビューデータを配列に変換
  const userRatings = userReview
    ? [
        userReview.mentorship_style || 0,
        userReview.lab_atmosphere || 0,
        userReview.achievement_activity || 0,
        userReview.constraint_level || 0,
        userReview.facility_quality || 0,
        userReview.work_style || 0,
        userReview.student_balance || 0,
      ]
    : null;

  // レーダーチャートのデータセットを構築
  const datasets = [
    {
      label: "全投稿者の平均評価",
      data: averageRatings,
      backgroundColor: "rgba(51, 225, 237, 0.2)",
      borderColor: "rgba(51, 225, 237, 1)",
      borderWidth: 2,
      pointBackgroundColor: "rgba(51, 225, 237, 1)",
      pointBorderColor: "#fff",
      pointHoverBackgroundColor: "#fff",
      pointHoverBorderColor: "rgba(51, 225, 237, 1)",
    },
  ];

  // ログインユーザーのレビューがある場合、データセットに追加
  if (userRatings) {
    datasets.push({
      label: "あなたの投稿済み評価",
      data: userRatings,
      backgroundColor: "rgba(244, 187, 66, 0.2)",
      borderColor: "rgba(244, 187, 66, 1)",
      borderWidth: 2,
      pointBackgroundColor: "rgba(244, 187, 66, 1)",
      pointBorderColor: "#fff",
      pointHoverBackgroundColor: "#fff",
      pointHoverBorderColor: "rgba(244, 187, 66, 1)",
    });
  }

  // レーダーチャートのデータ
  const chartData = {
    labels: labels,
    datasets: datasets,
  };

  // レーダーチャートのオプション
  const chartOptions = {
    responsive: true,
    maintainAspectRatio: true,
    scales: {
      r: {
        angleLines: {
          display: true,
        },
        suggestedMin: 0,
        suggestedMax: 5,
        ticks: {
          stepSize: 1,
          font: {
            size: 12,
          },
        },
        pointLabels: {
          font: {
            size: 14,
          },
        },
      },
    },
    plugins: {
      legend: {
        position: "top",
      },
      tooltip: {
        callbacks: {
          label: (context) => {
            return `${context.dataset.label}: ${context.raw.toFixed(2)}`;
          },
        },
      },
    },
  };

  return (
    <AppLayout title={`${lab.faculty.university.name} ${lab.faculty.name} ${lab.name}`}>
      <Head title={`${lab.faculty.university.name} ${lab.faculty.name} ${lab.name}`} />
      {/* パンくずリスト+レビュー投稿状態+ケバブメニュー 横並び */}
      <div className="w-full flex flex-row items-center justify-between">
        {/* パンくずリスト 左寄せ */}
        <div>
          <Breadcrumb university={lab.faculty.university} faculty={lab.faculty} lab={lab} query={query} />
        </div>
        {/* レビュー投稿状態 + ケバブメニュー 右寄せ */}
        <div className="flex items-center gap-2">
          {auth?.user && userReview ? (
            <p className="text-[#747D8C]">レビューを投稿済みです。</p>
          ) : (
            <p className="text-[#747D8C]">まだ、レビューを投稿していません。</p>
          )}
          {/* ケバブメニュー */}
          <div className="relative" ref={menuRef}>
            <button
              onClick={() => setIsMenuOpen(!isMenuOpen)}
              className="p-2 rounded-full"
            >
              <KebabIcon />
            </button>
            {isMenuOpen && <MenuPopover onEditClick={handleEditClick} />}
          </div>
        </div>
      </div>

      <div className="flex flex-col min-h-full">

        <div className="max-w-7xl mx-auto w-full">
          <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
                {/* 左側: レーダーチャート */}
                <div className="flex justify-center items-start">
                  {lab.reviews && lab.reviews.length > 0 ? (
                    <div className="w-full max-w-sm">
                      <Radar data={chartData} options={chartOptions} />
                    </div>
                  ) : (
                    <p className="text-center text-[#747D8C]">
                      まだレビューがありません
                    </p>
                  )}
                </div>

                {/* 右側: 総合評価と研究室概要 */}
                <div>
                  {/* 総合評価 */}
                  <div className="mb-4">
                    <h2 className="text-base font-semibold text-black mb-2">
                      総合評価({lab.reviews?.length || 0})
                    </h2>
                    {lab.reviews && lab.reviews.length > 0 ? (
                      <div className="flex items-center gap-2 ml-4">
                        <StarRating rating={overallAverage || 0} />
                        <span className="text-sm text-[#F4BB42]">
                          {formatRating(overallAverage, "0.00")}
                        </span>
                      </div>
                    ) : (
                      <p className="text-sm text-[#747D8C] ml-4">まだ評価がありません</p>
                    )}
                  </div>

                  {/* 研究室概要 */}
                  <h2 className="text-base font-semibold text-black mb-2">
                    研究室概要
                  </h2>
                  <p className="text-sm text-[#747D8C] whitespace-pre-wrap leading-tight ml-4">
                    {lab.description || "概要はまだ登録されていません"}
                  </p>

                  {/* 研究室ページ */}
                  <div className="mt-4">
                    <h2 className="text-base font-semibold text-black mb-2">
                      研究室ページ
                    </h2>
                    {lab.url ? (
                      <a
                        href={lab.url}
                        target="_blank"
                        rel="noopener noreferrer"
                        className="text-sm text-[#747D8C] hover:text-black hover:underline break-all ml-4"
                      >
                        {lab.url}
                      </a>
                    ) : (
                      <p className="text-sm text-[#747D8C] ml-4">URLはまだ登録されていません</p>
                    )}
                  </div>

                  {/* 教授 */}
                  <div className="mt-4">
                    <h2 className="text-base font-semibold text-black mb-2">
                      教授  
                    </h2>
                    <p className="text-sm text-[#747D8C] ml-4">
                      {lab.professor_name ? `${lab.professor_name} 先生` : "教授名はまだ登録されていません"}
                    </p>
                  </div>

                  {/* 男女比 */}
                  <div className="mt-4">
                    <h2 className="text-base font-semibold text-black mb-2">
                      男女比{(lab.gender_ratio_male != null && lab.gender_ratio_female != null) && `(${lab.gender_ratio_male}:${lab.gender_ratio_female})`}
                    </h2>
                    {(lab.gender_ratio_male != null && lab.gender_ratio_female != null) ? (
                      <div className="flex w-full h-6 rounded overflow-hidden text-sm text-white font-medium ml-4">
                        {lab.gender_ratio_male > 0 && (
                          <div
                            className="flex items-center justify-center"
                            style={{
                              backgroundColor: "#7BB3CE",
                              width: `${(lab.gender_ratio_male / (lab.gender_ratio_male + lab.gender_ratio_female)) * 100}%`,
                            }}
                          ></div>
                        )}
                        {lab.gender_ratio_female > 0 && (
                          <div
                            className="flex items-center justify-center"
                            style={{
                              backgroundColor: "#E89EB9",
                              width: `${(lab.gender_ratio_female / (lab.gender_ratio_male + lab.gender_ratio_female)) * 100}%`,
                            }}
                          ></div>
                        )}
                      </div>
                    ) : (
                      <p className="text-sm text-[#747D8C] ml-4">男女比はまだ登録されていません</p>
                    )}
                  </div>

                  {/* コメント一覧 */}
                  <div className="mt-4">
                    <h2 className="text-base font-semibold text-black mb-2">
                      {comments?.length || 0}件のコメント
                    </h2>
                    {comments && comments.length > 0 ? (
                      <div className="space-y-3 ml-4">
                        {/* 最初の1件は常に表示 */}
                        <div key={comments[0].id} className="border-b border-gray-200 pb-3">
                          <h3 className="text-sm font-medium text-black">
                            {comments[0].user?.name || "匿名"}
                          </h3>
                          <p className="text-sm text-[#747D8C] mt-1 whitespace-pre-wrap">
                            {comments[0].content}
                          </p>
                        </div>

                        {/* 2件以上の場合、もっと見るボタンまたは残りのコメントを表示 */}
                        {comments.length > 1 && (
                          <>
                            {showAllComments ? (
                              // 残りのコメントを表示
                              comments.slice(1).map((comment) => (
                                <div key={comment.id} className="border-b border-gray-200 pb-3">
                                  <h3 className="text-sm font-medium text-black">
                                    {comment.user?.name || "匿名"}
                                  </h3>
                                  <p className="text-sm text-[#747D8C] mt-1 whitespace-pre-wrap">
                                    {comment.content}
                                  </p>
                                </div>
                              ))
                            ) : (
                              // もっと見るボタン
                              <button
                                onClick={() => setShowAllComments(true)}
                                className="text-sm text-[#747D8C] hover:text-black hover:underline"
                              >
                                もっと見る...
                              </button>
                            )}
                          </>
                        )}
                      </div>
                    ) : (
                      <p className="text-sm text-[#747D8C] ml-4">まだコメントがありません</p>
                    )}
                  </div>

                  {/* ブックマークアイコン */}
                  <div className="mt-4 flex items-center justify-end">
                    <div className="flex items-center gap-1 min-w-[50px] justify-end">
                      <svg
                        width="20"
                        height="20"
                        viewBox="0 0 24 24"
                        xmlns="http://www.w3.org/2000/svg"
                        onClick={handleBookmarkClick}
                        className={`flex-shrink-0 ${auth?.user ? "cursor-pointer hover:opacity-70" : "cursor-default"}`}
                      >
                        <path
                          d="M5 2C4.44772 2 4 2.44772 4 3V21C4 21.3746 4.21048 21.7178 4.54555 21.8892C4.88062 22.0606 5.28335 22.0315 5.59026 21.8137L12 17.229L18.4097 21.8137C18.7166 22.0315 19.1194 22.0606 19.4545 21.8892C19.7895 21.7178 20 21.3746 20 21V3C20 2.44772 19.5523 2 19 2H5Z"
                          fill={isBookmarked ? "#747D8C" : "transparent"}
                          stroke="#747D8C"
                          strokeWidth="2"
                        />
                      </svg>
                      <span className="text-sm text-gray-600 tabular-nums min-w-[1.5ch] text-left">{currentBookmarkCount}</span>
                    </div>
                  </div>
                </div>
              </div>
            </div>

          {/* ボタンエリア */}
          <div className="mt-auto pt-8 pb-12 flex justify-center gap-4">
            <CreateReviewButton routerName="review.create" params={{ lab: lab }} />
          </div>
          </div>
      {/* 研究室編集モーダル */}
      <EditLabModal
        isOpen={isEditModalOpen}
        onClose={() => setIsEditModalOpen(false)}
        lab={lab}
      />
    </AppLayout>
  );
};

export default Show;

2.4 バリデーション修正

先ほどのコントローラーのstoreメソッドの修正と同様に、updateメソッドに修正を加えます。

一部間違えて実装されていた個所もあたので、ついでに直しておきましたw

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

namespace App\Http\Controllers;

// use宣言...

class LabController extends Controller
{
    use AuthorizesRequests;

    // その他のメソッド定義...

    public function update(Request $request, Lab $lab)
    {
        $this->authorize('update', Lab::class);

        $validated = $request->validate([
            'name' => 'required|string|max:50|unique:labs,name,' . $lab->id . ',id,faculty_id,' . $lab->faculty_id,
            'description' => 'nullable|string|max:150', // 修正
            'url' => 'nullable|url|max:255',
            'professor_name' => 'nullable|string|max:25', // 追加
            'professor_url' => 'nullable|url|max:255',
            'gender_ratio_male' => 'required|integer|min:0|max:10',
            'gender_ratio_female' => [
                'required',
                'integer',
                'min:0',
                'max:10',
                function ($attribute, $value, $fail) use ($request) {
                    $male = (int) $request->input('gender_ratio_male', 0);
                    $female = (int) $value;
                    if ($male + $female !== 10) {
                        $fail('男女比の合計は10である必要があります。');
                    }
                },
            ],
            'comment' => 'required|string|max:255',
            'version' => 'required|integer',
        ]);

        DB::beginTransaction();

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

            // 更新
            $current->name = $validated['name'];
            $current->description = $validated['description'];
            $current->url = $validated['url'];
            $current->professor_name = $validated['professor_name']; // 追加
            $current->professor_url = $validated['professor_url'];
            $current->gender_ratio_male = $validated['gender_ratio_male']; // 修正
            $current->gender_ratio_female = $validated['gender_ratio_female']; // 修正
            $current->version += 1;
            $current->save();

            // 履歴保存
            $userId = $request->user()->id;
            $lab->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','description','url','professor_url'])
                    ->map(fn($new, $key) => [
                        'old' => $old[$key] ?? null,
                        'new' => $new,
                    ])
                    ->toArray();

                $current->creator->notify(
                    new ModelChangedNotification('edited', '研究室', $current->name, $changes)
                );
            }
            
            return redirect()->route('labs.show', ['lab' => $lab])->with('success', '研究室が更新されました。');
        } catch (\Exception $e) {
            DB::rollBack();
            throw $e;
        }
    }
}

2.5 動作確認

ケバブメニュー押下でメニューポップオーバーが開きますので、「編集する」をクリック。
一番上のメニューが空欄になっていますが、これは次回直すので今は気にしないでください。
image.png

適当な値を入力して送信。
image.png

更新されました!
image.png

編集モーダルの作成もこれにて完了です!
コミット・プッシュ、PR作成・マージをしてリモートリポジトリにあるdevelopブランチに変更を反映させましょう。

3. (おまけその9)教授URLをUIへ反映

例によって、おまけコーナーです。
不具合修正を行いたいと思います。

研究室を作成・編集する際に「教授URL」を入力していたと思います。
バックエンドでも処理して、DBにも登録されています。

しかし、研究室詳細画面において、それを表示していませんでした。

今回は、それを修正したいと思います。

3.1 ブランチ運用

先ほど変更をローカルのdevelopブランチに反映させて、最新化します。
そこから、新しいブランチを切って作業します。

今までテキトーだったブランチ名は、今回はしっかりと決めていきたいと思います!(笑)
ブランチ名: fix/frontend/missing-implementation-professor_url

3.2 研究室詳細画面修正

方針としては、教授URLをそのまま表示するのではなく、URLがあれば教授名にリンクを張るように修正したいと思います!

クリックでコードを表示
\project-root\src\resources\js\Pages\Lab\Show.jsx
                  {/* 教授 */}
                  <div className="mt-4">
                    <h2 className="text-base font-semibold text-black mb-2">
                      教授  
                    </h2>
                    {lab.professor_name ? (
                      lab.professor_url ? (
                        <a
                          href={lab.professor_url}
                          target="_blank"
                          rel="noopener noreferrer"
                          className="text-sm text-[#747D8C] hover:text-black hover:underline break-all ml-4"
                        >
                          {`${lab.professor_name} 先生`}
                        </a>
                      ) : (
                        <p className="text-sm text-[#747D8C] ml-4">
                          {`${lab.professor_name} 先生`}
                        </p>
                      )
                    ) : (
                      <p className="text-sm text-[#747D8C] ml-4">
                        教授名はまだ登録されていません
                      </p>
                    )}
                  </div>

3.3 動作確認

教授URLがある場合は、リンクとして機能します。
image.png

試しに、先ほど作った編集機能でURLを空欄のまま更新してみると、URLがないので押せなくなりました。
image.png

出来たら、例によって、コミット・プッシュ、PR作成・マージをお忘れなく行いましょう!

4. (おまけその10)バックエンドPHPDocその2

前回に引き続き、バックエンドのコントローラーのリファクタリングを行っていきましょう~。

4.1 ブランチ運用

前回使った、refactor/php-doc/controllersブランチにチェックアウトして作業をしていきます。

4.2 研究室コントローラー修正

前回、大学・学部コントローラーを修正したので、今回は続きとして、研究室コントローラーを修正しましょう。

今日、作成・編集モーダルを作成したために作成・編集ページは必要なくなりました。
それに伴って、createeditメソッドは使われなくなったので、削除しておきました。

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

namespace App\Http\Controllers;

// use 宣言...

class LabController extends Controller
{
    use AuthorizesRequests;

    public function show(Lab $lab, Request $request): Response
    {
      // 処理...
    }

    public function store(Request $request, Faculty $faculty): RedirectResponse
    {
      // 処理...
    }

    public function index(Faculty $faculty, Request $request): Response
    {
      // 処理...
    }

    public function update(Request $request, Lab $lab): RedirectResponse
    {
      // 処理...
    }

    public function history(Lab $lab): Response
    {
      // 処理...
    }
}

※前回引き続き、肝心のPHPDocが抜けています。
適切なものを付けておいてください。

あとは、以下の二つのページコンポーネントは使われなくなったので削除しておきましょう。

  • resources/js/Pages/Lab/Create.jsx
  • resources/js/Pages/Lab/Edit.jsx

できたら、コミット・プッシュしておきましょう。
PR作成・マージはまだしないでおいてください。

5. まとめ・次回予告

お疲れ様でした!

今日は、研究室の作成・編集モーダルを作成しました。
編集・作成モーダルシリーズも残すところ、次回のレビュー作成・編集モーダルを作成すれば完了です!

あと一息ですので、引き続き頑張って行きましょう!

これまでの記事一覧

☆要件定義・設計編

☆環境構築編

☆バックエンド実装編

☆フロントエンド実装編

軽く宣伝

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

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

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?