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ボタンは機能していない。長くなりすぎとるもん。またにしましょう



この記事が気に入ったらサポートをしてみませんか?