1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

1
Posted at

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

0. 初めに

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

全てのソースコードを公開しているので、シリーズの最初から見てくだされば、初心者の方でもWebアプリケーションを開発する方法を学ぶことができます!

1ヵ月以上投稿をお休みしてしまってすみませんでした。
先月は本業の方が過去一番忙しくて、夜遅くまで残って作業をする日が多かったです。(+_+)

どうにか落ち着いてきたので、再開していきたいと思います!
それに伴って、投稿頻度もあげていければなと思っておりますのでよろしくお願いいたします!!

今回は、前回に引き続き作成・編集モーダルの開発を進めます!
今日は、レビューの作成・編集用モーダルを作りたいと思います。

1. 作成モーダル作成

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

1.1 ブランチ運用

ローカルリポジトリのdevelopブランチを最新化して、そこから新しくfeature/frontend/review-create-modalというブランチを切って作業します。

1.2 レビュー作成ボタン削除

まず、いつも画面の下にあって、スクロールしないと見られなかったレビュー作成用のボタンを削除したいと思います。

image.png
せっかく作ったのですが、スクロールしないと見えない点多くのユーザーがレビューを投稿するよりも閲覧することの方が多いだろうという点から、ここまで目立つボタンとして配置する必要はないかなと判断しました。

よって、以下のコンポーネント削除してください。

  • resources/js/Components/Review/CreateReviewButton.jsx

1.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 CreateReviewModal from '@/Components/Review/CreateReviewModal';
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 [isCreateModalOpen, setIsCreateModalOpen] = 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 handleAddReviewClick = () => {
    setIsMenuOpen(false);
    setIsCreateModalOpen(true);
  };

  /**
   * メニューポップオーバーの「編集する」クリック時の処理
   * @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
                addLabel="レビューを投稿する"
                onAddClick={handleAddReviewClick}
                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>
                {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>

              {/* 男女比 */}
              <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>
      {/* レビュー作成モーダル */}
      <CreateReviewModal
        isOpen={isCreateModalOpen}
        onClose={() => setIsCreateModalOpen(false)}
        lab={lab}
      />
      {/* 研究室編集モーダル */}
      <EditLabModal
        isOpen={isEditModalOpen}
        onClose={() => setIsEditModalOpen(false)}
        lab={lab}
      />
    </AppLayout>
  );
};

export default Show;

1.4 送信ボタン作成

