refactor: split the questionnaire into teamer and bus feedback

This commit is contained in:
fromme committed 2026-10-27 17:22:22 +01:00
1 parent 2f4f9800dd
commit a0170f5952
35 files changed
+1114 -554

No files matched your search

@@ -1,144 +1,28 @@
# Questionnaire a teamer fills in after a placement (Disposition, not SeasonAssignment).
# Questionnaire a teamer fills in after a placement with bus escort, in addition to
# teamer_feedback.yaml and on the same page (App\Service\Questionnaire\DispositionContext).
#
# Replaces the Google forms "RL-Feedback 25-26" and "Bus-Feedback 25-26"; the question-by-question
# spec, with every decision taken, is in .ddev/plans/questionnaires/. Every teamer gets the
# sections up to "Sonstiges"; the sections marked `only_for: bus_escort` are added when the
# assignment had a bus escort. Questions whose answer the disposition already knows (name, job
# profile, house, dates, destination, escort pickup) are not asked: they are snapshotted into the
# response's context instead.
#
# Parsed and validated by App\Config\QuestionnaireCatalog; QuestionnaireCatalogTest builds it.
# Replaces the Google form "Bus-Feedback 25-26"; the question-by-question spec, with every decision
# taken, is in .ddev/plans/questionnaires/bus-feedback-25-26.yaml. Questions whose answer the
# disposition already knows (travel date, destination, Reiseleitung, first pickup) are not asked:
# they are snapshotted into the response's context instead.
#
# Labels, help texts, section titles and option labels may be edited in place. Renaming or
# removing a question or option key, or changing a type, needs a new version: copy this file to
# teamer_assignment.v1.yaml first, then bump `version` here. Submitted answers are keyed by it.
# bus_feedback.v1.yaml first, then bump `version` here. Submitted answers are keyed by it.
#
# Parsed and validated by App\Config\QuestionnaireCatalog; QuestionnaireCatalogTest builds it.
#
# Section fields: key, title, only_for (a App\Enum\QuestionnaireFlag value), questions.
# Question fields: key, label, type (text | textarea | choice | multi | date | rating),
# required (default false), help, widget (choice only: radio | dropdown, default radio),
# options (choice and multi only), show_if ({question, equals | in | lte}, earlier questions only).
key: teamer_assignment
key: bus_feedback
version: 1
title: "Reiseleiter/-in Feedback"
description: |-
Nur durch euer Feedback können wir besser werden! 🙂
Bei Bewertunsskalen: 1= schlecht, 5 = sehr gut
title: "Bus-Feedback"
sections:
- key: organisation
title: "Organisation & Ablauf"
questions:
- key: office_briefing_rating
label: "Wie gut hast du dich vorab vom Büro gebrieft gefühlt?"
type: rating
required: true
- key: office_briefing_low_reason
label: "Wenn 1 - 3 Sterne Bewertung, warum?"
type: textarea
required: true
show_if: { question: office_briefing_rating, lte: 3 }
- key: team_week_seminar
label: "War das Wochenteam-Seminar hilfreich für dich? Warst du bzw. wurdest du gut vorbereitet?"
type: choice
required: true
options:
- { key: very_helpful, label: "Ja, die Schulung war sehr hilfreich und ich fühlte mich bestens vorbereitet." }
- { key: mostly_helpful, label: "Ja, größtenteils – einige Themen hätte man noch vertiefen können." }
- { key: partly_helpful, label: "Es ging – die Schulung war teilweise hilfreich, aber nicht ausreichend für alle Aufgaben." }
- { key: rather_not_helpful, label: "Eher nein – ich fühlte mich in vielen Bereichen unvorbereitet." }
- { key: not_helpful, label: "Nein, die Schulung war kaum hilfreich und hat mich nicht auf den Arbeitsalltag vorbereitet." }
- { key: not_attended, label: "Ich habe nicht teilgenommen." }
- key: onsite_organisation_rating
label: "Wie gut lief die Reiseorganisation vor Ort?"
type: rating
required: true
- key: had_organisational_problems
label: "Gab es organisatorische Probleme?"
type: choice
required: true
options:
- { key: "yes", label: "Ja" }
- { key: "no", label: "Nein" }
- key: organisational_problems
label: "Wenn ja, welche?"
type: text
show_if: { question: had_organisational_problems, equals: "yes" }
- key: communication
title: "Kommunikation & Zusammenarbeit"
questions:
- key: teamer_collaboration_rating
label: "Wie lief die Kommunikation & Zusammenarbeit mit den E&P Teamer:innen vor Ort während der Reise?"
type: rating
required: true
- key: support_wished
label: "Welche Unterstützung hättest du dir zusätzlich gewünscht?"
type: textarea
- key: house_manager_problem_handling
label: "Wie ist deine Hausleitung mit Problemen vor Ort umgegangen?"
type: choice
required: true
options:
- { key: very_good, label: "Sehr gut – Probleme wurden schnell, fair und lösungsorientiert behandelt." }
- { key: good, label: "Gut – die meisten Anliegen wurden ernst genommen und bearbeitet." }
- { key: mixed, label: "Teils gut/teils schlecht – manche Probleme wurden gelöst, andere nicht." }
- { key: rather_poor, label: "Eher schlecht – es wurde wenig unternommen oder nicht auf Augenhöhe kommuniziert." }
- { key: very_poor, label: "Sehr schlecht – Probleme wurden ignoriert oder verschlimmert." }
- key: felt_overwhelmed
label: "Gab es Momente, in denen du dich überfordert gefühlt hast?"
type: choice
required: true
options:
- { key: "yes", label: "Ja" }
- { key: "no", label: "Nein" }
- key: overwhelmed_reason
label: "Wenn ja, wodurch?"
type: textarea
show_if: { question: felt_overwhelmed, equals: "yes" }
- key: improvement
title: "Verbesserung"
questions:
- key: wished_differently
label: "Was hättest du dir anders oder besser gewünscht?"
type: textarea
- key: would_work_again
label: "Würdest du wieder als Reiseleitung für E&P arbeiten?"
type: choice
required: true
options:
- { key: "yes", label: "Ja" }
- { key: "no", label: "Nein" }
- key: would_not_work_again_reason
label: "Wenn nein, warum nicht?"
type: textarea
show_if: { question: would_work_again, equals: "no" }
- key: other
title: "Sonstiges"
questions:
- key: anything_else
label: |-
Du hast nun schon vieles beantwortet,
aber nirgendwo konntest du folgende Aspekte loswerden:
type: textarea
- key: bus_trip
title: "Busfahrt"
only_for: bus_escort
questions:
- key: direction
label: "Hin- oder Rückfahrt"
@@ -167,7 +51,6 @@ sections:
- key: bus_team
title: "Busteam"
only_for: bus_escort
questions:
- key: driver_names
label: "Busfahrer-Namen:"
@@ -176,7 +59,6 @@ sections:
- key: bus_first_pickup
title: "1. Abfahrtsort"
only_for: bus_escort
questions:
- key: on_time_at_first_pickup
label: "Bus pünktlich am 1. Abfahrtsort?"
@@ -194,7 +76,6 @@ sections:
- key: bus_rating
title: "Bewertung Bus & Fahrer"
only_for: bus_escort
questions:
- key: overall_impression
label: "Gesamteindruck des Busses"
@@ -275,7 +156,6 @@ sections:
- key: bus_pickups
title: "Ladestellen"
only_for: bus_escort
questions:
- key: drivers_knew_pickups
label: "Waren den Fahrern alle Abfahrtsorte bekannt?"
@@ -293,7 +173,6 @@ sections:
- key: bus_destination
title: "Zielkenntnis"
only_for: bus_escort
questions:
- key: drivers_knew_destination
label: "War den Busfahrern der Zielort und die Ausladestelle bekannt?"
@@ -305,7 +184,6 @@ sections:
- key: bus_equipment
title: "Bus, Technik & Allgemeines"
only_for: bus_escort
questions:
- key: video
label: "Video/DVD-Gerät"
@@ -355,7 +233,6 @@ sections:
- key: bus_damage
title: "Schäden"
only_for: bus_escort
questions:
- key: bus_damage
label: "Entstanden während der Fahrt Schäden am Bus?"
@@ -374,7 +251,6 @@ sections:
- key: bus_remarks
title: "Anmerkungen zur Busfahrt"
only_for: bus_escort
questions:
- key: bus_remarks
label: "Das ging gar nicht / ist besonders hervorzuheben:"
+135
View File
@@ -0,0 +1,135 @@
# Questionnaire every teamer fills in after a placement (Disposition, not SeasonAssignment).
#
# Replaces the Google form "RL-Feedback 25-26"; the question-by-question spec, with every decision
# taken, is in .ddev/plans/questionnaires/rl-feedback-25-26.yaml. A teamer who escorted the bus
# also gets bus_feedback.yaml, on the same page. Questions whose answer the disposition already
# knows (name, job profile, house, arrival day) are not asked: they are snapshotted into the
# response's context instead.
#
# Labels, help texts, section titles and option labels may be edited in place. Renaming or
# removing a question or option key, or changing a type, needs a new version: copy this file to
# teamer_feedback.v1.yaml first, then bump `version` here. Submitted answers are keyed by it.
#
# Parsed and validated by App\Config\QuestionnaireCatalog; QuestionnaireCatalogTest builds it.
#
# Question fields: key, label, type (text | textarea | choice | multi | date | rating),
# required (default false), help, widget (choice only: radio | dropdown, default radio),
# options (choice and multi only), show_if ({question, equals | in | lte}, earlier questions only).
key: teamer_feedback
version: 1
title: "Reiseleiter/-in Feedback"
description: |-
Nur durch euer Feedback können wir besser werden! 🙂
Bei Bewertunsskalen: 1= schlecht, 5 = sehr gut
sections:
- key: organisation
title: "Organisation & Ablauf"
questions:
- key: office_briefing_rating
label: "Wie gut hast du dich vorab vom Büro gebrieft gefühlt?"
type: rating
required: true
- key: office_briefing_low_reason
label: "Wenn 1 - 3 Sterne Bewertung, warum?"
type: textarea
required: true
show_if: { question: office_briefing_rating, lte: 3 }
- key: team_week_seminar
label: "War das Wochenteam-Seminar hilfreich für dich? Warst du bzw. wurdest du gut vorbereitet?"
type: choice
required: true
options:
- { key: very_helpful, label: "Ja, die Schulung war sehr hilfreich und ich fühlte mich bestens vorbereitet." }
- { key: mostly_helpful, label: "Ja, größtenteils – einige Themen hätte man noch vertiefen können." }
- { key: partly_helpful, label: "Es ging – die Schulung war teilweise hilfreich, aber nicht ausreichend für alle Aufgaben." }
- { key: rather_not_helpful, label: "Eher nein – ich fühlte mich in vielen Bereichen unvorbereitet." }
- { key: not_helpful, label: "Nein, die Schulung war kaum hilfreich und hat mich nicht auf den Arbeitsalltag vorbereitet." }
- { key: not_attended, label: "Ich habe nicht teilgenommen." }
- key: onsite_organisation_rating
label: "Wie gut lief die Reiseorganisation vor Ort?"
type: rating
required: true
- key: had_organisational_problems
label: "Gab es organisatorische Probleme?"
type: choice
required: true
options:
- { key: "yes", label: "Ja" }
- { key: "no", label: "Nein" }
- key: organisational_problems
label: "Wenn ja, welche?"
type: text
show_if: { question: had_organisational_problems, equals: "yes" }
- key: communication
title: "Kommunikation & Zusammenarbeit"
questions:
- key: teamer_collaboration_rating
label: "Wie lief die Kommunikation & Zusammenarbeit mit den E&P Teamer:innen vor Ort während der Reise?"
type: rating
required: true
- key: support_wished
label: "Welche Unterstützung hättest du dir zusätzlich gewünscht?"
type: textarea
- key: house_manager_problem_handling
label: "Wie ist deine Hausleitung mit Problemen vor Ort umgegangen?"
type: choice
required: true
options:
- { key: very_good, label: "Sehr gut – Probleme wurden schnell, fair und lösungsorientiert behandelt." }
- { key: good, label: "Gut – die meisten Anliegen wurden ernst genommen und bearbeitet." }
- { key: mixed, label: "Teils gut/teils schlecht – manche Probleme wurden gelöst, andere nicht." }
- { key: rather_poor, label: "Eher schlecht – es wurde wenig unternommen oder nicht auf Augenhöhe kommuniziert." }
- { key: very_poor, label: "Sehr schlecht – Probleme wurden ignoriert oder verschlimmert." }
- key: felt_overwhelmed
label: "Gab es Momente, in denen du dich überfordert gefühlt hast?"
type: choice
required: true
options:
- { key: "yes", label: "Ja" }
- { key: "no", label: "Nein" }
- key: overwhelmed_reason
label: "Wenn ja, wodurch?"
type: textarea
show_if: { question: felt_overwhelmed, equals: "yes" }
- key: improvement
title: "Verbesserung"
questions:
- key: wished_differently
label: "Was hättest du dir anders oder besser gewünscht?"
type: textarea
- key: would_work_again
label: "Würdest du wieder als Reiseleitung für E&P arbeiten?"
type: choice
required: true
options:
- { key: "yes", label: "Ja" }
- { key: "no", label: "Nein" }
- key: would_not_work_again_reason
label: "Wenn nein, warum nicht?"
type: textarea
show_if: { question: would_work_again, equals: "no" }
- key: other
title: "Sonstiges"
questions:
- key: anything_else
label: |-
Du hast nun schon vieles beantwortet,
aber nirgendwo konntest du folgende Aspekte loswerden:
type: textarea
+2
View File
@@ -473,3 +473,5 @@ when@dev:
when@test:
parameters:
mailing_bus_transport: 'sync'
# the questionnaire tests need it offered; any day before the placements they create
questionnaire_go_live: '2026-01-01'
+29 -29
View File
@@ -4,7 +4,6 @@ namespace App\Config;
use App\Enum\ChoiceWidget;
use App\Enum\ConditionOperator;
use App\Enum\QuestionnaireFlag;
use App\Enum\QuestionType;
use App\Model\Questionnaire\Condition;
use App\Model\Questionnaire\Definition;
@@ -27,12 +26,17 @@ use Symfony\Component\Yaml\Yaml;
class QuestionnaireCatalog
{
/**
* The questionnaire a teamer fills in after a placement; see DispositionVoter::QUESTIONNAIRE.
* The questionnaire every teamer fills in after a placement; see DispositionVoter::QUESTIONNAIRE.
*/
public const string TEAMER_ASSIGNMENT = 'teamer_assignment';
public const string TEAMER_FEEDBACK = 'teamer_feedback';
/**
* The one a teamer who escorted the bus fills in as well, on the same page.
*/
public const string BUS_FEEDBACK = 'bus_feedback';
private const array ROOT_FIELDS = ['key', 'version', 'title', 'description', 'sections'];
private const array SECTION_FIELDS = ['key', 'title', 'only_for', 'questions'];
private const array SECTION_FIELDS = ['key', 'title', 'questions'];
private const array QUESTION_FIELDS = ['key', 'label', 'type', 'required', 'help', 'widget', 'options', 'show_if'];
private const array OPTION_FIELDS = ['key', 'label'];
@@ -80,6 +84,26 @@ class QuestionnaireCatalog
return $this->load(sprintf('%s/%s.v%d.yaml', $this->questionnaireDir, $key, $version));
}
/**
* The keys of the questionnaires there are, i.e. of their current files.
*
* @return list<string>
*/
public function keys(): array
{
$keys = [];
foreach (glob($this->questionnaireDir.'/*.yaml') ?: [] as $file) {
if (1 === preg_match('/^([a-z0-9_]+)\.yaml$/', basename($file), $match)) {
$keys[] = $match[1];
}
}
sort($keys);
return $keys;
}
/**
* Every definition in the directory, archived versions included.
*
@@ -130,8 +154,6 @@ class QuestionnaireCatalog
$sectionKeys = [];
/** @var array<string, Question> $questions every question parsed so far, for show_if */
$questions = [];
/** @var array<string, QuestionnaireFlag|null> $questionFlags the only_for of each question's section */
$questionFlags = [];
foreach ($data['sections'] as $index => $sectionData) {
$context = sprintf('%s: section #%d', $file, $index + 1);
@@ -149,38 +171,16 @@ class QuestionnaireCatalog
throw $this->error($context, 'questions must be a non-empty list');
}
$onlyFor = null;
if (array_key_exists('only_for', $sectionData)) {
$onlyFor = QuestionnaireFlag::tryFrom((string) $sectionData['only_for'])
?? throw $this->error($context, sprintf('unknown only_for "%s"', $sectionData['only_for']));
}
$sectionQuestions = [];
foreach ($sectionData['questions'] as $questionData) {
$question = $this->buildQuestion($questionData, $questions, $context);
// A question that may be asked without the flag cannot depend on one that is only
// asked with it: it would stay hidden for everyone without the flag.
if (null !== $question->showIf
&& null !== ($controllingFlag = $questionFlags[$question->showIf->question])
&& $controllingFlag !== $onlyFor
) {
throw $this->error(sprintf('%s: question "%s": show_if', $context, $question->key), sprintf('"%s" is only asked for %s', $question->showIf->question, $controllingFlag->value));
}
$questions[$question->key] = $question;
$questionFlags[$question->key] = $onlyFor;
$sectionQuestions[] = $question;
}
$sections[] = new Section(
$key,
$this->optionalString($sectionData['title'] ?? null, 'title', $context),
$sectionQuestions,
$onlyFor,
);
$sections[] = new Section($key, $this->optionalString($sectionData['title'] ?? null, 'title', $context), $sectionQuestions);
}
return new Definition(
@@ -5,12 +5,14 @@ namespace App\Controller\Administrative\Questionnaire;
use App\Config\QuestionnaireCatalog;
use App\Controller\Traits\ReturnUrlTrait;
use App\Entity\QuestionnaireResponse;
use App\Repository\QuestionnaireResponseRepository;
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\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Requirement\Requirement;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
@@ -23,10 +25,12 @@ class DetailController extends AbstractController
public function __construct(
private readonly QuestionnaireCatalog $catalog,
private readonly QuestionnaireResponseRepository $responseRepository,
) {
}
#[Route('/administrative/questionnaire/{uuid}', name: 'app_administrative_questionnaire_detail')]
// the requirement keeps /administrative/questionnaire/list from being read as a uuid
#[Route('/administrative/questionnaire/{uuid}', name: 'app_administrative_questionnaire_detail', requirements: ['uuid' => Requirement::UUID])]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(
#[MapEntity(mapping: ['uuid' => 'uuid'])]
@@ -38,9 +42,19 @@ class DetailController extends AbstractController
throw new NotFoundHttpException();
}
// the other questionnaire of the same placement, e.g. the bus feedback beside the teamer feedback
$siblings = array_filter(
$this->responseRepository->findByDisposition($response->getDisposition()),
static fn (QuestionnaireResponse $sibling): bool => $sibling !== $response && $sibling->isSubmitted(),
);
return $this->render('administrative/questionnaire/detail.html.twig', [
'definition' => $this->catalog->get($response->getQuestionnaire(), $response->getVersion()),
'response' => $response,
'siblings' => array_map(fn (QuestionnaireResponse $sibling): array => [
'response' => $sibling,
'title' => $this->catalog->get($sibling->getQuestionnaire(), $sibling->getVersion())->title,
], array_values($siblings)),
'returnUrl' => $this->getReturnUrl($request, 'app_administrative_questionnaire_index'),
]);
}
@@ -13,8 +13,9 @@ use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* The submitted teamer questionnaires. Only administrative users read them: they rate the house
* manager among others, so house managers are deliberately not let in.
* The submitted responses to one questionnaire, teamer or bus feedback. Only administrative users
* read them: they rate the house manager among others, so house managers are deliberately not
* let in.
*/
class IndexController extends AbstractController
{
@@ -26,14 +27,25 @@ class IndexController extends AbstractController
) {
}
#[Route('/administrative/questionnaire', name: 'app_administrative_questionnaire_index')]
#[Route(
'/administrative/questionnaire/list/{questionnaire}',
name: 'app_administrative_questionnaire_index',
requirements: ['questionnaire' => '[a-z0-9_]+'],
defaults: ['questionnaire' => QuestionnaireCatalog::TEAMER_FEEDBACK],
)]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(Request $request): Response
public function index(string $questionnaire, Request $request): Response
{
$keys = $this->catalog->keys();
if (!in_array($questionnaire, $keys, true)) {
throw $this->createNotFoundException(sprintf('No questionnaire "%s"', $questionnaire));
}
$filterDto = $this->filterHandler->getFilterSettings();
$pagination = $this->paginator->paginate(
$this->responseRepository->getListQuery(QuestionnaireCatalog::TEAMER_ASSIGNMENT, $filterDto),
$this->responseRepository->getListQuery($questionnaire, $filterDto),
$request->query->getInt('page', 1),
$request->query->getInt('limit', 50),
[
@@ -43,7 +55,9 @@ class IndexController extends AbstractController
);
return $this->render('administrative/questionnaire/index.html.twig', [
'definition' => $this->catalog->get(QuestionnaireCatalog::TEAMER_ASSIGNMENT),
'definition' => $this->catalog->get($questionnaire),
// for switching between the questionnaires
'definitions' => array_map($this->catalog->get(...), $keys),
'pagination' => $pagination,
'filterDto' => $filterDto,
]);
@@ -5,8 +5,8 @@ namespace App\Controller\Administrative\Questionnaire;
use App\Config\QuestionnaireCatalog;
use App\Controller\Traits\ReturnUrlTrait;
use App\Entity\Assignment;
use App\Enum\QuestionnaireFlag;
use App\Form\QuestionnaireType;
use App\Model\Questionnaire\Definition;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
@@ -15,10 +15,11 @@ 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 - before or after
* the trip, placed or not. It shows the assignment's own variant, with or without the bus
* sections; ?bus_escort=1 or 0 tries the other one. Nothing is saved: "Absenden" shows the answers
* as they would be stored, "Entwurf speichern" just keeps them in the form.
* The questionnaires as a teamer gets them, for trying out on any assignment - before or after the
* trip, placed or not. It shows what a placement on this assignment gets - the teamer feedback,
* and the bus feedback too for a bus escort; ?bus_escort=1 or 0 tries the other case. Nothing is
* saved: "Absenden" shows the answers as they would be stored, "Entwurf speichern" just keeps them
* in the form.
*/
class TestController extends AbstractController
{
@@ -39,12 +40,13 @@ class TestController extends AbstractController
$busEscort = $request->query->has('bus_escort')
? $request->query->getBoolean('bus_escort')
: $assignment->hasBusEscort();
$definition = $this->catalog
->get(QuestionnaireCatalog::TEAMER_ASSIGNMENT)
->forFlags($busEscort ? [QuestionnaireFlag::BUS_ESCORT] : [])
;
$definitions = [$this->catalog->get(QuestionnaireCatalog::TEAMER_FEEDBACK)];
$form = $this->createForm(QuestionnaireType::class, [], ['definition' => $definition]);
if ($busEscort) {
$definitions[] = $this->catalog->get(QuestionnaireCatalog::BUS_FEEDBACK);
}
$form = $this->createForm(QuestionnaireType::class, [], ['definitions' => $definitions]);
$form->handleRequest($request);
$submitted = null;
@@ -52,7 +54,10 @@ class TestController extends AbstractController
if ($form->isSubmitted() && $form->isValid()) {
if (QuestionnaireType::isFinal($form)) {
$submitted = $definition->withoutHiddenAnswers($form->getData());
$submitted = array_map(static fn (Definition $definition): array => [
'definition' => $definition,
'answers' => $definition->withoutHiddenAnswers($form->getData()[$definition->key] ?? []),
], $definitions);
} else {
$draft = true;
}
@@ -60,7 +65,6 @@ class TestController extends AbstractController
return $this->render('administrative/questionnaire/test.html.twig', [
'assignment' => $assignment,
'definition' => $definition,
'form' => $form,
'submitted' => $submitted,
'draft' => $draft,
@@ -2,6 +2,7 @@
namespace App\Controller\Common;
use App\Controller\Traits\ReturnUrlTrait;
use App\Form\QuestionnaireResponseFilterType;
use App\Htmx\HxRedirectResponse;
use App\Service\Common\QuestionnaireResponseFilterHandler;
@@ -11,8 +12,13 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* One filter for both questionnaire lists; it returns to the list it was opened from.
*/
class QuestionnaireResponseFilterController extends AbstractController
{
use ReturnUrlTrait;
public function __construct(private readonly QuestionnaireResponseFilterHandler $filterHandler)
{
}
@@ -28,7 +34,7 @@ class QuestionnaireResponseFilterController extends AbstractController
if ($form->isSubmitted() && $form->isValid()) {
$this->filterHandler->handleRequest($form);
return new HxRedirectResponse($this->generateUrl('app_administrative_questionnaire_index'));
return new HxRedirectResponse($this->getReturnUrl($request, 'app_administrative_questionnaire_index'));
}
return $this->render('common/modal_questionnaire_response_filter.html.twig', [
@@ -39,10 +45,10 @@ class QuestionnaireResponseFilterController extends AbstractController
#[Route('/common/questionnaire-response/filter/reset', name: 'app_common_questionnaire_response_filter_reset')]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function reset(): Response
public function reset(Request $request): Response
{
$this->filterHandler->resetFilterSettings();
return $this->redirectToRoute('app_administrative_questionnaire_index');
return $this->redirect($this->getReturnUrl($request, 'app_administrative_questionnaire_index'));
}
}
@@ -5,6 +5,7 @@ namespace App\Controller\Teamer\Questionnaire;
use App\Config\QuestionnaireCatalog;
use App\Entity\Disposition;
use App\Repository\QuestionnaireResponseRepository;
use App\Service\Questionnaire\DispositionContext;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
@@ -12,8 +13,8 @@ use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* The teamer's own submitted questionnaire, read-only, rendered with the definition version it
* was submitted under.
* The teamer's own submitted questionnaires for a placement, read-only, each rendered with the
* definition version it was submitted under.
*/
class DetailController extends AbstractController
{
@@ -30,16 +31,34 @@ class DetailController extends AbstractController
#[MapEntity(mapping: ['uuid' => 'uuid'])]
Disposition $disposition,
): Response {
$response = $this->responseRepository->findOneByDisposition(QuestionnaireCatalog::TEAMER_ASSIGNMENT, $disposition);
$submitted = [];
$open = false;
$responses = $this->responseRepository->findByDisposition($disposition);
if (true !== $response?->isSubmitted()) {
// what applies now, plus anything submitted under an earlier state of the assignment
$keys = array_unique([...DispositionContext::questionnaires($disposition->getAssignment()), ...array_keys($responses)]);
foreach ($keys as $key) {
$response = $responses[$key] ?? null;
if (true === $response?->isSubmitted()) {
$submitted[] = [
'definition' => $this->catalog->get($key, $response->getVersion()),
'response' => $response,
];
} else {
$open = true;
}
}
if ([] === $submitted) {
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,
'submitted' => $submitted,
'open' => $open,
]);
}
}
@@ -18,9 +18,13 @@ use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* The teamer's questionnaire after a placement - with the bus sections if it was a bus escort -
* saved as a draft until it is submitted. A submitted one is final and only shown read-only
* The teamer's questionnaires after a placement on one page: the teamer feedback, and after a bus
* escort the bus feedback too. Saved as drafts until submitted; one submit submits all of them,
* each as a response of its own. Submitted ones are final and only shown read-only
* (DetailController).
*
* Only the questionnaires not yet submitted are offered, so one that applies later - an escort
* added after the teamer submitted - can still be filled in.
*/
class EditController extends AbstractController
{
@@ -40,45 +44,60 @@ class EditController extends AbstractController
Disposition $disposition,
Request $request,
): Response {
$response = $this->responseRepository->findOneByDisposition(QuestionnaireCatalog::TEAMER_ASSIGNMENT, $disposition);
$responses = $this->responseRepository->findByDisposition($disposition);
$definitions = [];
if (true === $response?->isSubmitted()) {
foreach (DispositionContext::questionnaires($disposition->getAssignment()) as $key) {
if (true !== ($responses[$key] ?? null)?->isSubmitted()) {
$definitions[] = $this->catalog->get($key);
}
}
if ([] === $definitions) {
return $this->redirectToRoute('app_teamer_questionnaire_detail', ['uuid' => $disposition->getUuid()]);
}
$definition = $this->catalog
->get(QuestionnaireCatalog::TEAMER_ASSIGNMENT)
->forFlags(DispositionContext::flags($disposition->getAssignment()))
;
$data = [];
$form = $this->createForm(QuestionnaireType::class, $response?->getAnswers() ?? [], [
'definition' => $definition,
]);
foreach ($definitions as $definition) {
$data[$definition->key] = ($responses[$definition->key] ?? null)?->getAnswers() ?? [];
}
$form = $this->createForm(QuestionnaireType::class, $data, ['definitions' => $definitions]);
$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);
foreach ($definitions as $definition) {
$response = $responses[$definition->key] ?? null;
if (null === $response) {
$response = QuestionnaireResponse::forDisposition($definition, $disposition);
$this->entityManager->persist($response);
$responses[$definition->key] = $response;
}
$answers = $form->getData()[$definition->key] ?? [];
if (true === $final) {
$response->submit($definition, $answers, $context, new \DateTimeImmutable());
} else {
$response->saveDraft($answers, $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(),
]);
foreach ($definitions as $definition) {
$this->logger->info($final ? 'Submit questionnaire' : 'Save questionnaire draft', [
'questionnaire' => $definition->key,
'questionnaire_response_id' => $responses[$definition->key]->getId(),
'disposition_id' => $disposition->getId(),
'teamer_id' => $disposition->getTeamer()->getId(),
]);
}
if (true === $final) {
$this->addFlash('success', 'Danke! Dein Fragebogen wurde abgeschickt.');
@@ -91,11 +110,15 @@ class EditController extends AbstractController
return $this->redirectToRoute('app_teamer_questionnaire_edit', ['uuid' => $disposition->getUuid()]);
}
$drafts = array_filter($responses, static fn (QuestionnaireResponse $response): bool => !$response->isSubmitted());
return $this->render('teamer/questionnaire/edit.html.twig', [
'disposition' => $disposition,
'definition' => $definition,
'response' => $response,
'form' => $form,
'lastSaved' => [] === $drafts ? null : max(array_map(
static fn (QuestionnaireResponse $response): \DateTimeInterface => $response->getUpdatedAt() ?? $response->getCreatedAt(),
$drafts,
)),
]);
}
}
-13
View File
@@ -1,13 +0,0 @@
<?php
namespace App\Enum;
/**
* Something a questionnaire's subject may or may not have, deciding whether a section marked
* `only_for` it is part of the questionnaire at all. Unlike show_if, it is not answered by the
* respondent but known beforehand - for a disposition, by DispositionContext::flags().
*/
enum QuestionnaireFlag: string
{
case BUS_ESCORT = 'bus_escort';
}
+47 -29
View File
@@ -21,8 +21,10 @@ use Symfony\Component\Form\FormInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Builds a questionnaire form from its definition. The data is the flat answers array keyed by
* question key; each section is a child with inherit_data, so it groups the fields on screen
* Builds one form from one or more questionnaire definitions - a teamer after a bus escort answers
* teamer_feedback and bus_feedback on one page. The data is keyed by questionnaire key, each
* holding that questionnaire's flat answers keyed by question key: every questionnaire is a child
* of its own, and each of its sections a child with inherit_data, which groups the fields on screen
* without nesting the answers.
*
* Two buttons: "draft" saves whatever is filled in, "submit" also demands every required question
@@ -39,21 +41,32 @@ class QuestionnaireType extends AbstractType
public function buildForm(FormBuilderInterface $builder, array $options): void
{
/** @var Definition $definition */
$definition = $options['definition'];
/** @var list<Definition> $definitions */
$definitions = $options['definitions'];
foreach ($definition->sections as $section) {
$sectionBuilder = $builder->create($section->key, FormType::class, [
'inherit_data' => true,
'label' => $section->title ?? false,
'block_prefix' => 'questionnaire_section',
foreach ($definitions as $definition) {
$questionnaireBuilder = $builder->create($definition->key, FormType::class, [
'label' => $definition->title,
'help' => $definition->description,
'empty_data' => [],
'block_prefix' => 'questionnaire_part',
]);
foreach ($section->questions as $question) {
$sectionBuilder->add($question->key, ...$this->field($question));
foreach ($definition->sections as $section) {
$sectionBuilder = $builder->create($section->key, FormType::class, [
'inherit_data' => true,
'label' => $section->title ?? false,
'block_prefix' => 'questionnaire_section',
]);
foreach ($section->questions as $question) {
$sectionBuilder->add($question->key, ...$this->field($definition, $question));
}
$questionnaireBuilder->add($sectionBuilder);
}
$builder->add($sectionBuilder);
$builder->add($questionnaireBuilder);
}
$builder
@@ -63,24 +76,26 @@ class QuestionnaireType extends AbstractType
->add(self::SUBMIT, SubmitType::class, [
'label' => 'Absenden',
])
->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event) use ($definition): void {
->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event) use ($definitions): void {
$form = $event->getForm();
if (!$this->isFinal($form)) {
return;
}
$answers = $form->getData();
foreach ($definitions as $definition) {
$answers = $form->getData()[$definition->key] ?? [];
foreach ($definition->sections as $section) {
foreach ($section->questions as $question) {
if ($question->required
&& $definition->isVisible($question, $answers)
&& $this->isEmpty($answers[$question->key] ?? null)
) {
$form->get($section->key)->get($question->key)->addError(
new FormError('Bitte beantworte diese Frage.')
);
foreach ($definition->sections as $section) {
foreach ($section->questions as $question) {
if ($question->required
&& $definition->isVisible($question, $answers)
&& $this->isEmpty($answers[$question->key] ?? null)
) {
$form->get($definition->key)->get($section->key)->get($question->key)->addError(
new FormError('Bitte beantworte diese Frage.')
);
}
}
}
}
@@ -91,8 +106,10 @@ class QuestionnaireType extends AbstractType
public function configureOptions(OptionsResolver $resolver): void
{
$resolver
->setRequired('definition')
->setAllowedTypes('definition', Definition::class)
->setRequired('definitions')
->setAllowedTypes('definitions', Definition::class.'[]')
->setAllowedValues('definitions', static fn (array $definitions): bool => [] !== $definitions
&& count($definitions) === count(array_unique(array_map(static fn (Definition $definition): string => $definition->key, $definitions))))
->setDefaults([
'data_class' => null,
'empty_data' => [],
@@ -114,16 +131,17 @@ class QuestionnaireType extends AbstractType
/**
* @return array{0: class-string, 1: array<string, mixed>}
*/
private function field(Question $question): array
private function field(Definition $definition, Question $question): array
{
$common = [
'label' => $question->label,
'help' => $question->help,
'required' => false,
// read by assets/controllers/questionnaire_controller.js
'row_attr' => ['data-question-key' => $question->key] + (null === $question->showIf ? [] : [
// read by assets/controllers/questionnaire_controller.js; prefixed with the questionnaire,
// since question keys are only unique within one
'row_attr' => ['data-question-key' => $definition->key.'.'.$question->key] + (null === $question->showIf ? [] : [
'data-show-if' => json_encode([
'question' => $question->showIf->question,
'question' => $definition->key.'.'.$question->showIf->question,
'operator' => $question->showIf->operator->value,
'value' => $question->showIf->value,
], JSON_THROW_ON_ERROR),
-22
View File
@@ -2,8 +2,6 @@
namespace App\Model\Questionnaire;
use App\Enum\QuestionnaireFlag;
/**
* One version of a questionnaire as read from config/questionnaires/. Responses store its key and
* version, and their answers are keyed by Question::$key.
@@ -22,26 +20,6 @@ readonly class Definition
) {
}
/**
* The questionnaire as one subject gets it: without the sections marked `only_for` a flag the
* subject does not have. Key and version stay, so answers to it are answers to this definition.
*
* @param list<QuestionnaireFlag> $flags
*/
public function forFlags(array $flags): static
{
return new static(
$this->key,
$this->version,
$this->title,
$this->description,
array_values(array_filter(
$this->sections,
static fn (Section $section): bool => null === $section->onlyFor || in_array($section->onlyFor, $flags, true),
)),
);
}
/**
* @return array<string, Question> in the order they are asked, keyed by question key
*/
+1 -5
View File
@@ -2,19 +2,15 @@
namespace App\Model\Questionnaire;
use App\Enum\QuestionnaireFlag;
readonly class Section
{
/**
* @param list<Question> $questions
* @param QuestionnaireFlag|null $onlyFor part of the questionnaire only when the subject has this flag
* @param list<Question> $questions
*/
public function __construct(
public string $key,
public ?string $title,
public array $questions,
public ?QuestionnaireFlag $onlyFor = null,
) {
}
}
+32 -13
View File
@@ -77,30 +77,38 @@ class DispositionRepository extends ServiceEntityRepository
}
/**
* The teamer's placements whose questionnaire (config/questionnaires/teamer_assignment.yaml) is
* still to be submitted: not started, or saved as a draft. The rules are those of
* The teamer's placements with a questionnaire still to be submitted - not started, or saved as
* a draft: the teamer feedback, and after a bus escort the bus feedback too
* (DispositionContext::questionnaires(), spelled out here in DQL). The rules are those of
* DispositionVoter::QUESTIONNAIRE - over (today included), on or after go-live, neither called
* off nor skip-formalities - so every row shown on the dashboard can be opened.
*
* The response is joined as an arbitrary entity because the association is one-way; the
* questionnaire key has to be part of the JOIN condition, or a response to another
* questionnaire would hide this one.
* The responses are joined as arbitrary entities because the association is one-way; the
* questionnaire key has to be part of each JOIN condition, or one questionnaire's response
* would stand in for the other's.
*
* Rows are [0 => Disposition, 'responseStatus' => ?QuestionnaireResponseStatus].
* Rows are [0 => Disposition, 'teamerStatus' => ?QuestionnaireResponseStatus,
* 'busStatus' => ?QuestionnaireResponseStatus].
*/
public function getPendingQuestionnaireQuery(Teamer $teamer, \DateTimeImmutable $goLive): Query
{
$qb = $this->createQueryBuilder('disposition');
return $qb
->select('disposition', 'assignment', 'destination', 'response.status AS responseStatus')
->select('disposition', 'assignment', 'destination', 'teamer_response.status AS teamerStatus', 'bus_response.status AS busStatus')
->innerJoin('disposition.assignment', 'assignment')
->innerJoin('assignment.destination', 'destination')
->leftJoin(
QuestionnaireResponse::class,
'response',
'teamer_response',
Join::ON,
'response.disposition = disposition AND response.questionnaire = :questionnaire'
'teamer_response.disposition = disposition AND teamer_response.questionnaire = :teamerQuestionnaire'
)
->leftJoin(
QuestionnaireResponse::class,
'bus_response',
Join::ON,
'bus_response.disposition = disposition AND bus_response.questionnaire = :busQuestionnaire'
)
->where($qb->expr()->andX(
$qb->expr()->eq('disposition.teamer', ':teamer'),
@@ -110,13 +118,23 @@ class DispositionRepository extends ServiceEntityRepository
$qb->expr()->gte('destination.dateTo', ':goLive'),
$qb->expr()->lte('destination.dateTo', ':today'),
$qb->expr()->orX(
$qb->expr()->isNull('response.id'),
$qb->expr()->eq('response.status', ':draft')
$qb->expr()->isNull('teamer_response.id'),
$qb->expr()->eq('teamer_response.status', ':draft'),
// a bus escort, i.e. Assignment::hasBusEscort(), owing the bus feedback
$qb->expr()->andX(
$qb->expr()->isNotNull('assignment.pickup'),
$qb->expr()->neq('assignment.pickup', 0),
$qb->expr()->orX(
$qb->expr()->isNull('bus_response.id'),
$qb->expr()->eq('bus_response.status', ':draft')
)
)
)
))
->orderBy('destination.dateTo', \SortDirection::Descending)
->setParameter('teamer', $teamer)
->setParameter('questionnaire', QuestionnaireCatalog::TEAMER_ASSIGNMENT)
->setParameter('teamerQuestionnaire', QuestionnaireCatalog::TEAMER_FEEDBACK)
->setParameter('busQuestionnaire', QuestionnaireCatalog::BUS_FEEDBACK)
->setParameter('dispositionCalledOff', Disposition::STATUS_CALLED_OFF)
->setParameter('assignmentStatuses', [Assignment::STATUS_CALLED_OFF, Assignment::STATUS_DELETED])
->setParameter('skipFormalities', false)
@@ -135,7 +153,8 @@ class DispositionRepository extends ServiceEntityRepository
return array_map(
static fn (array $row): array => [
'disposition' => $row[0],
'draft' => QuestionnaireResponseStatus::DRAFT === $row['responseStatus'],
'draft' => QuestionnaireResponseStatus::DRAFT === $row['teamerStatus']
|| QuestionnaireResponseStatus::DRAFT === $row['busStatus'],
],
$this->getPendingQuestionnaireQuery($teamer, $goLive)->getResult(),
);
@@ -80,8 +80,17 @@ class QuestionnaireResponseRepository extends ServiceEntityRepository
return $qb->getQuery();
}
public function findOneByDisposition(string $questionnaire, Disposition $disposition): ?QuestionnaireResponse
/**
* @return array<string, QuestionnaireResponse> keyed by questionnaire key
*/
public function findByDisposition(Disposition $disposition): array
{
return $this->findOneBy(['questionnaire' => $questionnaire, 'disposition' => $disposition]);
$responses = [];
foreach ($this->findBy(['disposition' => $disposition]) as $response) {
$responses[$response->getQuestionnaire()] = $response;
}
return $responses;
}
}
+5 -5
View File
@@ -117,11 +117,11 @@ class DispositionVoter extends Voter
}
/**
* Whether the teamer may fill in, and later read, the questionnaire after this placement
* (config/questionnaires/teamer_assignment.yaml). Every placement gets it; one with a bus
* escort gets the bus sections as well, which is the form's business, not this voter's. Only
* the teamer themself: the office reads the submitted responses through ROLE_ADMINISTRATIVE,
* not through this attribute.
* Whether the teamer may fill in, and later read, the questionnaires after this placement:
* every placement gets the teamer feedback, a bus escort the bus feedback as well
* (DispositionContext::questionnaires() - which ones is not this voter's business). Only the
* teamer themself: the office reads the submitted responses through ROLE_ADMINISTRATIVE, not
* through this attribute.
*
* "Over" is the destination's end date, not Disposition::STATUS_ENDED: the cron sets that a
* run late, and it moves on to checking_invoice and completed while the date stays true. An
@@ -2,9 +2,9 @@
namespace App\Service\Questionnaire;
use App\Config\QuestionnaireCatalog;
use App\Entity\Assignment;
use App\Entity\Disposition;
use App\Enum\QuestionnaireFlag;
/**
* What a disposition already knows, snapshotted into a QuestionnaireResponse so the questionnaire
@@ -16,14 +16,19 @@ use App\Enum\QuestionnaireFlag;
final class DispositionContext
{
/**
* Which `only_for` sections of the questionnaire apply to a placement on this assignment.
* Takes the assignment so the office's test mode can ask before anyone is placed.
* The questionnaires a placement on this assignment gets, in the order they are shown: every
* teamer the teamer feedback, a bus escort the bus feedback as well. Takes the assignment so
* the office's test mode can ask before anyone is placed.
*
* @return list<QuestionnaireFlag>
* DispositionRepository::getPendingQuestionnaireQuery() spells the same rule in DQL.
*
* @return list<string> questionnaire keys
*/
public static function flags(Assignment $assignment): array
public static function questionnaires(Assignment $assignment): array
{
return $assignment->hasBusEscort() ? [QuestionnaireFlag::BUS_ESCORT] : [];
return $assignment->hasBusEscort()
? [QuestionnaireCatalog::TEAMER_FEEDBACK, QuestionnaireCatalog::BUS_FEEDBACK]
: [QuestionnaireCatalog::TEAMER_FEEDBACK];
}
/**
@@ -35,6 +35,20 @@
</div>
{% endfor %}
</dl>
{% if siblings is not empty %}
<h2 class="text-lg font-bold pt-8 pb-2">
Zum selben Einsatz
</h2>
<ul>
{% for sibling in siblings %}
<li>
<a href="{{ path('app_administrative_questionnaire_detail', { 'uuid': sibling.response.uuid, 'r': app.request.query.get('r') }) }}" class="underline">
{{ sibling.title }}
</a>
</li>
{% endfor %}
</ul>
{% endif %}
<div class="pt-8">
<a href="{{ returnUrl }}" class="underline">
zurück
@@ -1,12 +1,22 @@
{% extends 'administrative/layout.html.twig' %}
{% block title %}Fragebögen{% endblock %}
{% block title %}Fragebögen: {{ definition.title }}{% endblock %}
{% block content %}
<div class="flex items-start justify-between pb-4">
<h1 class="text-2xl font-bold">
Fragebögen: {{ definition.title }}
</h1>
<div>
<h1 class="text-2xl font-bold pb-2">
Fragebögen: {{ definition.title }}
</h1>
<div class="flex flex-wrap gap-2">
{% for other in definitions %}
<a href="{{ path('app_administrative_questionnaire_index', { 'questionnaire': other.key }) }}"
class="{{ html_classes('btn btn--small', { 'btn--secondary': other.key != definition.key }) }}">
{{ other.title }}
</a>
{% endfor %}
</div>
</div>
<div class="flex flex-col items-end space-y-2 md:flex-row md:items-center md:space-x-2 md:space-y-0">
<button type="button"
class="{{ html_classes('btn btn--small', { 'btn--secondary': filterDto.active }) }}"
@@ -1,10 +1,10 @@
{% extends 'administrative/layout.html.twig' %}
{% block title %}{{ definition.title }} (Testmodus){% endblock %}
{% block title %}Fragebogen testen{% endblock %}
{% block content %}
<h1 class="text-2xl font-bold pb-4">
{{ definition.title }}
Fragebogen testen
</h1>
{% include '_partials/_alert.html.twig' with {
'type': 'warning',
@@ -14,14 +14,14 @@
{% embed '_partials/_alert.html.twig' with { 'type': 'info' } %}
{% block message %}
{% if busEscort %}
Mit den Fragen zur Busfahrt, wie bei einer Busbegleitung.
Mit dem Bus-Feedback, wie bei einer Busbegleitung.
{% else %}
Ohne die Fragen zur Busfahrt, wie bei einem Einsatz ohne Busbegleitung.
Ohne das Bus-Feedback, wie bei einem Einsatz ohne Busbegleitung.
{% endif %}
{% if busEscort != assignment.hasBusEscort %}
Dieser Einsatz hat {{ assignment.hasBusEscort ? 'eine' : 'keine' }} Busbegleitung.
{% endif %}
<a href="{{ otherVariant }}" class="underline">{{ busEscort ? 'Ohne' : 'Mit' }} Busfahrt testen</a>
<a href="{{ otherVariant }}" class="underline">{{ busEscort ? 'Ohne' : 'Mit' }} Bus-Feedback testen</a>
{% endblock %}
{% endembed %}
<div class="grid lg:grid-cols-3 gap-8 items-start">
@@ -31,18 +31,22 @@
'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="flex flex-col gap-y-12">
{% for item in submitted %}
<section>
<h2 class="text-2xl font-bold pb-4">
{{ item.definition.title }}
</h2>
{% include 'questionnaire/_answers.html.twig' with { 'definition': item.definition, 'answers': item.answers } %}
</section>
{% endfor %}
</div>
<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',
+16
View File
@@ -173,6 +173,22 @@
{%- endif -%}
{%- endblock pickup_choice_widget -%}
{#
# One questionnaire within a questionnaire form (App\Form\QuestionnaireType), which may hold
# several: its title, its description and its sections.
#}
{%- block questionnaire_part_row -%}
<section class="pb-4">
<h2 class="text-2xl font-bold pb-2">{{ label }}</h2>
{%- if help is not empty -%}
<p class="pb-6 text-gray-700">{{ help|nl2br }}</p>
{%- endif -%}
{%- for child in form -%}
{{- form_row(child) -}}
{%- endfor -%}
</section>
{%- endblock questionnaire_part_row -%}
{#
# 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
@@ -1,17 +1,32 @@
{% extends 'teamer/layout.html.twig' %}
{% block title %}{{ definition.title }}{% endblock %}
{% block title %}Fragebogen zu deinem Einsatz{% endblock %}
{% block content %}
<h1 class="text-2xl font-bold pb-4">
{{ definition.title }}
Fragebogen zu deinem Einsatz
</h1>
<p class="pb-8 text-gray-700">
Abgeschickt am {{ response.submittedAt|date('d.m.Y, H:i') }} Uhr. Danke für dein Feedback!
Danke für dein Feedback!
</p>
<div class="grid lg:grid-cols-3 gap-8 items-start">
<div class="lg:col-span-2">
{% include 'questionnaire/_answers.html.twig' with { 'answers': response.answers } %}
<div class="lg:col-span-2 flex flex-col gap-y-12">
{% for item in submitted %}
<section>
<h2 class="text-2xl font-bold pb-1">
{{ item.definition.title }}
</h2>
<p class="pb-6 text-sm text-gray-500">
Abgeschickt am {{ item.response.submittedAt|date('d.m.Y, H:i') }} Uhr
</p>
{% include 'questionnaire/_answers.html.twig' with { 'definition': item.definition, 'answers': item.response.answers } %}
</section>
{% endfor %}
{% if open %}
<a href="{{ path('app_teamer_questionnaire_edit', { 'uuid': disposition.uuid }) }}" class="btn inline-block self-start">
Offenen Fragebogen ausfüllen
</a>
{% endif %}
</div>
<div>
<h2 class="text-lg font-bold pb-4">
+5 -10
View File
@@ -1,16 +1,11 @@
{% extends 'teamer/layout.html.twig' %}
{% block title %}{{ definition.title }}{% endblock %}
{% block title %}Fragebogen zu deinem Einsatz{% endblock %}
{% block content %}
<h1 class="text-2xl font-bold pb-4">
{{ definition.title }}
<h1 class="text-2xl font-bold pb-8">
Fragebogen zu deinem Einsatz
</h1>
{% if definition.description %}
<p class="pb-8 text-gray-700">
{{ definition.description|nl2br }}
</p>
{% endif %}
<div class="grid lg:grid-cols-3 gap-8 items-start">
<div class="lg:col-span-2">
{% include 'questionnaire/_form.html.twig' %}
@@ -20,9 +15,9 @@
Dein Einsatz
</h2>
{% include '_partials/_assignment_info.html.twig' with { 'assignment': disposition.assignment } %}
{% if response %}
{% if lastSaved %}
<p class="pt-6 text-sm text-gray-500">
Entwurf zuletzt gespeichert am {{ (response.updatedAt ?? response.createdAt)|date('d.m.Y, H:i') }} Uhr
Entwurf zuletzt gespeichert am {{ lastSaved|date('d.m.Y, H:i') }} Uhr
</p>
{% endif %}
</div>
+16 -69
View File
@@ -7,7 +7,6 @@ namespace App\Tests\Config;
use App\Config\QuestionnaireCatalog;
use App\Enum\ChoiceWidget;
use App\Enum\ConditionOperator;
use App\Enum\QuestionnaireFlag;
use App\Enum\QuestionType;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
@@ -47,22 +46,20 @@ class QuestionnaireCatalogTest extends TestCase
}
}
/**
* What the disposition already knows was decided not to be asked; it is snapshotted into the
* response instead. These are the Google forms' keys for those questions.
*/
public function testTeamerAssignmentDoesNotAskWhatTheDispositionKnows(): void
public function testTheTwoQuestionnairesAreTheCurrentKeys(): void
{
$definition = new QuestionnaireCatalog(self::SHIPPED_DIR)->get('teamer_assignment');
foreach (['name', 'job_profile', 'house', 'assignment_start', 'travel_date', 'destination', 'guide_name', 'first_pickup_location'] as $key) {
$this->assertNull($definition->getQuestion($key), $key);
}
$this->assertSame(
[QuestionnaireCatalog::BUS_FEEDBACK, QuestionnaireCatalog::TEAMER_FEEDBACK],
new QuestionnaireCatalog(self::SHIPPED_DIR)->keys(),
);
}
public function testTeamerAssignmentLowRatingReasonIsRequiredOnlyForLowRatings(): void
public function testLowRatingReasonIsRequiredOnlyForLowRatings(): void
{
$question = new QuestionnaireCatalog(self::SHIPPED_DIR)->get('teamer_assignment')->getQuestion('office_briefing_low_reason');
$question = new QuestionnaireCatalog(self::SHIPPED_DIR)
->get(QuestionnaireCatalog::TEAMER_FEEDBACK)
->getQuestion('office_briefing_low_reason')
;
$this->assertTrue($question->required);
$this->assertSame('office_briefing_rating', $question->showIf->question);
@@ -74,9 +71,9 @@ class QuestionnaireCatalogTest extends TestCase
* Google's single "Bitte ankreuzen:" checkbox list allowed "WC defekt" and "WC funktionstüchtig"
* together; it was split into one single choice per topic.
*/
public function testTeamerAssignmentEquipmentIsOneSingleChoicePerTopic(): void
public function testBusEquipmentIsOneSingleChoicePerTopic(): void
{
$definition = new QuestionnaireCatalog(self::SHIPPED_DIR)->get('teamer_assignment');
$definition = new QuestionnaireCatalog(self::SHIPPED_DIR)->get(QuestionnaireCatalog::BUS_FEEDBACK);
foreach (['video', 'wc', 'luggage', 'smoking', 'snow_chains'] as $key) {
$question = $definition->getQuestion($key);
@@ -85,26 +82,12 @@ class QuestionnaireCatalogTest extends TestCase
}
}
/**
* Every teamer gets the questionnaire; only a bus escort gets the bus part, and nobody gets a
* bus section without it.
*/
public function testTeamerAssignmentBusPartIsOnlyForBusEscorts(): void
public function testArchivedVersionsAreNotKeys(): void
{
$definition = new QuestionnaireCatalog(self::SHIPPED_DIR)->get(QuestionnaireCatalog::TEAMER_ASSIGNMENT);
$this->write('sample', ['version' => 2] + $this->sample());
$this->write('sample.v1', $this->sample());
foreach ($definition->sections as $section) {
$this->assertSame(
str_starts_with($section->key, 'bus_') ? QuestionnaireFlag::BUS_ESCORT : null,
$section->onlyFor,
$section->key,
);
}
$withoutBus = $definition->forFlags([]);
$this->assertNull($withoutBus->getQuestion('bus_company'));
$this->assertNotNull($withoutBus->getQuestion('office_briefing_rating'));
$this->assertNotNull($definition->forFlags([QuestionnaireFlag::BUS_ESCORT])->getQuestion('bus_company'));
$this->assertSame(['sample'], new QuestionnaireCatalog($this->dir)->keys());
}
public function testChoiceDefaultsToRadio(): void
@@ -204,41 +187,12 @@ class QuestionnaireCatalogTest extends TestCase
static fn (array $d): array => self::withQuestion(self::withQuestion($d, 0, ['type' => 'rating', 'options' => null]), 1, ['show_if' => ['question' => 'answer', 'lte' => 6]]),
'lte needs a rating from 1 to 5',
];
yield 'unknown only_for' => [
static fn (array $d): array => self::withSection($d, ['only_for' => 'ferry']),
'unknown only_for "ferry"',
];
yield 'key does not match the file name' => [
static fn (array $d): array => ['key' => 'other'] + $d,
'does not match the file name',
];
}
/**
* A question everyone gets cannot depend on a bus-only question: without the bus part it
* would never be shown.
*/
public function testQuestionCannotDependOnAQuestionOnlyAskedWithAFlag(): void
{
$definition = $this->sample();
$definition['sections'][0]['only_for'] = 'bus_escort';
$definition['sections'][] = [
'key' => 'later',
'questions' => [[
'key' => 'other_reason',
'label' => 'Warum?',
'type' => 'text',
'show_if' => ['question' => 'answer', 'equals' => 'yes'],
]],
];
$this->write('sample', $definition);
$this->expectException(\UnexpectedValueException::class);
$this->expectExceptionMessage('"answer" is only asked for bus_escort');
new QuestionnaireCatalog($this->dir)->get('sample');
}
public function testArchivedFileWithTheWrongVersionIsRejected(): void
{
$this->write('sample', ['version' => 3] + $this->sample());
@@ -290,13 +244,6 @@ class QuestionnaireCatalogTest extends TestCase
return $definition;
}
private static function withSection(array $definition, array $fields): array
{
$definition['sections'][0] = $fields + $definition['sections'][0];
return $definition;
}
private function write(string $name, array $definition): void
{
file_put_contents(sprintf('%s/%s.yaml', $this->dir, $name), Yaml::dump($definition, 10));
@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace App\Tests\Config;
use App\Config\QuestionnaireCatalog;
use App\Entity\Application;
use App\Entity\Assignment;
use App\Entity\Disposition;
use App\Entity\Teamer;
use App\Service\Questionnaire\DispositionContext;
use PHPUnit\Framework\TestCase;
/**
* Every question of the two Google forms the questionnaires replace ("RL-Feedback 25-26" and
* "Bus-Feedback 25-26") is either asked or answered from the placement - none may get lost when
* a definition is edited.
*
* The lists below are the Google forms' questions in their order, with what each became:
* "question:<key>" is asked, "context:<field>" is taken from the disposition and snapshotted into
* the response (DispositionContext::build()). Google's "Bitte ankreuzen:" checkbox list was split
* into five single-choice questions, so it maps to five.
*/
class QuestionnaireCompletenessTest extends TestCase
{
private const array TEAMER_FEEDBACK = [
'Name' => ['context:teamerName'],
'Jobprofil' => ['context:jobProfileName'],
'Haus' => ['context:hotel'],
'Einsatzzeitraum (Anreisetag)' => ['context:dateFrom'],
'Wie gut hast du dich vorab vom Büro gebrieft gefühlt?' => ['question:office_briefing_rating'],
'Wenn 1 - 3 Sterne Bewertung, warum?' => ['question:office_briefing_low_reason'],
'War das Wochenteam-Seminar hilfreich für dich? Warst du bzw. wurdest du gut vorbereitet?' => ['question:team_week_seminar'],
'Wie gut lief die Reiseorganisation vor Ort?' => ['question:onsite_organisation_rating'],
'Gab es organisatorische Probleme?' => ['question:had_organisational_problems'],
'Wenn ja, welche?' => ['question:organisational_problems'],
'Wie lief die Kommunikation & Zusammenarbeit mit den E&P Teamer:innen vor Ort während der Reise?' => ['question:teamer_collaboration_rating'],
'Welche Unterstützung hättest du dir zusätzlich gewünscht?' => ['question:support_wished'],
'Wie ist deine Hausleitung mit Problemen vor Ort umgegangen?' => ['question:house_manager_problem_handling'],
'Gab es Momente, in denen du dich überfordert gefühlt hast?' => ['question:felt_overwhelmed'],
'Wenn ja, wodurch?' => ['question:overwhelmed_reason'],
'Was hättest du dir anders oder besser gewünscht?' => ['question:wished_differently'],
'Würdest du wieder als Reiseleitung für E&P arbeiten?' => ['question:would_work_again'],
'Wenn nein, warum nicht?' => ['question:would_not_work_again_reason'],
'Du hast nun schon vieles beantwortet, aber nirgendwo konntest du folgende Aspekte loswerden:' => ['question:anything_else'],
];
private const array BUS_FEEDBACK = [
'(Datum, ohne Titel)' => ['context:escortPickupDate', 'context:dateTo'],
'Hin- oder Rückfahrt' => ['question:direction'],
'Reiseziel:' => ['context:destinationProduct'],
'Bus-Unternehmen:' => ['question:bus_company'],
'Name des Busunternehmens:' => ['question:bus_company_other'],
'Busfahrer-Namen:' => ['question:driver_names'],
'Reiseleitung:' => ['context:teamerName'],
'1. Abfahrtsort' => ['context:escortPickup', 'context:hotel'],
'Bus pünktlich am 1. Abfahrtsort?' => ['question:on_time_at_first_pickup'],
'Wieviel Verspätung hatte der Bus am 1. Abfahrtsort?' => ['question:delay_at_first_pickup'],
'Gesamteindruck des Busses' => ['question:overall_impression'],
'Alter des Fahrzeugs' => ['question:vehicle_age'],
'Sitzkomfort im Bus' => ['question:seating_comfort'],
'Freundlichkeit der Fahrer' => ['question:driver_friendliness'],
'Pausenzeiten' => ['question:breaks'],
'Temperatur im Bus' => ['question:temperature'],
'Verhalten der Gäste im Bus' => ['question:guest_behaviour'],
'Waren den Fahrern alle Abfahrtsorte bekannt?' => ['question:drivers_knew_pickups'],
'Welche Abfahrtsorte waren nicht bekannt? Gab es dadurch Probleme?' => ['question:unknown_pickups'],
'War den Busfahrern der Zielort und die Ausladestelle bekannt?' => ['question:drivers_knew_destination'],
'Bitte ankreuzen:' => ['question:video', 'question:wc', 'question:luggage', 'question:smoking', 'question:snow_chains'],
'Entstanden während der Fahrt Schäden am Bus?' => ['question:bus_damage'],
'Was ist passiert?' => ['question:damage_description'],
'Das ging gar nicht / ist besonders hervorzuheben:' => ['question:bus_remarks'],
];
public function testEveryTeamerFeedbackQuestionIsAskedOrDerived(): void
{
$this->assertComplete(QuestionnaireCatalog::TEAMER_FEEDBACK, self::TEAMER_FEEDBACK);
}
public function testEveryBusFeedbackQuestionIsAskedOrDerived(): void
{
$this->assertComplete(QuestionnaireCatalog::BUS_FEEDBACK, self::BUS_FEEDBACK);
}
/**
* The Google forms had 19 and 24 questions; with the checkbox list split there are 43 places
* an answer comes from.
*/
public function testTheMappingCoversBothGoogleForms(): void
{
$this->assertCount(19, self::TEAMER_FEEDBACK);
$this->assertCount(24, self::BUS_FEEDBACK);
}
/**
* A bus escort answers every question of both forms; anyone else every question of the
* teamer form.
*/
public function testABusEscortGetsBothQuestionnairesAndEveryoneTheTeamerOne(): void
{
$this->assertSame(
[QuestionnaireCatalog::TEAMER_FEEDBACK, QuestionnaireCatalog::BUS_FEEDBACK],
DispositionContext::questionnaires((new Assignment())->setPickup(4711)),
);
$this->assertSame(
[QuestionnaireCatalog::TEAMER_FEEDBACK],
DispositionContext::questionnaires((new Assignment())->setPickup(0)),
);
}
/**
* @param array<string, list<string>> $googleQuestions
*/
private function assertComplete(string $questionnaire, array $googleQuestions): void
{
$definition = new QuestionnaireCatalog(__DIR__.'/../../config/questionnaires')->get($questionnaire);
$contextFields = array_keys(DispositionContext::build(new Disposition(new Application(new Assignment(), new Teamer()))));
$mapped = [];
foreach ($googleQuestions as $label => $targets) {
foreach ($targets as $target) {
[$kind, $key] = explode(':', $target);
if ('question' === $kind) {
$this->assertNotNull($definition->getQuestion($key), sprintf('"%s" is not asked as "%s"', $label, $key));
$mapped[] = $key;
} else {
$this->assertContains($key, $contextFields, sprintf('"%s" is not snapshotted as "%s"', $label, $key));
}
}
}
// and nothing is asked that the Google form did not ask
$this->assertSame([], array_values(array_diff(array_keys($definition->getQuestions()), $mapped)));
}
}
@@ -72,8 +72,8 @@ class TestControllerTest extends WebTestCase
$this->assertResponseIsSuccessful();
$this->assertStringContainsString('Testmodus', $crawler->text());
// the assignment has no escort, so the questionnaire comes without the bus part
$this->assertStringContainsString('Ohne die Fragen zur Busfahrt', $crawler->text());
$this->assertCount(0, $crawler->filter('[data-question-key="bus_company"]'));
$this->assertStringContainsString('Ohne das Bus-Feedback', $crawler->text());
$this->assertCount(0, $crawler->filter('[data-question-key="bus_feedback.bus_company"]'));
$this->assertCount(1, $crawler->filter('form[data-controller="questionnaire"]'));
}
@@ -92,11 +92,11 @@ class TestControllerTest extends WebTestCase
{
$this->client->loginUser($this->user('ROLE_TEAM_ADMIN'));
$form = $this->open()->selectButton('Entwurf speichern')->form();
$form['questionnaire[improvement][wished_differently]'] = 'Mehr Schnee';
$form['questionnaire[teamer_feedback][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->assertSame('Mehr Schnee', $crawler->filter('textarea[name="questionnaire[teamer_feedback][improvement][wished_differently]"]')->text());
$this->assertNothingSaved();
}
@@ -104,7 +104,7 @@ class TestControllerTest extends WebTestCase
{
$this->client->loginUser($this->user('ROLE_TEAM_ADMIN'));
$form = $this->open()->selectButton('Absenden')->form();
$definition = self::getContainer()->get(QuestionnaireCatalog::class)->get(QuestionnaireCatalog::TEAMER_ASSIGNMENT)->forFlags([]);
$definition = self::getContainer()->get(QuestionnaireCatalog::class)->get(QuestionnaireCatalog::TEAMER_FEEDBACK);
foreach ($definition->sections as $section) {
foreach ($section->questions as $question) {
@@ -112,7 +112,7 @@ class TestControllerTest extends WebTestCase
continue;
}
$form[sprintf('questionnaire[%s][%s]', $section->key, $question->key)] = match ($question->type) {
$form[sprintf('questionnaire[%s][%s][%s]', $definition->key, $section->key, $question->key)] = match ($question->type) {
QuestionType::CHOICE => $question->options[0]->key,
QuestionType::RATING => '5',
default => 'Antwort',
@@ -132,8 +132,8 @@ class TestControllerTest extends WebTestCase
$this->client->loginUser($this->user('ROLE_TEAM_ADMIN'));
$crawler = $this->client->request('GET', sprintf('/administrative/questionnaire/test/%s?bus_escort=1', $this->assignment->getUuid()));
$this->assertStringContainsString('Mit den Fragen zur Busfahrt', $crawler->text());
$this->assertCount(1, $crawler->filter('[data-question-key="bus_company"]'));
$this->assertStringContainsString('Mit dem Bus-Feedback', $crawler->text());
$this->assertCount(1, $crawler->filter('[data-question-key="bus_feedback.bus_company"]'));
}
/**
@@ -0,0 +1,230 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Teamer\Questionnaire;
use App\Config\QuestionnaireCatalog;
use App\Entity\Application;
use App\Entity\Assignment;
use App\Entity\Destination;
use App\Entity\Disposition;
use App\Entity\JobProfile;
use App\Entity\QuestionnaireResponse;
use App\Entity\Teamer;
use App\Entity\User;
use App\Enum\QuestionnaireResponseStatus;
use App\Enum\QuestionType;
use App\RequiredTeamerCheck\RequiredTeamerCheckRegistry;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\DomCrawler\Crawler;
use Symfony\Component\DomCrawler\Form;
/**
* The teamer's page: one form, and as many responses as questionnaires apply - the teamer feedback
* for every placement, the bus feedback as well after a bus escort.
*
* Runs in a transaction that is rolled back; the client keeps its kernel so every request shares
* that connection. The profile checks that would otherwise send the teamer elsewhere first are
* stubbed out - they are not what is tested here.
*/
class EditControllerTest extends WebTestCase
{
private KernelBrowser $client;
private EntityManagerInterface $entityManager;
private Teamer $teamer;
protected function setUp(): void
{
$this->client = static::createClient(server: ['HTTPS' => true]);
$this->client->disableReboot();
self::getContainer()->set(RequiredTeamerCheckRegistry::class, $this->createStub(RequiredTeamerCheckRegistry::class));
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
$this->entityManager->getConnection()->beginTransaction();
$this->teamer = (new Teamer())->setFirstName('Kim')->setLastName('Muster')->setGender('d');
$user = (new User())
->setFirstName('Kim')
->setLastName('Muster')
->setEmail('[email protected]')
->setRoles(['ROLE_TEAMER'])
->setBusProAddressId(1)
->setBusProPersonId(1)
->setTeamer($this->teamer)
;
$this->teamer->setUser($user);
$this->entityManager->persist($this->teamer);
$this->entityManager->persist($user);
$this->entityManager->flush();
$this->client->loginUser($user);
}
protected function tearDown(): void
{
$this->entityManager->getConnection()->rollBack();
parent::tearDown();
}
public function testABusEscortGetsBothQuestionnairesOnOnePage(): void
{
$crawler = $this->open($this->placement(busEscort: true));
$this->assertResponseIsSuccessful();
$this->assertCount(1, $crawler->filter('[data-question-key="teamer_feedback.office_briefing_rating"]'));
$this->assertCount(1, $crawler->filter('[data-question-key="bus_feedback.bus_company"]'));
}
public function testAPlacementWithoutEscortGetsTheTeamerFeedbackOnly(): void
{
$crawler = $this->open($this->placement(busEscort: false));
$this->assertResponseIsSuccessful();
$this->assertCount(1, $crawler->filter('[data-question-key="teamer_feedback.office_briefing_rating"]'));
$this->assertCount(0, $crawler->filter('[data-question-key^="bus_feedback."]'));
}
public function testADraftSavesOneResponsePerQuestionnaire(): void
{
$disposition = $this->placement(busEscort: true);
$form = $this->open($disposition)->selectButton('Entwurf speichern')->form();
$form['questionnaire[bus_feedback][bus_team][driver_names]'] = 'Hans';
$this->client->submit($form);
$this->assertResponseRedirects(sprintf('/teamer/questionnaire/%s', $disposition->getUuid()));
$this->assertSame([
QuestionnaireCatalog::BUS_FEEDBACK => QuestionnaireResponseStatus::DRAFT,
QuestionnaireCatalog::TEAMER_FEEDBACK => QuestionnaireResponseStatus::DRAFT,
], $this->statuses($disposition));
// reopened, the draft is back in the form
$crawler = $this->open($disposition);
$this->assertSame('Hans', $crawler->filter('input[name="questionnaire[bus_feedback][bus_team][driver_names]"]')->attr('value'));
}
public function testOneSubmitSubmitsBoth(): void
{
$disposition = $this->placement(busEscort: true);
$form = $this->open($disposition)->selectButton('Absenden')->form();
$this->answerAll($form, [QuestionnaireCatalog::TEAMER_FEEDBACK, QuestionnaireCatalog::BUS_FEEDBACK]);
$this->client->submit($form);
$this->assertResponseRedirects(sprintf('/teamer/questionnaire/%s/detail', $disposition->getUuid()));
$this->assertSame([
QuestionnaireCatalog::BUS_FEEDBACK => QuestionnaireResponseStatus::SUBMITTED,
QuestionnaireCatalog::TEAMER_FEEDBACK => QuestionnaireResponseStatus::SUBMITTED,
], $this->statuses($disposition));
// the read-only view shows both, and the form is closed
$crawler = $this->client->followRedirect();
$this->assertStringContainsString('Reiseleiter/-in Feedback', $crawler->text());
$this->assertStringContainsString('Bus-Feedback', $crawler->text());
$this->open($disposition);
$this->assertResponseRedirects(sprintf('/teamer/questionnaire/%s/detail', $disposition->getUuid()));
}
/**
* Nothing is half-submitted: a bus question left open keeps both questionnaires unsubmitted.
*/
public function testAnIncompleteBusFeedbackSubmitsNeither(): void
{
$disposition = $this->placement(busEscort: true);
$form = $this->open($disposition)->selectButton('Absenden')->form();
$this->answerAll($form, [QuestionnaireCatalog::TEAMER_FEEDBACK]);
$this->client->submit($form);
$this->assertResponseStatusCodeSame(422);
$this->assertSame([], $this->statuses($disposition));
}
public function testWithoutEscortOneResponseIsSubmitted(): void
{
$disposition = $this->placement(busEscort: false);
$form = $this->open($disposition)->selectButton('Absenden')->form();
$this->answerAll($form, [QuestionnaireCatalog::TEAMER_FEEDBACK]);
$this->client->submit($form);
$this->assertSame([QuestionnaireCatalog::TEAMER_FEEDBACK => QuestionnaireResponseStatus::SUBMITTED], $this->statuses($disposition));
}
private function open(Disposition $disposition): Crawler
{
return $this->client->request('GET', sprintf('/teamer/questionnaire/%s', $disposition->getUuid()));
}
/**
* Answers every required question that is shown without a follow-up: first option, rating 5.
*
* @param list<string> $questionnaires
*/
private function answerAll(Form $form, array $questionnaires): void
{
$catalog = self::getContainer()->get(QuestionnaireCatalog::class);
foreach ($questionnaires as $key) {
foreach ($catalog->get($key)->sections as $section) {
foreach ($section->questions as $question) {
if (!$question->required || null !== $question->showIf) {
continue;
}
$form[sprintf('questionnaire[%s][%s][%s]', $key, $section->key, $question->key)] = match ($question->type) {
QuestionType::CHOICE => $question->options[0]->key,
QuestionType::RATING => '5',
default => 'Antwort',
};
}
}
}
}
/**
* @return array<string, QuestionnaireResponseStatus> keyed by questionnaire, sorted
*/
private function statuses(Disposition $disposition): array
{
$this->entityManager->clear();
$statuses = [];
foreach ($this->entityManager->getRepository(QuestionnaireResponse::class)->findBy(['disposition' => $disposition->getId()]) as $response) {
$statuses[$response->getQuestionnaire()] = $response->getStatus();
}
ksort($statuses);
return $statuses;
}
private function placement(bool $busEscort): Disposition
{
$destination = (new Destination())
->setProduct('Davos Klosters - Schwendi')
->setHotel('Schwendi')
->setHotelCode('DKI')
->setHotelBusProId(1)
->setDateFrom(new \DateTimeImmutable('-8 days'))
->setDateTo(new \DateTimeImmutable('yesterday'))
->setPickups([['busProId' => 4711, 'city' => 'Stuttgart', 'street' => 'Busbahnhof', 'time' => '21:30']])
;
$jobProfile = (new JobProfile())->setName('Reiseleitung')->setCategory(JobProfile::CATEGORY_GUIDE);
$assignment = (new Assignment())
->setDestination($destination)
->setJobProfile($jobProfile)
->setPickup($busEscort ? 4711 : 0)
->setStatus(Assignment::STATUS_STAFFED)
;
$disposition = (new Disposition(new Application($assignment, $this->teamer)))->setStatus(Disposition::STATUS_ENDED);
foreach ([$destination, $jobProfile, $assignment, $disposition] as $entity) {
$this->entityManager->persist($entity);
}
$this->entityManager->flush();
return $disposition;
}
}
+122 -91
View File
@@ -14,11 +14,11 @@ use App\Model\Questionnaire\Section;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
/**
* Runs against the shipped teamer_assignment definition, so a definition that parses but cannot be
* turned into a working form fails here.
* Runs against the shipped definitions - teamer feedback alone, and with the bus feedback on the
* same page as a bus escort gets them - so a definition that parses but cannot be turned into a
* working form fails here.
*
* Every submit goes through Form::submit() with clearMissing, as a browser request does: an
* unchecked radio group is simply absent from the request, and without clearMissing it would keep
@@ -28,7 +28,7 @@ class QuestionnaireTypeTest extends KernelTestCase
{
public function testEmptyDraftIsValid(): void
{
$form = $this->createForm($this->teamerAssignment());
$form = $this->createForm($this->both());
$form->submit([QuestionnaireType::DRAFT => '']);
$this->assertTrue($form->isValid());
@@ -40,85 +40,92 @@ class QuestionnaireTypeTest extends KernelTestCase
*/
public function testFormSentWithoutAButtonIsADraft(): void
{
$form = $this->createForm($this->teamerAssignment());
$form = $this->createForm($this->both());
$form->submit([]);
$this->assertTrue($form->isValid());
$this->assertFalse(QuestionnaireType::isFinal($form));
}
public function testCompleteSubmitIsValidAndFlat(): void
public function testCompleteSubmitIsValidAndKeyedByQuestionnaire(): void
{
$definition = $this->teamerAssignment();
$form = $this->createForm($definition);
$form->submit($this->complete($definition) + [QuestionnaireType::SUBMIT => '']);
$definitions = $this->both();
$form = $this->createForm($definitions);
$form->submit($this->complete($definitions) + [QuestionnaireType::SUBMIT => '']);
$this->assertTrue($form->isValid(), (string) $form->getErrors(true));
$this->assertTrue(QuestionnaireType::isFinal($form));
$answers = $form->getData();
// keyed by question, not by section, and a rating comes back as an int
$this->assertSame(5, $answers['office_briefing_rating']);
$this->assertSame('very_helpful', $answers['team_week_seminar']);
$this->assertArrayNotHasKey('organisation', $answers);
}
public function testEmptySubmitDemandsTheVisibleRequiredQuestionsOnly(): void
{
$definition = $this->teamerAssignment();
$form = $this->createForm($definition);
$form->submit([QuestionnaireType::SUBMIT => '']);
$this->assertFalse($form->isValid());
$this->assertCount(1, $this->field($form, 'organisation', 'office_briefing_rating')->getErrors());
$this->assertCount(1, $this->field($form, 'bus_equipment', 'wc')->getErrors());
// required, but shown only after a rating of 3 or less, or for another bus company
$this->assertCount(0, $this->field($form, 'organisation', 'office_briefing_low_reason')->getErrors());
$this->assertCount(0, $this->field($form, 'bus_trip', 'bus_company_other')->getErrors());
// optional
$this->assertCount(0, $this->field($form, 'communication', 'support_wished')->getErrors());
$data = $form->getData();
$this->assertSame([QuestionnaireCatalog::TEAMER_FEEDBACK, QuestionnaireCatalog::BUS_FEEDBACK], array_keys($data));
// keyed by question within each questionnaire, not by section, and a rating is an int
$this->assertSame(5, $data[QuestionnaireCatalog::TEAMER_FEEDBACK]['office_briefing_rating']);
$this->assertSame('outbound', $data[QuestionnaireCatalog::BUS_FEEDBACK]['direction']);
$this->assertArrayNotHasKey('organisation', $data[QuestionnaireCatalog::TEAMER_FEEDBACK]);
}
/**
* A placement without bus escort gets the questionnaire without the bus sections, and can
* submit it without them.
* Every required question of either questionnaire is demanded, as far as it is shown.
*/
public function testWithoutBusEscortTheBusPartIsNeitherShownNorDemanded(): void
public function testEmptySubmitDemandsTheVisibleRequiredQuestionsOfBoth(): void
{
$definition = $this->teamerAssignment()->forFlags([]);
$form = $this->createForm($definition);
$form = $this->createForm($this->both());
$form->submit([QuestionnaireType::SUBMIT => '']);
$this->assertFalse($form->has('bus_trip'));
$this->assertFalse($form->isValid());
$form->submit($this->complete($definition) + [QuestionnaireType::SUBMIT => '']);
foreach ($this->both() as $definition) {
foreach ($definition->sections as $section) {
foreach ($section->questions as $question) {
$this->assertCount(
$question->required && null === $question->showIf ? 1 : 0,
$form->get($definition->key)->get($section->key)->get($question->key)->getErrors(),
$definition->key.'.'.$question->key,
);
}
}
}
}
/**
* A placement without bus escort gets the teamer feedback only, and can submit it alone.
*/
public function testTheTeamerFeedbackAloneCanBeSubmitted(): void
{
$definitions = [$this->teamerFeedback()];
$form = $this->createForm($definitions);
$this->assertFalse($form->has(QuestionnaireCatalog::BUS_FEEDBACK));
$form->submit($this->complete($definitions) + [QuestionnaireType::SUBMIT => '']);
$this->assertTrue($form->isValid(), (string) $form->getErrors(true));
}
public function testLowRatingDemandsAReason(): void
{
$definition = $this->teamerAssignment();
$data = $this->complete($definition);
$data['organisation']['office_briefing_rating'] = '3';
$definitions = $this->both();
$data = $this->complete($definitions);
$data[QuestionnaireCatalog::TEAMER_FEEDBACK]['organisation']['office_briefing_rating'] = '3';
$form = $this->createForm($definition);
$form = $this->createForm($definitions);
$form->submit($data + [QuestionnaireType::SUBMIT => '']);
$this->assertFalse($form->isValid());
$this->assertCount(1, $this->field($form, 'organisation', 'office_briefing_low_reason')->getErrors());
$this->assertCount(1, $form->get(QuestionnaireCatalog::TEAMER_FEEDBACK)->get('organisation')->get('office_briefing_low_reason')->getErrors());
$this->assertCount(1, $form->getErrors(true));
}
public function testOtherBusCompanyDemandsItsName(): void
{
$definition = $this->teamerAssignment();
$data = $this->complete($definition);
$data['bus_trip']['bus_company'] = 'other';
$definitions = $this->both();
$data = $this->complete($definitions);
$data[QuestionnaireCatalog::BUS_FEEDBACK]['bus_trip']['bus_company'] = 'other';
$form = $this->createForm($definition);
$form = $this->createForm($definitions);
$form->submit($data + [QuestionnaireType::SUBMIT => '']);
$this->assertCount(1, $this->field($form, 'bus_trip', 'bus_company_other')->getErrors());
$this->assertCount(1, $form->get(QuestionnaireCatalog::BUS_FEEDBACK)->get('bus_trip')->get('bus_company_other')->getErrors());
}
/**
@@ -127,25 +134,37 @@ class QuestionnaireTypeTest extends KernelTestCase
*/
public function testNoFieldIsHtmlRequired(): void
{
$view = $this->createForm($this->teamerAssignment())->createView();
$view = $this->createForm($this->both())->createView();
foreach ($view as $section) {
foreach ($section as $field) {
$this->assertFalse($field->vars['required'], $field->vars['full_name']);
foreach ($view as $questionnaire) {
foreach ($questionnaire as $section) {
foreach ($section as $field) {
$this->assertFalse($field->vars['required'], $field->vars['full_name']);
}
}
}
}
/**
* Question keys are only unique within one questionnaire, so the browser sees them prefixed.
*/
public function testConditionalQuestionCarriesItsConditionForTheBrowser(): void
{
$view = $this->createForm($this->teamerAssignment())->createView();
$view = $this->createForm($this->both())->createView();
$row = $this->view($view, 'organisation', 'office_briefing_low_reason');
$row = $view->children[QuestionnaireCatalog::TEAMER_FEEDBACK]->children['organisation']->children['office_briefing_low_reason'];
$this->assertSame('teamer_feedback.office_briefing_low_reason', $row->vars['row_attr']['data-question-key']);
$this->assertSame(
['question' => 'office_briefing_rating', 'operator' => 'lte', 'value' => 3],
['question' => 'teamer_feedback.office_briefing_rating', 'operator' => 'lte', 'value' => 3],
json_decode($row->vars['row_attr']['data-show-if'], true),
);
$this->assertArrayNotHasKey('data-show-if', $this->view($view, 'organisation', 'office_briefing_rating')->vars['row_attr']);
}
public function testTheSameQuestionnaireCannotBeAddedTwice(): void
{
$this->expectException(\Symfony\Component\OptionsResolver\Exception\InvalidOptionsException::class);
$this->createForm([$this->teamerFeedback(), $this->teamerFeedback()]);
}
public function testMultiAndDateAnswersRoundTrip(): void
@@ -160,50 +179,69 @@ class QuestionnaireTypeTest extends KernelTestCase
]),
]);
$form = $this->createForm($definition, ['extras' => ['ski_box'], 'travel_date' => '2026-01-10']);
$form = $this->createForm([$definition], ['sample' => ['extras' => ['ski_box'], 'travel_date' => '2026-01-10']]);
$view = $form->createView();
$this->assertTrue($this->view($view, 'main', 'extras')->children[1]->vars['checked']);
$main = $view->children['sample']->children['main'];
$this->assertTrue($main->children['extras']->children[1]->vars['checked']);
$form = $this->createForm($definition);
$form = $this->createForm([$definition]);
$form->submit([
'main' => ['extras' => ['trailer', 'ski_box'], 'travel_date' => $this->view($view, 'main', 'travel_date')->vars['value']],
'sample' => ['main' => ['extras' => ['trailer', 'ski_box'], 'travel_date' => $main->children['travel_date']->vars['value']]],
QuestionnaireType::SUBMIT => '',
]);
$this->assertTrue($form->isValid(), (string) $form->getErrors(true));
$this->assertSame(['trailer', 'ski_box'], $form->getData()['extras']);
$this->assertSame('2026-01-10', $form->getData()['travel_date']);
$this->assertSame(['trailer', 'ski_box'], $form->getData()['sample']['extras']);
$this->assertSame('2026-01-10', $form->getData()['sample']['travel_date']);
}
private function teamerAssignment(): Definition
private function teamerFeedback(): Definition
{
return new QuestionnaireCatalog(__DIR__.'/../../config/questionnaires')->get('teamer_assignment');
return $this->catalog()->get(QuestionnaireCatalog::TEAMER_FEEDBACK);
}
/**
* @return list<Definition> what a bus escort gets
*/
private function both(): array
{
return [$this->teamerFeedback(), $this->catalog()->get(QuestionnaireCatalog::BUS_FEEDBACK)];
}
private function catalog(): QuestionnaireCatalog
{
return new QuestionnaireCatalog(__DIR__.'/../../config/questionnaires');
}
/**
* A submission answering every question that is shown when each choice takes its first option
* and each rating is 5.
*
* @return array<string, array<string, mixed>>
* @param list<Definition> $definitions
*
* @return array<string, array<string, array<string, mixed>>>
*/
private function complete(Definition $definition): array
private function complete(array $definitions): array
{
$data = [];
$answers = [];
foreach ($definition->sections as $section) {
foreach ($section->questions as $question) {
$answer = match ($question->type) {
QuestionType::CHOICE => $question->options[0]->key,
QuestionType::MULTI => [$question->options[0]->key],
QuestionType::RATING => 5,
QuestionType::DATE => '01.01.2026',
QuestionType::TEXT, QuestionType::TEXTAREA => 'Antwort',
};
$answers[$question->key] = $answer;
foreach ($definitions as $definition) {
$answers = [];
if ($definition->isVisible($question, $answers)) {
$data[$section->key][$question->key] = is_int($answer) ? (string) $answer : $answer;
foreach ($definition->sections as $section) {
foreach ($section->questions as $question) {
$answer = match ($question->type) {
QuestionType::CHOICE => $question->options[0]->key,
QuestionType::MULTI => [$question->options[0]->key],
QuestionType::RATING => 5,
QuestionType::DATE => '01.01.2026',
QuestionType::TEXT, QuestionType::TEXTAREA => 'Antwort',
};
$answers[$question->key] = $answer;
if ($definition->isVisible($question, $answers)) {
$data[$definition->key][$section->key][$question->key] = is_int($answer) ? (string) $answer : $answer;
}
}
}
}
@@ -211,26 +249,19 @@ class QuestionnaireTypeTest extends KernelTestCase
return $data;
}
private function createForm(Definition $definition, array $answers = []): FormInterface
/**
* @param list<Definition> $definitions
*/
private function createForm(array $definitions, array $data = []): FormInterface
{
self::bootKernel();
/** @var FormFactoryInterface $formFactory */
$formFactory = self::getContainer()->get(FormFactoryInterface::class);
return $formFactory->create(QuestionnaireType::class, $answers, [
'definition' => $definition,
return $formFactory->create(QuestionnaireType::class, $data, [
'definitions' => $definitions,
'csrf_protection' => false,
]);
}
private function field(FormInterface $form, string $section, string $question): FormInterface
{
return $form->get($section)->get($question);
}
private function view(FormView $view, string $section, string $question): FormView
{
return $view->children[$section]->children[$question];
}
}
@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Tests\Model\Questionnaire;
use App\Enum\ConditionOperator;
use App\Enum\QuestionnaireFlag;
use App\Enum\QuestionType;
use App\Model\Questionnaire\Condition;
use App\Model\Questionnaire\Definition;
@@ -45,19 +44,6 @@ class DefinitionTest extends TestCase
$this->assertFalse($definition->isVisible($details, ['damage' => 'no', 'claim' => 'expected']));
}
public function testForFlagsDropsTheSectionsOfFlagsTheSubjectLacks(): void
{
$definition = new Definition('sample', 2, 'Beispiel', null, [
new Section('everyone', null, [new Question('rating', 'Wie war es?', QuestionType::RATING)]),
new Section('bus', null, [new Question('bus', 'Wie war der Bus?', QuestionType::RATING)], QuestionnaireFlag::BUS_ESCORT),
]);
$this->assertSame(['rating'], array_keys($definition->forFlags([])->getQuestions()));
$this->assertSame(['rating', 'bus'], array_keys($definition->forFlags([QuestionnaireFlag::BUS_ESCORT])->getQuestions()));
// still the same questionnaire, so answers to it are answers to this definition
$this->assertSame(2, $definition->forFlags([])->version);
}
public function testWithoutHiddenAnswersDropsHiddenEmptyAndUnknownAnswers(): void
{
$answers = [
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Tests\Repository;
use App\Config\QuestionnaireCatalog;
use App\Entity\Assignment;
use App\Entity\Disposition;
use App\Entity\Teamer;
@@ -64,25 +65,56 @@ class DispositionRepositoryQuestionnaireTest extends KernelTestCase
$this->assertSame([['disposition' => $disposition, 'draft' => true]], $this->pending());
}
public function testASubmittedQuestionnaireIsNoLongerPending(): void
public function testAnEscortWithBothQuestionnairesSubmittedIsNoLongerPending(): void
{
$this->respond($this->escort(), submit: true);
$disposition = $this->escort();
$this->respond($disposition, submit: true);
$this->respond($disposition, submit: true, questionnaire: QuestionnaireCatalog::BUS_FEEDBACK);
$this->assertSame([], $this->pending());
}
/**
* The questionnaire key belongs in the JOIN condition: in the WHERE clause, a submitted
* response to some other questionnaire would hide this one.
* A bus escort owes both questionnaires; the teamer feedback alone does not settle it.
*/
public function testAResponseToAnotherQuestionnaireDoesNotCount(): void
public function testAnEscortWithoutTheBusFeedbackIsStillPending(): void
{
$disposition = $this->escort();
$this->respond($disposition, submit: true, questionnaire: 'other');
$this->respond($disposition, submit: true);
$this->assertSame([['disposition' => $disposition, 'draft' => false]], $this->pending());
}
public function testAnEscortWithABusFeedbackDraftSaysDraft(): void
{
$disposition = $this->escort();
$this->respond($disposition, submit: true);
$this->respond($disposition, submit: false, questionnaire: QuestionnaireCatalog::BUS_FEEDBACK);
$this->assertSame([['disposition' => $disposition, 'draft' => true]], $this->pending());
}
/**
* Each questionnaire key belongs in its JOIN condition: in the WHERE clause, the response to
* one questionnaire would stand in for the other's.
*/
public function testTheBusFeedbackDoesNotStandInForTheTeamerFeedback(): void
{
$disposition = $this->escort();
$this->respond($disposition, submit: true, questionnaire: QuestionnaireCatalog::BUS_FEEDBACK);
$this->assertSame([['disposition' => $disposition, 'draft' => false]], $this->pending());
}
public function testAPlacementWithoutBusEscortOnlyOwesTheTeamerFeedback(): void
{
$disposition = $this->escort();
$disposition->getAssignment()->setPickup(0);
$this->respond($disposition, submit: true);
$this->assertSame([], $this->pending());
}
public function testAPlacementWithoutBusEscortIsPendingToo(): void
{
$disposition = $this->escort();
@@ -50,7 +50,7 @@ trait QuestionnaireFixturesTrait
return $disposition;
}
private function respond(Disposition $disposition, bool $submit, string $questionnaire = QuestionnaireCatalog::TEAMER_ASSIGNMENT): QuestionnaireResponse
private function respond(Disposition $disposition, bool $submit, string $questionnaire = QuestionnaireCatalog::TEAMER_FEEDBACK): QuestionnaireResponse
{
$definition = new Definition($questionnaire, 1, 'Beispiel', null, []);
$response = QuestionnaireResponse::forDisposition($definition, $disposition);
@@ -94,6 +94,6 @@ class QuestionnaireResponseRepositoryTest extends KernelTestCase
{
$this->entityManager->flush();
return $this->repository->getListQuery(QuestionnaireCatalog::TEAMER_ASSIGNMENT, $filter)->getResult();
return $this->repository->getListQuery(QuestionnaireCatalog::TEAMER_FEEDBACK, $filter)->getResult();
}
}
@@ -4,13 +4,13 @@ declare(strict_types=1);
namespace App\Tests\Service\Questionnaire;
use App\Config\QuestionnaireCatalog;
use App\Entity\Application;
use App\Entity\Assignment;
use App\Entity\Destination;
use App\Entity\Disposition;
use App\Entity\JobProfile;
use App\Entity\Teamer;
use App\Enum\QuestionnaireFlag;
use App\Service\Questionnaire\DispositionContext;
use PHPUnit\Framework\TestCase;
@@ -43,10 +43,16 @@ class DispositionContextTest extends TestCase
$this->assertNull($context['escortPickupDate']);
}
public function testOnlyABusEscortGetsTheBusSections(): void
public function testEveryoneGetsTheTeamerFeedbackAndABusEscortTheBusFeedbackToo(): void
{
$this->assertSame([QuestionnaireFlag::BUS_ESCORT], DispositionContext::flags($this->disposition(pickup: 4711)->getAssignment()));
$this->assertSame([], DispositionContext::flags($this->disposition(pickup: 0)->getAssignment()));
$this->assertSame(
[QuestionnaireCatalog::TEAMER_FEEDBACK, QuestionnaireCatalog::BUS_FEEDBACK],
DispositionContext::questionnaires($this->disposition(pickup: 4711)->getAssignment()),
);
$this->assertSame(
[QuestionnaireCatalog::TEAMER_FEEDBACK],
DispositionContext::questionnaires($this->disposition(pickup: 0)->getAssignment()),
);
}
private function disposition(int $pickup): Disposition
+47 -15
View File
@@ -28,10 +28,13 @@ class QuestionnaireTemplateTest extends KernelTestCase
{
$crawler = $this->renderForm();
$this->assertCount(count($this->definition()->getQuestions()), $crawler->filter('[data-question-key]'));
$this->assertCount(
count($this->definition()->getQuestions()) + count($this->busDefinition()->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'),
'{"question":"bus_feedback.bus_company","operator":"equals","value":"other"}',
$crawler->filter('[data-question-key="bus_feedback.bus_company_other"]')->attr('data-show-if'),
);
}
@@ -40,12 +43,25 @@ class QuestionnaireTemplateTest extends KernelTestCase
$crawler = $this->renderForm();
$this->assertSame('Organisation & Ablauf', trim($crawler->filter('fieldset legend')->first()->text()));
$this->assertCount(count($this->definition()->sections), $crawler->filter('fieldset'));
$this->assertCount(
count($this->definition()->sections) + count($this->busDefinition()->sections),
$crawler->filter('fieldset'),
);
}
/**
* On one page, each questionnaire keeps its own heading.
*/
public function testEachQuestionnaireHasItsHeading(): void
{
$headings = $this->renderForm()->filter('section > h2')->each(static fn (Crawler $h2): string => trim($h2->text()));
$this->assertSame([$this->definition()->title, $this->busDefinition()->title], $headings);
}
public function testRatingIsFiveRadiosInARadioGroup(): void
{
$group = $this->renderForm()->filter('[data-question-key="office_briefing_rating"] [role="radiogroup"]');
$group = $this->renderForm()->filter('[data-question-key="teamer_feedback.office_briefing_rating"] [role="radiogroup"]');
$this->assertCount(5, $group->filter('input[type="radio"]'));
}
@@ -57,23 +73,24 @@ class QuestionnaireTemplateTest extends KernelTestCase
->submit($definition, [
'office_briefing_rating' => 2,
'office_briefing_low_reason' => 'Briefing kam zu spät',
'bus_company' => 'heess',
'had_organisational_problems' => 'no',
'organisational_problems' => 'hidden, so dropped',
], [], new \DateTimeImmutable())
;
$html = self::getContainer()->get(Environment::class)->render('questionnaire/_answers.html.twig', [
'definition' => $definition,
'answers' => $response->getAnswers(),
]);
$html = $this->renderAnswers($definition, $response->getAnswers());
$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);
$this->assertStringNotContainsString('hidden, so dropped', $html);
// nothing was answered there, so the section is left out
$this->assertStringNotContainsString('Bewertung Bus &amp; Fahrer', $html);
$this->assertStringNotContainsString('Kommunikation &amp; Zusammenarbeit', $html);
$html = $this->renderAnswers($this->busDefinition(), ['bus_company' => 'heess']);
$this->assertStringContainsString('Heeß', $html);
$this->assertStringNotContainsString('heess', $html);
}
private function renderForm(): Crawler
@@ -82,7 +99,7 @@ class QuestionnaireTemplateTest extends KernelTestCase
$container = self::getContainer();
$form = $container->get(FormFactoryInterface::class)->create(QuestionnaireType::class, [], [
'definition' => $this->definition(),
'definitions' => [$this->definition(), $this->busDefinition()],
'csrf_protection' => false,
]);
@@ -94,8 +111,23 @@ class QuestionnaireTemplateTest extends KernelTestCase
return new Crawler($html);
}
private function renderAnswers(Definition $definition, array $answers): string
{
self::bootKernel();
return self::getContainer()->get(Environment::class)->render('questionnaire/_answers.html.twig', [
'definition' => $definition,
'answers' => $answers,
]);
}
private function definition(): Definition
{
return new QuestionnaireCatalog(__DIR__.'/../../config/questionnaires')->get(QuestionnaireCatalog::TEAMER_ASSIGNMENT);
return new QuestionnaireCatalog(__DIR__.'/../../config/questionnaires')->get(QuestionnaireCatalog::TEAMER_FEEDBACK);
}
private function busDefinition(): Definition
{
return new QuestionnaireCatalog(__DIR__.'/../../config/questionnaires')->get(QuestionnaireCatalog::BUS_FEEDBACK);
}
}