inertia.js(react) + survey.js - 9: 集計前準備
集計に関してはこれはアンケートごとにまったく事なるロジックである事が見込まれる。すなわちプラグイン形式にしないと全く歯が立たないと思われる。
routes/web.phpの整理
Route::group(['middleware' => ['auth', 'verified']], function () {
Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard');
Route::get('surveys/{survey}/take', [SurveyTakingController::class, 'show'])->name('surveys.take');
Route::resource('surveys.responses', SurveyResponseController::class);
Route::get('surveys/{survey}/responses/{response}/aggregate', [SurveyResponseController::class, 'aggregate'])->name('surveys.responses.aggregate');
});
このように集計用のrouteを一本作る
Route::get('surveys/{survey}/responses/{response}/aggregate'
内容はまだ書かなくともよい。
集計viewへのリンク
これは過去回答表示画面でtabっぽいものを作成する。
resources/js/Pages/SurveyResponses/Show.jsx
<div className="py-12">
<div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div className="mb-4">
<nav className="flex">
<a
href={route('surveys.responses.show', { survey: surveyModel.id, response: surveyModel.response_id })}
className="py-2 px-4 rounded-t-lg bg-blue-500 text-white border-b-0"
>
{t("Response View")}
</a>
<Link
href={route('surveys.responses.aggregate', { survey: surveyModel.id, response: surveyModel.response_id })}
className="py-2 px-4 rounded-t-lg bg-gray-200 text-gray-500 border border-gray-300"
>
{t("Aggregate View")}
</Link>
</nav>
</div>
こんな感じでtabっぽいものが書かれる。ここでAggregate Viewの中身は何もなくていいから、書く。
コントローラーのアクション
app/Http/Controllers/SurveyResponseController.php
public function aggregate(Survey $survey, SurveyResponse $response, SurveyService $surveyService)
{
$surveyData = $surveyService->getSurveyData($survey);
$survey->response_id = $response->id;
$previewData = $survey->getUserResponseData(auth()->id());
return Inertia::render('SurveyResponses/Aggregate', [
'surveyModel' => $survey,
'surveyData' => $surveyData,
'responseData' => $previewData,
]);
}
このようにコピペ対応でok。Viewもコピペでよい
% cp resources/js/Pages/SurveyResponses/Show.jsx resources/js/Pages/SurveyResponses/Aggregate.jsx
まあそうはいっても、previewのコンポーネントなどは必要ないので、消す
resources/js/Pages/SurveyResponses/Aggregate.jsx
import React, { useCallback } from 'react';
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, router, usePage, Link } from '@inertiajs/react';
import { useLaravelReactI18n } from 'laravel-react-i18n';
export default function SurveyResponseShow({
auth, surveyModel, surveyData, responseData,
}) {
const { t } = useLaravelReactI18n();
const { locale } = usePage().props;
return (
<AuthenticatedLayout
user={auth.user}
header={<h2 className="font-semibold text-xl text-gray-800 leading-tight">{surveyModel.title}</h2>}
>
<Head title={surveyModel.title} />
<div className="py-12">
<div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div className="mb-4">
<nav className="flex">
<a
href={route('surveys.responses.show', { survey: surveyModel.id, response: surveyModel.response_id })}
className="py-2 px-4 rounded-t-lg bg-blue-500 text-white border-b-0"
>
{t("Response View")}
</a>
<Link
href={route('surveys.responses.aggregate', { survey: surveyModel.id, response: surveyModel.response_id })}
className="py-2 px-4 rounded-t-lg bg-gray-200 text-gray-500 border border-gray-300"
>
{t("Aggregate View")}
</Link>
</nav>
</div>
</div>
</div>
</AuthenticatedLayout>
);
}
さて、ここでtabに手を入れる。Aggregate Viewのときは、こちらをActiveにするべきだろうし、hoverのときの色とかあと何故かaタグになってたので直した
<nav className="flex">
<Link
href={route('surveys.responses.show', { survey: surveyModel.id, response: surveyModel.response_id })}
className="py-2 px-4 rounded-t-lg bg-gray-200 text-gray-500 border border-gray-300 hover:bg-gray-300"
>
{t("Response View")}
</Link>
<Link
href={route('surveys.responses.aggregate', { survey: surveyModel.id, response: surveyModel.response_id })}
className="py-2 px-4 rounded-t-lg bg-blue-500 text-white border-b-0 hover:bg-blue-700"
>
{t("Aggregate View")}
</Link>
</nav>
とはいえこれをバリバリ書いていくのは辛いので、Componentにしよう
resources/js/Components/SurveyResponseTabs.jsx
import { Link } from '@inertiajs/react';
import { useLaravelReactI18n } from 'laravel-react-i18n';
export default function SurveyResponseTabs({ surveyModel, activeTab }) {
const { t } = useLaravelReactI18n();
return (
<nav className="flex">
<Link
href={route('surveys.responses.show', { survey: surveyModel.id, response: surveyModel.response_id })}
className={`py-2 px-4 rounded-t-lg border border-gray-300 ${activeTab === 'view' ? 'bg-blue-500 text-white hover:bg-blue-700' : 'bg-gray-200 text-gray-500 hover:bg-gray-300'}`}
>
{t("Response View")}
</Link>
<Link
href={route('surveys.responses.aggregate', { survey: surveyModel.id, response: surveyModel.response_id })}
className={`py-2 px-4 rounded-t-lg border-b-0 ${activeTab === 'aggregate' ? 'bg-blue-500 text-white hover:bg-blue-700' : 'bg-gray-200 text-gray-500 hover:bg-gray-300'}`}
>
{t("Aggregate View")}
</Link>
</nav>
);
}
そしたら
import React, { useCallback } from 'react';
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, router, usePage } from '@inertiajs/react';
import SurveyResponseTabs from '@/Components/SurveyResponseTabs';
export default function SurveyResponseAggregate({
auth, surveyModel, surveyData, responseData,
}) {
const { locale } = usePage().props;
return (
<AuthenticatedLayout
user={auth.user}
header={<h2 className="font-semibold text-xl text-gray-800 leading-tight">{surveyModel.title}</h2>}
>
<Head title={surveyModel.title} />
<div className="py-12">
<div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div className="mb-4">
<SurveyResponseTabs surveyModel={surveyModel} activeTab="aggregate" />
</div>
</div>
</div>
</AuthenticatedLayout>
);
}
こんな感じだったり
resources/js/Pages/SurveyResponses/Show.jsx
import React, { useCallback } from 'react';
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Model } from 'survey-core';
import { Head, router, usePage } from '@inertiajs/react';
import SurveyWrapper from '@/Components/SurveyWrapper';
import SurveyResponseTabs from '@/Components/SurveyResponseTabs';
export default function SurveyResponseShow({
auth, surveyModel, surveyData, responseData,
}) {
const { locale } = usePage().props;
const survey = new Model(surveyData);
const surveyComplete = useCallback((sender) => {
router.post(route('surveys.previewStore', surveyModel.id), sender.data);
}, []);
survey.onComplete.add(surveyComplete);
return (
<AuthenticatedLayout
user={auth.user}
header={<h2 className="font-semibold text-xl text-gray-800 leading-tight">{surveyModel.title}</h2>}
>
<Head title={surveyModel.title} />
<div className="py-12">
<div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div className="mb-4">
<SurveyResponseTabs surveyModel={surveyModel} activeTab="view" />
</div>
<SurveyWrapper
model={survey}
readOnly
responseData={responseData}
locale={locale}
/>
</div>
</div>
</AuthenticatedLayout>
);
}
こんな感じだったりで対応していく
細々としたもの
言語
lang/ja.json
"Aggregate View": "分析",
"Response View": "回答",
シンプルに
あとAggregateのときもbreadcrumbを
routes/breadcrumbs.php
Breadcrumbs::for('surveys.responses.aggregate', function(BreadcrumbTrail $trail, Survey $survey, SurveyResponse $response)
{
$trail->parent('dashboard');
$trail->push($survey->title, route('surveys.responses.aggregate', [$survey, $response]));
});
とりま完成したもの
非常にチンケな分析viewだが、まあ何もないから仕方ないね。
分析用モックアップと構造の変更
まず、識別子を与える必要がある。
まあここでは単純にtypeとしよう。これはまず質問セットに付ける。
public function up(): void
{
Schema::create('survey_questions', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->json('question_data');
$table->string('type')->unique()->comment('id for aggregate');
$table->timestamps();
});
}
このtypeを設定する。これはseedでやってますわね。
database/seeders/SurveyQuestionSeeder.php
<?php
namespace Database\Seeders;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\File;
use App\Models\SurveyQuestion;
class SurveyQuestionSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
$files = File::glob(database_path('seeders/data/*.json'));
foreach ($files as $file) {
$jsonString = File::get($file);
$questionData = json_decode($jsonString, true);
$name = $questionData['title'] ?? null;
if (!$name) {
$name = '質問セット_'. now()->format('YmdHis');
}
SurveyQuestion::factory()->create([
'name' => $name,
'question_data' => $questionData,
]);
}
}
}
これをこんな感じにする
public function run(): void
{
$files = File::glob(database_path('seeders/data/*.json'));
foreach ($files as $file) {
$jsonString = File::get($file);
$questionData = json_decode($jsonString, true);
$name = $questionData['title'] ?? null;
if (!$name) {
$name = '質問セット_'. now()->format('YmdHis');
}
$fileName = pathinfo($file, PATHINFO_FILENAME);
$type = Str::camel(str_replace('_', ' ', $fileName));
SurveyQuestion::factory()->create([
'name' => $name,
'type' => $type,
'question_data' => $questionData,
]);
}
要するにtypeに database/seeders/data/survey_questions.json これから吸いあげたとき surveyQuestionをセットするという事になる。
これでseedしなおして
artisan migrate:fresh --seed
中身を確認すると
Psy Shell v0.11.21 (PHP 8.2.10 — cli) by Justin Hileman
> SurveyQuestion::all()
[!] Aliasing 'SurveyQuestion' to 'App\Models\SurveyQuestion' for this Tinker session.
= Illuminate\Database\Eloquent\Collection {#7511
all: [
App\Models\SurveyQuestion {#7513
id: 1,
name: "年次調査 2023",
question_data: "{"pages": [{"name": "基本情報", "elements": [{"name": "fullName", "type": "text", "title": "お名前を教えてください。", "isRequired": true}, {"name": "gender", "type": "radiogroup", "title": "性別は?", "choices": ["男", "女", "その他"], "isRequired": true}]}, {"name": "職業と収入", "elements": [{"name": "occupation", "type": "text", "title": "現在の職業は何ですか?", "isRequired": false}, {"name": "incomeSources", "type": "checkbox", "title": "収入源は何ですか?(複数選択可)", "choices": ["給与", "投資", "ビジネス", "その他"], "isRequired": false}]}], "title": "年次調査 2023"}",
type: "surveyQuestions",
created_at: "2023-10-11 09:33:30",
updated_at: "2023-10-11 09:33:30",
},
],
}
このように正しくtypeが入っているのがわかる。
survey作るときにtypeを移す
こんどはsurveysだが
Schema::create('surveys', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->text('description')->nullable();
$table->json('settings')->nullable();
$table->string('type')->comment('id for aggregate');
$table->timestamps();
});
ここにはuniqueは付けない。まずこれにはCRUDをちょっと修正しないといけないが、まずseedが通らなくなるので修正する
seedを修正する
database/seeders/SurveySeeder.php
public function run(SurveyService $surveyService): void
{
$surveyQuestion = SurveyQuestion::first();
$survey = Survey::factory()->create([
'title' => 'ダミーの質問',
'description' => 'ダミーの質問です',
'type' => $surveyQuestion->type,
]);
$surveyStructure = $surveyQuestion->question_data;
$surveyService->createSurveyPage($survey, $surveyStructure);
}
createを修正する
モデルにtypeを追加して
class Survey extends Model
{
use HasFactory;
protected $fillable = [
'title',
'description',
'settings',
'type',
];
以下のようにつける
public function store(SurveyRequest $request, SurveyService $surveyService): RedirectResponse
{
$data = $request->validated();
$data['settings'] = [];
$surveyQuestionSetId = $request->survey_question_set_id;
$surveyQuestion = SurveyQuestion::findOrFail($surveyQuestionSetId);
$surveyStructure = $surveyQuestion->question_data;
$data['type'] = $surveyQuestion->type; // これ
DB::beginTransaction();
$survey = Survey::create($data);
$surveyService->createSurveyPage($survey, $surveyStructure);
DB::commit();
return redirect(route('surveys.index'))
->with(['success' => __('New Survey Created')])
;
}
ここまでで準備完了。
ユーザーが受験する
ま、今は管理者も受験できるんすけどね。
分析タブ
app/Http/Controllers/SurveyResponseController.php
public function aggregate(Survey $survey, SurveyResponse $response, SurveyService $surveyService)
{
$surveyData = $surveyService->getSurveyData($survey);
$survey->response_id = $response->id;
$previewData = $survey->getUserResponseData(auth()->id());
dd($survey->type);
return Inertia::render('SurveyResponses/Aggregate', [
'surveyModel' => $survey,
'surveyData' => $surveyData,
'responseData' => $previewData,
]);
}
これでようやくtypeが取れるようになる。このtypeに基いて分析する、んだけど今は分析する事もないので、とりあえず雑にハードコードしてみる。
分析コンポーネント
これは resources/js/Components/AggregateViews に配置することとしようか。今
admin@ip-172-31-31-170:inertia-survey-demo % ./vendor/bin/sail artisan tinker
Psy Shell v0.11.21 (PHP 8.2.10 — cli) by Justin Hileman
> Survey::all()
[!] Aliasing 'Survey' to 'App\Models\Survey' for this Tinker session.
= Illuminate\Database\Eloquent\Collection {#7511
all: [
App\Models\Survey {#7513
id: 1,
title: "ダミーの質問",
description: "ダミーの質問です",
settings: null,
type: "surveyQuestions",
created_at: "2023-10-11 09:44:44",
updated_at: "2023-10-11 09:44:44",
},
],
}
このようになっているので、viewのパスを
resources/js/Components/AggregateViews/surveyQuestions.jsx とした。内容は以下の通り
export default function surveyQuestions() {
return (
<div>test</div>
);
}
そたらば
app/Http/Controllers/SurveyResponseController.php
public function aggregate(Survey $survey, SurveyResponse $response, SurveyService $surveyService)
{
$surveyData = $surveyService->getSurveyData($survey);
$survey->response_id = $response->id;
$previewData = $survey->getUserResponseData(auth()->id());
$currentComponent = $survey->type;
return Inertia::render('SurveyResponses/Aggregate', [
'surveyModel' => $survey,
'surveyData' => $surveyData,
'component' => $currentComponent,
]);
}
$currentComponentに$survey->typeを入れこんでいる。ここでは SurveyQuestions となるだろう。
viewであるがまあ精一杯やってこんな感じかな…
import React from 'react';
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, usePage } from '@inertiajs/react';
import SurveyResponseTabs from '@/Components/surveyResponseTabs';
import surveyQuestions from '@/Components/AggregateViews/SurveyQuestions';
const componentMap = {
surveyQuestions,
};
export default function SurveyResponseAggregate({
auth, surveyModel, surveyData, component
}) {
const Aggregate = componentMap[component];
return (
<AuthenticatedLayout
user={auth.user}
header={<h2 className="font-semibold text-xl text-gray-800 leading-tight">{surveyModel.title}</h2>}
>
<Head title={surveyModel.title} />
<div className="py-12">
<div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div className="mb-4">
<SurveyResponseTabs surveyModel={surveyModel} activeTab="aggregate" />
</div>
{Aggregate && <Aggregate />}
</div>
</div>
</AuthenticatedLayout>
);
}
この内容は適当に考えてくださいっちゅーことで
分析が無いとき
resources/js/Components/AggregateViews/SurveyQuestions-demo.jsx
こういう風にリネームして消滅した場合、普通に分析を押すとエラーになる。これを回避する。
public function show(Survey $survey, SurveyResponse $response, SurveyService $surveyService)
{
$surveyData = $surveyService->getSurveyData($survey);
$survey->response_id = $response->id;
$previewData = $survey->getUserResponseData(auth()->id());
$componentPath = resource_path('js/Components/AggregateViews/') . $component . '.jsx';
$isComponentAvailable = file_exists($componentPath);
return Inertia::render('SurveyResponses/Show', [
'surveyModel' => $survey,
'surveyData' => $surveyData,
'responseData' => $previewData,
'isComponentAvailable' => $isComponentAvailable,
]);
}
ファイルチェックしてあるなしを判断する。
resources/js/Pages/SurveyResponses/Show.jsx
export default function SurveyResponseShow({
auth, surveyModel, surveyData, responseData, isComponentAvailable,
}) {
const { locale } = usePage().props;
const survey = new Model(surveyData);
const surveyComplete = useCallback((sender) => {
router.post(route('surveys.previewStore', surveyModel.id), sender.data);
}, []);
survey.onComplete.add(surveyComplete);
return (
<AuthenticatedLayout
user={auth.user}
header={<h2 className="font-semibold text-xl text-gray-800 leading-tight">{surveyModel.title}</h2>}
>
<Head title={surveyModel.title} />
<div className="py-12">
<div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div className="mb-4">
<SurveyResponseTabs surveyModel={surveyModel} activeTab="view" isComponentAvailable={isComponentAvailable} />
</div>
resources/js/Components/SurveyResponseTabs.jsx
import { Link } from '@inertiajs/react';
import { useLaravelReactI18n } from 'laravel-react-i18n';
export default function SurveyResponseTabs({ surveyModel, activeTab, isComponentAvailable }) {
const { t } = useLaravelReactI18n();
return (
<nav className="flex">
<Link
href={route('surveys.responses.show', { survey: surveyModel.id, response: surveyModel.response_id })}
className={`py-2 px-4 rounded-t-lg border border-gray-300 ${activeTab === 'view' ? 'bg-blue-500 text-white hover:bg-blue-700' : 'bg-gray-200 text-gray-500 hover:bg-gray-300'}`}
>
{t("Response View")}
</Link>
{isComponentAvailable && (
<Link
href={route('surveys.responses.aggregate', { survey: surveyModel.id, response: surveyModel.response_id })}
className={`py-2 px-4 rounded-t-lg border-b-0 ${activeTab === 'aggregate' ? 'bg-blue-500 text-white hover:bg-blue-700' : 'bg-gray-200 text-gray-500 hover:bg-gray-300'}`}
>
{t("Aggregate View")}
</Link>
)}
</nav>
);
}
まあこんなもんじゃろ。Controllerで集計データーを作るとかはまあ考えてみてくださいな。