0
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アプリケーション開発に挑戦してみた!(フロントエンド実装編⑫)~大学作成・編集モーダル作成~

0
Posted at

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

0. 初めに

こんにちは!
実務1年目のWeb開発エンジニアです。

Webアプリケーションの作り方を一から解説しているシリーズです。

今回は、モーダルシリーズ第二弾ということで、大学作成・編集用モーダルを作りたいと思います。

現状は、作成・編集用のページを用意していましたが、わざわざページ遷移させるほどでもないかなと思ったので、モーダルを表示させてそこで作成・編集をしてもらうようにします。
よりユーザーにとって使いやすい機能となるでしょう!

1. 作成モーダル作成

まず最初は、作成モーダルから作っていきましょう。

1.1 ブランチ運用

developブランチを最新化させて、そこから新規ブランチを切って作業をします。
ブランチ名: feature/frontend/university-create-modal

1.2 送信ボタン作成

モーダルの一番下に標示させる「作成する」ボタンを作りましょう。
「編集する」ボタンと共通化したいので、以下のような汎用性のあるコンポーネントを作成しましょう。

\project-root\src\resources\js\Components\University\UniversitySubmitButton.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 UniversitySubmitButton = ({ 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 UniversitySubmitButton;

「作成」か「編集」かを表すmodepropsで受け取れるようにしました。

1.3 作成モーダル作成

続いて、今作成したボタンコンポーネントをインポートして、作成モーダルコンポーネントを作りましょう。

\project-root\src\resources\js\Components\University\CreateUniversityModal.jsx
import { useForm } from '@inertiajs/react';
import Modal from '../Common/Modal';
import UniversitySubmitButton from './UniversitySubmitButton';
import InputField from '../Common/InputField';

/**
 * 大学作成モーダル
 * @param {Object} props
 * @param {boolean} props.isOpen - モーダルの開閉状態
 * @param {Function} props.onClose - モーダルを閉じる
 * @returns {JSX.Element} コンポーネントのJSX
 */

const CreateUniversityModal = ({ isOpen, onClose }) => {

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

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

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

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

        {/* 大学種別ラジオボタン */}
        <div className="mb-4">
          <div className="flex gap-4">
            <label className="flex items-center">
              <input
                type="radio"
                name="type"
                value="national"
                checked={data.type === 'national'}
                onChange={() => setData('type', 'national')}
                className="mr-1"
                style={{ accentColor: '#297FF0' }}
              />
              国立
            </label>
            <label className="flex items-center">
              <input
                type="radio"
                name="type"
                value="public"
                checked={data.type === 'public'}
                onChange={() => setData('type', 'public')}
                className="mr-1"
                style={{ accentColor: '#297FF0' }}
              />
              公立
            </label>
            <label className="flex items-center">
              <input
                type="radio"
                name="type"
                value="private"
                checked={data.type === 'private'}
                onChange={() => setData('type', 'private')}
                className="mr-1"
                style={{ accentColor: '#297FF0' }}
              />
              私立
            </label>
          </div>
        </div>
      </div>

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

前回と同様にModalコンポーネントを使用しています。

1.4 マイページ編集

最後に、このモーダルをどこから呼び出すのかを考えます。

マイページに「作成済み大学」の一覧を作って、そこにモーダルを開くためのボタンを用意することにしました!

よって、マイページ用のコンポーネントを修正します。

\project-root\src\resources\js\Pages\MyPage\Index.jsx
import CreateUniversityModal from "@/Components/University/CreateUniversityModal"; // 追加

const Index = ({ title, user, bookmarks = [] }) => {
	// 追加: 大学作成モーダルの開閉状態
	const [isUniversityModalOpen, setUniversityModalOpen] = useState(false);

    // 既存の処理...

	return (
		<AppLayout title={title}>
			<Head title={title} />
			{/* 通知アイコン(右肩に赤丸バッジ) */}
			<div className="w-full flex justify-end">
				<div className="relative">
					<img src={NotificationIcon} alt="通知" className="w-7 h-7 cursor-pointer" />
					<span className="absolute -top-2 -right-2 w-5 h-5 bg-red-500 rounded-full z-10 flex items-center justify-center text-xs font-bold text-white">
            {0}
          </span>
				</div>
			</div>
			<div className="mt-2">

				{/* 基本情報... */}

				{/* ブックマーク済み研究室... */}

				{/* 追加: 作成済み大学 */}
				<div className="flex items-center justify-between border-b border-black pb-2 w-full">
				<h2 className="text-xl font-bold text-black">作成済み大学</h2>
				<span className="text-sm text-[#747D8C]">
					作成件数: 0件
				</span>
				</div>
				<div className="mt-4 flex flex-col items-center">
				<p className="text-[#747D8C]">作成済みの大学はありません。</p>
				<button
					type="button"
					onClick={() => setUniversityModalOpen(true)}
				>
					追加
				</button>
				</div>
				{/* 大学作成モーダル */}
				<CreateUniversityModal isOpen={isUniversityModalOpen} onClose={() => setUniversityModalOpen(false)} />
			</div>
		</AppLayout>
	);
};

export default Index;

ただし、「作成済み大学」を追加するなら「作成済み学部」と「作成済み研究室」の一覧も追加する必要があり、マイページの修正範囲が広くなってしまうので、とりあえず暫定的に0件として簡易的な実装にしました。

後から、修正します。

1.5 動作確認

大学名を入力できるか、ラジオボタンで区分を選択できるか、送信ボタンを押して問題なく作成できるかを確認してみてください!
image.png

できたら、完了です!

※(追記)
現時点だと、「国立」以外の区分だと投稿できないバグがあります。
本記事の後半で修正しますので、この段階では気にしないでください。
すみません。((+_+))

変更をコミット・プッシュして、PRを作成し、リモートのdevelopブランチにマージしましょう。

2. 編集モダール作成

次に、編集用のモーダルを作成しましょう!

2.1 ブランチ運用

ローカルのdevelopブランチを最新化させて、新規ブランチを切って作業します。
ブランチ名: feature/frontend/university-edit-modal

2.2 編集モーダル作成

編集モーダルコンポーネントを作ってみましょう!

\project-root\src\resources\js\Components\University\EditUniversityModal.jsx
import { useForm } from '@inertiajs/react';
import Modal from '../Common/Modal';
import UniversitySubmitButton from './UniversitySubmitButton';
import InputField from '../Common/InputField';
import TextareaField from '../Common/TextareaField';

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

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

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

/**
 * 大学編集フォーム
 * @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,
  });

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

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

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

        {/* 大学種別ラジオボタン */}
        <div className="mb-4">
          <div className="flex gap-4">
            <label className="flex items-center">
              <input
                type="radio"
                name="type"
                value="national"
                checked={data.type === 'national'}
                onChange={() => setData('type', 'national')}
                className="mr-1"
                style={{ accentColor: '#297FF0' }}
              />
              国立
            </label>
            <label className="flex items-center">
              <input
                type="radio"
                name="type"
                value="public"
                checked={data.type === 'public'}
                onChange={() => setData('type', 'public')}
                className="mr-1"
                style={{ accentColor: '#297FF0' }}
              />
              公立
            </label>
            <label className="flex items-center">
              <input
                type="radio"
                name="type"
                value="private"
                checked={data.type === 'private'}
                onChange={() => setData('type', 'private')}
                className="mr-1"
                style={{ accentColor: '#297FF0' }}
              />
              私立
            </label>
          </div>
        </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">
        <UniversitySubmitButton 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 EditUniversityModal;

また、レイアウトをそろえるために、テキストエリア用のコンポーネントを作成しました。

\project-root\src\resources\js\Components\Common\TextareaField.jsx
/**
 * テキストエリアフィールドコンポーネント
 * @param {Object} props
 * @param {string} props.value - 値
 * @param {Function} props.onChange - 変更ハンドラ
 * @param {string} [props.placeholder] - プレースホルダー
 * @param {string} [props.size='md'] - サイズ ('sm' | 'md' | 'lg')
 * @param {number} [props.rows=3] - 行数
 * @param {string} [props.className=''] - 追加クラス
 * @returns {JSX.Element}
 */
const TextareaField = ({
  value,
  onChange,
  placeholder,
  size = 'md',
  rows = 3,
  className = '',
}) => {
  const sizeClasses = {
    sm: 'px-3 py-1 text-sm',
    md: 'px-4 py-2',
    lg: 'px-4 py-3 text-lg',
  };

  return (
    <div
      className={`
        rounded-lg
        bg-[#E2EDF6]
        border border-[#E2EDF6]
        shadow-[inset_0_2px_4px_rgba(0,0,0,0.15)]
        ${sizeClasses[size]}
        ${className}
      `}
    >
      <textarea
        value={value}
        onChange={onChange}
        placeholder={placeholder}
        rows={rows}
        className="w-full bg-transparent border-none outline-none focus:ring-0 p-0 m-0 resize-none"
      />
    </div>
  );
};

export default TextareaField;

2.3 メニューポップオーバー作成

では、このモーダルはどうやって開かせるかと考えたのですが、学部一覧ページにポップアップメニューを表示させて、そこに「編集する」というメニューを用意し、クリックで開かせることにしました。

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

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

export default MenuPopover;

2.4 ケバブアイコン作成

ではでは、このメニューポップアップはどのようにして開かせようかと思ったところですが、ケバブアイコン(三点リーダー)を学部一覧ページにおいて、クリック時に開くようにしたいと思います。

\project-root\src\resources\js\Components\Common\KebabIcon.jsx
/**
 * ケバブメニューアイコンのコンポーネント
 * @returns {JSX.Element} コンポーネントのJSX
 */
const KebabIcon = () => (
  <svg
    width="18"
    height="18"
    viewBox="0 0 24 24"
    fill="#747D8C"
  >
    <circle cx="12" cy="5" r="2" />
    <circle cx="12" cy="12" r="2" />
    <circle cx="12" cy="19" r="2" />
  </svg>
);

export default KebabIcon;

2.5 学部一覧画面修正

ポップオーバーの表示やケバブアイコンの設置をします。

\project-root\src\resources\js\Pages\Faculty\Index.jsx
import { Head } from "@inertiajs/react";
import { useState, useEffect, useRef } from "react";
import AppLayout from '@/Layouts/AppLayout';
import FacultyCard from '../../Components/Faculty/FacultyCard';
import Breadcrumb from '../../Components/Common/Breadcrumb';
import MenuPopover from "@/Components/University/MenuPopover";
import KebabIcon from "@/Components/Common/KebabIcon";
import EditUniversityModal from "@/Components/University/EditUniversityModal";

const Index = ({ faculties, university, query = '' }) => {
  const [isMenuOpen, setIsMenuOpen] = useState(false);
  const [isEditModalOpen, setIsEditModalOpen] = useState(false);
  const menuRef = useRef(null);

  const hasResults = faculties.length > 0;

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

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

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

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

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

        {/* コンテンツ部分 */}
        {hasResults ? (
          <div className="w-full grid grid-cols-3 gap-6 mt-8 justify-items-center">
            {faculties.map(faculty => (
              <FacultyCard key={faculty.id} faculty={faculty} query={query} />
            ))}
          </div>
        ) : (
          <div className="flex-1 flex items-center justify-center">
            <p className="text-[#747D8C]">まだ学部が登録されていません。</p>
          </div>
        )}
      </div>
      {/* 大学編集モーダル */}
      <EditUniversityModal
        isOpen={isEditModalOpen}
        onClose={() => setIsEditModalOpen(false)}
        university={university}
      />
    </AppLayout>
  );
};

export default Index;

2.6 コントローラー修正

実は、作成モーダルの時に気が付いた方も多いと思いますが、「私立」・「公立」だとうまく投稿ができません。

というのも、この'type'というカラムを追加した時にコントローラーの修正を忘れていたため、どのように投稿してもデフォルト値の国立になってしまうんですよね...

すみませんでした。

なので、投稿時と更新時の処理の両方を合わせてここで修正してしまいましょう。

\project-root\src\app\Http\Controllers\UniversityController.php
class UniversityController extends Controller
{
    public function store(Request $request)
    {
        $this->authorize('create', University::class);
        
        $validated = $request->validate([
            'name' => 'required|string|max:50|unique:universities,name',
            'type' => 'required|string|in:national,public,private', // 追加
        ]);

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

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

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

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

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

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

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

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

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

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

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

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

※修正しないメソッド部分は、省略しています。

2.7 バリデーション追加

'comment'のバリデーションが抜けていたので追加します。

\project-root\src\resources\lang\ja\validation.php
<?php

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

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

];

2.8 動作確認

ケバブアイコンクリックでメニューポップオーバーが出ます。
image.png

「編集する」をクリックすると、編集用モーダルが表示されます。
バリデーションメッセージが出るか試してみましょう。
image.png

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

更新できました!
image.png

完了です。
コミットしておきましょう。

3. まとめ・次回予告

今回は、大学作成・編集機能をページ遷移からモーダル表示に変更してより使いやすくしました!

モーダルの表示方法などで悩みましたが、マイページやケバブアイコン、メニューポップオーバーなどを活用しましたね!

この調子で、次回は、学部作成・編集モーダルを作成したいと思います。(^^♪

最後まで読んでくれてありがとうございました。

これまでの記事一覧

☆要件定義・設計編

☆環境構築編

☆バックエンド実装編

☆フロントエンド実装編

軽く宣伝

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

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

0
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
0
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?