見出し画像

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で集計データーを作るとかはまあ考えてみてくださいな。





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