From 9fedc5b5d3a0adc21050a8173d0171780379c48b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Tue, 27 Oct 2026 17:07:17 +0100 Subject: [PATCH] feat: let the office test the questionnaire on any assignment --- .../Questionnaire/DetailController.php | 8 +- .../Questionnaire/TestController.php | 62 +++++++ .../Teamer/Questionnaire/DetailController.php | 7 +- .../Teamer/Questionnaire/EditController.php | 8 +- .../assignment/detail.html.twig | 13 +- .../questionnaire/detail.html.twig | 2 +- .../questionnaire/test.html.twig | 59 +++++++ templates/questionnaire/_answers.html.twig | 7 +- templates/questionnaire/_form.html.twig | 17 ++ .../teamer/questionnaire/detail.html.twig | 2 +- templates/teamer/questionnaire/edit.html.twig | 14 +- .../Questionnaire/TestControllerTest.php | 165 ++++++++++++++++++ tests/Twig/QuestionnaireTemplateTest.php | 2 +- 13 files changed, 337 insertions(+), 29 deletions(-) create mode 100644 src/Controller/Administrative/Questionnaire/TestController.php create mode 100644 templates/administrative/questionnaire/test.html.twig create mode 100644 templates/questionnaire/_form.html.twig create mode 100644 tests/Controller/Administrative/Questionnaire/TestControllerTest.php diff --git a/src/Controller/Administrative/Questionnaire/DetailController.php b/src/Controller/Administrative/Questionnaire/DetailController.php index a81577e..da95cd5 100644 --- a/src/Controller/Administrative/Questionnaire/DetailController.php +++ b/src/Controller/Administrative/Questionnaire/DetailController.php @@ -5,6 +5,7 @@ namespace App\Controller\Administrative\Questionnaire; use App\Config\QuestionnaireCatalog; use App\Controller\Traits\ReturnUrlTrait; use App\Entity\QuestionnaireResponse; +use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -27,8 +28,11 @@ class DetailController extends AbstractController #[Route('/administrative/questionnaire/{uuid}', name: 'app_administrative_questionnaire_detail')] #[IsGranted('ROLE_ADMINISTRATIVE')] - public function index(QuestionnaireResponse $response, Request $request): Response - { + public function index( + #[MapEntity(mapping: ['uuid' => 'uuid'])] + QuestionnaireResponse $response, + Request $request, + ): Response { // a draft is still the teamer's if (false === $response->isSubmitted()) { throw new NotFoundHttpException(); diff --git a/src/Controller/Administrative/Questionnaire/TestController.php b/src/Controller/Administrative/Questionnaire/TestController.php new file mode 100644 index 0000000..b7b53bf --- /dev/null +++ b/src/Controller/Administrative/Questionnaire/TestController.php @@ -0,0 +1,62 @@ + 'uuid'])] + Assignment $assignment, + Request $request, + ): Response { + $definition = $this->catalog->get(QuestionnaireCatalog::TEAMER_ESCORT); + + $form = $this->createForm(QuestionnaireType::class, [], ['definition' => $definition]); + $form->handleRequest($request); + + $submitted = null; + $draft = false; + + if ($form->isSubmitted() && $form->isValid()) { + if (QuestionnaireType::isFinal($form)) { + $submitted = $definition->withoutHiddenAnswers($form->getData()); + } else { + $draft = true; + } + } + + return $this->render('administrative/questionnaire/test.html.twig', [ + 'assignment' => $assignment, + 'definition' => $definition, + 'form' => $form, + 'submitted' => $submitted, + 'draft' => $draft, + 'returnUrl' => $this->getReturnUrl($request, 'app_administrative_assignment_detail', ['uuid' => $assignment->getUuid()]), + ]); + } +} diff --git a/src/Controller/Teamer/Questionnaire/DetailController.php b/src/Controller/Teamer/Questionnaire/DetailController.php index 9185e8b..a8fdd3a 100644 --- a/src/Controller/Teamer/Questionnaire/DetailController.php +++ b/src/Controller/Teamer/Questionnaire/DetailController.php @@ -5,6 +5,7 @@ namespace App\Controller\Teamer\Questionnaire; use App\Config\QuestionnaireCatalog; use App\Entity\Disposition; use App\Repository\QuestionnaireResponseRepository; +use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; @@ -25,8 +26,10 @@ class DetailController extends AbstractController #[Route('/teamer/questionnaire/{uuid}/detail', name: 'app_teamer_questionnaire_detail')] #[IsGranted('ROLE_TEAMER')] #[IsGranted('QUESTIONNAIRE', subject: 'disposition')] - public function index(Disposition $disposition): Response - { + public function index( + #[MapEntity(mapping: ['uuid' => 'uuid'])] + Disposition $disposition, + ): Response { $response = $this->responseRepository->findOneByDisposition(QuestionnaireCatalog::TEAMER_ESCORT, $disposition); if (true !== $response?->isSubmitted()) { diff --git a/src/Controller/Teamer/Questionnaire/EditController.php b/src/Controller/Teamer/Questionnaire/EditController.php index 6892100..a9dc30f 100644 --- a/src/Controller/Teamer/Questionnaire/EditController.php +++ b/src/Controller/Teamer/Questionnaire/EditController.php @@ -10,6 +10,7 @@ use App\Repository\QuestionnaireResponseRepository; use App\Service\Questionnaire\DispositionContext; use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; +use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -33,8 +34,11 @@ class EditController extends AbstractController #[Route('/teamer/questionnaire/{uuid}', name: 'app_teamer_questionnaire_edit')] #[IsGranted('ROLE_TEAMER')] #[IsGranted('QUESTIONNAIRE', subject: 'disposition')] - public function index(Disposition $disposition, Request $request): Response - { + public function index( + #[MapEntity(mapping: ['uuid' => 'uuid'])] + Disposition $disposition, + Request $request, + ): Response { $response = $this->responseRepository->findOneByDisposition(QuestionnaireCatalog::TEAMER_ESCORT, $disposition); if (true === $response?->isSubmitted()) { diff --git a/templates/administrative/assignment/detail.html.twig b/templates/administrative/assignment/detail.html.twig index 0e2f3a5..a67048d 100644 --- a/templates/administrative/assignment/detail.html.twig +++ b/templates/administrative/assignment/detail.html.twig @@ -256,8 +256,15 @@ - - zurück - +
+ + zurück + + + Fragebogen testen + +
{% endblock %} diff --git a/templates/administrative/questionnaire/detail.html.twig b/templates/administrative/questionnaire/detail.html.twig index 485d39b..108e932 100644 --- a/templates/administrative/questionnaire/detail.html.twig +++ b/templates/administrative/questionnaire/detail.html.twig @@ -12,7 +12,7 @@

