質問機能は作成済みですので、同様にメインとなる機能である回答機能を作成していきます。
とりあえず回答用のテーブルのマイグレーションを作成しておきます。
php artisan make:migration create_answers --create answers
中身はこんな感じです。
class CreateAnswers extends Migration
{
public function up()
{
Schema::create('answers', function (Blueprint $table) {
$table->bigIncrements('id');
$table->bigInteger('question_id')->unsigned()->index();
$table->bigInteger('user_id')->unsigned()->index();
$table->text('body');
$table->timestamps();
$table->foreign('question_id')->references('id')->on('questions')->onDelete('cascade');
});
}
public function down()
{
Schema::dropIfExists('answers');
}
}
どの質問への回答か、どのユーザーが回答したか、回答の内容のカラムを追加しています。user_idは実際には不要で、question_idでquestionsを検索し、questionsのreceived_user_idでどのユーザが回答したかは分かります。
ただし、回答者用の自分の回答一覧ページを作る際に、わざわざ上記のような形でJOINして検索を行わなくてはならないため負荷が増える可能性があります。具体的には下記のようなSQLです。個人的にはJOIN先のみでWHEREするのは非常に微妙だと思っています。
SELECT answers.*
FROM answers
LEFT JOIN questions ON questions.id = answers.question_id
WHERE questions.received_user_id = {{ユーザーID}}
そのためリレーショナルデータベース的には不要ですが、検索を軽量化するための冗長化としてuser_idも入れています。これで下記のシンプルなSQLで取得が可能となります。むちゃくちゃ軽いです。
SELECT * FROM answers WHERE user_id = {{ユーザーID}}
何にしろマイグレーションを実行しておきます。
php artisan migrate
本来であれば自分に来た質問一覧ページからリンクをクリックして回答ページへ行く流れですが、とりあえず回答処理を作ってしまいたいので直接回答ページから作っていきます。
回答するページのURLはquestions/{id}/received
とします。とりあえずルーティングを設定します。authミドルウェアの中に入れましょう。
Route::group(['middleware' => ['auth']], function () {
Route::get('questions/{id}/received', 'QuestionController@received');
Route::resource('questions', 'QuestionController')->only(['store']);
});
回答フォーム用の処理を入れていきます。QuestionControllerに下記のようなアクションを入れます。
public function received($questionId)
{
$question = Question::where('id', $questionId)
->where('received_user_id', Auth::id())
->firstOrFail();
return view('question.received', compact('question'));
}
取得する質問データが自分に宛てられたものかどうかもチェックしており、違う場合は本来存在しないURLですのでfirstOrFailで404にしています。このあたりはチームでやっているのであれば各プロジェクトのルールによって適宜適切なエラー処理のルールで対応していくと良いと思います。
続いて表示するためのテンプレートをresources/views/question/received.blade.php
として作成します。
@extends('layouts.app')
@section('content')
<section class="text-center pt-4">
<h1 style="font-size: 1.5rem">回答する</h1>
<div class="card">
<div class="card-body">
{{ $question->body }}
</div>
</div>
</section>
@endsection
とりあえず回答時に見るための質問表示です。Bootstrapのカードで適当に表示しています。
http://localhost:8000/questions/1/received
にアクセスすると下記が表示されます。ホスト名、ポート、ID(URLの例で1になっているところ)は適宜自分の環境に合わせてください。認証ガードをしていますのでうまく表示されない場合はログインしておいてください。表示が崩れる場合はyarn hot
の実行も必要です。
続いてcard(の閉じタグ)の下にフォームを入れていきます。質問フォームとほとんど同じです。
<div class="card mb-4">
<div class="card-body">
{{ $question->body }}
</div>
</div>
{!! Form::open(['url' => url('answers')]) !!}
{!! Form::hidden('question_id', $question->id) !!}
<div class="form-group">
{!! Form::textarea('body', null, [
'class' => 'form-control',
'placeholder' => '回答する',
'required' => true,
]) !!}
</div>
{!! Form::Submit('回答する', ['class' => 'btn btn-primary']) !!}
{!! Form::close() !!}
質問とくっついてしまうので質問のclassにmb-4
でmargin-bottomも入れています。
formもできたので回答を登録できるようにします。まずは回答処理用のコントローラを作成します。
php artisan make:controller AnswerController
ついでにモデルも使うと思うので作っておきます
php artisan make:model Models/Answer
Questionの時と同様、guardedを設定しておきます。
class Answer extends Model
{
protected $guarded = ['id'];
}
回答データを保存する際、質問データの子として保存しますので、その関係を質問のモデルにも定義しておきます。Models/Question.php
をに下記のような定義を追加します。
public function answer()
{
return $this->hasOne(Answer::class);
}
これはそのままの通り、Question has one answer、つまり質問データは一つの回答データを持つ、という定義でリレーションといいます。このようにリレーションを定義しておくことでデータを保存する際や取得する時に簡単に処理を作成することができるようになっています。詳しくは下記の公式マニュアルにも書かれており、他にも様々な種類のリレーションの定義ができるようになっています。
実際に回答登録処理を作成します。まずは回答用のルーティングを作成します。answersというところです。なんとなくですが、問題がでるところでなければアルファベット順で書くと増えた時に見やすいと思います。
Route::group(['middleware' => ['auth']], function () {
Route::resource('answers', 'AnswerController')->only(['store']);
Route::get('questions/{id}/received', 'QuestionController@received');
Route::resource('questions', 'QuestionController')->only(['store']);
});
ところでこのresource
とは何かを一旦解説します。resourceというのは、基本的な処理のためのアクションをまとめてルーティングに登録できる機能です。Laravelでは具体的には下記になっています。
メソッド | アクション | 意味 |
---|---|---|
GET | index | 一覧 |
GET | create | 新規登録ページ |
POST | store | 新規登録処理 |
GET | edit | 編集ページ |
PUT, PATCH | update | 更新処理 |
DELETE | destroy | 削除 |
アクションというのはコントローラのメソッド名ですので分かりやすいと思います。今回の例でいうと回答の登録処理にはstoreというメソッドを追加します。
メソッドというのはちょっと最初はわかりにくいかもしれませんが、アプリケーションにどのような処理をするか、というのを伝えるためには実はURLだけでなく、URLとメソッドの組み合わせが必要になっています。
普段ブラウザでWebにアクセスする場合は意識していないと思いますが、単にURLにアクセスしているだけではなく、実はGETメソッドでそのURLにアクセスを行っています。そしてformで何かを登録する際も、その登録先のURLにただアクセスしているわけではなく、POST, PUT, PATCH, DELETEのどれかでアクセスを行っています。
つまり例えば同じ/questions
というURLでも、ブラウザで単にアクセスした場合はGETのアクセスになるので一覧ページが表示され、formでPOSTした場合はデータが登録される、ということになります。とりあえず今のところは深く考える必要はなく、上記のテーブルのメソッドとアクションの組み合わせでコントローラに処理を書けば良い、ということになります。そして、そもそもRoute::resource
というメソッドがそれを勝手にいい感じに処理してくれるため、とりあえずは深く考える必要はありません。
回答ページであるquestions/{id}/received
のように、resourceの形ではなく独自で定義したい場合にRoute::get
やRoute::post
等を利用することができる、という感じです。
マニュアルでもこのあたりは書かれています。
さて、では実際にAnswerControllerに回答の登録処理を作成します。
namespace App\Http\Controllers;
use App\Models\Answer;
use App\Models\Question;
use Auth;
use Illuminate\Http\Request;
class AnswerController extends Controller
{
public function store(Request $request)
{
$question = Question::where('id', $request->input('question_id'))
->where('received_user_id', Auth::id())
->firstOrFail();
$answer = new Answer($request->all());
$answer->user_id = Auth::id();
$question->answer()->save($answer);
session()->flash('success', '回答しました。');
return redirect("questions/{$question->id}/received");
}
}
最初は回答フォームの処理と同様、自分に来た質問かどうかをチェックしています。
次に回答データを作成しています。$question->answer()->save($answer);
という処理により、その$question
の配下になるように回答を保存してくれます。この保存の仕方だとわざわざ$answer->question_id
を自分で代入しなくても勝手に入れて保存してくれます。先程モデルに定義したリレーションによってこれができるようになっています。
これで実際に回答フォームから回答を登録すると正常にデータが入ります。
既に回答した質問に回答フォームを表示してしまうとおかしいため、回答済みの場合は回答データを表示するようにしておきます。回答ページのテンプレートの回答フォームを条件分岐で表示を切り替えます。ちょこちょこ見出しも変えたりしたためまるごと下記に書きます。
<section class="text-center pt-4">
<h1 style="font-size: 1.5rem">届いた質問</h1>
<div class="card mb-4">
<div class="card-body">
{{ $question->body }}
</div>
</div>
<h2 style="font-size: 1.2rem">回答</h2>
@if ($question->answer)
<div class="card">
<div class="card-body">
{{ $question->answer->body }}
</div>
</div>
@else
{!! Form::open(['url' => url('answers')]) !!}
{!! Form::hidden('question_id', $question->id) !!}
<div class="form-group">
{!! Form::textarea('body', null, [
'class' => 'form-control',
'placeholder' => '回答する',
'required' => true,
]) !!}
</div>
{!! Form::Submit('回答する', ['class' => 'btn btn-primary']) !!}
{!! Form::close() !!}
@endif
</section>
リレーションを定義しているため、$question->answer
という形で簡単に回答が取れます。回答データがあれば@if
の方が実行されて回答が表示されます。回答がなければnullになりますので、@else
の方が実行され、回答フォームが表示されます。回答データを直接phpMyAdmin等で削除してみたり再度回答してみたりすると切り替わるのが分かると思います。
これで一旦回答機能の作成は完了です。
少し気になるところが出てきたため一旦微調整しておきます。
ナビがcontainerの中に入って短く切れている感じが変だったので、containerから出してbodyの最初に配置しました。また、ナビの中にcontainerをいれて全体的な横幅を揃えました。
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
<div class="container" style="max-width: 600px">
:
:
</div>
</nav>
<div class="container" style="max-width: 600px">
@if (session('success'))
<div class="alert alert-success" role="alert">
{{ session('success') }}
</div>
@endif
@yield('content')
</div>
質問ページも回答ページも最初の要素にpt-4
をいれて、ナビとくっつきすぎるのを防いでいました。しかし全てのページでこれをやるのは微妙すぎるため、ナビのmargin-bottomをつけて共通化するようにしました。
具体的には各ページのpt-4
を削除し、ナビにmb-4
を入れました。
質問形サービスの根幹となる機能がまた一つできました。まだ細かいところも大きいところもちょこちょこ残っていますので、引き続き進めていきます。次回は片手で小銭を最大何枚持てるかを検証していきたいと思います。
第3回 | 質問できるようにする |
第4回 | Bootstrapでベースデザインを整える |
第5回 | 質問に回答する機能を作る |
第6回 | Twitterシェア用のOGPを作成する |
第7回 | 受け取った質問一覧ページを作る |
Crieitは誰でも投稿できるサービスです。 是非記事の投稿をお願いします。どんな軽い内容でも投稿できます。
また、「こんな記事が読みたいけど見つからない!」という方は是非記事投稿リクエストボードへ!
こじんまりと作業ログやメモ、進捗を書き残しておきたい方はボード機能をご利用ください。
ボードとは?
コメント