クリックでコードを見る
\project-root\src\resources\js\Components\Review\ReviewSubmitButton.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 ReviewSubmitButton = ({ 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 ReviewSubmitButton;

1.5 作成モーダル作成

クリックでコードを見る
\project-root\src\resources\js\Components\Review\CreateReviewModal.jsx
import { useForm } from '@inertiajs/react';
import { useState } from 'react';
import Modal from '../Common/Modal';
import ReviewSubmitButton from './ReviewSubmitButton';
import StarIcon from '@/Components/Lab/Star/StarIcon';

/**
 * レビュー作成モーダル
 * @param {Object} props
 * @param {boolean} props.isOpen - モーダルの開閉状態
 * @param {Function} props.onClose - モーダルを閉じる
 * @param {Object} props.lab - 研究室オブジェクト
 * @returns {JSX.Element} コンポーネントのJSX
 */

const CreateReviewModal = ({ isOpen, onClose, lab }) => {
  return (
    <Modal
      isOpen={isOpen}
      onClose={onClose}
      title="レビューを作成する"
      size='sm'
    >
      <div className="h-[300px] flex flex-col">
        {/* フォーム領域 */}
        <div className="flex-1 overflow-y-auto">
          <CreateReviewForm onClose={onClose} lab={lab} />
        </div>
      </div>
    </Modal>
  );
};

/**
 * レビュー作成フォーム
 * @param {Object} props
 * @param {Function} props.onClose - モーダルを閉じる
 * @param {Object} props.lab - 研究室オブジェクト
 * @returns {JSX.Element} コンポーネントのJSX
 */
const CreateReviewForm = ({ onClose, lab }) => {
  const { data, setData, post, processing, errors, reset } = useForm({
    mentorship_style: 0,
    lab_atmosphere: 0,
    achievement_activity: 0,
    constraint_level: 0,
    facility_quality: 0,
    work_style: 0,
    student_balance: 0,
  });

  const handleRatingSelect = (field) => (value) => {
    setData(field, value);
  };

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

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

        <StarRatingInput
          label="指導スタイル"
          value={data.mentorship_style}
          error={errors.mentorship_style}
          onChange={handleRatingSelect('mentorship_style')}
        />

        <StarRatingInput
          label="雰囲気・文化"
          value={data.lab_atmosphere}
          error={errors.lab_atmosphere}
          onChange={handleRatingSelect('lab_atmosphere')}
        />

        <StarRatingInput
          label="成果・活動"
          value={data.achievement_activity}
          error={errors.achievement_activity}
          onChange={handleRatingSelect('achievement_activity')}
        />

        <StarRatingInput
          label="拘束度"
          value={data.constraint_level}
          error={errors.constraint_level}
          onChange={handleRatingSelect('constraint_level')}
        />

        <StarRatingInput
          label="設備"
          value={data.facility_quality}
          error={errors.facility_quality}
          onChange={handleRatingSelect('facility_quality')}
        />

        <StarRatingInput
          label="働き方"
          value={data.work_style}
          error={errors.work_style}
          onChange={handleRatingSelect('work_style')}
        />

        <StarRatingInput
          label="人数バランス"
          value={data.student_balance}
          error={errors.student_balance}
          onChange={handleRatingSelect('student_balance')}
        />
      </div>

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

const StarRatingInput = ({ label, value, onChange, error }) => {
  const [hoverValue, setHoverValue] = useState(0);
  const activeValue = hoverValue || value;
  const inputId = `star-rating-${label}`;

  return (
    <div className="mb-4">
      <div className="flex items-baseline">
        <label id={inputId} className="text-sm font-medium text-black">
          {label}
        </label>
      </div>

      <ErrorSlot message={error} />

      <div
        role="radiogroup"
        aria-labelledby={inputId}
        className="flex items-center gap-1"
        onMouseLeave={() => setHoverValue(0)}
      >
        {[1, 2, 3, 4, 5].map((starValue) => {
          const isFilled = starValue <= activeValue;
          return (
            <button
              key={starValue}
              type="button"
              role="radio"
              aria-checked={value === starValue}
              onMouseEnter={() => setHoverValue(starValue)}
              onFocus={() => setHoverValue(starValue)}
              onBlur={() => setHoverValue(0)}
              onClick={() => onChange(starValue)}
              className="p-1 rounded hover:bg-black/5 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#33E1ED]"
            >
              <StarIcon size={24} fillPercentage={isFilled ? 100 : 0} />
            </button>
          );
        })}
      </div>
    </div>
  );
};

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

export default CreateReviewModal;

星アイコンの大きさを調整したかったので、コンポーネントのpropsを追加して調整できるようにしておきました。

resources/js/Components/Lab/Star/StarIcon.jsx

クリックでコードを見る
\project-root\src\resources\js\Components\Lab\Star\StarIcon.jsx
import { useId } from "react";

/**
 * 星アイコンコンポーネント(グラデーション塗りつぶし対応)
 * @param {Object} props - コンポーネントのprops
 * @param {number} [props.fillPercentage=100] - 塗りつぶし割合(0〜100)
 * @param {number} [props.size=16] - アイコンサイズ(px)
 * @param {string} [props.className] - 追加クラス
 * @returns {JSX.Element} コンポーネントのJSX
 */
const StarIcon = ({ fillPercentage, size = 16, className }) => {
  const id = useId();
  const gradientId = `starGradient-${id}`;
  const fillColor = "#F4BB42";
  const emptyColor = "#E2EDF6";

  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 42 42"
      fill="none"
      xmlns="http://www.w3.org/2000/svg"
      className={className}
    >
      <defs>
        <linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
          <stop offset={`${fillPercentage}%`} stopColor={fillColor} />
          <stop offset={`${fillPercentage}%`} stopColor={emptyColor} />
        </linearGradient>
      </defs>
      <path 
        d="M20.4125 1.29884L15.6511 10.953L4.99795 12.5061C3.08753 12.7832 2.32191 15.1384 3.70733 16.4874L11.4146 23.9978L9.5917 34.6072C9.26358 36.5249 11.2834 37.9613 12.975 37.0645L22.5052 32.0551L32.0355 37.0645C33.7271 37.9541 35.7469 36.5249 35.4188 34.6072L33.5959 23.9978L41.3032 16.4874C42.6886 15.1384 41.923 12.7832 40.0125 12.5061L29.3594 10.953L24.598 1.29884C23.7448 -0.421992 21.273 -0.443867 20.4125 1.29884Z" 
        fill={`url(#${gradientId})`}
      />
    </svg>
  );
};

export default StarIcon;

1.6 バリデーション修正

レビューの初期値を0にしたのですが、現在のバリデーションメッセージは「1以上にしてください」と「5以下にしてください」しかありません。

0の場合は入力忘れということになるので、「必須です」というように表示したいです。

ReviewRatingRequestを作成する

そこで思いついた(正確にはAIに教えてもらった)のが、レビュー投稿用のリクエストクラスを作成してしまうというものです。
以下のファイルを新規作成してみてください。
app/Http/Requests/ReviewRatingRequest.php

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

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class ReviewRatingRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    protected function prepareForValidation(): void
    {
        $ratingFields = [
            'mentorship_style',
            'lab_atmosphere',
            'achievement_activity',
            'constraint_level',
            'facility_quality',
            'work_style',
            'student_balance',
        ];

        $converted = [];

        foreach ($ratingFields as $field) {
            if ($this->has($field) && (int) $this->input($field) === 0) {
                $converted[$field] = null;
            }
        }

        if ($converted !== []) {
            $this->merge($converted);
        }
    }

    public function rules(): array
    {
        return [
            'mentorship_style' => ['bail', 'required', 'integer', 'min:1', 'max:5'],
            'lab_atmosphere' => ['bail', 'required', 'integer', 'min:1', 'max:5'],
            'achievement_activity' => ['bail', 'required', 'integer', 'min:1', 'max:5'],
            'constraint_level' => ['bail', 'required', 'integer', 'min:1', 'max:5'],
            'facility_quality' => ['bail', 'required', 'integer', 'min:1', 'max:5'],
            'work_style' => ['bail', 'required', 'integer', 'min:1', 'max:5'],
            'student_balance' => ['bail', 'required', 'integer', 'min:1', 'max:5'],
        ];
    }
}

FormRequestを継承したクラスは、rulesメソッドにバリデーションルールを書くことができるのですが、このクラスではprepareForValidationを用意して、0を'null'にあらかじめ変換します。

バリデーションメッセージを追加する

これで、要件を満たせます!

resources/lang/ja/validation.php

クリックでコードを見る
\project-root\src\resources\lang\ja\validation.php
        // レビュー関連
        'mentorship_style' => [
            'required' => '指導スタイルは必須です',
            'max' => '指導スタイルは5以下の値にしてください。',
            'min' => '指導スタイルは1以上の値にしてください。',
        ],
        'lab_atmosphere' => [
            'required' => '雰囲気・文化は必須です',
            'max' => '雰囲気・文化は5以下の値にしてください。',
            'min' => '雰囲気・文化は1以上の値にしてください。',
        ],
        'achievement_activity' => [
            'required' => '成果・活動は必須です',
            'max' => '成果・活動は5以下の値にしてください。',
            'min' => '成果・活動は1以上の値にしてください。',
        ],
        'constraint_level' => [
            'required' => '拘束度は必須です',
            'max' => '拘束度は5以下の値にしてください。',
            'min' => '拘束度は1以上の値にしてください。',
        ],
        'facility_quality' => [
            'required' => '設備は必須です',
            'max' => '設備は5以下の値にしてください。',
            'min' => '設備は1以上の値にしてください。',
        ],
        'work_style' => [
            'required' => '働き方は必須です',
            'max' => '働き方は5以下の値にしてください。',
            'min' => '働き方は1以上の値にしてください。',
        ],
        'student_balance' => [
            'required' => '人数バランスは必須です',
            'max' => '人数バランスは5以下の値にしてください。',
            'min' => '人数バランスは1以上の値にしてください。',
        ],

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

バリデーションのルールをリクエスト側に書くようにしたので、コントローラーの方はもっとシンプルになります。

app/Http/Controllers/ReviewController.php

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

namespace App\Http\Controllers;

use App\Http\Requests\ReviewRatingRequest; // 追加
use App\Models\Lab;
use App\Models\Review;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Inertia\Inertia;

class ReviewController extends Controller
{
    use AuthorizesRequests;
    
    public function create(Lab $lab) {
        // ポリシーで認可をチェック
        $this->authorize('create', [Review::class, $lab]);
        return Inertia::render('Review/Create', ['lab' => $lab]);
    }

    public function store(ReviewRatingRequest $request, Lab $lab) { // 修正: 引数の型
        // ポリシーで認可をチェック
        $this->authorize('create', [Review::class, $lab]);
        $validated = $request->validated(); // 修正: シンプルに

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

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

    public function edit(Review $review) {
        // ポリシーで認可をチェック
        $this->authorize('update', $review);
        $review->load('lab');
        return Inertia::render('Review/Edit', ['review' => $review,]);
    }

    public function update(ReviewRatingRequest $request, Review $review) { // 修正: 引数の型
        // ポリシーで認可をチェック
        $this->authorize('update', $review);
        $validated = $request->validated(); // 修正: シンプルに

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

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

    public function destroy(Review $review) {
        // ポリシーで認可をチェック
        $this->authorize('delete', $review);

        $labId = $review->lab_id; // 追加: 研究室IDを取得

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

1.7 動作確認

ここまでできたら、実際に画面を触ってみましょう。
ケバブメニューアイコンからメニューポップアップを開き、「レビューを投稿する」が表示されることを確認。
image.png

クリックで、作成モーダルが開かれるかをチェック。
image.png

レビューを適当に選択して、「レビューする」をクリックすると...
image.png

レビューが投稿できました!
image.png

ちなみに、何も選択せずに送信しようとするとしっかり意図したとおりのメッセージが表示されました。
image.png

問題なさそうでしたら、コミット・プッシュ、PR作成・developブランチへのマージをしましょう。

2. 編集モーダル作成

次に編集モーダルを作りましょう。

2.1 ブランチ運用

developブランチにチェックアウトして先ほどの更新履歴をプルしましょう。

プル出来たら、そこから新しくfeature/frontend/review-edit-modalというブランチを切って作業を開始します!

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

レビューを投稿済みの時は、「レビューを投稿する」というメニューは表示されないようにします。

resources/js/Components/Common/MenuPopover.jsx

クリックでコードを見る
\project-root\src\resources\js\Components\Common\MenuPopover.jsx
const MenuPopover = ({ addLabel, onAddClick, onEditClick }) => {
  const menuItems = [
    ...(addLabel ? [{ label: addLabel, onClick: onAddClick }] : []), // 修正
    { label: '編集する', onClick: onEditClick },
    { label: '編集履歴を見る', onClick: () => {} },
    { label: '削除依頼をする', onClick: () => {} },
  ];

2.2 編集モーダル作成

レビューには削除機能があるので、今までと違ってアイコン押下で編集可能状態に切り替えられるようにしています。

resources/js/Components/Review/EditReviewModal.jsx

クリックでコードを見る
\src\resources\js\Components\Review\EditReviewModal.jsx
import { useForm } from '@inertiajs/react';
import { useState } from 'react';
import Modal from '../Common/Modal';
import ReviewSubmitButton from './ReviewSubmitButton';
import StarIcon from '@/Components/Lab/Star/StarIcon';
import EditIcon from '@/Assets/icons/edit.svg';
import TrashIcon from '@/Assets/icons/trash.svg';

/**
 * レビュー編集モーダル
 * @param {Object} props
 * @param {boolean} props.isOpen - モーダルの開閉状態
 * @param {Function} props.onClose - モーダルを閉じる
 * @param {Object} props.lab - 研究室オブジェクト
 * @param {Object} props.review - 編集するレビューオブジェクト
 * @param {Function} props.onDelete - 削除ボタン押下時のコールバック
 * @returns {JSX.Element} コンポーネントのJSX
 */

const EditReviewModal = ({ isOpen, onClose, lab, review, onDelete }) => {
  const [isEditing, setIsEditing] = useState(false);

  const handleClose = () => {
    setIsEditing(false);
    onClose();
  };

  const modalTitle = (
    <span className="flex items-center w-full">
      <span className={isEditing ? 'text-[#297FF0] border-b-2 border-[#297FF0]' : ''}>
        {isEditing ? 'レビューを編集する' : 'あなたが投稿済したレビュー'}
      </span>
      <span className="flex items-center gap-2 ml-2">
        {isEditing ? (
          <button 
            type="button" 
            onClick={() => setIsEditing(false)} 
            className="text-[#747D8C] text-sm"
          >
            キャンセル
          </button>
        ) : (
          <>
            <button type="button" onClick={() => setIsEditing(true)} aria-label="編集">
              <img src={EditIcon} alt="編集" className="w-5 h-5" />
            </button>
            <button type="button" onClick={onDelete} aria-label="削除">
              <img src={TrashIcon} alt="削除" className="w-5 h-5" />
            </button>
          </>
        )}
      </span>
    </span>
  );

  return (
    <Modal
      isOpen={isOpen}
      onClose={handleClose}
      title={modalTitle}
      size='sm'
    >
      <div className="h-[300px] flex flex-col">
        {/* フォーム領域 */}
        <div className="flex-1 overflow-y-auto">
          <EditReviewForm onClose={handleClose} lab={lab} review={review} isEditing={isEditing} />
        </div>
      </div>
    </Modal>
  );
};

/**
 * レビュー編集フォーム
 * @param {Object} props
 * @param {Function} props.onClose - モーダルを閉じる
 * @param {Object} props.lab - 研究室オブジェクト
 * @param {Object} props.review - 編集するレビューオブジェクト
 * @returns {JSX.Element} コンポーネントのJSX
 */
const EditReviewForm = ({ onClose, lab, review, isEditing }) => {
  const { data, setData, post, processing, errors, reset } = useForm({
    mentorship_style: review?.mentorship_style || 0,
    lab_atmosphere: review?.lab_atmosphere || 0,
    achievement_activity: review?.achievement_activity || 0,
    constraint_level: review?.constraint_level || 0,
    facility_quality: review?.facility_quality || 0,
    work_style: review?.work_style || 0,
    student_balance: review?.student_balance || 0,
  });

  const handleRatingSelect = (field) => (value) => {
    setData(field, value);
  };

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

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

        <StarRatingInput
          label="指導スタイル"
          value={data.mentorship_style}
          error={errors.mentorship_style}
          onChange={handleRatingSelect('mentorship_style')}
          disabled={!isEditing}
        />

        <StarRatingInput
          label="雰囲気・文化"
          value={data.lab_atmosphere}
          error={errors.lab_atmosphere}
          onChange={handleRatingSelect('lab_atmosphere')}
          disabled={!isEditing}
        />

        <StarRatingInput
          label="成果・活動"
          value={data.achievement_activity}
          error={errors.achievement_activity}
          onChange={handleRatingSelect('achievement_activity')}
          disabled={!isEditing}
        />

        <StarRatingInput
          label="拘束度"
          value={data.constraint_level}
          error={errors.constraint_level}
          onChange={handleRatingSelect('constraint_level')}
          disabled={!isEditing}
        />

        <StarRatingInput
          label="設備"
          value={data.facility_quality}
          error={errors.facility_quality}
          onChange={handleRatingSelect('facility_quality')}
          disabled={!isEditing}
        />

        <StarRatingInput
          label="働き方"
          value={data.work_style}
          error={errors.work_style}
          onChange={handleRatingSelect('work_style')}
          disabled={!isEditing}
        />

        <StarRatingInput
          label="人数バランス"
          value={data.student_balance}
          error={errors.student_balance}
          onChange={handleRatingSelect('student_balance')}
          disabled={!isEditing}
        />
      </div>

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

const StarRatingInput = ({ label, value, onChange, error, disabled = false }) => {
  const [hoverValue, setHoverValue] = useState(0);
  const activeValue = disabled ? value : (hoverValue || value);
  const inputId = `star-rating-${label}`;

  return (
    <div className="mb-4">
      <div className="flex items-baseline">
        <label id={inputId} className="text-sm font-medium text-black">
          {label}
        </label>
      </div>

      <ErrorSlot message={error} />

      <div
        role="radiogroup"
        aria-labelledby={inputId}
        className="flex items-center gap-1"
        onMouseLeave={() => !disabled && setHoverValue(0)}
      >
        {[1, 2, 3, 4, 5].map((starValue) => {
          const isFilled = starValue <= activeValue;
          return (
            <button
              key={starValue}
              type="button"
              role="radio"
              aria-checked={value === starValue}
              disabled={disabled}
              onMouseEnter={() => !disabled && setHoverValue(starValue)}
              onFocus={() => !disabled && setHoverValue(starValue)}
              onBlur={() => !disabled && setHoverValue(0)}
              onClick={() => !disabled && onChange(starValue)}
              className={`p-1 rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-[#33E1ED] ${
                disabled ? 'cursor-default' : 'hover:bg-black/5'
              }`}
            >
              <StarIcon size={24} fillPercentage={isFilled ? 100 : 0} />
            </button>
          );
        })}
      </div>
    </div>
  );
};

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

export default EditReviewModal;

2.3 削除アラート作成

削除アイコンを押した直後に削除機能が実行されてしまうと間違ってクリックしてしまったときに大変なので、一度確認用のアラートモーダルを表示するようにしたいと思います。

resources/js/Components/Common/AlertModal.jsx

クリックでコードを見る
\project-root\src\resources\js\Components\Common\AlertModal.jsx
import Modal from "./Modal";
import AlertCancelButton from "./AlertCancelButton";
import AlertActionButton from "./AlertActionButton";

/**
 * アラートモーダルコンポーネント
 * @param {Object} props
 * @param {boolean} props.isOpen - モーダルの開閉状態
 * @param {Function} props.onClose - モーダルを閉じる関数
 * @param {string} props.title - アラートのタイトル
 * @param {string} props.message - アラートのメッセージ
 * @param {string} [props.actionLabel='削除する'] - アクションボタンのラベル
 * @param {Function} props.onAction - アクションボタン押下時のコールバック
 * @param {string} [props.cancelLabel='キャンセル'] - キャンセルボタンのラベル
 * @param {boolean} [props.isProcessing=false] - 処理中かどうか
 */
const AlertModal = ({
  isOpen,
  onClose,
  title,
  message,
  actionLabel = "削除する",
  onAction,
  cancelLabel = "キャンセル",
  isProcessing = false,
}) => {
  return (
    <Modal isOpen={isOpen} onClose={onClose} title={title} size="sm">
      <p className="text-sm text-gray-600">{message}</p>

      <div className="mt-6 flex justify-end gap-3">
        <AlertCancelButton
          onClick={onClose}
          disabled={isProcessing}
          label={cancelLabel}
        />

        <AlertActionButton
          onClick={onAction}
          disabled={isProcessing}
          label={actionLabel}
          isProcessing={isProcessing}
        />
      </div>
    </Modal>
  );
};

export default AlertModal;

ゴミ箱のアイコンは以下を適宜ダウンロードして使ってみてください。
trash

2.4 キャンセルボタン・実行ボタン作成

このアラートモーダルで使うためのボタンコンポーネントを用意します。

  • resources/js/Components/Common/AlertCancelButton.jsx
クリックでコードを見る
\project-root\src\resources\js\Components\Common\AlertCancelButton.jsx
/**
 * アラートモーダル用キャンセルボタンコンポーネント
 * @param {Object} props
 * @param {Function} props.onClick - ボタン押下時のコールバック
 * @param {boolean} [props.disabled=false] - 無効状態
 * @param {string} [props.label='キャンセル'] - ボタンのラベル
 */
const AlertCancelButton = ({ onClick, disabled = false, label = 'キャンセル' }) => {
  return (
    <button
      type="button"
      onClick={onClick}
      disabled={disabled}
      className="px-6 py-2 bg-[#EEF7FB] text-[#747D8C] shadow-md font-bold rounded-md hover:shadow-lg transition-shadow cursor-pointer min-w-[100px] text-sm"
    >
      {label}
    </button>
  );
};

export default AlertCancelButton;

  • resources/js/Components/Common/AlertActionButton.jsx
クリックでコードを見る
\project-root\src\resources\js\Components\Common\AlertActionButton.jsx
/**
 * アラートモーダル用アクションボタンコンポーネント
 * @param {Object} props
 * @param {Function} props.onClick - ボタン押下時のコールバック
 * @param {boolean} [props.disabled=false] - 無効状態
 * @param {string} [props.label='削除する'] - ボタンのラベル
 * @param {boolean} [props.isProcessing=false] - 処理中かどうか
 */
const AlertActionButton = ({
  onClick,
  disabled = false,
  label = '削除する',
  isProcessing = false,
}) => {
  return (
    <button
      type="button"
      onClick={onClick}
      disabled={disabled}
      className={`
        px-6 py-2 bg-[#EEF7FB] text-[#FF0000] font-bold rounded-md hover:shadow-lg transition-shadow cursor-pointer min-w-[100px] text-sm
        ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
      `}
      style={{
        boxShadow: 'inset 0 0 0 3px #EEF7FB, inset 0 0 0 4px #FF0000, 0 1px 3px 0 rgba(0,0,0,0.1), 0 1px 2px -1px rgba(0,0,0,0.1)',
      }}
    >
      {isProcessing ? '処理中...' : label}
    </button>
  );
};

export default AlertActionButton;

2.5 研究室詳細画面修正

では、作成した編集モダール及び削除アラートを表示できるように研究室詳細画面を修正しましょう。

resources/js/Pages/Lab/Show.jsx

クリックでコードを見る
\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 CreateReviewModal from '@/Components/Review/CreateReviewModal';
import EditReviewModal from '@/Components/Review/EditReviewModal'; // 追加
import MenuPopover from '@/Components/Common/MenuPopover';
import KebabIcon from '@/Components/Common/KebabIcon';
import EditLabModal from '@/Components/Lab/EditLabModal';
import AlertModal from '@/Components/Common/AlertModal'; // 追加
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 [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
  const [isLabEditModalOpen, setIsLabEditModalOpen] = useState(false); // 追加
  const [isReviewEditModalOpen, setIsReviewEditModalOpen] = useState(false); // 追加
  const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false);  // 追加
  const [isDeleting, setIsDeleting] = 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 handleAddReviewClick = () => {
    setIsMenuOpen(false);
    setIsCreateModalOpen(true);
  };

  /**
   * 修正: メニューポップオーバーの「編集する」クリック時の処理
   * @returns {void}
   */
  const handleLabEditClick = () => {
    setIsMenuOpen(false);
    setIsLabEditModalOpen(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 ? (
            <button
              type="button"
              onClick={() => setIsReviewEditModalOpen(true)}
              className="text-[#747D8C] hover:underline cursor-pointer"
            >
              レビューを投稿済みです。
            </button>
          ) : (
            <p className="text-[#747D8C]">まだ、レビューを投稿していません。</p>
          )}
          {/* ケバブメニュー */}
          <div className="relative" ref={menuRef}>
            <button onClick={() => setIsMenuOpen(!isMenuOpen)} className="p-2 rounded-full">
              <KebabIcon />
            </button>
            {isMenuOpen && (
              <MenuPopover
                {...(!userReview ? { addLabel: 'レビューを投稿する', onAddClick: handleAddReviewClick } : {})}
                onEditClick={handleLabEditClick}
              />
            )}
          </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>
                {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>

              {/* 男女比 */}
              <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>
      {/* レビュー作成モーダル */}
      <CreateReviewModal
        isOpen={isCreateModalOpen}
        onClose={() => setIsCreateModalOpen(false)}
        lab={lab}
      />
      {/* 修正: レビュー編集モーダル */}
      <EditReviewModal
        isOpen={isReviewEditModalOpen}
        onClose={() => setIsReviewEditModalOpen(false)}
        lab={lab}
        review={userReview}
        onDelete={() => {
          setIsReviewEditModalOpen(false);
          setIsDeleteAlertOpen(true);
        }}
      />

      {/* 修正: レビュー削除確認モーダル */}
      <AlertModal
        isOpen={isDeleteAlertOpen}
        onClose={() => {
          setIsDeleteAlertOpen(false);
          setIsReviewEditModalOpen(true);
        }}
        title="レビューの削除"
        message="投稿済みのレビューを削除します。本当に削除しますか?"
        actionLabel="削除する"
        cancelLabel="キャンセル"
        isProcessing={isDeleting}
        onAction={() => {
          setIsDeleting(true);
          router.delete(route('review.destroy', userReview.id), {
            preserveScroll: true,
            onSuccess: () => {
              setIsDeleteAlertOpen(false);
              setIsDeleting(false);
            },
            onError: () => {
              setIsDeleting(false);
            },
          });
        }}
      />

      {/* 修正: 研究室編集モーダル */}
      <EditLabModal
        isOpen={isLabEditModalOpen}
        onClose={() => setIsLabEditModalOpen(false)}
        lab={lab}
      />
    </AppLayout>
  );
};

export default Show;

2.6 動作確認

右上の「レビューを投稿済みです。」をクリックすると編集用モーダルが開きます。この時点では、評価値の入力はまだできません。
image.png

編集アイコンをクリックすると入力できるようになるので、適当に値を変えて送信してみると...
image.png

値が更新されました!
image.png

一方で削除アイコンをクリックすると削除アラートが開きます。「削除する」をクリックすると...
image.png

消えました!
image.png

問題なさそうならコミット・プッシュ、PR作成・developブランチへのマージを忘れずに行っておきましょう!

3. (おまけその10)レビューがない場合の研究室詳細画面

おまけコーナーです。

現状だと、レビューが0件の時に余白が広すぎてちょっと違和感があります。
これを直したいと思います。
image.png

3.1 ブランチ運用

先ほどの作業履歴をローカルのdevelopブランチにプルして新しいブランチを切って作業をします。

ブランチ名は、fix/frontend/the-margins-on-the-lab-detail-too-wide-with-no-reviewsとかにします。

なんかちょっと長いかな...

3.2 研究室詳細画面修正

現在は、三項演算子でレビューがない時はグラフ自体を表示しないようにしているので、これを取り去ってグラフの枠自体は表示するように直します。
resources/js/Pages/Lab/Show.jsx

クリックでコードを見る
\project-root\src\resources\js\Pages\Lab\Show.jsx
            {/* 左側: レーダーチャート */}
            <div className="flex justify-center items-start">
              <div className="w-full max-w-sm">
                <Radar data={chartData} options={chartOptions} />
              </div>
            </div>

3.3 動作確認

ちょっと見栄えが良くなったと思います!
image.png

できたら、コミット・プッシュ、PR作成・マージをしましょう。

4. (おまけその11)修正: 送信後にモーダルの値が残ってしまう問題

適当に学部を作成します。
image.png

作成した学部を編集します。
image.png

学部名は正しく編集されていますが、再び編集モーダルを開くと前に入力した値が残ったままです。
image.png

これは、大学・学部・研究室・レビューのすべてに通じる問題なのでここでまとめて直したいと思います!

4.1 ブランチ運用

先ほどの更新履歴をdevelopブランチに取り込んで新しいブランチを切って作業します。

ブランチ名は、fix/frontend/initializing-values-on-the-submitting-modals

4.2 大学・学部・研究室・レビュー編集モーダル修正

まずは、編集モーダルたちを修正します。
修正方法は同じです。

例: resources/js/Components/University/EditUniversityModal.jsx

クリックでコードを見る
\project-root\src\resources\js\Components\University\EditUniversityModal.jsx
import { useEffect } from 'react'; // 追加

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

  useEffect(() => {
    reset();
    setData({
      name: university?.name ?? '',
      type: university?.type ?? 'national',
      comment: '',
      version: university?.version ?? 1,
    })
  }, [university]);

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

  return (...)

このように送信成功時にreset();を呼び出すことにしました。

しかし、これだけだと更新後の大学名等を反映できないので、useEffectをインポートしてuniversityの値を監視して値をセットしなおすようにしました。
ここでも一応リセットを入れています。

学部・研究室・レビュー編集モーダルも同様の修正をしてください!(記事が長くなってきたので割愛w)

ついでに、今までレビュー編集モーダルにlabを渡していましたが、使っていなかったので消しておきました。
併せて確認してみてください。

  • resources/js/Components/Review/EditReviewModal.jsx
\project-root\src\resources\js\Components\Review\EditReviewModal.jsx
/**
 * レビュー編集モーダル
 * @param {Object} props
 * @param {boolean} props.isOpen - モーダルの開閉状態
 * @param {Function} props.onClose - モーダルを閉じる
 * @param {Object} props.review - 編集するレビューオブジェクト
 * @param {Function} props.onDelete - 削除ボタン押下時のコールバック
 * @returns {JSX.Element} コンポーネントのJSX
 */

const EditReviewModal = ({ isOpen, onClose, review, onDelete }) => {
      // ...
        {/* フォーム領域 */}
        <div className="flex-1 overflow-y-auto">
          <EditReviewForm onClose={handleClose} review={review} isEditing={isEditing} />
        </div>

/**
 * レビュー編集フォーム
 * @param {Object} props
 * @param {Function} props.onClose - モーダルを閉じる
 * @param {Object} props.review - 編集するレビューオブジェクト
 * @returns {JSX.Element} コンポーネントのJSX
 */
const EditReviewForm = ({ onClose, review, isEditing }) => {
...

呼び出し元からも削除しておきましょう。

  • resources/js/Pages/Lab/Show.jsx
\project-root\src\resources\js\Pages\Lab\Show.jsx
      {/* レビュー編集モーダル */}
      <EditReviewModal
        isOpen={isReviewEditModalOpen}
        onClose={() => setIsReviewEditModalOpen(false)}
        review={userReview}
        onDelete={() => {
          setIsReviewEditModalOpen(false);
          setIsDeleteAlertOpen(true);
        }}
      />

4.3 大学・学部・研究室作成モーダル修正

作成モーダルの方はどうしようか迷ったのですが、作成後は別ページに移動しますし、ブラウザバックした時に残っていてもよいのかなとか思ったので、あえてリセットはしないことにしました。

何か不都合があれば、その時に直したいと思います。

ただ、使わないのにresetがあるのが少し不自然なので消しておきましょう。

例: resources/js/Components/University/CreateUniversityModal.jsx

\project-root\src\resources\js\Components\University\CreateUniversityModal.jsx
const { data, setData, post, processing, errors } = useForm({
    name: '',
    type: 'national',
  });

他の学部・研究室モーダルからもresetを削除してください。

4.4 レビュー作成モーダル修正

一方で、レビューは投稿後に別ページへ移動しないので、編集モーダルと同様の修正が必要です。

resources/js/Components/Review/CreateReviewModal.jsx

クリックでコードを見る
\project-root\src\resources\js\Components\Review\CreateReviewModal.jsx
import { useForm } from '@inertiajs/react';
import { useState, useEffect } from 'react';
import Modal from '../Common/Modal';
import ReviewSubmitButton from './ReviewSubmitButton';
import StarIcon from '@/Components/Lab/Star/StarIcon';

/**
 * レビュー作成モーダル
 * @param {Object} props
 * @param {boolean} props.isOpen - モーダルの開閉状態
 * @param {Function} props.onClose - モーダルを閉じる
 * @param {Object} props.lab - 研究室オブジェクト
 * @param {Object} props.review - レビューオブジェクト
 * @returns {JSX.Element} コンポーネントのJSX
 */

const CreateReviewModal = ({ isOpen, onClose, lab, review }) => {
  return (
    <Modal
      isOpen={isOpen}
      onClose={onClose}
      title="レビューを作成する"
      size='sm'
    >
      <div className="h-[300px] flex flex-col">
        {/* フォーム領域 */}
        <div className="flex-1 overflow-y-auto">
          <CreateReviewForm onClose={onClose} lab={lab} review={review} />
        </div>
      </div>
    </Modal>
  );
};

/**
 * レビュー作成フォーム
 * @param {Object} props
 * @param {Function} props.onClose - モーダルを閉じる
 * @param {Object} props.lab - 研究室オブジェクト
 * @param {Object} props.review - レビューオブジェクト
 * @returns {JSX.Element} コンポーネントのJSX
 */
const CreateReviewForm = ({ onClose, lab, review }) => {
  const { data, setData, post, processing, errors, reset } = useForm({
    mentorship_style: 0,
    lab_atmosphere: 0,
    achievement_activity: 0,
    constraint_level: 0,
    facility_quality: 0,
    work_style: 0,
    student_balance: 0,
  });

  useEffect(() => {
    reset();
    setData({
      mentorship_style: 0,
      lab_atmosphere: 0,
      achievement_activity: 0,
      constraint_level: 0,
      facility_quality: 0,
      work_style: 0,
      student_balance: 0,
    });
  }, [review]);

  const handleRatingSelect = (field) => (value) => {
    setData(field, value);
  };

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

  return (...)

4.5 動作確認

例えば、学部を編集して、
image.png

送信後にもう一度編集モーダルを開くと、このように値がリセットされて新しい更新後の大学名がプレースホルダーに入っていますね!
image.png

大学・研究室・レビューについてもテストできるのでしてみてください!

※レビュー作成に関しては、作成後に削除して再度作成モーダルを開くと確認できます。

問題なさそうでしたら、コミット・プッシュ、PR作成・マージをよろしくです。

5. まとめ・次回予告

今回は作成・編集モーダルシリーズ最後のレビューを作りました!

次回は、まだモーダルですが、コメント一覧を表示するモーダルを作成したいと思います!
お楽しみに~

これまでの記事一覧

☆要件定義・設計編

☆環境構築編

☆バックエンド実装編

☆フロントエンド実装編

軽く宣伝

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

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

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?