- {% include 'questionnaire/_answers.html.twig' %} + {% include 'questionnaire/_answers.html.twig' with { 'answers': response.answers } %}
{# the snapshot taken on submit, so it shows the placement as the teamer answered for it #} diff --git a/templates/administrative/questionnaire/test.html.twig b/templates/administrative/questionnaire/test.html.twig new file mode 100644 index 0000000..2ae242e --- /dev/null +++ b/templates/administrative/questionnaire/test.html.twig @@ -0,0 +1,59 @@ +{% extends 'administrative/layout.html.twig' %} + +{% block title %}{{ definition.title }} (Testmodus){% endblock %} + +{% block content %} +

+ {{ definition.title }} +

+ {% include '_partials/_alert.html.twig' with { + 'type': 'warning', + 'message': 'Testmodus: Der Fragebogen verhält sich wie für Teamer:innen, es wird aber nichts gespeichert.' + } %} + {% if not assignment.hasBusEscort %} + {% include '_partials/_alert.html.twig' with { + 'type': 'info', + 'message': 'Dieser Einsatz hat keine Busbegleitung. Teamer:innen bekommen den Fragebogen hier nicht.' + } %} + {% endif %} +
+
+ {% if submitted is not null %} + {% include '_partials/_alert.html.twig' with { + 'type': 'success', + 'message': 'So würde der abgeschickte Fragebogen gespeichert. Ausgeblendete Fragen fallen dabei weg.' + } %} + {% include 'questionnaire/_answers.html.twig' with { 'answers': submitted } %} + + {% else %} + {% if definition.description %} +

+ {{ definition.description|nl2br }} +

+ {% endif %} + {% if draft %} + {% include '_partials/_alert.html.twig' with { + 'type': 'success', + 'message': 'Der Entwurf wäre jetzt gespeichert. Im Testmodus bleiben die Angaben nur im Formular.' + } %} + {% endif %} + {% include 'questionnaire/_form.html.twig' %} + {% endif %} +
+
+

+ Einsatz +

+ {% include '_partials/_assignment_info.html.twig' %} + +
+
+{% endblock %} diff --git a/templates/questionnaire/_answers.html.twig b/templates/questionnaire/_answers.html.twig index 40e17f6..2d94052 100644 --- a/templates/questionnaire/_answers.html.twig +++ b/templates/questionnaire/_answers.html.twig @@ -1,10 +1,9 @@ {# - # 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 + # Questionnaire answers, read-only. Expects `definition` (the version they were given under) and + # `answers`, keyed by question key. 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)) %} diff --git a/templates/questionnaire/_form.html.twig b/templates/questionnaire/_form.html.twig new file mode 100644 index 0000000..47f87cb --- /dev/null +++ b/templates/questionnaire/_form.html.twig @@ -0,0 +1,17 @@ +{# + # A questionnaire form (App\Form\QuestionnaireType) with its two buttons. Shared by the teamer's + # page and the office's test mode, so a test shows exactly what a teamer gets. + #} +{{ 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) }} diff --git a/templates/teamer/questionnaire/detail.html.twig b/templates/teamer/questionnaire/detail.html.twig index d1f0a06..340cb8e 100644 --- a/templates/teamer/questionnaire/detail.html.twig +++ b/templates/teamer/questionnaire/detail.html.twig @@ -11,7 +11,7 @@

- {% include 'questionnaire/_answers.html.twig' %} + {% include 'questionnaire/_answers.html.twig' with { 'answers': response.answers } %}

diff --git a/templates/teamer/questionnaire/edit.html.twig b/templates/teamer/questionnaire/edit.html.twig index f4949e0..798777f 100644 --- a/templates/teamer/questionnaire/edit.html.twig +++ b/templates/teamer/questionnaire/edit.html.twig @@ -13,19 +13,7 @@ {% 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) }} + {% include 'questionnaire/_form.html.twig' %}

