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アプリケーション開発に挑戦してみた!(その40)

0. 初めに

こんにちは!

実務1年目駆け出しエンジニアによるWebアプリケーション開発解説シリーズです。
かなり終わりが見えてきました、フロントエンド編ですが、今日は退会ページを作成したいと思います。

今日からGWということで連日投稿に挑戦したいと思います...!!

1. 退会ページ作成

現在、マイページにて「退会」ボタンがありますが、クリックても何も起こりません。

退会ページが開かれるように、ページコンポーネントを作成しましょう。
image.png

1.1 ブランチ運用

いつも通り、developブランチを最新化させて、新規ブランチを切って作業します。

ブランチ名は、feature/frontend/withdrawal-pageとかにしましょう。

1.2 ルーティング追加

authミドルウェアのマイページ関連あたりに追加です。

routes/web.php

クリックでコードを見る
\project-root\src\routes\web.php
    // マイページ関連
    Route::get('/mypage', [MyPageController::class, 'showUser'])->name('mypage.index');
    Route::get('/mypage/edit', [MyPageController::class, 'editUser'])->name('mypage.edit');
    Route::put('/mypage', [MyPageController::class, 'updateUser'])->name('mypage.update');
    Route::delete('/mypage', [MyPageController::class, 'deleteUser'])->name('mypage.delete');
    Route::get('/mypage/bookmarks', [MyPageController::class, 'showBookmarks'])->name('mypage.bookmarks');
    Route::get('/mypage/withdrawal', [MyPageController::class, 'showWithdrawal'])->name('mypage.withdrawal'); // 追加
    Route::delete('/mypage/bookmarks/{bookmark}', [MyPageController::class, 'removeBookmark'])->name('mypage.bookmarks.remove');

1.3 コントローラーメソッド追加

マイページコントローラーにメソッドを新規追加です。
deleteUserの上あたりが良いかと思います。

app/Http/Controllers/MyPageController.php

クリックでコードを見る
\project-root\src\app\Http\Controllers\MyPageController.php
    /**
     * 退会用ページを表示する
     */
    public function showWithdrawal(): Response
    {
        return Inertia::render('MyPage/Withdrawal');
    }

    public function deleteUser()
    {

    }

1.4 ページコンポーネント作成

resources/js/Pages/MyPage/Withdrawal.jsx

クリックでコードを見る
\project-root\src\resources\js\Pages\MyPage\Withdrawal.jsx
import AppLayout from "@/Layouts/AppLayout";
import { Head, useForm, Link } from "@inertiajs/react";
import AlertModal from "@/Components/Common/AlertModal";
import { useState } from "react";

/**
 * 退会ページコンポーネント
 * @returns {JSX.Element}
 */
const Withdrawal = () => {
	const [isConfirmOpen, setConfirmOpen] = useState(false);
	const { delete: destroy, processing } = useForm();

	const handleWithdrawal = () => {
		destroy(route("mypage.delete"), {
			onSuccess: () => setConfirmOpen(false),
		});
	};

	return (
		<AppLayout title="退会">
			<Head title="退会" />
			<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>
				</div>

				<div className="mt-6 space-y-4 text-[#747D8C]">
					<p>
						退会すると、以下のデータがすべて削除されます。この操作は取り消せません。
					</p>
					<ul className="list-disc list-inside space-y-1">
						<li>アカウント情報(ニックネーム・メールアドレス)</li>
						<li>ブックマーク</li>
						<li>投稿したレビュー・コメント</li>
					</ul>
					<p className="font-semibold text-[#FF0000]">
						本当に退会しますか?
					</p>
				</div>

				<div className="mt-8 flex items-center gap-4">
					<button
						type="button"
						onClick={() => setConfirmOpen(true)}
						className="px-6 py-2 bg-[#FF0000] text-white font-bold rounded-md hover:opacity-80 transition cursor-pointer"
					>
						退会する
					</button>
					<Link
						href={route("mypage.index")}
						className="px-6 py-2 bg-[#EEF7FB] text-[#747D8C] shadow-md font-bold rounded-md hover:shadow-lg transition-shadow"
					>
						マイページに戻る
					</Link>
				</div>
			</div>

			<AlertModal
				isOpen={isConfirmOpen}
				onClose={() => setConfirmOpen(false)}
				title="退会の確認"
				message="退会すると元に戻せません。本当に退会しますか?"
				actionLabel="退会する"
				onAction={handleWithdrawal}
				cancelLabel="キャンセル"
				isProcessing={processing}
			/>
		</AppLayout>
	);
};

