From 5e4e574076c691c1ba8e5c6a267429d21e280f86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Tue, 27 Oct 2026 16:56:49 +0100 Subject: [PATCH] feat: let teamers fill in and read their questionnaire --- .../controllers/questionnaire_controller.js | 74 +++++++++++++ src/Config/QuestionnaireCatalog.php | 5 + .../Teamer/Questionnaire/DetailController.php | 42 ++++++++ .../Teamer/Questionnaire/EditController.php | 93 ++++++++++++++++ src/Form/QuestionnaireType.php | 5 +- src/Menu/TeamerMenuBuilder.php | 6 ++ templates/forms.html.twig | 32 ++++++ templates/questionnaire/_answers.html.twig | 51 +++++++++ .../teamer/questionnaire/detail.html.twig | 23 ++++ templates/teamer/questionnaire/edit.html.twig | 42 ++++++++ tests/Twig/QuestionnaireTemplateTest.php | 101 ++++++++++++++++++ 11 files changed, 472 insertions(+), 2 deletions(-) create mode 100644 assets/controllers/questionnaire_controller.js create mode 100644 src/Controller/Teamer/Questionnaire/DetailController.php create mode 100644 src/Controller/Teamer/Questionnaire/EditController.php create mode 100644 templates/questionnaire/_answers.html.twig create mode 100644 templates/teamer/questionnaire/detail.html.twig create mode 100644 templates/teamer/questionnaire/edit.html.twig create mode 100644 tests/Twig/QuestionnaireTemplateTest.php diff --git a/assets/controllers/questionnaire_controller.js b/assets/controllers/questionnaire_controller.js new file mode 100644 index 0000000..96683b9 --- /dev/null +++ b/assets/controllers/questionnaire_controller.js @@ -0,0 +1,74 @@ +import { Controller } from '@hotwired/stimulus' + +/* + * Shows a questionnaire question only while its show_if holds, mirroring + * App\Model\Questionnaire\Definition::isVisible(): the condition must be met and the question it + * depends on must itself be shown. + * + * Every question row carries data-question-key; a conditional one also data-show-if, the JSON + * {question, operator, value} that App\Form\QuestionnaireType writes. A hidden row stays in the + * form and is submitted: a draft keeps its answer, and the server drops it on submit. + */ +export default class extends Controller { + + connect() { + this.update() + } + + update() { + const visible = {} + + // Rows come in definition order, and a condition may only name an earlier question, so + // the question a row depends on has always been decided by the time the row is reached. + for (const row of this.element.querySelectorAll('[data-question-key]')) { + const key = row.dataset.questionKey + const condition = row.dataset.showIf ? JSON.parse(row.dataset.showIf) : null + + visible[key] = null === condition + || (true === visible[condition.question] && this.isMet(condition)) + + row.hidden = false === visible[key] + } + } + + isMet({ question, operator, value }) { + const answer = this.answer(question) + + if (null === answer) { + return false + } + + switch (operator) { + case 'equals': + return answer === value + case 'in': + return value.includes(answer) + case 'lte': + return Number(answer) <= value + default: + return false + } + } + + answer(key) { + const row = this.element.querySelector(`[data-question-key="${key}"]`) + + if (null === row) { + return null + } + + const checked = row.querySelector('input[type="radio"]:checked') + + if (null !== checked) { + return checked.value + } + + const select = row.querySelector('select') + + if (null !== select) { + return '' === select.value ? null : select.value + } + + return null + } +} diff --git a/src/Config/QuestionnaireCatalog.php b/src/Config/QuestionnaireCatalog.php index 4bf5c60..a251a9a 100644 --- a/src/Config/QuestionnaireCatalog.php +++ b/src/Config/QuestionnaireCatalog.php @@ -25,6 +25,11 @@ use Symfony\Component\Yaml\Yaml; */ class QuestionnaireCatalog { + /** + * The questionnaire a teamer fills in after a bus escort; see DispositionVoter::QUESTIONNAIRE. + */ + public const string TEAMER_ESCORT = 'teamer_escort'; + private const array ROOT_FIELDS = ['key', 'version', 'title', 'description', 'sections']; private const array SECTION_FIELDS = ['key', 'title', 'questions']; private const array QUESTION_FIELDS = ['key', 'label', 'type', 'required', 'help', 'widget', 'options', 'show_if']; diff --git a/src/Controller/Teamer/Questionnaire/DetailController.php b/src/Controller/Teamer/Questionnaire/DetailController.php new file mode 100644 index 0000000..9185e8b --- /dev/null +++ b/src/Controller/Teamer/Questionnaire/DetailController.php @@ -0,0 +1,42 @@ +responseRepository->findOneByDisposition(QuestionnaireCatalog::TEAMER_ESCORT, $disposition); + + if (true !== $response?->isSubmitted()) { + return $this->redirectToRoute('app_teamer_questionnaire_edit', ['uuid' => $disposition->getUuid()]); + } + + return $this->render('teamer/questionnaire/detail.html.twig', [ + 'disposition' => $disposition, + 'definition' => $this->catalog->get($response->getQuestionnaire(), $response->getVersion()), + 'response' => $response, + ]); + } +} diff --git a/src/Controller/Teamer/Questionnaire/EditController.php b/src/Controller/Teamer/Questionnaire/EditController.php new file mode 100644 index 0000000..6892100 --- /dev/null +++ b/src/Controller/Teamer/Questionnaire/EditController.php @@ -0,0 +1,93 @@ +responseRepository->findOneByDisposition(QuestionnaireCatalog::TEAMER_ESCORT, $disposition); + + if (true === $response?->isSubmitted()) { + return $this->redirectToRoute('app_teamer_questionnaire_detail', ['uuid' => $disposition->getUuid()]); + } + + $definition = $this->catalog->get(QuestionnaireCatalog::TEAMER_ESCORT); + + $form = $this->createForm(QuestionnaireType::class, $response?->getAnswers() ?? [], [ + 'definition' => $definition, + ]); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + if (null === $response) { + $response = QuestionnaireResponse::forDisposition($definition, $disposition); + $this->entityManager->persist($response); + } + + $context = DispositionContext::build($disposition); + $final = QuestionnaireType::isFinal($form); + + if (true === $final) { + $response->submit($definition, $form->getData(), $context, new \DateTimeImmutable()); + } else { + $response->saveDraft($form->getData(), $context); + } + + $this->entityManager->flush(); + + $this->logger->info($final ? 'Submit questionnaire' : 'Save questionnaire draft', [ + 'questionnaire' => $response->getQuestionnaire(), + 'questionnaire_response_id' => $response->getId(), + 'disposition_id' => $disposition->getId(), + 'teamer_id' => $disposition->getTeamer()->getId(), + ]); + + if (true === $final) { + $this->addFlash('success', 'Danke! Dein Fragebogen wurde abgeschickt.'); + + return $this->redirectToRoute('app_teamer_questionnaire_detail', ['uuid' => $disposition->getUuid()]); + } + + $this->addFlash('success', 'Dein Entwurf wurde gespeichert.'); + + return $this->redirectToRoute('app_teamer_questionnaire_edit', ['uuid' => $disposition->getUuid()]); + } + + return $this->render('teamer/questionnaire/edit.html.twig', [ + 'disposition' => $disposition, + 'definition' => $definition, + 'response' => $response, + 'form' => $form, + ]); + } +} diff --git a/src/Form/QuestionnaireType.php b/src/Form/QuestionnaireType.php index 5604fc6..0168dce 100644 --- a/src/Form/QuestionnaireType.php +++ b/src/Form/QuestionnaireType.php @@ -120,13 +120,14 @@ class QuestionnaireType extends AbstractType 'label' => $question->label, 'help' => $question->help, 'required' => false, - 'row_attr' => null === $question->showIf ? [] : [ + // read by assets/controllers/questionnaire_controller.js + 'row_attr' => ['data-question-key' => $question->key] + (null === $question->showIf ? [] : [ 'data-show-if' => json_encode([ 'question' => $question->showIf->question, 'operator' => $question->showIf->operator->value, 'value' => $question->showIf->value, ], JSON_THROW_ON_ERROR), - ], + ]), ]; return match ($question->type) { diff --git a/src/Menu/TeamerMenuBuilder.php b/src/Menu/TeamerMenuBuilder.php index 0c097b3..35a93c2 100644 --- a/src/Menu/TeamerMenuBuilder.php +++ b/src/Menu/TeamerMenuBuilder.php @@ -134,6 +134,12 @@ class TeamerMenuBuilder extends AbstractMenuBuilder 'linkAttributes' => [ 'title' => 'Letzte Einsätze', ], + // the questionnaire after a past bus escort + 'extras' => [ + 'routes' => [ + ['pattern' => '/^app_teamer_questionnaire_/'], + ], + ], ]); $menu->addChild('Dokumente', [ diff --git a/templates/forms.html.twig b/templates/forms.html.twig index 3ecb176..4c94e80 100644 --- a/templates/forms.html.twig +++ b/templates/forms.html.twig @@ -173,6 +173,38 @@ {%- endif -%} {%- endblock pickup_choice_widget -%} +{# + # A section of a questionnaire (App\Form\QuestionnaireType): its title and its questions. The + # questions' rows carry data-question-key and, when conditional, data-show-if, which + # assets/controllers/questionnaire_controller.js reads to show and hide them. + #} +{%- block questionnaire_section_row -%} +
+ {%- if label is not same as(false) -%} + {{ label }} + {%- endif -%} +
+ {%- for child in form -%} + {{- form_row(child) -}} + {%- endfor -%} +
+
+{%- endblock questionnaire_section_row -%} + +{# A 1-5 rating as a row of buttons, the radios themselves hidden. #} +{%- block questionnaire_rating_widget -%} +
+ {%- for child in form -%} + + {%- endfor -%} +
+{%- endblock questionnaire_rating_widget -%} + {%- block birthday_widget -%}
{{ form_widget(form.children['day']) }} diff --git a/templates/questionnaire/_answers.html.twig b/templates/questionnaire/_answers.html.twig new file mode 100644 index 0000000..40e17f6 --- /dev/null +++ b/templates/questionnaire/_answers.html.twig @@ -0,0 +1,51 @@ +{# + # A submitted questionnaire response, read-only. Expects `definition` (the version the response + # was submitted under) and `response`. Questions that are hidden or were left open are skipped; + # an answer the definition does not know (an option key since renamed) is shown as stored rather + # than dropped. + #} +{% set answers = response.answers %} +
+ {% for section in definition.sections %} + {% set shown = section.questions|filter(question => answers[question.key] is defined and definition.isVisible(question, answers)) %} + {% if shown is not empty %} +
+ {% if section.title %} +

+ {{ section.title }} +

+ {% endif %} +
+ {% for question in shown %} + {% set answer = answers[question.key] %} +
+
+ {{ question.label|nl2br }} +
+
+ {% if question.type.value == 'rating' %} + + + {% for grade in range(1, 5) %} + {{ icon('star', html_classes('w-4 h-4', { 'text-gray-200': answer < grade, 'text-primary': answer >= grade })) }} + {% endfor %} + + {{ answer }} von 5 + + {% elseif question.type.value == 'choice' %} + {{ (question.option(answer).label ?? answer) }} + {% elseif question.type.value == 'multi' %} + {{ answer|map(key => (question.option(key).label ?? key))|join(', ') }} + {% elseif question.type.value == 'date' %} + {{ answer|date('d.m.Y') }} + {% else %} + {{ answer|nl2br }} + {% endif %} +
+
+ {% endfor %} +
+
+ {% endif %} + {% endfor %} +
diff --git a/templates/teamer/questionnaire/detail.html.twig b/templates/teamer/questionnaire/detail.html.twig new file mode 100644 index 0000000..d1f0a06 --- /dev/null +++ b/templates/teamer/questionnaire/detail.html.twig @@ -0,0 +1,23 @@ +{% extends 'teamer/layout.html.twig' %} + +{% block title %}{{ definition.title }}{% endblock %} + +{% block content %} +

+ {{ definition.title }} +

+

+ Abgeschickt am {{ response.submittedAt|date('d.m.Y, H:i') }} Uhr. Danke für dein Feedback! +

+
+
+ {% include 'questionnaire/_answers.html.twig' %} +
+
+

+ Dein Einsatz +

+ {% include '_partials/_assignment_info.html.twig' with { 'assignment': disposition.assignment } %} +
+
+{% endblock %} diff --git a/templates/teamer/questionnaire/edit.html.twig b/templates/teamer/questionnaire/edit.html.twig new file mode 100644 index 0000000..f4949e0 --- /dev/null +++ b/templates/teamer/questionnaire/edit.html.twig @@ -0,0 +1,42 @@ +{% extends 'teamer/layout.html.twig' %} + +{% block title %}{{ definition.title }}{% endblock %} + +{% block content %} +

+ {{ definition.title }} +

+ {% if definition.description %} +

+ {{ definition.description|nl2br }} +

+ {% endif %} +
+
+ {{ form_start(form, { attr: { 'data-controller': 'questionnaire', 'data-action': 'change->questionnaire#update' } }) }} + {% for child in form|filter(child => child.vars.name not in ['draft', 'submit']) %} + {{ form_row(child) }} + {% endfor %} +
+ {{ form_widget(form.submit, { attr: { class: 'btn' } }) }} + {{ form_widget(form.draft, { attr: { class: 'btn btn--secondary' } }) }} +
+

+ Als Entwurf kannst du den Fragebogen jederzeit speichern und später weiter ausfüllen. + Abgeschickt ist er endgültig. +

+ {{ form_end(form) }} +
+
+

+ Dein Einsatz +

+ {% include '_partials/_assignment_info.html.twig' with { 'assignment': disposition.assignment } %} + {% if response %} +

+ Entwurf zuletzt gespeichert am {{ (response.updatedAt ?? response.createdAt)|date('d.m.Y, H:i') }} Uhr +

+ {% endif %} +
+
+{% endblock %} diff --git a/tests/Twig/QuestionnaireTemplateTest.php b/tests/Twig/QuestionnaireTemplateTest.php new file mode 100644 index 0000000..e707e1e --- /dev/null +++ b/tests/Twig/QuestionnaireTemplateTest.php @@ -0,0 +1,101 @@ +renderForm(); + + $this->assertCount(count($this->definition()->getQuestions()), $crawler->filter('[data-question-key]')); + $this->assertSame( + '{"question":"bus_company","operator":"equals","value":"other"}', + $crawler->filter('[data-question-key="bus_company_other"]')->attr('data-show-if'), + ); + } + + public function testSectionsAreFieldsetsWithTheirTitle(): void + { + $crawler = $this->renderForm(); + + $this->assertSame('Organisation & Ablauf', trim($crawler->filter('fieldset legend')->first()->text())); + $this->assertCount(count($this->definition()->sections), $crawler->filter('fieldset')); + } + + public function testRatingIsFiveRadiosInARadioGroup(): void + { + $group = $this->renderForm()->filter('[data-question-key="office_briefing_rating"] [role="radiogroup"]'); + + $this->assertCount(5, $group->filter('input[type="radio"]')); + } + + public function testAnswersShowLabelsNotKeysAndSkipHiddenQuestions(): void + { + $definition = $this->definition(); + $response = QuestionnaireResponse::forDisposition($definition, new Disposition(new Application(new Assignment(), new Teamer()))) + ->submit($definition, [ + 'office_briefing_rating' => 2, + 'office_briefing_low_reason' => 'Briefing kam zu spät', + 'bus_company' => 'heess', + 'had_organisational_problems' => 'no', + ], [], new \DateTimeImmutable()) + ; + + $html = self::getContainer()->get(Environment::class)->render('questionnaire/_answers.html.twig', [ + 'definition' => $definition, + 'response' => $response, + ]); + + $this->assertStringContainsString('2 von 5', $html); + $this->assertStringContainsString('Briefing kam zu spät', $html); + $this->assertStringContainsString('Heeß', $html); + $this->assertStringContainsString('Nein', $html); + $this->assertStringNotContainsString('heess', $html); + // nothing was answered there, so the section is left out + $this->assertStringNotContainsString('Bewertung Bus & Fahrer', $html); + } + + private function renderForm(): Crawler + { + self::bootKernel(); + $container = self::getContainer(); + + $form = $container->get(FormFactoryInterface::class)->create(QuestionnaireType::class, [], [ + 'definition' => $this->definition(), + 'csrf_protection' => false, + ]); + + $html = $container->get(Environment::class) + ->createTemplate('{{ form(form) }}') + ->render(['form' => $form->createView()]) + ; + + return new Crawler($html); + } + + private function definition(): Definition + { + return new QuestionnaireCatalog(__DIR__.'/../../config/questionnaires')->get(QuestionnaireCatalog::TEAMER_ESCORT); + } +}