diff --git a/tests/Controller/Administrative/Questionnaire/TestControllerTest.php b/tests/Controller/Administrative/Questionnaire/TestControllerTest.php new file mode 100644 index 0000000..a045a7b --- /dev/null +++ b/tests/Controller/Administrative/Questionnaire/TestControllerTest.php @@ -0,0 +1,165 @@ +client = static::createClient(server: ['HTTPS' => true]); + $this->client->disableReboot(); + $this->entityManager = self::getContainer()->get(EntityManagerInterface::class); + $this->entityManager->getConnection()->beginTransaction(); + + $destination = (new Destination()) + ->setProduct('Davos Klosters - Schwendi') + ->setHotel('Schwendi') + ->setHotelCode('DKI') + ->setHotelBusProId(1) + ->setDateFrom(new \DateTimeImmutable('+1 month')) + ->setDateTo(new \DateTimeImmutable('+1 month +7 days')) + ->setPickups([]) + ; + // no escort and still ahead: the test mode does not care + $jobProfile = (new JobProfile())->setName('Reiseleitung')->setCategory(JobProfile::CATEGORY_GUIDE); + $this->assignment = (new Assignment()) + ->setDestination($destination) + ->setJobProfile($jobProfile) + ->setStatus(Assignment::STATUS_PUBLISHED) + ; + $this->entityManager->persist($jobProfile); + $this->entityManager->persist($destination); + $this->entityManager->persist($this->assignment); + $this->entityManager->flush(); + } + + protected function tearDown(): void + { + $this->entityManager->getConnection()->rollBack(); + parent::tearDown(); + } + + public function testTheOfficeSeesTheQuestionnaireForAnyAssignment(): void + { + $this->client->loginUser($this->user('ROLE_MANAGER')); + $crawler = $this->open(); + + $this->assertResponseIsSuccessful(); + $this->assertStringContainsString('Testmodus', $crawler->text()); + $this->assertStringContainsString('keine Busbegleitung', $crawler->text()); + $this->assertCount(1, $crawler->filter('form[data-controller="questionnaire"]')); + } + + public function testAnIncompleteSubmitShowsTheErrorsAndSavesNothing(): void + { + $this->client->loginUser($this->user('ROLE_TEAM_ADMIN')); + $crawler = $this->client->submit($this->open()->selectButton('Absenden')->form()); + + // Symfony answers an invalid submitted form with 422, as it would for a teamer + $this->assertResponseStatusCodeSame(422); + $this->assertStringContainsString('Bitte beantworte diese Frage.', $crawler->text()); + $this->assertNothingSaved(); + } + + public function testADraftKeepsTheAnswersInTheFormAndSavesNothing(): void + { + $this->client->loginUser($this->user('ROLE_TEAM_ADMIN')); + $form = $this->open()->selectButton('Entwurf speichern')->form(); + $form['questionnaire[improvement][wished_differently]'] = 'Mehr Schnee'; + $crawler = $this->client->submit($form); + + $this->assertStringContainsString('Der Entwurf wäre jetzt gespeichert', $crawler->text()); + $this->assertSame('Mehr Schnee', $crawler->filter('textarea[name="questionnaire[improvement][wished_differently]"]')->text()); + $this->assertNothingSaved(); + } + + public function testACompleteSubmitShowsTheAnswersAndSavesNothing(): void + { + $this->client->loginUser($this->user('ROLE_TEAM_ADMIN')); + $form = $this->open()->selectButton('Absenden')->form(); + $definition = self::getContainer()->get(QuestionnaireCatalog::class)->get(QuestionnaireCatalog::TEAMER_ESCORT); + + foreach ($definition->sections as $section) { + foreach ($section->questions as $question) { + if (!$question->required || null !== $question->showIf) { + continue; + } + + $form[sprintf('questionnaire[%s][%s]', $section->key, $question->key)] = match ($question->type) { + QuestionType::CHOICE => $question->options[0]->key, + QuestionType::RATING => '5', + default => 'Antwort', + }; + } + } + + $crawler = $this->client->submit($form); + + $this->assertStringContainsString('So würde der abgeschickte Fragebogen gespeichert', $crawler->text()); + $this->assertStringContainsString('Boonk Reisen', $crawler->text()); + $this->assertNothingSaved(); + } + + /** + * The app answers a teamer in the office area with a redirect to their dashboard, not a 403. + */ + public function testATeamerHasNoAccess(): void + { + $this->client->loginUser($this->user('ROLE_TEAMER')); + $this->open(); + + $this->assertResponseRedirects('/teamer'); + } + + private function open(): Crawler + { + return $this->client->request('GET', sprintf('/administrative/questionnaire/test/%s', $this->assignment->getUuid())); + } + + private function assertNothingSaved(): void + { + $this->assertSame(0, $this->entityManager->getRepository(QuestionnaireResponse::class)->count([])); + } + + private function user(string $role): User + { + $user = (new User()) + ->setFirstName('Olga') + ->setLastName('Office') + ->setEmail(sprintf('olga.office.%s@example.com', strtolower($role))) + ->setRoles([$role]) + ->setBusProAddressId(1) + ->setBusProPersonId(1) + ; + + $this->entityManager->persist($user); + $this->entityManager->flush(); + + return $user; + } +} diff --git a/tests/Twig/QuestionnaireTemplateTest.php b/tests/Twig/QuestionnaireTemplateTest.php index e707e1e..cd8eab5 100644 --- a/tests/Twig/QuestionnaireTemplateTest.php +++ b/tests/Twig/QuestionnaireTemplateTest.php @@ -64,7 +64,7 @@ class QuestionnaireTemplateTest extends KernelTestCase $html = self::getContainer()->get(Environment::class)->render('questionnaire/_answers.html.twig', [ 'definition' => $definition, - 'response' => $response, + 'answers' => $response->getAnswers(), ]); $this->assertStringContainsString('2 von 5', $html);