export default Withdrawal;

1.5 マイページ修正

resources/js/Pages/MyPage/Index.jsx

クリックでコードを見る
\project-root\src\resources\js\Pages\MyPage\Index.jsx
				{/* 退会 */}
				<div className="mt-6 mb-4 flex items-center">
					<button
						type="button"
						onClick={() => router.get(route('mypage.withdrawal'))}
						className="text-lg font-semibold text-[#747D8C] hover:underline cursor-pointer text-left"
					>
						退会
					</button>
				</div>

1.6 動作確認

適当なアカウントでログインして、マイページから退会ページが開くかを確認してください。

image.png

「退会する」ボタンをクリックすると、アラートモーダルが開きます。
image.png

実行すると、アカウントが削除されてホームページに戻ります。
image.png

ここまでできたら、コミット・プッシュ、PR作成・マージを忘れずに行いましょう!

2. マイページ関連未実装部分実装

マイページに関して実装を後回しにしていた部分がいくつかあったので、それらを実装します。

  • 作成済み大学の表示
  • 作成済み学部の表示
  • 作成済み研究室の表示
  • ユーザー情報の更新

2.1 ブランチ運用

例によって、developブランチを最新化させて、そこから新規にfeature/frontend/mypage-not-implementedなどの名前で新規ブランチを切って作業します。

2.2 作成済み大学・学部・研究室表示

自分が作成した大学・学部・研究室の一覧を見られるようにします。

マイページコントローラー修正

app/Http/Controllers/MyPageController.php

クリックでコードを見る
\project-root\src\app\Http\Controllers\MyPageController.php
use App\Models\University; // 追加
use App\Models\Faculty; // 追加
use App\Models\Lab; // 追加

