inertia.js(react) + survey.js - 8: 手直し
文言
TAKEとかだと訳し辛いかな…
<PrimaryButton href={route('surveys.take', survey.id)}>
<VscChecklist className="mr-2" /> {t('Take The Survey')}
</PrimaryButton>
翻訳ja.json
"Take The Survey": "アンケート調査の開始",
まあ何ていうか最終的にここはカスタムしてもいいかもしれませんね。
survey.jsのlocale
これは実はjaとかをライブラリーに渡す事が可能
app/Http/Controllers/SurveyTakingController.php
return Inertia::render('Surveys/Take', [
'surveyModel' => $survey,
'surveyData' => $surveyData,
'responseData' => null, // TODO
'readOnly' => $responseCount ? true : false,
'surveyLocale' => 'ja', // とりあえずハードコード
]);
などとコントローラーから渡しておいての
resources/js/Pages/Surveys/Take.jsx
import React, { useCallback } from 'react';
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import 'survey-core/defaultV2.min.css';
import "survey-core/survey.i18n";
import { Model } from 'survey-core';
import { Survey } from 'survey-react-ui';
import { Head, router } from '@inertiajs/react';
import { VscTrash } from 'react-icons/vsc';
import { useLaravelReactI18n } from 'laravel-react-i18n';
export default function SurveyTake({
auth, surveyModel, surveyData, responseData, readOnly, surveyLocale
}) {
const survey = new Model(surveyData);
survey.locale = surveyLocale;
if (readOnly) {
survey.mode = 'display';
}
const surveyComplete = useCallback((sender) => {
router.post(route('surveys.responses.store', surveyModel.id), sender.data);
}, []);
survey.onComplete.add(surveyComplete);
survey.data = responseData;
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">
<Survey model={survey} />
</div>
</div>
</AuthenticatedLayout>
);
}
となる。ただ、これはpreviewと共通化できる箇所がかなりあるはずだ。
ロケールの取得
app/Http/Controllers/SurveyTakingController.php
use Illuminate\Support\Facades\App;
しといての
return Inertia::render('Surveys/Take', [
'surveyModel' => $survey,
'surveyData' => $surveyData,
'responseData' => null, // TODO
'readOnly' => $responseCount ? true : false,
'surveyLocale' => App::getLocale(),
]);
のようにApp::getLocale()で取れる。
なお、言語の設定をDBから取り出したりとかいうのをしたい場合は
を使ってもいいかもしれない。まあ今回はそこまではいいか…
shareでlocaleを渡す
そうはいってもlocaleを渡すにあたって
use Illuminate\Support\Facades\App;
とか毎回書くのも面倒くさいというのもあり(\Appとか書けるっちゃ書けるんだけど…)、inertiaのshareで渡してしまう事にする。
app/Http/Middleware/HandleInertiaRequests.php
public function share(Request $request): array
{
$locale = App::getLocale();
return array_merge(parent::share($request), [
'auth' => [
'user' => $request->user(),
'isAdmin' => $request->user()?->hasRole('admin') ?? false,
],
'ziggy' => function () use ($request) {
return array_merge((new Ziggy)->toArray(), [
'location' => $request->url(),
]);
},
'flash' => [
'success' => fn () => $request->session()->get('success')
],
'locale' => $locale,
]);
}
以降の統合セクションで修正していこう。
コピペ倒してるところを統合するっ
お気付きの通り?同じような構造が結構含まれてきている。これは
% grep '<Survey' resources -r
resources/js/Pages/SurveyResponses/Show.jsx: <Survey model={survey} />
resources/js/Pages/Surveys/Show.jsx: <Survey model={survey} />
resources/js/Pages/Surveys/Take.jsx: <Survey model={survey} />
こういうのが3つもあるからである。これはちょっとよくない
ちなみに
resources/js/Pages/SurveyResponses/Show.jsx: 受験確認
resources/js/Pages/Surveys/Show.jsx:プレビュー
resources/js/Pages/Surveys/Take.jsx: 受験
ということになっている。
当然これはコンポーネントになっているのが正解である。
まず受験に手を入れる
resources/js/Pages/Surveys/Take.jsx 現状
import React, { useCallback } from 'react';
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import 'survey-core/defaultV2.min.css';
import "survey-core/survey.i18n";
import { Model } from 'survey-core';
import { Survey } from 'survey-react-ui';
import { Head, router } from '@inertiajs/react';
import { VscTrash } from 'react-icons/vsc';
import { useLaravelReactI18n } from 'laravel-react-i18n';
export default function SurveyTake({
auth, surveyModel, surveyData, responseData, readOnly, surveyLocale
}) {
const survey = new Model(surveyData);
survey.locale = surveyLocale;
if (readOnly) {
survey.mode = 'display';
}
const surveyComplete = useCallback((sender) => {
router.post(route('surveys.responses.store', surveyModel.id), sender.data);
}, []);
survey.onComplete.add(surveyComplete);
survey.data = responseData;
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">
<Survey model={survey} />
</div>
</div>
</AuthenticatedLayout>
);
}
こうなっている。どの程度共通化するかは議論の余地があるが
<div className="py-12">
<div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
<Survey model={survey} />
</div>
</div>
の
<div className="py-12">
<div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
この辺は外枠なので、ここは内包しない方がええだろう。
と考えると結局survey.jsの主要な部分だけ resources/js/Components/SurveyWrapper.jsx に移動するとかいう事になるってわけで、まず resources/js/Components/SurveyWrapper.jsx を作成する。名前は結局survey.jsをコールしてるだけなのでWrapperとした。
resources/js/Components/SurveyWrapper.jsx
import { Survey } from 'survey-react-ui';
import 'survey-core/defaultV2.min.css';
import 'survey-core/survey.i18n';
export default function SurveyWrapper({ model }) {
return (
<Survey model={model} />
);
}
そうすると呼び出しはこうなるだろう
resources/js/Pages/Surveys/Take.jsx
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">
<SurveyWrapper model={survey} />
</div>
</AuthenticatedLayout>
);
これを拡張していく。まずreadOnlyかどうかswitchとか付けてみる
<SurveyWrapper model={survey} readOnly={readOnly} />
import React from 'react';
import { Survey } from 'survey-react-ui';
import 'survey-core/defaultV2.min.css';
import 'survey-core/survey.i18n';
export default function SurveyWrapper({ model, readOnly }) {
if (readOnly) {
model.mode = 'display';
}
return (
<Survey model={model} />
);
}
このときはプレビューデーター(レスポンスデーター)とセットであるから、dataも渡しておく
export default function SurveyWrapper({ model, readOnly, responseData}) {
if (readOnly && responseData) {
surveyModel.mode = 'display';
surveyModel.data = responseData;
}
<SurveyWrapper model={survey} readOnly={readOnly} responseData={responseData} locale={surveyLocale} />
さらにlocaleを渡せるようにする。
import React from 'react';
import { Survey } from 'survey-react-ui';
import 'survey-core/defaultV2.min.css';
import 'survey-core/survey.i18n';
export default function SurveyWrapper({ model, readOnly, responseData, locale }) {
if (readOnly && responseData) {
surveyModel.mode = 'display';
surveyModel.data = responseData;
}
if (locale) {
model.locale = locale;
}
return (
<Survey model={model} />
);
}
これでコンポーネント側は完成なのでコール側を
<SurveyWrapper model={survey} readOnly={readOnly} responseData={responseData} locale={surveyLocale} />
ただし、このjsxは「受験」に特化しているため、もう少し簡略化できる、のと、shareで渡したlocaleとかも統合すると最終的に以下のようなjsxになるだろう。
resources/js/Pages/Surveys/Take.jsx
import React, { useCallback } from 'react';
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, router, usePage } from '@inertiajs/react';
import { Model } from 'survey-core';
import SurveyWrapper from '@/Components/SurveyWrapper';
export default function SurveyTake({
auth, surveyModel, surveyData
}) {
const survey = new Model(surveyData);
const { locale } = usePage().props;
const surveyComplete = useCallback((sender) => {
router.post(route('surveys.responses.store', 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">
<SurveyWrapper model={survey} locale={locale} />
</div>
</div>
</AuthenticatedLayout>
);
}
この場合コントローラーもガバっと削ってこんな感じになった
app/Http/Controllers/SurveyTakingController.php
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use App\Models\Survey;
use Inertia\Inertia;
use Inertia\Response;
use App\Services\SurveyService;
class SurveyTakingController extends Controller
{
public function show(Survey $survey, SurveyService $surveyService): Response
{
$pagesData = $surveyService->getSurveyData($survey);
$settings = $survey->settings;
$surveyData = [
'pages' => $pagesData,
];
if ($settings) {
$surveyData = array_merge($surveyData, $settings);
}
$surveyData = json_encode($surveyData, JSON_UNESCAPED_UNICODE);
return Inertia::render('Surveys/Take', [
'surveyModel' => $survey,
'surveyData' => $surveyData,
]);
}
}
プレビュー
これもほぼ同様に改造していくが、こちらは回答データーありである。
resources/js/Pages/Surveys/Show.jsx
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';
import { VscTrash } from 'react-icons/vsc';
import { useLaravelReactI18n } from 'laravel-react-i18n';
import DangerButton from '@/Components/DangerButton';
export default function SurveyShow({
auth, surveyModel, surveyData, responseData, readOnly,
}) {
const { t } = useLaravelReactI18n();
const survey = new Model(surveyData);
if (readOnly) {
survey.mode = 'display';
}
const surveyComplete = useCallback((sender) => {
router.post(route('surveys.previewStore', surveyModel.id), sender.data);
}, []);
survey.onComplete.add(surveyComplete);
survey.data = responseData;
const handleDelete = () => {
router.delete(route('surveys.previewDestroy', surveyModel.id));
};
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">
{readOnly && (
<div className="text-right mb-4">
<DangerButton onClick={() => handleDelete()}>
<VscTrash className="mr-2" /> {t('Delete Preview Data')}
</DangerButton>
</div>
)}
<Survey model={survey} />
</div>
</div>
</AuthenticatedLayout>
);
}
これはちょっとややこしく、DangerButtonが入っている、つかこれ面倒くさいから外に出してもいいかも。
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="pt-12">
<div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
{readOnly && (
<div className="text-right">
<DangerButton onClick={() => handleDelete()}>
<VscTrash className="mr-2" /> {t('Delete Preview Data')}
</DangerButton>
</div>
)}
</div>
</div>
<div className="py-12">
<div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
<Survey model={survey} />
</div>
</div>
</AuthenticatedLayout>
);
としといての
import React, { useCallback } from 'react';
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Model } from 'survey-core';
import { Head, router, usePage } from '@inertiajs/react';
import { VscTrash } from 'react-icons/vsc';
import { useLaravelReactI18n } from 'laravel-react-i18n';
import DangerButton from '@/Components/DangerButton';
import SurveyWrapper from '@/Components/SurveyWrapper';
export default function SurveyShow({
auth, surveyModel, surveyData, responseData, readOnly,
}) {
const { t } = useLaravelReactI18n();
const survey = new Model(surveyData);
const { locale } = usePage().props;
const surveyComplete = useCallback((sender) => {
router.post(route('surveys.previewStore', surveyModel.id), sender.data);
}, []);
survey.onComplete.add(surveyComplete);
const handleDelete = () => {
router.delete(route('surveys.previewDestroy', surveyModel.id));
};
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="pt-12">
<div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
{readOnly && (
<div className="text-right">
<DangerButton onClick={() => handleDelete()}>
<VscTrash className="mr-2" /> {t('Delete Preview Data')}
</DangerButton>
</div>
)}
</div>
</div>
<div className="py-12">
<div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
<SurveyWrapper
model={survey}
readOnly={readOnly}
responseData={responseData}
locale={locale}
/>
</div>
</div>
</AuthenticatedLayout>
);
}
これはdeleteとかの処理があるからそんなに短くはならないかも。この部分とかcallbackをもcomponentに含めることもできるけどまあいいかみたいなところはある。やりたければやってみてくださいな。
コントローラー
public function show(Request $request, Survey $survey, SurveyService $surveyService): Response
{
$pagesData = $surveyService->getSurveyData($survey);
$settings = $survey->settings;
$surveyData = [
'pages' => $pagesData,
];
if ($settings) {
$surveyData = array_merge($surveyData, $settings);
}
$surveyData = json_encode($surveyData, JSON_UNESCAPED_UNICODE);
$responseCount = 0;
if ($previewData = $request->session()->get('preview_data')) {
$responseCount = count($previewData);
}
return Inertia::render('Surveys/Show', [
'surveyModel' => $survey,
'surveyData' => $surveyData,
'responseData' => $previewData,
'readOnly' => $responseCount ? true : false,
]);
}
ここは回答データーのある/なしで処理を変更しているので、どうしても長くなる。Serviceに移動してもいいかも。これは後で検討する。
受験確認
resources/js/Pages/SurveyResponses/Show.jsx
これはもうご承知の通りpreviewとほぼおなじである。
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';
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">
<SurveyWrapper
model={survey}
readOnly
responseData={responseData}
locale={locale}
/>
</div>
</div>
</AuthenticatedLayout>
);
}
コントローラー app/Http/Controllers/SurveyResponseController.php
public function show(Survey $survey, SurveyResponse $response, SurveyService $surveyService)
{
$pagesData = $surveyService->getSurveyData($survey);
$settings = $survey->settings;
$surveyData = [
'pages' => $pagesData,
];
if ($settings) {
$surveyData = array_merge($surveyData, $settings);
}
$surveyData = json_encode($surveyData, JSON_UNESCAPED_UNICODE);
$responseCount = $survey->responses()->where('user_id', auth()->id())->count();
$previewData = [];
if ($responseCount) {
$responseDetails = $survey->responses()
->with('details.element')
->where('user_id', auth()->id())
->first()
->details;
$previewData = array_reduce($responseDetails->toArray(), function ($carry, $detail) {
$responseValue = $detail['response_value'];
// $responseValueが配列であり、'value'キーが存在する場合
if (is_array($responseValue) && isset($responseValue['value'])) {
$value = $responseValue['value']; // 'value'キーの値を取り出す
} else {
$value = $responseValue; // それ以外の場合、$responseValueをそのまま使用
}
$carry[$detail['survey_element_id']] = $value;
return $carry;
}, []);
}
return Inertia::render('SurveyResponses/Show', [
'surveyModel' => $survey,
'surveyData' => $surveyData,
'responseData' => $previewData,
]);
}
コントローラーのリファクタリング
まずこの3つのコードをみると
$pagesData = $surveyService->getSurveyData($survey);
$settings = $survey->settings;
$surveyData = [
'pages' => $pagesData,
];
if ($settings) {
$surveyData = array_merge($surveyData, $settings);
}
$surveyData = json_encode($surveyData, JSON_UNESCAPED_UNICODE);
この辺が共通である。結局最終的に得たいのは$surveyDataなのであるからこれもサービスに内包してしまう。
app/Services/SurveyService.php
public function getSurveyData(Survey $survey): Collection
{
$pages = $survey->pages()->with(['elements.choices'])->get();
$pagesData = $pages->map(function ($page) {
$elements = $page->elements->map(function ($element) {
$choices = $element->choices->map(function ($choice) {
return [
'value' => $choice->id,
'text' => $choice->choice,
];
})->all();
return [
'type' => $element->type,
'name' => (string)$element->id,
'title' => $element->title,
'isRequired' => $element->is_required,
'choices' => $choices,
];
})->all();
return [
'name' => $page->name,
'elements' => $elements,
];
});
return $pagesData;
}
とか考えるとこの名前はgetSurveyDataじゃなくてgetPagesDataの方がよい
public function getPagesData(Survey $survey): Collection
と改名する。さらに最終的に
$surveyData = $surveyService->getSurveyData($survey);
これで取得したいのでロジックをガバっと移す
public function getSurveyData(Survey $survey): string
{
$pagesData = $this->getPagesData($survey);
$settings = $survey->settings;
$surveyData = [
'pages' => $pagesData,
];
if ($settings) {
$surveyData = array_merge($surveyData, $settings);
}
$surveyData = json_encode($surveyData, JSON_UNESCAPED_UNICODE);
return $surveyData;
}
これで冒頭の部分がリファクターできたので、コントローラーを全部書き換える、とかやるときにテストが有効なんだけど今は書いてないからまあ仕方ないっすねw
app/Http/Controllers/SurveyTakingController.php とか
public function show(Survey $survey, SurveyService $surveyService): Response
{
$surveyData = $surveyService->getSurveyData($survey);
return Inertia::render('Surveys/Take', [
'surveyModel' => $survey,
'surveyData' => $surveyData,
]);
}
このようにほぼスリムになると、何が必要であるかが明確になりやすい。
レスポンスのリファクター
public function show(Survey $survey, SurveyResponse $response, SurveyService $surveyService)
{
$surveyData = $surveyService->getSurveyData($survey);
$responseCount = $survey->responses()->where('user_id', auth()->id())->count();
$previewData = [];
if ($responseCount) {
$responseDetails = $survey->responses()
->with('details.element')
->where('user_id', auth()->id())
->first()
->details;
$previewData = array_reduce($responseDetails->toArray(), function ($carry, $detail) {
$responseValue = $detail['response_value'];
// $responseValueが配列であり、'value'キーが存在する場合
if (is_array($responseValue) && isset($responseValue['value'])) {
$value = $responseValue['value']; // 'value'キーの値を取り出す
} else {
$value = $responseValue; // それ以外の場合、$responseValueをそのまま使用
}
$carry[$detail['survey_element_id']] = $value;
return $carry;
}, []);
}
return Inertia::render('SurveyResponses/Show', [
'surveyModel' => $survey,
'surveyData' => $surveyData,
'responseData' => $previewData,
]);
}
この部分に関してはモデルに移動しちゃっていいと思う。この場合$previewDataは適切じゃないので名前を変更している
app/Models/Survey.php
<?php
namespace App\Models;
+-- 3 lines: use Illuminate\Database\Eloquent\Factories\HasFactory;--------------------------------------------------------------------------
class Survey extends Model
{
use HasFactory;
+-- 6 lines: protected $fillable = [---------------------------------------------------------------------------------------------------------
+-- 4 lines: protected $casts = [------------------------------------------------------------------------------------------------------------
+-- 5 lines: public function pages(): HasMany------------------------------------------------------------------------------------------------
+-- 5 lines: public function responses(): HasMany--------------------------------------------------------------------------------------------
public function getUserResponseData($userId): array
{
$responseCount = $this->responses()->where('user_id', $userId)->count();
$responseData = [];
if ($responseCount) {
$responseDetails = $this->responses()
->with('details.element')
->where('user_id', $userId)
->first()
->details;
$responseData = array_reduce($responseDetails->toArray(), function ($carry, $detail) {
$responseValue = $detail['response_value'];
// $responseValueが配列であり、'value'キーが存在する場合
if (is_array($responseValue) && isset($responseValue['value'])) {
$value = $responseValue['value']; // 'value'キーの値を取り出す
} else {
$value = $responseValue; // それ以外の場合、$responseValueをそのまま使用
}
$carry[$detail['survey_element_id']] = $value;
return $carry;
}, []);
}
return $responseData;
}
}
そうすれば
app/Http/Controllers/SurveyResponseController.php
public function show(Survey $survey, SurveyResponse $response, SurveyService $surveyService)
{
$surveyData = $surveyService->getSurveyData($survey);
$previewData = $survey->getUserResponseData(auth()->id());
return Inertia::render('SurveyResponses/Show', [
'surveyModel' => $survey,
'surveyData' => $surveyData,
'responseData' => $previewData,
]);
}
こんな風になるだろう
まとめ
てかリファクタリングするとすげー長くなっちゃうので、ここまで。っていうのとリファクタリングにはやっぱりテストを伴った方がいいっす。
次回も再度整えて仕上げていくよー