実務1年目駆け出しエンジニアがLaravel&ReactでWebアプリケーション開発に挑戦してみた!(その33)
0. 初めに
こんにちは!
実務1年目のWeb開発エンジニアです。
Webアプリケーションの作り方を一から解説しているシリーズです。
今回は、モーダルシリーズ第二弾ということで、大学作成・編集用モーダルを作りたいと思います。
現状は、作成・編集用のページを用意していましたが、わざわざページ遷移させるほどでもないかなと思ったので、モーダルを表示させてそこで作成・編集をしてもらうようにします。
よりユーザーにとって使いやすい機能となるでしょう!
1. 作成モーダル作成
まず最初は、作成モーダルから作っていきましょう。
1.1 ブランチ運用
developブランチを最新化させて、そこから新規ブランチを切って作業をします。
ブランチ名: feature/frontend/university-create-modal
1.2 送信ボタン作成
モーダルの一番下に標示させる「作成する」ボタンを作りましょう。
「編集する」ボタンと共通化したいので、以下のような汎用性のあるコンポーネントを作成しましょう。
/**
* 大学作成・編集ボタンコンポーネント
* @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;
「作成」か「編集」かを表すmodeをpropsで受け取れるようにしました。
1.3 作成モーダル作成
続いて、今作成したボタンコンポーネントをインポートして、作成モーダルコンポーネントを作りましょう。
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 マイページ編集
最後に、このモーダルをどこから呼び出すのかを考えます。
マイページに「作成済み大学」の一覧を作って、そこにモーダルを開くためのボタンを用意することにしました!
よって、マイページ用のコンポーネントを修正します。
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 動作確認
大学名を入力できるか、ラジオボタンで区分を選択できるか、送信ボタンを押して問題なく作成できるかを確認してみてください!

できたら、完了です!
※(追記)
現時点だと、「国立」以外の区分だと投稿できないバグがあります。
本記事の後半で修正しますので、この段階では気にしないでください。
すみません。((+_+))
変更をコミット・プッシュして、PRを作成し、リモートのdevelopブランチにマージしましょう。
2. 編集モダール作成
次に、編集用のモーダルを作成しましょう!
2.1 ブランチ運用
ローカルのdevelopブランチを最新化させて、新規ブランチを切って作業します。
ブランチ名: feature/frontend/university-edit-modal
2.2 編集モーダル作成
編集モーダルコンポーネントを作ってみましょう!
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;
また、レイアウトをそろえるために、テキストエリア用のコンポーネントを作成しました。
/**
* テキストエリアフィールドコンポーネント
* @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 メニューポップオーバー作成
では、このモーダルはどうやって開かせるかと考えたのですが、学部一覧ページにポップアップメニューを表示させて、そこに「編集する」というメニューを用意し、クリックで開かせることにしました。
/**
* メニューポップオーバーコンポーネント
* @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 ケバブアイコン作成
ではでは、このメニューポップアップはどのようにして開かせようかと思ったところですが、ケバブアイコン(三点リーダー)を学部一覧ページにおいて、クリック時に開くようにしたいと思います。
/**
* ケバブメニューアイコンのコンポーネント
* @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 学部一覧画面修正
ポップオーバーの表示やケバブアイコンの設置をします。
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'というカラムを追加した時にコントローラーの修正を忘れていたため、どのように投稿してもデフォルト値の国立になってしまうんですよね...
すみませんでした。
なので、投稿時と更新時の処理の両方を合わせてここで修正してしまいましょう。
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'のバリデーションが抜けていたので追加します。
<?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 動作確認
「編集する」をクリックすると、編集用モーダルが表示されます。
バリデーションメッセージが出るか試してみましょう。

完了です。
コミットしておきましょう。
3. まとめ・次回予告
今回は、大学作成・編集機能をページ遷移からモーダル表示に変更してより使いやすくしました!
モーダルの表示方法などで悩みましたが、マイページやケバブアイコン、メニューポップオーバーなどを活用しましたね!
この調子で、次回は、学部作成・編集モーダルを作成したいと思います。(^^♪
最後まで読んでくれてありがとうございました。
これまでの記事一覧
☆要件定義・設計編
☆環境構築編
- その2: 環境構築編① ~WSL, Ubuntuインストール~
- その3: 環境構築編② ~Docker Desktopインストール~
- その4: 環境構築編③ ~Dockerコンテナ立ち上げ~
- その5: 環境構築編④ ~Laravelインストール~
- その6: 環境構築編⑤ ~Gitリポジトリ接続~
☆バックエンド実装編
- その7: バックエンド実装編① ~認証機能作成~
- その8: バックエンド実装編②前編 ~レビュー投稿機能作成~
- その8.5: バックエンド実装編②後編 ~レビュー投稿機能作成~
- その9: バックエンド実装編③ ~レビューCRUD機能作成~
- その10: バックエンド実装編④ ~レビューCRUD機能作成その2~
- その11: バックエンド実装編⑤ ~新規大学・学部・研究室作成機能作成~
- その12: バックエンド実装編⑥ ~大学検索機能作成~
- その13: バックエンド実装編⑦ ~大学・学部・研究室編集機能作成~
- その14: バックエンド実装編⑧ ~コメント投稿機能~
- その15: バックエンド実装編⑨ ~コメント編集・削除機能~
- その16: バックエンド実装編⑩ ~ブックマーク機能~
- その17: バックエンド実装編⑪ ~排他制御・トランザクション処理~
- その18: バックエンド実装編⑫ ~マイページ機能作成~
- その19: バックエンド実装編⑬ ~管理者アカウント機能作成~
- その20: バックエンド実装編⑭ ~通知機能作成~
- その21: バックエンド実装編⑮ ~ソーシャルログイン機能作成~
☆フロントエンド実装編
- その22: フロントエンド実装編① ~メインコンテンツ領域作成~
- その23: フロントエンド実装編② ~ヘッダー作成~
- その24: フロントエンド実装編③ ~サイドバー作成~
- その25: フロントエンド実装編④ ~ホームページ作成~
- その26: フロントエンド実装編⑤ ~大学検索結果画面作成~
- その27: フロントエンド実装編⑥ ~大学詳細・学部一覧画面作成~
- その28: フロントエンド実装編⑦ ~学部詳細・研究室一覧画面作成~
- その29: フロントエンド実装編⑧前編 ~研究室詳細・レビュー画面作成前編~
- その29.5: フロントエンド実装編⑧後編 ~研究室詳細・レビュー画面作成後編~
- その30: フロントエンド実装編⑨ ~マイページ作成~
- その31: フロントエンド実装編⑩ ~パンくずリスト作成~
- その32: フロントエンド実装編⑪ ~ログイン・新規登録モーダル作成~
軽く宣伝
YouTubeを始めました(というか始めてました)。
内容としては、Webエンジニアの生活や稼げるようになるまでの成長記録などを発信していく予定です。
現在、まったく再生されておらず、落ち込みそうなので、見てくださる方はぜひ高評価を教えもらえると励みになると思います。"(-""-)"