class MyPageController extends Controller
{
    /**
     * ユーザー情報を表示する
     */
    public function showUser(): Response // 追加
    {
        $user = Auth::user();

        // 管理者・一般ユーザー問わず通知を取得
        $notifications = $user->notifications()->latest()->get();

        // ブックマーク済み研究室とそのレビュー情報・総合評価を取得
        $bookmarks = $user->bookmarks()->with(['lab.reviews'])->get()->map(function($bookmark) {
            $lab = $bookmark->lab;
            if (!$lab) return null;

            // 評価項目
            $ratingColumns = [
                'mentorship_style',
                'lab_atmosphere',
                'achievement_activity',
                'constraint_level',
                'facility_quality',
                'work_style',
                'student_balance',
            ];
            // 各項目の平均値
            $averagePerItem = collect($ratingColumns)->mapWithKeys(function ($column) use ($lab) {
                return [$column => $lab->reviews->avg($column)];
            });
            // 総合評価値
            $overallAverage = $averagePerItem->avg();

            $lab->overall_avg = $overallAverage;
            foreach ($ratingColumns as $column) {
                $lab->{"avg_{$column}"} = $averagePerItem[$column];
            }
            // reviews_countも追加
            $lab->reviews_count = $lab->reviews->count();

            // 大学・学部名も追加
            $lab->load(['faculty.university']);

            return $lab;
        })->filter();

        // ユーザーが作成した大学・学部・研究室を取得
        $universities = University::where('created_by', $user->id)->get();
        $faculties = Faculty::where('created_by', $user->id)->with('university')->get();
        $createdLabs = Lab::where('created_by', $user->id)->with(['faculty.university', 'reviews'])->get()->map(function ($lab) {
            $ratingColumns = [
                'mentorship_style', 'lab_atmosphere', 'achievement_activity',
                'constraint_level', 'facility_quality', 'work_style', 'student_balance',
            ];
            $averagePerItem = collect($ratingColumns)->mapWithKeys(fn($col) => [$col => $lab->reviews->avg($col)]);
            $lab->overall_avg = $averagePerItem->avg();
            foreach ($ratingColumns as $col) {
                $lab->{"avg_{$col}"} = $averagePerItem[$col];
            }
            $lab->reviews_count = $lab->reviews->count();
            return $lab;
        });

        return Inertia::render('MyPage/Index', [
            'title' => "{$user->name}さんのマイページ",
            'user' => $user,
            'notifications' => $notifications,
            'bookmarks' => $bookmarks,
            'universities' => $universities,
            'faculties' => $faculties,
            'createdLabs' => $createdLabs,
        ]);
    }

マイページコンポーネント修正

resources/js/Pages/MyPage/Index.jsx

クリックでコードを見る
\project-root\src\resources\js\Pages\MyPage\Index.jsx
import UniversityCard from "@/Components/University/UniversityCard"; // 追加
import FacultyCard from "@/Components/Faculty/FacultyCard"; // 追加

/**
 * マイページのトップコンポーネント
 * @param {Object} props - コンポーネントのprops
 * @param {string} props.title - ページタイトル
 * @param {Object} props.user - ユーザー情報オブジェクト
 * @param {Array} [props.bookmarks=[]] - ブックマークされた研究室のリスト
 * @param {Array} [props.notifications=[]] - 通知オブジェクトの配列
 * @returns {JSX.Element} コンポーネントのJSX
 */
const Index = ({ title, user, bookmarks = [], notifications = [], universities = [], faculties = [], createdLabs = [] }) => {
	// 大学作成モーダルの開閉状態
	const [isUniversityModalOpen, setUniversityModalOpen] = useState(false);
	// 横スクロール用インデックス
	const [currentIndex, setCurrentIndex] = useState(0);
	// 大学カルーセル用インデックス
	const [uniIndex, setUniIndex] = useState(0);
	// 学部カルーセル用インデックス
	const [facIndex, setFacIndex] = useState(0);
	// 研究室カルーセル用インデックス
	const [labIndex, setLabIndex] = useState(0);

	const handlePrev = () => {
		setCurrentIndex((prev) => Math.max(prev - 1, 0));
	};
	const handleNext = () => {
		setCurrentIndex((prev) => Math.min(prev + 1, bookmarks.length - 1));
	};

	const handleUniPrev = () => {
		setUniIndex((prev) => Math.max(prev - 1, 0));
	};
	const handleUniNext = () => {
		setUniIndex((prev) => Math.min(prev + 1, universities.length - 1));
	};

	const handleFacPrev = () => {
		setFacIndex((prev) => Math.max(prev - 1, 0));
	};
	const handleFacNext = () => {
		setFacIndex((prev) => Math.min(prev + 1, faculties.length - 1));
	};

	const handleLabPrev = () => {
		setLabIndex((prev) => Math.max(prev - 1, 0));
	};
	const handleLabNext = () => {
		setLabIndex((prev) => Math.min(prev + 1, createdLabs.length - 1));
	};

	// 横スクロールもサポート(スワイプやホイール)
	const handleScroll = (e) => {
		if (bookmarks.length <= 1) return;
		if (e.deltaX > 10 || e.deltaY > 10) {
			handleNext();
		} else if (e.deltaX < -10 || e.deltaY < -10) {
			handlePrev();
		}
	};

	// タッチスワイプ対応
	const touchStartX = useRef(0);
	const handleTouchStart = (e) => {
		touchStartX.current = e.touches[0].clientX;
	};
	const handleTouchEnd = (e) => {
		const touchEndX = e.changedTouches[0].clientX;
		if (touchEndX - touchStartX.current > 50) {
			handlePrev();
		} else if (touchEndX - touchStartX.current < -50) {
			handleNext();
		}
	};

	// 大学カルーセル用タッチスワイプ
	const uniTouchStartX = useRef(0);
	const handleUniTouchStart = (e) => {
		uniTouchStartX.current = e.touches[0].clientX;
	};
	const handleUniTouchEnd = (e) => {
		const touchEndX = e.changedTouches[0].clientX;
		if (touchEndX - uniTouchStartX.current > 50) {
			handleUniPrev();
		} else if (touchEndX - uniTouchStartX.current < -50) {
			handleUniNext();
		}
	};

	// 学部カルーセル用タッチスワイプ
	const facTouchStartX = useRef(0);
	const handleFacTouchStart = (e) => {
		facTouchStartX.current = e.touches[0].clientX;
	};
	const handleFacTouchEnd = (e) => {
		const touchEndX = e.changedTouches[0].clientX;
		if (touchEndX - facTouchStartX.current > 50) {
			handleFacPrev();
		} else if (touchEndX - facTouchStartX.current < -50) {
			handleFacNext();
		}
	};

	// 研究室カルーセル用タッチスワイプ
	const labTouchStartX = useRef(0);
	const handleLabTouchStart = (e) => {
		labTouchStartX.current = e.touches[0].clientX;
	};
	const handleLabTouchEnd = (e) => {
		const touchEndX = e.changedTouches[0].clientX;
		if (touchEndX - labTouchStartX.current > 50) {
			handleLabPrev();
		} else if (touchEndX - labTouchStartX.current < -50) {
			handleLabNext();
		}
	};

	// LabCardを1件だけ表示
	const currentLab = bookmarks[currentIndex];
	const currentUniversity = universities[uniIndex];
	const currentFaculty = faculties[facIndex];
	const currentCreatedLab = createdLabs[labIndex];

	// 大学カルーセル用ホイールスクロール
	const handleUniScroll = (e) => {
		if (universities.length <= 1) return;
		if (e.deltaX > 10 || e.deltaY > 10) {
			handleUniNext();
		} else if (e.deltaX < -10 || e.deltaY < -10) {
			handleUniPrev();
		}
	};

	// 学部カルーセル用ホイールスクロール
	const handleFacScroll = (e) => {
		if (faculties.length <= 1) return;
		if (e.deltaX > 10 || e.deltaY > 10) {
			handleFacNext();
		} else if (e.deltaX < -10 || e.deltaY < -10) {
			handleFacPrev();
		}
	};

	// 研究室カルーセル用ホイールスクロール
	const handleLabScroll = (e) => {
		if (createdLabs.length <= 1) return;
		if (e.deltaX > 10 || e.deltaY > 10) {
			handleLabNext();
		} else if (e.deltaX < -10 || e.deltaY < -10) {
			handleLabPrev();
		}
	};

	// ヘッダー右側に表示する通知ドロップダウン
	const notificationHeaderIcon = (
		<NotificationDropdown notifications={notifications} />
	);

	return (
		<AppLayout title={title} headerRight={notificationHeaderIcon}>
			<Head title={title} />
			<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]">
						利用開始日: {user?.created_at ? new Date(user.created_at).toLocaleDateString('ja-JP') : ''}
					</span>
				</div>

				{/* ニックネーム */}
				<div className="mt-6 mb-4 flex items-center">
					<h3 className="text-lg font-semibold text-[#747D8C] w-40 shrink-0">ニックネーム</h3>
					<div className="w-96">
						<UserInfoBar value={user?.name} onOpenEditModal={() => {}} />
					</div>
				</div>

				{/* e-Mailアドレス */}
				<div className="mt-6 mb-4 flex items-center">
					<h3 className="text-lg font-semibold text-[#747D8C] w-40 shrink-0">e-Mailアドレス</h3>
					<div className="w-96">
						<UserInfoBar value={user?.email} onOpenEditModal={() => {}} />
					</div>
				</div>

				{/* パスワード */}
				<div className="mt-6 mb-4 flex items-center">
					<h3 className="text-lg font-semibold text-[#747D8C] w-40 shrink-0">パスワード</h3>
					<div className="w-96">
						<UserInfoBar value="••••••••" onOpenEditModal={() => {}} />
					</div>
				</div>

				{/* 退会 */}
				<div className="mt-6 mb-4 flex items-center">
					<button
						type="button"
						onClick={() => router.get(route('mypage.withdrawal'))}
						className="text-lg font-semibold text-[#747D8C] hover:underline cursor-pointer text-left"
					>
						退会
					</button>
				</div>

				{/* ブックマーク済み研究室 */}
				<div className="flex items-center justify-between border-b border-black pb-2 w-full mt-8">
					<h2 className="text-xl font-bold text-black">ブックマーク済み研究室</h2>
					<span className="text-sm text-[#747D8C]">保存済み: {bookmarks.length}</span>
				</div>
				<div className="mt-4 flex flex-col items-center">
					{bookmarks.length === 0 ? (
						<p className="text-[#747D8C]">ブックマーク済みの研究室はありません。</p>
					) : (
						<div
							className="flex items-center w-full max-w-2xl"
							onWheel={handleScroll}
							onTouchStart={handleTouchStart}
							onTouchEnd={handleTouchEnd}
						>
							{/* ≪ボタン */}
							<button
								type="button"
								onClick={handlePrev}
								disabled={currentIndex === 0}
								className={`text-2xl px-2 text-[#747D8C] hover:text-gray-600 transition ${currentIndex === 0 ? 'opacity-30 cursor-default' : 'cursor-pointer'}`}
							></button>
							<div className="flex-1 flex flex-col items-center">
								{/* 大学・学部名の表示 */}
								<div className="mb-2 text-lg font-semibold text-[#747D8C] text-center">
									{currentLab.faculty?.university?.name} {currentLab.faculty?.name}
								</div>
								<div className="flex justify-center w-full">
									<div className="max-w-xl w-full">
										<LabCard lab={currentLab} />
									</div>
								</div>
								<div className="mt-2 text-sm text-[#747D8C] text-center">
									{currentIndex + 1} / {bookmarks.length}
								</div>
							</div>
							{/* ≫ボタン */}
							<button
								type="button"
								onClick={handleNext}
								disabled={currentIndex === bookmarks.length - 1}
								className={`text-2xl px-2 text-[#747D8C] hover:text-gray-600 transition ${currentIndex === bookmarks.length - 1 ? 'opacity-30 cursor-default' : 'cursor-pointer'}`}
							></button>
						</div>
					)}
				</div>

				{/* 作成済み大学 */}
				<div className="flex items-center justify-between border-b border-black pb-2 w-full mt-8">
					<h2 className="text-xl font-bold text-black">作成済み大学</h2>
					<span className="text-sm text-[#747D8C]">
						作成件数: {universities.length}</span>
				</div>
				<div className="mt-4 flex flex-col items-center">
					{universities.length === 0 ? (
						<p className="text-[#747D8C]">作成済みの大学はありません。</p>
					) : (
						<div
							className="flex items-center w-full max-w-2xl"
							onWheel={handleUniScroll}
							onTouchStart={handleUniTouchStart}
							onTouchEnd={handleUniTouchEnd}
						>
							{/* ≪ボタン */}
							<button
								type="button"
								onClick={handleUniPrev}
								disabled={uniIndex === 0}
								className={`text-2xl px-2 text-[#747D8C] hover:text-gray-600 transition ${uniIndex === 0 ? 'opacity-30 cursor-default' : 'cursor-pointer'}`}
							></button>
							<div className="flex-1 flex flex-col items-center">
								<div className="mb-2 text-lg font-semibold text-[#747D8C] text-center">
									{currentUniversity.name}
								</div>
								<div className="flex justify-center w-full">
									<div className="max-w-xl w-full">
										<UniversityCard university={currentUniversity} />
									</div>
								</div>
								<div className="mt-2 text-sm text-[#747D8C] text-center">
									{uniIndex + 1} / {universities.length}
								</div>
							</div>
							{/* ≫ボタン */}
							<button
								type="button"
								onClick={handleUniNext}
								disabled={uniIndex === universities.length - 1}
								className={`text-2xl px-2 text-[#747D8C] hover:text-gray-600 transition ${uniIndex === universities.length - 1 ? 'opacity-30 cursor-default' : 'cursor-pointer'}`}
							></button>
						</div>
					)}
					<button
						type="button"
						onClick={() => setUniversityModalOpen(true)}
					>
						追加
					</button>
				</div>
				{/* 大学作成モーダル */}
				<CreateUniversityModal isOpen={isUniversityModalOpen} onClose={() => setUniversityModalOpen(false)} />

				{/* 作成済み学部 */}
				<div className="flex items-center justify-between border-b border-black pb-2 w-full mt-8">
					<h2 className="text-xl font-bold text-black">作成済み学部</h2>
					<span className="text-sm text-[#747D8C]">
						作成件数: {faculties.length}</span>
				</div>
				<div className="mt-4 flex flex-col items-center">
					{faculties.length === 0 ? (
						<p className="text-[#747D8C]">作成済みの学部はありません。</p>
					) : (
						<div
							className="flex items-center w-full max-w-2xl"
							onWheel={handleFacScroll}
							onTouchStart={handleFacTouchStart}
							onTouchEnd={handleFacTouchEnd}
						>
							<button
								type="button"
								onClick={handleFacPrev}
								disabled={facIndex === 0}
								className={`text-2xl px-2 text-[#747D8C] hover:text-gray-600 transition ${facIndex === 0 ? 'opacity-30 cursor-default' : 'cursor-pointer'}`}
							></button>
							<div className="flex-1 flex flex-col items-center">
								<div className="mb-2 text-lg font-semibold text-[#747D8C] text-center">
									{currentFaculty.university?.name}
								</div>
								<div className="flex justify-center w-full">
									<div className="max-w-xl w-full flex justify-center">
										<FacultyCard faculty={currentFaculty} />
									</div>
								</div>
								<div className="mt-2 text-sm text-[#747D8C] text-center">
									{facIndex + 1} / {faculties.length}
								</div>
							</div>
							<button
								type="button"
								onClick={handleFacNext}
								disabled={facIndex === faculties.length - 1}
								className={`text-2xl px-2 text-[#747D8C] hover:text-gray-600 transition ${facIndex === faculties.length - 1 ? 'opacity-30 cursor-default' : 'cursor-pointer'}`}
							></button>
						</div>
					)}
				</div>

				{/* 作成済み研究室 */}
				<div className="flex items-center justify-between border-b border-black pb-2 w-full mt-8">
					<h2 className="text-xl font-bold text-black">作成済み研究室</h2>
					<span className="text-sm text-[#747D8C]">
						作成件数: {createdLabs.length}</span>
				</div>
				<div className="mt-4 flex flex-col items-center">
					{createdLabs.length === 0 ? (
						<p className="text-[#747D8C]">作成済みの研究室はありません。</p>
					) : (
						<div
							className="flex items-center w-full max-w-2xl"
							onWheel={handleLabScroll}
							onTouchStart={handleLabTouchStart}
							onTouchEnd={handleLabTouchEnd}
						>
							<button
								type="button"
								onClick={handleLabPrev}
								disabled={labIndex === 0}
								className={`text-2xl px-2 text-[#747D8C] hover:text-gray-600 transition ${labIndex === 0 ? 'opacity-30 cursor-default' : 'cursor-pointer'}`}
							></button>
							<div className="flex-1 flex flex-col items-center">
								<div className="mb-2 text-lg font-semibold text-[#747D8C] text-center">
									{currentCreatedLab.faculty?.university?.name} {currentCreatedLab.faculty?.name}
								</div>
								<div className="flex justify-center w-full">
									<div className="max-w-xl w-full">
										<LabCard lab={currentCreatedLab} />
									</div>
								</div>
								<div className="mt-2 text-sm text-[#747D8C] text-center">
									{labIndex + 1} / {createdLabs.length}
								</div>
							</div>
							<button
								type="button"
								onClick={handleLabNext}
								disabled={labIndex === createdLabs.length - 1}
								className={`text-2xl px-2 text-[#747D8C] hover:text-gray-600 transition ${labIndex === createdLabs.length - 1 ? 'opacity-30 cursor-default' : 'cursor-pointer'}`}
							></button>
						</div>
					)}
				</div>
			</div>
		</AppLayout>
	);
};

