feat: let the office test the questionnaire on any assignment
This commit is contained in:
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 %}
|
||||
@@ -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)) %}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user