実務未経験エンジニアがLaravel&ReactでWebアプリケーション開発に挑戦してみた!(その15)
0. 初めに
こんにちは。
このシリーズでは、まだ実務未経験の僕がWebアプリケーションを頑張って作る様子をお届けしています。
初心者にもわかりやすく解説しているつもりなので、ぜひその1から読んでみてください!
今回は、前回に引き続き、コメント機能を作ります。
前回はコメントの投稿機能を作りました。
今日は、コメントの編集と削除機能を作りたいと思います!「
1. ブランチ運用
例によって、develop
ブランチを最新化して、新たに feature/13-edit-delete-comment
という名前でブランチを切ります。
2. コントローラー修正
まずは、コメントコントローラーに edit()
, store()
, destroy()
メソッドを追加しましょう!
// 追加するメソッド
public function edit(Comment $comment)
{
// ポリシーで認可をチェック
$this->authorize('update', $comment);
return Inertia::render('Comment/Edit', [
'comment' => $comment,
]);
}
public function update(Request $request, Comment $comment)
{
// ポリシーで認可をチェック
$this->authorize('update', $comment);
// バリデーション
$validated = $request->validate([
'content' => 'required|string|max:1000',
]);
// バリデーション済みのデータを更新
$comment->content = $validated['content'];
$comment->save();
return redirect()->route('labs.show', ['lab' => $comment->lab])->with('success', 'コメントが更新されました。');
}
public function destroy(Comment $comment)
{
// ポリシーで認可をチェック
$this->authorize('delete', $comment);
// コメントを削除
$comment->delete();
return redirect()->route('labs.show', ['lab' => $comment->lab])->with('success', 'コメントが削除されました。');
}
次に、研究室コントローラーの show()
メソッドをコメントの情報を渡す処理を追加する形で修正します。
public function show(Lab $lab)
{
// 大学・学部、レビューのデータも一緒に渡す
// universityはfacultyを経由して取得
$lab->load(['faculty.university', 'reviews']);
// コメントデータを取得(投稿者情報も含む)
$comments = $lab->comments()->with('user')->latest()->get();
// 平均値を計算するために、評価項目のカラム名を定義
$ratingColumns = [
'mentorship_style',
'lab_atmosphere',
'achievement_activity',
'constraint_level',
'facility_quality',
'work_style',
'student_balance',
];
// 1. 各評価項目のユーザー間の平均値 (Average per Item) を計算
$averagePerItem = collect($ratingColumns)->mapWithKeys(function ($column) use ($lab) {
// 各評価項目の平均を計算(全レビューを対象)
return [$column => $lab->reviews->avg($column)];
});
// 2. 新しい「総合評価」:各項目の平均値のさらに平均を計算
// $averagePerItem の値(平均点)をコレクションとして取り出し、その平均を求める
$overallAverage = $averagePerItem->avg();
// 3. 現在のユーザーのレビューを取得
$userReview = null;
$userOverallAverage = null;
if (Auth::check()) {
$userReview = $lab->reviews->where('user_id', Auth::id())->first();
// ユーザーのレビューが存在する場合、個別の総合評価を計算
if ($userReview) {
$userRatings = collect($ratingColumns)->map(function ($column) use ($userReview) {
return $userReview->$column;
})->filter(function ($value) {
return $value !== null;
});
$userOverallAverage = $userRatings->avg();
}
}
// 修正:
// 研究室のデータに加えて、求めたレビューの平均値とユーザーのレビュー、コメント、認証情報も一緒に渡す
return Inertia::render('Lab/Show', [
'lab' => $lab,
'overallAverage' => $overallAverage,
'averagePerItem' => $averagePerItem,
'userReview' => $userReview,
'userOverallAverage' => $userOverallAverage,
'ratingData' => [
'columns' => $ratingColumns,
],
'comments' => $comments, // 追加
'auth' => [
'user' => Auth::user() // 追加
],
]);
}
最後にポリシーを追加です。
<?php
namespace App\Policies;
use App\Models\User;
class CommentPolicy
{
// ユーザーが研究室を作成できるかどうかを判定
public function create(User $user)
{
// 研究室を作成できるのはログインユーザーのみ
return $user->exists;
}
// 追加: ユーザーがコメントを編集できるかどうかを判定
public function update(User $user, Comment $comment)
{
// 自分が投稿したコメントのみ編集可能
return $user->id === $comment->user_id;
}
// 追加: ユーザーがコメントを削除できるかどうかを判定
public function delete(User $user, Comment $comment)
{
// 自分が投稿したコメントのみ削除可能
return $user->id === $comment->user_id;
}
}
3. ルーティング追加
続いて、ルーティングを追加します。
例によって、auth
ミドルウェア内に追加してください。
// コメント関連
Route::get('/labs/{lab}/comments/create', [CommentController::class, 'create'])->name('comment.create');
Route::post('/labs/{lab}/comments', [CommentController::class, 'store'])->name('comment.store');
Route::get('/comments/{comment}/edit', [CommentController::class, 'edit'])->name('comment.edit'); // 追加
Route::put('/comments/{comment}', [CommentController::class, 'update'])->name('comment.update'); // 追加
Route::delete('/comments/{comment}', [CommentController::class, 'destroy'])->name('comment.destroy'); // 追加
4. Reactコンポーネント作成
コメント編集ページのReactコンポーネントを作成します。
import React, { useState } from 'react';
import { Head, Link, router } from '@inertiajs/react';
export default function Edit({ comment }) {
const [content, setContent] = useState(comment.content || '');
const [errors, setErrors] = useState({});
const [processing, setProcessing] = useState(false);
const handleSubmit = (e) => {
e.preventDefault();
setProcessing(true);
router.put(route('comment.update', { comment: comment.id }), {
content: content,
}, {
onSuccess: () => {
// 成功時の処理は既にリダイレクトで処理される
},
onError: (errors) => {
setErrors(errors);
setProcessing(false);
},
onFinish: () => {
setProcessing(false);
}
});
};
const handleCancel = () => {
router.get(route('labs.show', { lab: comment.lab_id }));
};
return (
<div>
<Head title="コメントを編集" />
<h1>コメントを編集</h1>
{/* 研究室詳細に戻るボタン */}
<div>
<Link href={route('labs.show', { lab: comment.lab_id })}>
<button type="button">研究室詳細に戻る</button>
</Link>
</div>
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="content">コメント内容:</label>
<textarea
id="content"
value={content}
onChange={(e) => setContent(e.target.value)}
rows="5"
cols="50"
maxLength="1000"
required
/>
{errors.content && (
<div style={{ color: 'red' }}>
{errors.content}
</div>
)}
</div>
<div>
<button type="submit" disabled={processing}>
{processing ? '更新中...' : 'コメントを更新'}
</button>
<button type="button" onClick={handleCancel} disabled={processing}>
キャンセル
</button>
</div>
</form>
<div>
<p>文字数制限: 1000文字まで</p>
<p>現在の文字数: {content.length}/1000</p>
</div>
</div>
);
}
また、研究室詳細ページのReactコンポーネントを修正して、コメント一覧を表示できるようにしましょう。
import React from 'react';
import { Head, Link, router } from '@inertiajs/react';
// propsとして新しく追加されたプロパティも受け取る
export default function Show({
lab,
overallAverage,
averagePerItem,
userReview,
userOverallAverage,
ratingData,
comments,
auth
}) {
const reviewCount = lab.reviews ? lab.reviews.length : 0;
// ratingColumnsが空の場合、フォールバック用の配列を使用
const fallbackRatingColumns = [
'mentorship_style',
'lab_atmosphere',
'achievement_activity',
'constraint_level',
'facility_quality',
'work_style',
'student_balance',
];
const ratingColumns = ratingData?.columns || fallbackRatingColumns;
const actualRatingColumns = ratingColumns.length > 0 ? ratingColumns : fallbackRatingColumns;
const itemLabels = {
mentorship_style: '指導スタイル',
lab_atmosphere: '雰囲気・文化',
achievement_activity: '成果・活動',
constraint_level: '拘束度',
facility_quality: '設備',
work_style: '働き方',
student_balance: '人数バランス',
};
const formatAverage = (value) => {
return value !== null && value !== undefined ? value.toFixed(2) : 'データなし';
};
const handleDeleteReview = (reviewId) => {
if (confirm('本当に削除してもよろしいですか?')) {
router.delete(route('review.destroy', { review: reviewId }), {
onSuccess: () => {
// 成功時の処理
alert('レビューが削除されました。');
},
onError: (error) => {
// エラー時の処理
alert('レビューの削除に失敗しました。');
}
});
}
};
const handleCreateReview = () => {
router.get(route('review.create', { lab: lab.id }));
};
const handleEditReview = () => {
router.get(route('review.edit', { review: userReview.id }));
};
const handleCreateComment = () => {
router.get(route('comment.create', { lab: lab.id }));
};
const handleEditComment = (commentId) => {
router.get(route('comment.edit', { comment: commentId }));
};
const handleDeleteComment = (commentId) => {
if (confirm('本当に削除してもよろしいですか?')) {
router.delete(route('comment.destroy', { comment: commentId }), {
onSuccess: () => {
// 成功時の処理
alert('コメントが削除されました。');
},
onError: (error) => {
// エラー時の処理
alert('コメントの削除に失敗しました。');
}
});
}
};
return (
<div>
<Head title={`${lab.name}の詳細`} />
<h1>{lab.name} の詳細ページ</h1>
{/* 研究室一覧に戻るボタン */}
<div>
<Link href={route('labs.index', lab.faculty_id)}>
<button>研究室一覧に戻る</button>
</Link>
</div>
{/* 研究室編集ボタン */}
<div>
<Link href={route('lab.edit', lab.id)}>
<button>研究室を編集</button>
</Link>
</div>
{/* 編集履歴ボタン */}
<div>
<Link href={route('lab.history', lab.id)}>
<button>編集履歴を見る</button>
</Link>
</div>
{/* コメント投稿ボタン */}
<div>
<button onClick={handleCreateComment}>
コメントを投稿する
</button>
</div>
<p>大学: {lab.faculty?.university?.name}</p>
<p>学部: {lab.faculty?.name}</p>
<p>研究室の説明: {lab.description}</p>
<p>研究室のURL: <a href={lab.url} target="_blank" rel="noopener noreferrer">{lab.url}</a></p>
<p>教授のURL: <a href={lab.professor_url} target="_blank" rel="noopener noreferrer">{lab.professor_url}</a></p>
<p>男女比(男): {lab.gender_ratio_male}</p>
<p>男女比(女): {lab.gender_ratio_female}</p>
<hr />
<h2>レビュー</h2>
<p>レビュー数: {reviewCount}</p>
{/* 全体の平均評価を表示 */}
<h3>全体の評価(平均)</h3>
<p><strong>総合評価: </strong>{formatAverage(overallAverage)}</p>
<h4>各評価項目の平均:</h4>
{averagePerItem && Object.keys(averagePerItem).length > 0 ? (
<ul>
{Object.entries(averagePerItem).map(([itemKey, averageValue]) => (
<li key={itemKey}>
<p>
<strong>{itemLabels[itemKey] || itemKey}:</strong>
{formatAverage(averageValue)}
</p>
</li>
))}
</ul>
) : (
<p>まだ評価データがありません。</p>
)}
{/* ユーザーのレビューが存在する場合に表示 */}
{userReview ? (
<div>
<h3>あなたの投稿したレビュー</h3>
<p><strong>総合評価: </strong>{formatAverage(userOverallAverage)}</p>
<h4>各評価項目:</h4>
<ul>
{actualRatingColumns && actualRatingColumns.length > 0 ? (
actualRatingColumns.map((column) => {
const value = userReview[column];
return (
<li key={column}>
<p>
<strong>{itemLabels[column] || column}:</strong>
{value !== null && value !== undefined
? (typeof value === 'number' ? value.toFixed(2) : value)
: '未評価'}
</p>
</li>
)
})
) : (
<li>評価項目データがありません</li>
)}
</ul>
<div>
<button onClick={() => handleDeleteReview(userReview.id)}>
このレビューを削除
</button>
<button onClick={handleEditReview}>
レビューを編集する
</button>
</div>
</div>
) : (
// レビューが存在しない場合はレビュー投稿ボタンを表示
<div>
<h3>レビューを投稿</h3>
<p>まだこの研究室のレビューを投稿していません。</p>
<button onClick={handleCreateReview}>
レビューを投稿する
</button>
</div>
)}
<hr />
{/* コメント一覧 */}
<h2>コメント</h2>
{comments && comments.length > 0 ? (
<div>
{comments.map((comment) => (
<div key={comment.id} style={{ border: '1px solid #ccc', padding: '10px', margin: '10px 0' }}>
<p><strong>投稿者:</strong> {comment.user?.name || '匿名'}</p>
<p><strong>投稿日:</strong> {new Date(comment.created_at).toLocaleDateString()}</p>
<p><strong>内容:</strong> {comment.content}</p>
{/* ログインしているユーザーが自分のコメントの場合のみ編集・削除ボタンを表示 */}
{auth && auth.user && auth.user.id === comment.user_id && (
<div>
<button onClick={() => handleEditComment(comment.id)}>
編集
</button>
<button onClick={() => handleDeleteComment(comment.id)}>
削除
</button>
</div>
)}
</div>
))}
</div>
) : (
<p>まだコメントがありません。</p>
)}
</div>
);
}
5. 動作確認
ここまでできたら、動作確認です。
まずは、ログインしていない状態で例の「ほげほげ研究室」の詳細ページを開いてみてましょう。
このように前回投稿したコメントが表示されていることが確認できます。
次に、前回コメントを投稿したユーザーでログインした状態でもう一度開きます。
このように自分が投稿したコメントは「編集」ボタンと「削除」ボタンが表示されています。
それでは、「編集」ボタンを押して、編集画面に遷移後、適当に内容を変えて「更新」ボタンを押します。
すると、研究室詳細ページに遷移し、コメントの内容が更新されていることが確認できます。
最後に、「削除」ボタンをクリックしましょう。
アラートが出ますので、「OK」をクリック。
6. まとめ・次回予告
ということで、今日はこれでおしまい!
今回もコンパクトに収められてよかったです。
技術的に特に新しい内容が出てこなくて少し退屈だったかもしれませんね💦
作業が完了したら、いつも通り、コミット・プッシュ、プルリクエスト作成・マージをお忘れなく!
次回は、ブックマーク機能を一緒に作っていきましょう!
ありがとうございました!(≧◇≦)
これまでの記事一覧
--- 要件定義・設計編 ---
--- 環境構築編 ---
- その2: 環境構築編① ~WSL, Ubuntuインストール~
- その3: 環境構築編② ~Docker Desktopインストール~
- その4: 環境構築編③ ~Dockerコンテナ立ち上げ~
- その5: 環境構築編④ ~Laravelインストール~
- その6: 環境構築編⑤ ~Gitリポジトリ接続~