feat: let the office test the questionnaire on any assignment

This commit is contained in:
fromme committed 2026-10-27 17:07:17 +01:00
1 parent a35f91ac63
commit 9fedc5b5d3
13 files changed
+337 -29

No files matched your search

@@ -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();
@@ -0,0 +1,62 @@
<?php
namespace App\Controller\Administrative\Questionnaire;
use App\Config\QuestionnaireCatalog;
use App\Controller\Traits\ReturnUrlTrait;
use App\Entity\Assignment;
use App\Form\QuestionnaireType;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* The teamer questionnaire as a teamer gets it, for trying out on any assignment - with or without
* bus escort, before or after the trip, placed or not. Nothing is saved: "Absenden" shows the
* answers as they would be stored, "Entwurf speichern" just keeps them in the form.
*/
class TestController extends AbstractController
{
use ReturnUrlTrait;
public function __construct(
private readonly QuestionnaireCatalog $catalog,
) {
}
#[Route('/administrative/questionnaire/test/{uuid}', name: 'app_administrative_questionnaire_test')]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(
#[MapEntity(mapping: ['uuid' => '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()]),
]);
}
}
@@ -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()) {
@@ -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()) {
@@ -256,8 +256,15 @@
</table>
</div>
</div>
<a href="{{ returnUrl }}" class="btn">
zurück
</a>
<div class="flex flex-wrap items-center gap-4">
<a href="{{ returnUrl }}" class="btn">
zurück
</a>
<a href="{{ path('app_administrative_questionnaire_test', { 'uuid': assignment.uuid, 'r': return_url() }) }}"
class="btn btn--secondary"
title="Den Fragebogen nach einer Busbegleitung für diesen Einsatz ausprobieren, ohne etwas zu speichern">
Fragebogen testen
</a>
</div>
</div>
{% endblock %}
@@ -12,7 +12,7 @@
</p>
<div class="grid lg:grid-cols-3 gap-8 items-start">
<div class="lg:col-span-2">
{% include 'questionnaire/_answers.html.twig' %}
{% include 'questionnaire/_answers.html.twig' with { 'answers': response.answers } %}
</div>
<div>
{# the snapshot taken on submit, so it shows the placement as the teamer answered for it #}
@@ -0,0 +1,59 @@
{% extends 'administrative/layout.html.twig' %}
{% block title %}{{ definition.title }} (Testmodus){% endblock %}
{% block content %}
<h1 class="text-2xl font-bold pb-4">
{{ definition.title }}
</h1>
{% 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 %}
<div class="grid lg:grid-cols-3 gap-8 items-start">
<div class="lg:col-span-2">
{% 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 } %}
<div class="pt-8">
<a href="{{ path('app_administrative_questionnaire_test', { 'uuid': assignment.uuid, 'r': app.request.query.get('r') }) }}" class="btn btn--secondary">
Erneut testen
</a>
</div>
{% else %}
{% if definition.description %}
<p class="pb-8 text-gray-700">
{{ definition.description|nl2br }}
</p>
{% 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 %}
</div>
<div>
<h2 class="text-lg font-bold pb-4">
Einsatz
</h2>
{% include '_partials/_assignment_info.html.twig' %}
<div class="pt-8">
<a href="{{ returnUrl }}" class="underline">
zurück
</a>
</div>
</div>
</div>
{% endblock %}
+3 -4
View File
@@ -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 %}
<div class="flex flex-col gap-y-8">
{% for section in definition.sections %}
{% set shown = section.questions|filter(question => answers[question.key] is defined and definition.isVisible(question, answers)) %}
+17
View File
@@ -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 %}
<div class="flex flex-wrap items-center gap-4 pt-4">
{{ form_widget(form.submit, { attr: { class: 'btn' } }) }}
{{ form_widget(form.draft, { attr: { class: 'btn btn--secondary' } }) }}
</div>
<p class="pt-2 text-sm text-gray-500">
Als Entwurf kannst du den Fragebogen jederzeit speichern und später weiter ausfüllen.
Abgeschickt ist er endgültig.
</p>
{{ form_end(form) }}
@@ -11,7 +11,7 @@
</p>
<div class="grid lg:grid-cols-3 gap-8 items-start">
<div class="lg:col-span-2">
{% include 'questionnaire/_answers.html.twig' %}
{% include 'questionnaire/_answers.html.twig' with { 'answers': response.answers } %}
</div>
<div>
<h2 class="text-lg font-bold pb-4">
+1 -13
View File
@@ -13,19 +13,7 @@
{% endif %}
<div class="grid lg:grid-cols-3 gap-8 items-start">
<div class="lg:col-span-2">
{{ 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 %}
<div class="flex flex-wrap items-center gap-4 pt-4">
{{ form_widget(form.submit, { attr: { class: 'btn' } }) }}
{{ form_widget(form.draft, { attr: { class: 'btn btn--secondary' } }) }}
</div>
<p class="pt-2 text-sm text-gray-500">
Als Entwurf kannst du den Fragebogen jederzeit speichern und später weiter ausfüllen.
Abgeschickt ist er endgültig.
</p>
{{ form_end(form) }}
{% include 'questionnaire/_form.html.twig' %}
</div>
<div>
<h2 class="text-lg font-bold pb-4">
@@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Administrative\Questionnaire;
use App\Config\QuestionnaireCatalog;
use App\Entity\Assignment;
use App\Entity\Destination;
use App\Entity\JobProfile;
use App\Entity\QuestionnaireResponse;
use App\Entity\User;
use App\Enum\QuestionType;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\DomCrawler\Crawler;
/**
* The office's test mode: the real questionnaire on any assignment, and nothing saved, whichever
* button is pressed. Runs in a transaction that is rolled back; the client keeps its kernel so
* every request shares that connection.
*/
class TestControllerTest extends WebTestCase
{
private KernelBrowser $client;
private EntityManagerInterface $entityManager;
private Assignment $assignment;
protected function setUp(): void
{
$this->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.%[email protected]', strtolower($role)))
->setRoles([$role])
->setBusProAddressId(1)
->setBusProPersonId(1)
;
$this->entityManager->persist($user);
$this->entityManager->flush();
return $user;
}
}
+1 -1
View File
@@ -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);