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
-
+
{% 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);