export default Index;

動作確認

適当なアカウントでログインして、作成後マイページで以下のように表示されるようになればOKです!
image.png

問題なさそうでしたら、コミットしておきましょう。

2.3 ユーザー情報更新

ユーザー情報を更新できるようにします。

マイページコントローラー修正

app/Http/Controllers/MyPageController.php

クリックでコードを見る
\project-root\src\app\Http\Controllers\MyPageController.php
  // ediUser()は削除
  
    /**
     * ユーザー情報を更新
     */
    public function updateUser(Request $request): RedirectResponse
    {
        $rules = [
            'nickname' => 'required|string|max:255',
            'email' => 'required|string|email|max:255|unique:users,email,' . Auth::id(),
        ];

        if ($request->filled('password')) {
            $rules['password'] = 'required|string|min:8|confirmed';
        }

        $request->validate($rules);

        /** @var User $user */
        $user = Auth::user();
        $user->name = $request->nickname;
        $user->email = $request->email;

        if ($request->filled('password')) {
            $user->password = Hash::make($request->password);
        }

        $user->save();

        return redirect()->route('mypage.index')->with('success', 'ユーザー情報を更新しました');
    }

ユーザー情報の更新は、モーダルで行うようにしたいので、ページを表示するためのメソッドであるeditUser()は不要になるので削除しました。

