
九保すこひです(フリーランスのITコンサルタント、エンジニア)
【皆さんへのお礼】
今回でなんと400記事になります
ほぼ何も続かない私ですが、みなさんが記事を読みに来ていただけるおかげでここまで続けることができました。
今後もぜひお気軽にご訪問ください
さてさて、それほど頻繁ではないのですが私はTwitter
を使っていて、ブログの記事が公開されると自動ツイートされるようにしています。
そんなツイッターですが、たまに流れてくるツイートで「えっ、こんなのあるんだ」と思うものがありました。
それが・・・・・・
アンケート機能
です。
Twitter
を日頃から使っている方ならよくご存知だと思いますが、流れとしては次のようになっています。
- 選択肢がいくつか表示される
- 選択肢を選ぶ
- アンケート結果が表示される
そこで
今回はLaravel
を使って、このアンケート機能を作ってみたいと思います。
ぜひ何かの参考になりましたら嬉しいです。
※ なお、今回は「400記事記念」ということで、比較的Laravel
やVue
に慣れた人に向けてにコードを書いています。ご期待下さい。
「Twitter は情報収集に役立ちますね
」
開発環境: Laravel 8.x
目次 [非表示]
前提として
なお、今回のアンケートは重複回答を防止するために「ログイン必須」にします。そのため、ログイン機能がインストールされていることが前提です。
もしまだの方は以下を参考にしてログイン機能を先に準備しておいてください。
参考記事: Laravel Breezeで「シンプルな」ログイン機能をインストール
データベースまわりをつくる
では、まず「モデル」と「マイグレーション」でデータベースまわりをつくっていきましょう。
必要なテーブルは以下3つです。
- 質問テーブル
- 質問の選択肢テーブル
- 回答テーブル
では、以下の3つのコマンドを実行してください。
php artisan make:model Question -m
php artisan make:model QuestionItem -m
php artisan make:model Answer -m
すると、モデルとマイグレーションのペアが3つ(全6ファイル)作成されるので、中身を以下のように変更します。
まずはマイグレーションです。
database/migrations/****_**_**_******_create_questions_table.php
public function up()
{
Schema::create('questions', function (Blueprint $table) {
$table->id();
$table->string('title')->comment('タイトル');
$table->dateTime('expired_at')->comment('有効期限');
$table->timestamps();
});
}
database/migrations/****_**_**_******_create_question_items_table.php
public function up()
{
Schema::create('question_items', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('question_id')->comment('質問ID');
$table->string('option')->comment('選択肢');
$table->timestamps();
$table->foreign('question_id')->references('id')->on('questions');
});
}
database/migrations/****_**_**_******_create_answers_table.php
public function up()
{
Schema::create('answers', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('user_id')->comment('ユーザーID');
$table->unsignedBigInteger('question_id')->comment('選択肢ID');
$table->unsignedBigInteger('question_item_id')->comment('選択肢ID');
$table->timestamps();
$table->foreign('user_id')->references('id')->on('users');
$table->foreign('question_id')->references('id')->on('questions');
$table->foreign('question_item_id')->references('id')->on('question_items');
});
}
そして、モデルです。
app/Models/Question.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Question extends Model
{
use HasFactory;
// Relationship
public function items()
{
return $this->hasMany(QuestionItem::class, 'question_id', 'id');
}
}
app/Models/QuestionItem.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class QuestionItem extends Model
{
use HasFactory;
// Relationship
public function answers()
{
return $this->hasMany(Answer::class, 'question_item_id', 'id');
}
}
※ なお、Answer.php
は初期のままでOKです。
テストデータをつくる
続いて、開発がしやすいようにテストデータをSeeder
でつくります。
以下のコマンドを実行してください。
php artisan make:seed QuestionSeeder
すると、Seeder
ファイルが作成されるので、中身を次のようにします。
database/seeders/QuestionSeeder.php
<?php
namespace Database\Seeders;
use App\Models\Question;
use App\Models\QuestionItem;
use Illuminate\Database\Seeder;
class QuestionSeeder extends Seeder
{
/**
* Run the database seeds.
*
* @return void
*/
public function run()
{
$test_questions = [
[
'title' => '好きな動物は?',
'options' => [
'犬',
'猫',
'パンダ',
'アライグマ',
'カモノハシ'
]
],
[
'title' => '行ってみたい国は?',
'options' => [
'アメリカ',
'イギリス',
'フランス',
'ドイツ',
'カナダ'
]
]
];
foreach ($test_questions as $test_question) {
$question = new Question();
$question->title = $test_question['title'];
$question->expired_at = today()->addDays(rand(3, 7));
$question->save();
$test_options = $test_question['options'];
foreach ($test_options as $test_option) {
$question_item = new QuestionItem();
$question_item->question_id = $question->id;
$question_item->option = $test_option;
$question_item->save();
}
}
}
}
では、作成したQestionSeeder
をLaravel
へ登録します。
database/seeders/DatabaseSeeder.php
// 省略
public function run()
{
// 省略
$this->call(QuestionSeeder::class);
}
以下のコマンドを実行してデータベースを再構築しましょう。
php artisan migrate:fresh --seed
実際のテーブルはこうなりました。
バリデーションをつくる
次に、コントローラーをつくる前にバリデーション(入力チェック)の専用FormRequest
をつくっておきましょう。
以下のコマンドを実行してください。
php artisan make:request QuestionRequest
するとファイルが作成されるので、中身を以下のように変更します。
app/Http/Requests/QuestionRequest.php
<?php
namespace App\Http\Requests;
use App\Models\Answer;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class QuestionRequest extends FormRequest
{
// 省略
public function rules()
{
return [
'question_id' => Rule::exists('question_items')->where(function ($query) { // question_id と question_item_id のペアが正しいかチェック
return $query->where('id', $this->question_item_id);
}),
'question_item_id' => function ($attribute, $value, $fail) { // 投票済みかどうかチェック
$user_id = auth()->id();
$exists = Answer::where('question_id', $this->question_id)
->where('user_id', $user_id)
->exists();
if ($exists === true) {
$fail('すでに投票済みです');
}
},
];
}
}
なお、2つ目のバリデーションは直接function
を使って指定していますが、本来は専用バリデーション・ルールをつくる方がいいでしょう。
もし気になる方は、以下のページを参照してください。
参考ページ: 独自バリデーション・ルールをつくる
コントローラーをつくる
では、次にコントローラーをつくります。
以下のコマンドを実行してください。
php artisan make:controller QuestionController
すると、コントローラーが作成されるので中身を次のようにしてください。
<?php
namespace App\Http\Controllers;
use App\Http\Requests\QuestionRequest;
use App\Models\Answer;
use App\Models\Question;
use App\Models\QuestionItem;
use Illuminate\Http\Request;
class QuestionController extends Controller
{
public function show(Question $question)
{
$now = now();
if($question->expired_at < $now) { // すでに有効期限切れの場合
abort(404);
}
$question->load('items');
$user_id = auth()->id();
$is_polled = Answer::where('question_id', $question->id)
->where('user_id', $user_id)
->exists();
return view('question.index')->with([
'question' => $question,
'is_polled' => $is_polled
]);
}
public function result(Question $question)
{
$question_items = QuestionItem::withCount('answers')
->where('question_id', $question->id)
->orderBy('answers_count', 'desc') // 件数で並び替え
->get();
$all_count = $question_items->sum('answers_count'); // すべての投票数
return $question_items->map(function($question_item) use($all_count) {
$percentage = ($all_count === 0)
? 0
: round($question_item->answers_count / $all_count * 100, 1); // 結果のパーセント化
return [
'option' => $question_item->option,
'percentage' => sprintf('%.1f', $percentage) // .0 が消えるのでフォーマット使用
];
});
}
public function store(QuestionRequest $request)
{
$answer = new Answer();
$answer->user_id = auth()->id();
$answer->question_id = $request->question_id;
$answer->question_item_id = $request->question_item_id;
$result = $answer->save();
return ['result' => $result];
}
}
ビューをつくる
そして、コントローラー内でセットしたビューです。
以下のファイルを作成してください。
resources/views/question/index.blade.php
<html>
<head>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.1/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div id="app" class="p-5">
<div class="row" v-if="isStatusQuestion">
<div class="col">
<h3 class="mb-3" v-text="question.title"></h3>
<div class="d-grid" v-for="item in question.items">
<button
type="button"
class="btn btn-outline-info rounded-pill mb-2"
v-text="item.option"
@click="poll(item.id)"></button>
</div>
</div>
<div class="col"></div>
<div class="col"></div>
</div>
<div class="row" v-if="isStatusResult">
<div class="col">
<h3 class="mb-3" v-text="question.title"></h3>
<div v-for="result in questionResults">
<v-question-result
:option="result.option"
:percentage="result.percentage"></v-question-result>
<br>
</div>
</div>
<div class="col"></div>
<div class="col"></div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.9.2/dist/umd/popper.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.1/dist/js/bootstrap.min.js"></script>
<script src="https://unpkg.com/vue@3.1.1/dist/vue.global.prod.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.21.1/axios.min.js"></script>
<script>
const questionResultComponent = {
props: {
option: {
type: String,
default: ''
},
percentage: {
type: Number,
default: 0
}
},
computed: {
barStyles() {
return {
backgroundColor: '#b2e9f8',
width: `${this.percentage}%`
};
}
},
template: `
<div class="position-relative py-1">
<div class="position-absolute" :style="barStyles">​</div>
<div class="position-absolute ps-1" v-text="option"></div>
<div class="position-absolute" style="right:0;">
<span v-text="percentage"></span> %
</div>
</div>
`
};
Vue.createApp({
data(){
return {
status: 'question', // `question` or `result`
question: @json($question),
isPolled: @json($is_polled),
questionResults: {}
}
},
methods: {
poll(questionItemId) {
const url = '{{ route('question.store') }}';
const params = {
question_id: this.question.id,
question_item_id: questionItemId
};
axios.post(url, params)
.then(response => {
if(response.data.result === true) {
this.getResult();
}
})
.catch(error => {
alert('バリデーション・エラー');
console.log(errors.response.data);
// TODO: ここでエラー処理
});
},
getResult() {
const url = '{{ route('question.result') }}/'+ this.question.id;
axios.get(url)
.then(response => {
this.status = 'result'; // 表示を切り替え
this.questionResults = response.data;
});
}
},
computed: {
isStatusQuestion(){
return (this.status === 'question');
},
isStatusResult(){
return (this.status === 'result');
}
},
mounted() {
if(this.isPolled === true) {
this.getResult();
}
}
})
.component('v-question-result', questionResultComponent)
.mount('#app');
</script>
</body>
</html>
ちょっとここは複雑なのですが、重要な点は、独自のコンポーネントquestionResultComponent
をつくっているところです。
ルートをつくる
では、最後にルートです。
今回はログインが必須なコンテンツですので、中身は次ようになります。
routes/web.php
// 省略
Route::middleware('auth')->group(function() {
Route::get('question/{question}', [QuestionController::class, 'show'])->name('question.show');
Route::get('question/result/{question?}', [QuestionController::class, 'result'])->name('question.result');
Route::post('question', [QuestionController::class, 'store'])->name('question.store');
});
これで作業は終了です。
お疲れ様でした
テストしてみる
今回は400回記念ですので、テストしている動画をYouTube
に用意しました。
ぜひご覧ください。
↓↓↓
企業様へのご提案
アンケート機能を利用すると、お客さんからの声だけでなく社内の意見や方向性を知るきっかけにすることができるかと思います。
もし、現在使用されているシステムへアンケート機能をつけたいとお考えでしたらぜひお気軽にご連絡ください。
どうぞよろしくお願いいたします。m(_ _)m
おわりに
ということで、今回はいつもと違い少しだけ本格的(複雑?)なコードをご紹介しましたが、いかがだったでしょうか。
正直なところプログラム初学者が見たら「うーん・・・プログラム嫌い」なんてことになるかもしれないので、少し悩んだのですが、なにせ今回は「400記事記念」ということでゴリ押ししてみました(笑)
とはいえ、コードは長いですが注目はそこではなく、「結局短いコードの集合体なんだな」と感じていただけたら嬉しいです。
長年コードを書いている私でも1行目から最後の行までをスラスラ書いているわけではなく、あっちに行ったりこっちに行ったり、はたまた長すぎるから分割したりという作業をくりかえしているに過ぎません。
つまり、根本を見つけたらある程度どころか、ながーーーーーーーーーーーーーーーい(京都銀行のCM知ってます?)コードでもそれほど怖くはなくなってくると思います。
ぜひいいきっかけになりましたら嬉しいです。
ではでは〜
「当初はブログなんて
すぐやめるんだろうな
って思ってました(笑)」