survey.js アンケート項目の作成と表示、結果の一部保存と表示
今回の目標
今、存在する構造が
public function up(): void
{
Schema::create('surveys', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->text('description')->nullable();
$table->timestamps();
});
}
これのみであるから、質問データーを追加しよう。これはSurveyElementというモデルにする。ちなみに、最初に表現するべきjsonは相変わらずこれ
const surveyJson = {
completedHtml: '',
elements: [{
name: "姓",
title: "姓を入力ください",
type: "text"
}, {
name: "名",
title: "名を入力ください",
type: "text"
}]
};
つまり、ここではelementが2本入っているという事になる。こちらも作成インターフェースを持たないのでfactory込みの作成となる
% ./vendor/bin/sail artisan make:model SurveyElement -mrfs
INFO Model [app/Models/SurveyElement.php] created successfully.
INFO Factory [database/factories/SurveyElementFactory.php] created successfully.
INFO Migration [database/migrations/2023_09_09_065401_create_survey_elements_table.php] created successfully.
INFO Seeder [database/seeders/SurveyElementSeeder.php] created successfully.
INFO Controller [app/Http/Controllers/SurveyElementController.php] created successfully.
SurveyElementの作成
idは当然必要として、実はすげー柔軟な設計を取る事もできる、たとえば問題データーを別のSurveyでも使いたいという場合だ。ただ、ここではまず一番シンプルな設計とする、何故ならこれはデモだからである。
と考えるとまず、title, typeである。nameは?
survey.jsにおけるname
上記のjsonを投入するとこんな感じになり、要するにnameはキーになるわけだ。となるとこれは別にIDでいいじゃねえかという事になるので特に設定しない。そしてsurvey.jsにおけるtypeはいくつか決まりがあるので、enumにする。まあこれだとtextしかないからtextしかないのだが
これらを踏まえたschemaは以下となる
Schema::create('survey_elements', function (Blueprint $table) {
$table->id();
$table->foreignId('survey_id')->constrained();
$table->string('title');
$table->enum('type', ['text']);
$table->timestamps();
});
ここで親IDとしてsurvey_idを与えているが、これを与えずに中間テーブルで管理すれば最初に提起した問題に対応できるかも。
factory
class SurveyElementFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'survey_id' => 1,
'type' => 'text',
'title' => $this->faker->sentence(),
];
}
}
まあこの辺は現在はseedで上書きするから、適当な定義でok
seed
database/seeders/DatabaseSeeder.php
$this->call([
SurveySeeder::class,
SurveyElementSeeder::class,
]);
database/seeders/SurveyElementSeeder.php
public function run(): void
{
$survey = \App\Models\Survey::first();
\App\Models\SurveyElement::factory()->create([
'survey_id' => $survey->id,
'title' => '姓を入力ください',
'type' => 'text',
]);
\App\Models\SurveyElement::factory()->create([
'survey_id' => $survey->id,
'title' => '名を入力ください',
'type' => 'text',
]);
}
データーを渡してみる
いま
resources/js/Pages/Surveys/Index.jsx
<Link href={ route("surveys.show", survey.id)}>
<button className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
Take Survey
</button>
</Link>
としてあって、Showは以下の通りなので
public function show(Survey $survey)
{
dd($survey);
}
ここでまず組み立てていくのだがrelationがねえな。とりあえず
dd($survey->elements());
ってやったときにelements一覧が出て欲しいってことは
app/Models/Survey.php
public function elements(): HasMany
{
return $this->hasMany(SurveyElement::class);
}
というリレーションになるべきだ。
じゃあ取ってみよう。
public function show(Survey $survey)
{
return Inertia::render('Surveys/Show', ['survey' => $survey]);
}
Showにはjsonが与えられる必要があるわけ、なんだけど今そのjsonに与えるべき構造となっていないから、組み立てる。これはまず
$elements = [];
foreach ($survey->elements as $element) {
$elements[] = [
'name' => $element->id,
'title' => $element->title,
'type' => $element->type,
];
}
こんなコードでいけるけど、こういうのをmapで書けるようにしておくと特にinertiaで渡すデーターを整形する場合は結構多発するのでよいと思う
$elements = $survey->elements()->get()->map(function ($element) {
return [
'name' => $element->id,
'title' => $element->title,
'type' => $element->type,
];
})->toArray();
まともあれ
array:2 [▼ // app/Http/Controllers/SurveyController.php:51
0 => array:3 [▼
"name" => 1
"title" => "姓を入力ください"
"type" => "text"
]
1 => array:3 [▼
"name" => 2
"title" => "名を入力ください"
"type" => "text"
]
]
のようなデータ構造が出来たので単純に送信する
public function show(Survey $survey)
{
$elements = $survey->elements()->get()->map(function ($element) {
return [
'name' => (string)$element->id, // 注意
'title' => $element->title,
'type' => $element->type,
];
})->toArray();
$surveyJson = [
'completedHtml' => '',
'elements' => $elements,
];
return Inertia::render('Surveys/Show', [
'surveyModel' => $survey,
'surveyJson' => $surveyJson
]);
}
ここで注意すべきはsurveyJsは整数値で渡すとバグるので、かならず文字キャストする事。strValとか""で括るとか方法は他にもあるけど、とりあえず今回は(string)キャストした。あとsurveyという文字がぶつかっちゃうのでsurveyModelとした、が、よくみちゃもうこれjsonじゃないんだよね、というわけで名前を変更したりして以下が洗練されたコードである
public function show(Survey $survey)
{
$elements = $survey->elements()->get()->map(function ($element) {
return [
'name' => (string)$element->id,
'title' => $element->title,
'type' => $element->type,
];
})->toArray();
$surveyData = [
'completedHtml' => '',
'elements' => $elements,
];
return Inertia::render('Surveys/Show', [
'surveyModel' => $survey,
'surveyData' => $surveyData
]);
}
そしたら受信側はもうあんまり考える事もない
import React, { useCallback } from 'react';
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import 'survey-core/defaultV2.min.css';
import { Model } from 'survey-core';
import { Survey } from 'survey-react-ui';
import { Head, router } from '@inertiajs/react';
export default function SurveyShow({ auth, surveyModel, surveyData }) {
const survey = new Model(surveyData)
const surveyComplete = useCallback((sender) => {
router.post(route('surveys.store'), sender.data )
}, []);
survey.onComplete.add(surveyComplete);
return (
<AuthenticatedLayout
user={auth.user}
header={<h2 className="font-semibold text-xl text-gray-800 leading-tight">Survey</h2>}
>
<Head title="Survey.js" />
<div className="py-12">
<div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
<Survey model={survey} />;
</div>
</div>
</AuthenticatedLayout>
);
}
まあこれだとちょっと前と変わらないので、少し設定を変更してみよう
$elements = $survey->elements()->get()->map(function ($element) {
return [
'name' => (string)$element->id,
'title' => $element->title,
'type' => $element->type,
];
})->toArray();
$surveyData = [
'showQuestionNumbers' => 'off',
'completedHtml' => '',
'elements' => $elements,
];
return Inertia::render('Surveys/Show', [
'surveyModel' => $survey,
'surveyData' => $surveyData
]);
}
このように設定値を渡す事ができる。今回は
'showQuestionNumbers' => 'off',
を追加したら
このようになるわけだ
受信側
とりあえず
public function store(Request $request)
{
dd($request->all());
}
しといてデーターを送信してみれば
こういう風になるわけ。で、今結果保存テーブルがないので考えてみよう。
% ./vendor/bin/sail artisan make:model SurveyResponse -mrc
INFO Model [app/Models/SurveyResponse.php] created successfully.
INFO Migration [database/migrations/2023_09_09_084712_create_survey_responses_table.php] created successfully.
INFO Controller [app/Http/Controllers/SurveyResponseController.php] created successfully.
このレスポンステーブルは実は回答詳細が入らない。これは回答があったという事だけを格納するテーブルである。ちょっと、書いてみよう。
public function up(): void
{
Schema::create('survey_responses', function (Blueprint $table) {
$table->id();
$table->foreignId('survey_id')->constrained();
$table->foreignId('user_id')->constrained();
$table->timestamps();
});
}
こうした場合、webからつっこまれるからこのようにモデルにfillableも与える
class SurveyResponse extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'survey_id',
];
}
ではDBをフレッシュして実際書いてみる
% ./vendor/bin/sail artisan migrate:fresh --seed
Dropping all tables ..................................................................... 86ms DONE
INFO Preparing database.
Creating migration table ................................................................ 26ms DONE
INFO Running migrations.
2014_10_12_000000_create_users_table .................................................... 44ms DONE
2014_10_12_100000_create_password_reset_tokens_table .................................... 56ms DONE
2019_08_19_000000_create_failed_jobs_table .............................................. 60ms DONE
2019_12_14_000001_create_personal_access_tokens_table ................................... 74ms DONE
2023_09_08_123630_create_surveys_table .................................................. 24ms DONE
2023_09_09_065401_create_survey_elements_table .......................................... 74ms DONE
2023_09_09_084712_create_survey_responses_table ........................................ 127ms DONE
INFO Seeding database.
Database\Seeders\SurveySeeder ............................................................. RUNNING
Database\Seeders\SurveySeeder ........................................................ 5.31 ms DONE
Database\Seeders\SurveyElementSeeder ...................................................... RUNNING
Database\Seeders\SurveyElementSeeder ................................................ 11.44 ms DONE
の前にrouteのupdate
というか今SurveyResponseコントローラーが追加されているわけだが、あくまでRESTFullの概念に基いてやるとSurveyのcrudはSurvey自体に発行されるべきであるからReponseはその下層のControllerでやるべきという事になるわけだ、というわけだから、というわけだからnested routeを作る
routes/web.php
use App\Http\Controllers\SurveyResponseController;
//...
Route::resource('surveys', SurveyController::class);
Route::resource('surveys.responses', SurveyResponseController::class);
とまあこうしておく。送信先も変更となる
resources/js/Pages/Surveys/Show.jsx
export default function SurveyShow({ auth, surveyModel, surveyData }) {
const survey = new Model(surveyData)
const surveyComplete = useCallback((sender) => {
router.post(route('surveys.store'), sender.data )
}, []);
survey.onComplete.add(surveyComplete);
とかしていたものを以下のように変更する
const surveyComplete = useCallback((sender) => {
router.post(route('surveys.responses.store', surveyModel.id), sender.data )
}, []);
そうして app/Http/Controllers/SurveyResponseController.php
public function store(Request $request, Survey $survey)
{
$data = $request->all();
dd($data);
}
とかするとちゃんとデーターが送られてくるのが確認できる、ま、とはいえ今回はデーターは保存しないんだけどね 。
use Illuminate\Http\RedirectResponse;
//...
public function store(Request $request, Survey $survey): RedirectResponse
{
SurveyResponse::create([
'user_id' => auth()->id(),
'survey_id' => $survey->id
]);
return redirect(route('surveys.index'))
->with(['success' => '保存しました(実際は保存できていない)'])
;
}
flashメッセージの問題
てか、せっかくreact使ってるのでreact-tostifyでも使ってみましょうか。
% ./vendor/bin/sail npm install react-toastify
js/Layouts/AuthenticatedLayout.jsx
import { useState, useEffect } from 'react';
import 'react-toastify/dist/ReactToastify.css';
import { toast } from 'react-toastify';
import { ToastContainer } from 'react-toastify';
export default function Authenticated({ user, header, children }) {
const [showingNavigationDropdown, setShowingNavigationDropdown] = useState(false);
useEffect(() => {
// コンポーネントがマウントされた時にトーストを表示
toast('Hello, this is a flash message!');
}, []);
みたいな事を書いておくと
こういうのが出てくるはずだ。
flashmessageの注入
これに関してcontrollerからlayoutにflash messageを注入する方法がある
app/Http/Middleware/HandleInertiaRequests.php
public function share(Request $request): array
{
return array_merge(parent::share($request), [
'auth' => [
'user' => $request->user(),
],
'ziggy' => function () use ($request) {
return array_merge((new Ziggy)->toArray(), [
'location' => $request->url(),
]);
},
'flash' => [
'success' => fn () => $request->session()->get('success')
],
]);
}
とまあこんな感じ。flash.statusでもいいんすよ。この辺は好きにやってください。もちろんこの場合はerrorも想定してキーをつくっとく。statusの場合は1つでいいかもしれないね
return array_merge(parent::share($request), [
'auth' => [
'user' => $request->user(),
],
'ziggy' => function () use ($request) {
return array_merge((new Ziggy)->toArray(), [
'location' => $request->url(),
]);
},
'flash' => [
'success' => fn () => $request->session()->get('success'),
'error' => fn () => $request->session()->get('error')
],
]);
最後の仕上げ
resources/js/Layouts/AuthenticatedLayout.jsx
export default function Authenticated({ user, header, children }) {
const [showingNavigationDropdown, setShowingNavigationDropdown] = useState(false);
const { flash } = usePage().props;
useEffect(() => {
if (flash.success) {
toast.success(flash.success);
}
if (flash.error) {
toast.error(flash.error);
}
}, [flash]);
とかいうのが出てくるだろう
Surveyに回答があるとき
surveyに回答があるときはもう一度回答させるのではなく、その回答を表示させるのだったが、とりあえずもう随分長くなったので、それはそれとして、とりあえず回答があったことを示す表示を付けてみよう。
app/Http/Controllers/SurveyController.php
public function index(): Response
{
// $surveys = Survey::latest()->get();
$userId = auth()->id();
$surveys = Survey::latest()->get()->each(function ($survey) use ($userId) {
$survey->hasResponse = $survey->hasResponseFromUser($userId);
});
dd($surveys);
return Inertia::render('Surveys/Index', ['surveys' => $surveys]);
}
indexでこのように「回答があったときはhasReponseにtrueを与える」みたいな処理を行いたい。たとえばddしているが回答がないときはこんな感じになる
#attributes: array:6 [▼
"id" => 1
"title" => "デモ1"
"description" => "survey.js機能確認用"
"created_at" => "2023-09-09 09:53:37"
"updated_at" => "2023-09-09 09:53:37"
"hasResponse" => false
]
これを実装する場合まずモデルにhasResponseFromUser($userId)的な定義が必要だろう。これは
public function hasResponseFromUser($userId): bool
{
return $this->responses()
->where('user_id', $userId)
->exists();
}
こうなるはずだ。このresponses()というのはhasManyでありqueryにuser_idをひっつけている。つまりresponseテーブルに当該ユーザーIDの回答があるときはtrueとなる。
public function responses(): HasMany
{
return $this->hasMany(SurveyResponse::class);
}
ちなみにこれhasOneなんじゃねーの?って思った人は鋭いが、まあこの手のシステムつのは往々にして複数回Surveyみたいな拡張が要求されるもんだから、まずはゆとりをもった設計にしとくといいんじゃないかな?ただまあ今の仕様ではhasOneでもokだし、複数回答しようとした場合はブロッキングが必要かもしれないが、その辺はnoteに書いてるようなアプリだしゆるくやろうw
いずれにせよddを消す
public function index(): Response
{
// $surveys = Survey::latest()->get();
$userId = auth()->id();
$surveys = Survey::latest()->get()->each(function ($survey) use ($userId) {
$survey->hasResponse = $survey->hasResponseFromUser($userId);
});
return Inertia::render('Surveys/Index', ['surveys' => $surveys]);
}
適当に回答する。またddしてみれば…
#attributes: array:6 [▼
"id" => 1
"title" => "デモ1"
"description" => "survey.js機能確認用"
"created_at" => "2023-09-09 09:53:37"
"updated_at" => "2023-09-09 09:53:37"
"hasResponse" => true
]
ってなもんだから、もうあとは簡単だ
<AuthenticatedLayout
user={auth.user}
header={<h2 className="font-semibold text-xl text-gray-800 leading-tight">Survey</h2>}
>
<Head title="Survey.js" />
<div className="py-12">
<div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div>
<h3 className="text-2xl font-semibold mb-4">Available Surveys</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{surveys.map((survey, index) => (
<div key={index} className="bg-white rounded-lg shadow-lg p-4">
<h4 className="text-lg font-semibold mb-2">{survey.title}</h4>
<p className="text-sm text-gray-700 mb-3">{survey.description}</p>
{survey.hasResponse ? (
<Link href={route("surveys.show", survey.id)}>
<button className="bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded">
View Your Response
</button>
</Link>
) : (
<Link href={route("surveys.show", survey.id)}>
<button className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
Take Survey
</button>
</Link>
)}
</div>
))}
</div>
</div>
</div>
</div>
</AuthenticatedLayout>
とかすりゃ回答があったら
こうなるだろう、ただし、まだView Your Responseボタンは機能していない。長くなりすぎとるもん。またにしましょう
この記事が気に入ったらサポートをしてみませんか?