ユーザー情報編集用モーダルコンポーネント作成

モーダル用に新規ファイルを作成します。

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

/**
 * ユーザー情報編集モーダル
 * @param {Object} props
 * @param {boolean} props.isOpen - モーダルの開閉状態
 * @param {Function} props.onClose - モーダルを閉じる
 * @param {Object} props.user - 編集対象のユーザーオブジェクト
 * @returns {JSX.Element} コンポーネントのJSX
 */
const EditUserModal = ({ isOpen, onClose, user }) => {
  return (
    <Modal
      isOpen={isOpen}
      onClose={onClose}
      title="ユーザー情報を編集する"
      size='sm'
    >
      <div className="h-[400px] flex flex-col">
        {/* フォーム領域 */}
        <div className="flex-1">
          <EditUserForm onClose={onClose} user={user} />
        </div>
      </div>
    </Modal>
  );
};

/**
 * ユーザー情報編集フォーム
 * @param {Object} props
 * @param {Function} props.onClose - モーダルを閉じる
 * @param {Object} props.user - 編集対象のユーザーオブジェクト
 * @returns {JSX.Element} コンポーネントのJSX
 */
const EditUserForm = ({ onClose, user }) => {
  const { data, setData, put, processing, errors, reset } = useForm({
    nickname: user?.name ?? '',
    email: user?.email ?? '',
    password: '',
    password_confirmation: '',
  });

  useEffect(() => {
    reset();
    setData({
      nickname: user?.name ?? '',
      email: user?.email ?? '',
      password: '',
      password_confirmation: '',
    });
  }, [user]);

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

  return (
    <form onSubmit={submit} className="h-full flex flex-col">
      <div className="flex-1">
        {/* ニックネーム */}
        <ErrorSlot message={errors.nickname} />
        <InputField
          type="text"
          value={data.nickname}
          onChange={e => setData('nickname', e.target.value)}
          placeholder="ニックネーム"
          size="sm"
          className="mb-2 w-full"
        />

        {/* メールアドレス */}
        <ErrorSlot message={errors.email} />
        <InputField
          type="email"
          value={data.email}
          onChange={e => setData('email', e.target.value)}
          placeholder="メールアドレス"
          size="sm"
          className="mb-2 w-full"
        />

        {/* 新しいパスワード */}
        <ErrorSlot message={errors.password} />
        <InputField
          type="password"
          value={data.password}
          onChange={e => setData('password', e.target.value)}
          placeholder="新しいパスワード(変更する場合のみ)"
          size="sm"
          className="mb-2 w-full"
        />

        {/* パスワード確認 */}
        <ErrorSlot message={errors.password_confirmation} />
        <InputField
          type="password"
          value={data.password_confirmation}
          onChange={e => setData('password_confirmation', e.target.value)}
          placeholder="新しいパスワード(確認用)"
          size="sm"
          className="mb-2 w-full"
        />
      </div>

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

また、このコンポーネント内で使われている送信ボタン用のコンポーネントも作りましょう。

  • resources/js/Components/MyPage/UserSubmitButton.jsx
クリックでコードを見る
\project-root\src\resources\js\Components\MyPage\UserSubmitButton.jsx
/**
 * ユーザー情報更新ボタンコンポーネント
 * @param {Object} props
 * @param {boolean} [props.disabled=false] - 無効状態
 * @param {string} [props.type='submit'] - ボタンタイプ
 * @returns {JSX.Element} コンポーネントのJSX
 */
const UserSubmitButton = ({ disabled = false, type = 'submit' }) => {
  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">
        更新する
      </span>
    </button>
  );
};

export default UserSubmitButton;

マイページコンポーネント修正

これらを呼び出せるように修正しましょう。

  • resources/js/Pages/MyPage/Index.jsx
クリックでコードを見る
\project-root\src\resources\js\Pages\MyPage\Index.jsx
import EditUserModal from "@/Components/MyPage/EditUserModal"; // 追加

const Index = ({ title, user, bookmarks = [], notifications = [], universities = [], faculties = [], createdLabs = [] }) => {
	// 大学作成モーダルの開閉状態
	const [isUniversityModalOpen, setUniversityModalOpen] = useState(false);
	// 追加: ユーザー情報編集モーダルの開閉状態
	const [isUserEditModalOpen, setUserEditModalOpen] = useState(false);
    // ...

    retrun (
    {/* ニックネーム */}
				<div className="mt-6 mb-4 flex items-center">
					<h3 className="text-lg font-semibold text-[#747D8C] w-40 shrink-0">ニックネーム</h3>
					<div className="w-96">
						<UserInfoBar value={user?.name} onOpenEditDialog={() => setUserEditModalOpen(true)} />
					</div>
				</div>

				{/* e-Mailアドレス */}
				<div className="mt-6 mb-4 flex items-center">
					<h3 className="text-lg font-semibold text-[#747D8C] w-40 shrink-0">e-Mailアドレス</h3>
					<div className="w-96">
						<UserInfoBar value={user?.email} onOpenEditDialog={() => setUserEditModalOpen(true)} />
					</div>
				</div>

				{/* パスワード */}
				<div className="mt-6 mb-4 flex items-center">
					<h3 className="text-lg font-semibold text-[#747D8C] w-40 shrink-0">パスワード</h3>
					<div className="w-96">
						<UserInfoBar value="••••••••" onOpenEditDialog={() => setUserEditModalOpen(true)} />
					</div>
				</div>
                
    )

また、もともとあった編集用のページコンポーネントは不要になったので削除します。

  • 削除: resources/js/Pages/MyPage/Edit.jsx

さらに不要になったルーティングも削除しておきましょう。

  • routes/web.php
クリックでコードを見る
\project-root\src\routes\web.php
Route::get('/mypage/edit', [MyPageController::class, 'editUser'])->name('mypage.edit'); // 削除

動作確認

image.png
image.png

バリデーションがはたらくことと更新ができることを確認できたら、コミット・プッシュ、PR作成、マージをお忘れなく!

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?