diff --git a/config/questionnaires/teamer_escort.yaml b/config/questionnaires/teamer_assignment.yaml similarity index 93% rename from config/questionnaires/teamer_escort.yaml rename to config/questionnaires/teamer_assignment.yaml index 4a21264..c1ec85a 100644 --- a/config/questionnaires/teamer_escort.yaml +++ b/config/questionnaires/teamer_assignment.yaml @@ -1,21 +1,24 @@ -# Questionnaire a teamer fills in after an assignment with bus escort. +# Questionnaire a teamer fills in after a placement (Disposition, not SeasonAssignment). # # 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/. 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. +# 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. # # 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_escort.v1.yaml first, then bump `version` here. Submitted answers are keyed by it. +# teamer_assignment.v1.yaml first, then bump `version` here. Submitted answers are keyed by 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_escort +key: teamer_assignment version: 1 title: "Reiseleiter/-in Feedback" description: |- @@ -135,6 +138,7 @@ sections: - key: bus_trip title: "Busfahrt" + only_for: bus_escort questions: - key: direction label: "Hin- oder Rückfahrt" @@ -163,6 +167,7 @@ sections: - key: bus_team title: "Busteam" + only_for: bus_escort questions: - key: driver_names label: "Busfahrer-Namen:" @@ -171,6 +176,7 @@ 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?" @@ -188,6 +194,7 @@ sections: - key: bus_rating title: "Bewertung Bus & Fahrer" + only_for: bus_escort questions: - key: overall_impression label: "Gesamteindruck des Busses" @@ -268,6 +275,7 @@ sections: - key: bus_pickups title: "Ladestellen" + only_for: bus_escort questions: - key: drivers_knew_pickups label: "Waren den Fahrern alle Abfahrtsorte bekannt?" @@ -285,6 +293,7 @@ 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?" @@ -296,6 +305,7 @@ sections: - key: bus_equipment title: "Bus, Technik & Allgemeines" + only_for: bus_escort questions: - key: video label: "Video/DVD-Gerät" @@ -345,6 +355,7 @@ 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?" @@ -363,6 +374,7 @@ sections: - key: bus_remarks title: "Anmerkungen zur Busfahrt" + only_for: bus_escort questions: - key: bus_remarks label: "Das ging gar nicht / ist besonders hervorzuheben:" diff --git a/config/services.yaml b/config/services.yaml index 2bc74dd..7502b52 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -90,9 +90,9 @@ parameters: season_start: '11-01' season_end: '04-01' - # The teamer questionnaire after a bus escort (config/questionnaires/teamer_escort.yaml) is - # offered for assignments ending on or after this day (Y-m-d). Set it to the deployment day: - # without a cutoff every past escort would show up as pending on the teamers' dashboards. + # The teamer questionnaire after a placement (config/questionnaires/teamer_assignment.yaml) is + # offered for placements ending on or after this day (Y-m-d). Set it to the deployment day: + # without a cutoff every past placement would show up as pending on the teamers' dashboards. # null offers it to nobody. questionnaire_go_live: ~ diff --git a/migrations/Version20261027140000.php b/migrations/Version20261027140000.php index 498e151..0340ebe 100644 --- a/migrations/Version20261027140000.php +++ b/migrations/Version20261027140000.php @@ -9,7 +9,7 @@ use Doctrine\Migrations\AbstractMigration; /** * Adds questionnaire_response: a respondent's answers to one questionnaire from - * config/questionnaires/, for now the teamer questionnaire after a bus-escort disposition. + * config/questionnaires/, for now the teamer questionnaire after a placement. * * Written by hand rather than taken from doctrine:migrations:diff, which also carries the * pre-existing drift of the dev database. diff --git a/src/Config/QuestionnaireCatalog.php b/src/Config/QuestionnaireCatalog.php index 7a83949..6e9ca17 100644 --- a/src/Config/QuestionnaireCatalog.php +++ b/src/Config/QuestionnaireCatalog.php @@ -4,6 +4,7 @@ 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; @@ -26,12 +27,12 @@ use Symfony\Component\Yaml\Yaml; class QuestionnaireCatalog { /** - * The questionnaire a teamer fills in after a bus escort; see DispositionVoter::QUESTIONNAIRE. + * The questionnaire a teamer fills in after a placement; see DispositionVoter::QUESTIONNAIRE. */ - public const string TEAMER_ESCORT = 'teamer_escort'; + public const string TEAMER_ASSIGNMENT = 'teamer_assignment'; private const array ROOT_FIELDS = ['key', 'version', 'title', 'description', 'sections']; - private const array SECTION_FIELDS = ['key', 'title', 'questions']; + private const array SECTION_FIELDS = ['key', 'title', 'only_for', 'questions']; private const array QUESTION_FIELDS = ['key', 'label', 'type', 'required', 'help', 'widget', 'options', 'show_if']; private const array OPTION_FIELDS = ['key', 'label']; @@ -53,7 +54,7 @@ class QuestionnaireCatalog /** * The services.yaml parameter questionnaire_go_live as a date: the teamer questionnaire is - * offered for assignments ending on or after it, and to nobody while it is null. + * offered for placements ending on or after it, and to nobody while it is null. */ public static function parseGoLive(?string $goLive): ?\DateTimeImmutable { @@ -129,6 +130,8 @@ class QuestionnaireCatalog $sectionKeys = []; /** @var array $questions every question parsed so far, for show_if */ $questions = []; + /** @var array $questionFlags the only_for of each question's section */ + $questionFlags = []; foreach ($data['sections'] as $index => $sectionData) { $context = sprintf('%s: section #%d', $file, $index + 1); @@ -146,15 +149,38 @@ 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); + $sections[] = new Section( + $key, + $this->optionalString($sectionData['title'] ?? null, 'title', $context), + $sectionQuestions, + $onlyFor, + ); } return new Definition( diff --git a/src/Controller/Administrative/Questionnaire/IndexController.php b/src/Controller/Administrative/Questionnaire/IndexController.php index ed48633..f83c186 100644 --- a/src/Controller/Administrative/Questionnaire/IndexController.php +++ b/src/Controller/Administrative/Questionnaire/IndexController.php @@ -33,7 +33,7 @@ class IndexController extends AbstractController $filterDto = $this->filterHandler->getFilterSettings(); $pagination = $this->paginator->paginate( - $this->responseRepository->getListQuery(QuestionnaireCatalog::TEAMER_ESCORT, $filterDto), + $this->responseRepository->getListQuery(QuestionnaireCatalog::TEAMER_ASSIGNMENT, $filterDto), $request->query->getInt('page', 1), $request->query->getInt('limit', 50), [ @@ -43,7 +43,7 @@ class IndexController extends AbstractController ); return $this->render('administrative/questionnaire/index.html.twig', [ - 'definition' => $this->catalog->get(QuestionnaireCatalog::TEAMER_ESCORT), + 'definition' => $this->catalog->get(QuestionnaireCatalog::TEAMER_ASSIGNMENT), 'pagination' => $pagination, 'filterDto' => $filterDto, ]); diff --git a/src/Controller/Administrative/Questionnaire/TestController.php b/src/Controller/Administrative/Questionnaire/TestController.php index b7b53bf..752c67f 100644 --- a/src/Controller/Administrative/Questionnaire/TestController.php +++ b/src/Controller/Administrative/Questionnaire/TestController.php @@ -5,6 +5,7 @@ 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 Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -14,9 +15,10 @@ use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\IsGranted; /** - * The teamer questionnaire as a teamer gets it, for trying out on any assignment - with or without - * bus escort, before or after the trip, placed or not. Nothing is saved: "Absenden" shows the - * answers as they would be stored, "Entwurf speichern" just keeps them in the form. + * 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. */ class TestController extends AbstractController { @@ -34,7 +36,13 @@ class TestController extends AbstractController Assignment $assignment, Request $request, ): Response { - $definition = $this->catalog->get(QuestionnaireCatalog::TEAMER_ESCORT); + $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] : []) + ; $form = $this->createForm(QuestionnaireType::class, [], ['definition' => $definition]); $form->handleRequest($request); @@ -56,6 +64,7 @@ class TestController extends AbstractController 'form' => $form, 'submitted' => $submitted, 'draft' => $draft, + 'busEscort' => $busEscort, 'returnUrl' => $this->getReturnUrl($request, 'app_administrative_assignment_detail', ['uuid' => $assignment->getUuid()]), ]); } diff --git a/src/Controller/Teamer/Questionnaire/DetailController.php b/src/Controller/Teamer/Questionnaire/DetailController.php index a8fdd3a..473b920 100644 --- a/src/Controller/Teamer/Questionnaire/DetailController.php +++ b/src/Controller/Teamer/Questionnaire/DetailController.php @@ -30,7 +30,7 @@ class DetailController extends AbstractController #[MapEntity(mapping: ['uuid' => 'uuid'])] Disposition $disposition, ): Response { - $response = $this->responseRepository->findOneByDisposition(QuestionnaireCatalog::TEAMER_ESCORT, $disposition); + $response = $this->responseRepository->findOneByDisposition(QuestionnaireCatalog::TEAMER_ASSIGNMENT, $disposition); if (true !== $response?->isSubmitted()) { return $this->redirectToRoute('app_teamer_questionnaire_edit', ['uuid' => $disposition->getUuid()]); diff --git a/src/Controller/Teamer/Questionnaire/EditController.php b/src/Controller/Teamer/Questionnaire/EditController.php index a9dc30f..ce9d39f 100644 --- a/src/Controller/Teamer/Questionnaire/EditController.php +++ b/src/Controller/Teamer/Questionnaire/EditController.php @@ -18,8 +18,9 @@ use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\IsGranted; /** - * The teamer's questionnaire after a bus escort, saved as a draft until it is submitted. A - * submitted one is final and only shown read-only (DetailController). + * 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 + * (DetailController). */ class EditController extends AbstractController { @@ -39,13 +40,16 @@ class EditController extends AbstractController Disposition $disposition, Request $request, ): Response { - $response = $this->responseRepository->findOneByDisposition(QuestionnaireCatalog::TEAMER_ESCORT, $disposition); + $response = $this->responseRepository->findOneByDisposition(QuestionnaireCatalog::TEAMER_ASSIGNMENT, $disposition); if (true === $response?->isSubmitted()) { return $this->redirectToRoute('app_teamer_questionnaire_detail', ['uuid' => $disposition->getUuid()]); } - $definition = $this->catalog->get(QuestionnaireCatalog::TEAMER_ESCORT); + $definition = $this->catalog + ->get(QuestionnaireCatalog::TEAMER_ASSIGNMENT) + ->forFlags(DispositionContext::flags($disposition->getAssignment())) + ; $form = $this->createForm(QuestionnaireType::class, $response?->getAnswers() ?? [], [ 'definition' => $definition, diff --git a/src/Enum/QuestionnaireFlag.php b/src/Enum/QuestionnaireFlag.php new file mode 100644 index 0000000..eca4ec4 --- /dev/null +++ b/src/Enum/QuestionnaireFlag.php @@ -0,0 +1,13 @@ +addChild('Fragebögen', [ 'route' => 'app_administrative_questionnaire_index', 'linkAttributes' => [ - 'title' => 'Fragebögen der Busbegleitungen', + 'title' => 'Fragebögen nach Einsätzen', ], 'extras' => [ - 'icon' => 'bus', + 'icon' => 'document', 'routes' => [ ['pattern' => '/^app_administrative_questionnaire_/'], ], diff --git a/src/Menu/ManagerMenuBuilder.php b/src/Menu/ManagerMenuBuilder.php index d9f9218..c55b01c 100644 --- a/src/Menu/ManagerMenuBuilder.php +++ b/src/Menu/ManagerMenuBuilder.php @@ -121,10 +121,10 @@ class ManagerMenuBuilder extends AbstractMenuBuilder $menu->addChild('Fragebögen', [ 'route' => 'app_administrative_questionnaire_index', 'linkAttributes' => [ - 'title' => 'Fragebögen der Busbegleitungen', + 'title' => 'Fragebögen nach Einsätzen', ], 'extras' => [ - 'icon' => 'bus', + 'icon' => 'document', 'routes' => [ ['pattern' => '/^app_administrative_questionnaire_/'], ], diff --git a/src/Model/Questionnaire/Definition.php b/src/Model/Questionnaire/Definition.php index efeec31..8359171 100644 --- a/src/Model/Questionnaire/Definition.php +++ b/src/Model/Questionnaire/Definition.php @@ -2,6 +2,8 @@ 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. @@ -20,6 +22,26 @@ 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 $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 in the order they are asked, keyed by question key */ diff --git a/src/Model/Questionnaire/Section.php b/src/Model/Questionnaire/Section.php index 18a7c12..249004f 100644 --- a/src/Model/Questionnaire/Section.php +++ b/src/Model/Questionnaire/Section.php @@ -2,15 +2,19 @@ namespace App\Model\Questionnaire; +use App\Enum\QuestionnaireFlag; + readonly class Section { /** - * @param list $questions + * @param list $questions + * @param QuestionnaireFlag|null $onlyFor part of the questionnaire only when the subject has this flag */ public function __construct( public string $key, public ?string $title, public array $questions, + public ?QuestionnaireFlag $onlyFor = null, ) { } } diff --git a/src/Repository/DispositionRepository.php b/src/Repository/DispositionRepository.php index 932be7f..8676154 100644 --- a/src/Repository/DispositionRepository.php +++ b/src/Repository/DispositionRepository.php @@ -77,10 +77,10 @@ class DispositionRepository extends ServiceEntityRepository } /** - * The teamer's placements whose questionnaire (config/questionnaires/teamer_escort.yaml) is + * 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 - * DispositionVoter::QUESTIONNAIRE - bus escort, over (today included), on or after go-live, - * neither called off nor skip-formalities - so every row shown on the dashboard can be opened. + * 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 @@ -107,8 +107,6 @@ class DispositionRepository extends ServiceEntityRepository $qb->expr()->neq('disposition.status', ':dispositionCalledOff'), $qb->expr()->notIn('assignment.status', ':assignmentStatuses'), $qb->expr()->eq('assignment.skipFormalities', ':skipFormalities'), - $qb->expr()->isNotNull('assignment.pickup'), - $qb->expr()->neq('assignment.pickup', 0), $qb->expr()->gte('destination.dateTo', ':goLive'), $qb->expr()->lte('destination.dateTo', ':today'), $qb->expr()->orX( @@ -118,7 +116,7 @@ class DispositionRepository extends ServiceEntityRepository )) ->orderBy('destination.dateTo', \SortDirection::Descending) ->setParameter('teamer', $teamer) - ->setParameter('questionnaire', QuestionnaireCatalog::TEAMER_ESCORT) + ->setParameter('questionnaire', QuestionnaireCatalog::TEAMER_ASSIGNMENT) ->setParameter('dispositionCalledOff', Disposition::STATUS_CALLED_OFF) ->setParameter('assignmentStatuses', [Assignment::STATUS_CALLED_OFF, Assignment::STATUS_DELETED]) ->setParameter('skipFormalities', false) diff --git a/src/Security/Voter/DispositionVoter.php b/src/Security/Voter/DispositionVoter.php index e3b20e1..bf9ce46 100644 --- a/src/Security/Voter/DispositionVoter.php +++ b/src/Security/Voter/DispositionVoter.php @@ -118,8 +118,10 @@ class DispositionVoter extends Voter /** * Whether the teamer may fill in, and later read, the questionnaire after this placement - * (config/questionnaires/teamer_escort.yaml). Only the teamer themself: the office reads the - * submitted responses through ROLE_ADMINISTRATIVE, not through this attribute. + * (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. * * "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 @@ -137,12 +139,11 @@ class DispositionVoter extends Voter } if (true === $disposition->isSkipFormalities() - || Disposition::STATUS_CALLED_OFF === $disposition->getStatus() - || true !== $disposition->getAssignment()?->hasBusEscort()) { + || Disposition::STATUS_CALLED_OFF === $disposition->getStatus()) { return false; } - $dateTo = $disposition->getAssignment()->getDestination()?->getDateTo(); + $dateTo = $disposition->getAssignment()?->getDestination()?->getDateTo(); return null !== $dateTo && $dateTo >= $this->questionnaireGoLive diff --git a/src/Service/Questionnaire/DispositionContext.php b/src/Service/Questionnaire/DispositionContext.php index 51584b0..46d3448 100644 --- a/src/Service/Questionnaire/DispositionContext.php +++ b/src/Service/Questionnaire/DispositionContext.php @@ -2,7 +2,9 @@ namespace App\Service\Questionnaire; +use App\Entity\Assignment; use App\Entity\Disposition; +use App\Enum\QuestionnaireFlag; /** * What a disposition already knows, snapshotted into a QuestionnaireResponse so the questionnaire @@ -13,6 +15,17 @@ use App\Entity\Disposition; */ 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. + * + * @return list + */ + public static function flags(Assignment $assignment): array + { + return $assignment->hasBusEscort() ? [QuestionnaireFlag::BUS_ESCORT] : []; + } + /** * @return array{ * teamerName: ?string, diff --git a/templates/administrative/assignment/detail.html.twig b/templates/administrative/assignment/detail.html.twig index a67048d..7209a06 100644 --- a/templates/administrative/assignment/detail.html.twig +++ b/templates/administrative/assignment/detail.html.twig @@ -262,7 +262,7 @@ + title="Den Fragebogen für diesen Einsatz ausprobieren, ohne etwas zu speichern"> Fragebogen testen diff --git a/templates/administrative/questionnaire/test.html.twig b/templates/administrative/questionnaire/test.html.twig index 2ae242e..4b89894 100644 --- a/templates/administrative/questionnaire/test.html.twig +++ b/templates/administrative/questionnaire/test.html.twig @@ -10,12 +10,20 @@ 'type': 'warning', 'message': 'Testmodus: Der Fragebogen verhält sich wie für Teamer:innen, es wird aber nichts gespeichert.' } %} - {% if not assignment.hasBusEscort %} - {% include '_partials/_alert.html.twig' with { - 'type': 'info', - 'message': 'Dieser Einsatz hat keine Busbegleitung. Teamer:innen bekommen den Fragebogen hier nicht.' - } %} - {% endif %} + {% set otherVariant = path('app_administrative_questionnaire_test', { 'uuid': assignment.uuid, 'bus_escort': busEscort ? 0 : 1, 'r': app.request.query.get('r') }) %} + {% embed '_partials/_alert.html.twig' with { 'type': 'info' } %} + {% block message %} + {% if busEscort %} + Mit den Fragen zur Busfahrt, wie bei einer Busbegleitung. + {% else %} + Ohne die Fragen zur Busfahrt, wie bei einem Einsatz ohne Busbegleitung. + {% endif %} + {% if busEscort != assignment.hasBusEscort %} + Dieser Einsatz hat {{ assignment.hasBusEscort ? 'eine' : 'keine' }} Busbegleitung. + {% endif %} + {{ busEscort ? 'Ohne' : 'Mit' }} Busfahrt testen + {% endblock %} + {% endembed %}
{% if submitted is not null %} diff --git a/templates/teamer/disposition/detail.html.twig b/templates/teamer/disposition/detail.html.twig index 9a0e79a..cb1db4e 100644 --- a/templates/teamer/disposition/detail.html.twig +++ b/templates/teamer/disposition/detail.html.twig @@ -219,10 +219,10 @@ {% endif %} {% endif %} - {# Questionnaire after a bus escort; the edit page forwards to the read-only view once submitted #} + {# Questionnaire after the placement; the edit page forwards to the read-only view once submitted #} {% if is_granted('QUESTIONNAIRE', disposition) %}

- Fragebogen zur Busbegleitung + Fragebogen zu deinem Einsatz

Wie lief dein Einsatz? Dein Feedback hilft uns, besser zu werden. diff --git a/templates/teamer/index.html.twig b/templates/teamer/index.html.twig index 8a48a10..28e9e25 100644 --- a/templates/teamer/index.html.twig +++ b/templates/teamer/index.html.twig @@ -18,7 +18,7 @@ {% if pendingQuestionnaires is not empty %} {% embed '_partials/_alert.html.twig' with { 'type': 'info' } %} {% block message %} -

Bitte fülle den Fragebogen zu deiner Busbegleitung aus:

+

Bitte fülle den Fragebogen zu deinem Einsatz aus:

    {% for row in pendingQuestionnaires %} {% set destination = row.disposition.assignment.destination %} diff --git a/tests/Config/QuestionnaireCatalogTest.php b/tests/Config/QuestionnaireCatalogTest.php index 558e878..28e85c8 100644 --- a/tests/Config/QuestionnaireCatalogTest.php +++ b/tests/Config/QuestionnaireCatalogTest.php @@ -7,6 +7,7 @@ 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; @@ -50,18 +51,18 @@ 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 testTeamerEscortDoesNotAskWhatTheDispositionKnows(): void + public function testTeamerAssignmentDoesNotAskWhatTheDispositionKnows(): void { - $definition = new QuestionnaireCatalog(self::SHIPPED_DIR)->get('teamer_escort'); + $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); } } - public function testTeamerEscortLowRatingReasonIsRequiredOnlyForLowRatings(): void + public function testTeamerAssignmentLowRatingReasonIsRequiredOnlyForLowRatings(): void { - $question = new QuestionnaireCatalog(self::SHIPPED_DIR)->get('teamer_escort')->getQuestion('office_briefing_low_reason'); + $question = new QuestionnaireCatalog(self::SHIPPED_DIR)->get('teamer_assignment')->getQuestion('office_briefing_low_reason'); $this->assertTrue($question->required); $this->assertSame('office_briefing_rating', $question->showIf->question); @@ -73,9 +74,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 testTeamerEscortEquipmentIsOneSingleChoicePerTopic(): void + public function testTeamerAssignmentEquipmentIsOneSingleChoicePerTopic(): void { - $definition = new QuestionnaireCatalog(self::SHIPPED_DIR)->get('teamer_escort'); + $definition = new QuestionnaireCatalog(self::SHIPPED_DIR)->get('teamer_assignment'); foreach (['video', 'wc', 'luggage', 'smoking', 'snow_chains'] as $key) { $question = $definition->getQuestion($key); @@ -84,6 +85,28 @@ 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 + { + $definition = new QuestionnaireCatalog(self::SHIPPED_DIR)->get(QuestionnaireCatalog::TEAMER_ASSIGNMENT); + + 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')); + } + public function testChoiceDefaultsToRadio(): void { $this->write('sample', $this->sample()); @@ -181,12 +204,41 @@ 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()); @@ -238,6 +290,13 @@ 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)); diff --git a/tests/Controller/Administrative/Questionnaire/TestControllerTest.php b/tests/Controller/Administrative/Questionnaire/TestControllerTest.php index a045a7b..0c7946b 100644 --- a/tests/Controller/Administrative/Questionnaire/TestControllerTest.php +++ b/tests/Controller/Administrative/Questionnaire/TestControllerTest.php @@ -71,7 +71,9 @@ class TestControllerTest extends WebTestCase $this->assertResponseIsSuccessful(); $this->assertStringContainsString('Testmodus', $crawler->text()); - $this->assertStringContainsString('keine Busbegleitung', $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->assertCount(1, $crawler->filter('form[data-controller="questionnaire"]')); } @@ -102,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_ESCORT); + $definition = self::getContainer()->get(QuestionnaireCatalog::class)->get(QuestionnaireCatalog::TEAMER_ASSIGNMENT)->forFlags([]); foreach ($definition->sections as $section) { foreach ($section->questions as $question) { @@ -121,10 +123,19 @@ class TestControllerTest extends WebTestCase $crawler = $this->client->submit($form); $this->assertStringContainsString('So würde der abgeschickte Fragebogen gespeichert', $crawler->text()); - $this->assertStringContainsString('Boonk Reisen', $crawler->text()); + $this->assertStringContainsString('Sehr gut – Probleme wurden schnell', $crawler->text()); $this->assertNothingSaved(); } + public function testTheBusPartCanBeTriedOnAnAssignmentWithoutEscort(): void + { + $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"]')); + } + /** * The app answers a teamer in the office area with a redirect to their dashboard, not a 403. */ diff --git a/tests/Form/QuestionnaireTypeTest.php b/tests/Form/QuestionnaireTypeTest.php index 70fcf20..5005cd0 100644 --- a/tests/Form/QuestionnaireTypeTest.php +++ b/tests/Form/QuestionnaireTypeTest.php @@ -17,7 +17,7 @@ use Symfony\Component\Form\FormInterface; use Symfony\Component\Form\FormView; /** - * Runs against the shipped teamer_escort definition, so a definition that parses but cannot be + * Runs against the shipped teamer_assignment definition, 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 @@ -28,7 +28,7 @@ class QuestionnaireTypeTest extends KernelTestCase { public function testEmptyDraftIsValid(): void { - $form = $this->createForm($this->teamerEscort()); + $form = $this->createForm($this->teamerAssignment()); $form->submit([QuestionnaireType::DRAFT => '']); $this->assertTrue($form->isValid()); @@ -40,7 +40,7 @@ class QuestionnaireTypeTest extends KernelTestCase */ public function testFormSentWithoutAButtonIsADraft(): void { - $form = $this->createForm($this->teamerEscort()); + $form = $this->createForm($this->teamerAssignment()); $form->submit([]); $this->assertTrue($form->isValid()); @@ -49,7 +49,7 @@ class QuestionnaireTypeTest extends KernelTestCase public function testCompleteSubmitIsValidAndFlat(): void { - $definition = $this->teamerEscort(); + $definition = $this->teamerAssignment(); $form = $this->createForm($definition); $form->submit($this->complete($definition) + [QuestionnaireType::SUBMIT => '']); @@ -65,7 +65,7 @@ class QuestionnaireTypeTest extends KernelTestCase public function testEmptySubmitDemandsTheVisibleRequiredQuestionsOnly(): void { - $definition = $this->teamerEscort(); + $definition = $this->teamerAssignment(); $form = $this->createForm($definition); $form->submit([QuestionnaireType::SUBMIT => '']); @@ -79,9 +79,25 @@ class QuestionnaireTypeTest extends KernelTestCase $this->assertCount(0, $this->field($form, 'communication', 'support_wished')->getErrors()); } + /** + * A placement without bus escort gets the questionnaire without the bus sections, and can + * submit it without them. + */ + public function testWithoutBusEscortTheBusPartIsNeitherShownNorDemanded(): void + { + $definition = $this->teamerAssignment()->forFlags([]); + $form = $this->createForm($definition); + + $this->assertFalse($form->has('bus_trip')); + + $form->submit($this->complete($definition) + [QuestionnaireType::SUBMIT => '']); + + $this->assertTrue($form->isValid(), (string) $form->getErrors(true)); + } + public function testLowRatingDemandsAReason(): void { - $definition = $this->teamerEscort(); + $definition = $this->teamerAssignment(); $data = $this->complete($definition); $data['organisation']['office_briefing_rating'] = '3'; @@ -95,7 +111,7 @@ class QuestionnaireTypeTest extends KernelTestCase public function testOtherBusCompanyDemandsItsName(): void { - $definition = $this->teamerEscort(); + $definition = $this->teamerAssignment(); $data = $this->complete($definition); $data['bus_trip']['bus_company'] = 'other'; @@ -111,7 +127,7 @@ class QuestionnaireTypeTest extends KernelTestCase */ public function testNoFieldIsHtmlRequired(): void { - $view = $this->createForm($this->teamerEscort())->createView(); + $view = $this->createForm($this->teamerAssignment())->createView(); foreach ($view as $section) { foreach ($section as $field) { @@ -122,7 +138,7 @@ class QuestionnaireTypeTest extends KernelTestCase public function testConditionalQuestionCarriesItsConditionForTheBrowser(): void { - $view = $this->createForm($this->teamerEscort())->createView(); + $view = $this->createForm($this->teamerAssignment())->createView(); $row = $this->view($view, 'organisation', 'office_briefing_low_reason'); $this->assertSame( @@ -159,9 +175,9 @@ class QuestionnaireTypeTest extends KernelTestCase $this->assertSame('2026-01-10', $form->getData()['travel_date']); } - private function teamerEscort(): Definition + private function teamerAssignment(): Definition { - return new QuestionnaireCatalog(__DIR__.'/../../config/questionnaires')->get('teamer_escort'); + return new QuestionnaireCatalog(__DIR__.'/../../config/questionnaires')->get('teamer_assignment'); } /** diff --git a/tests/Model/Questionnaire/DefinitionTest.php b/tests/Model/Questionnaire/DefinitionTest.php index 40cb0a0..531a3c2 100644 --- a/tests/Model/Questionnaire/DefinitionTest.php +++ b/tests/Model/Questionnaire/DefinitionTest.php @@ -5,6 +5,7 @@ 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; @@ -44,6 +45,19 @@ 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 = [ diff --git a/tests/Repository/DispositionRepositoryQuestionnaireTest.php b/tests/Repository/DispositionRepositoryQuestionnaireTest.php index cd6cc83..4e639aa 100644 --- a/tests/Repository/DispositionRepositoryQuestionnaireTest.php +++ b/tests/Repository/DispositionRepositoryQuestionnaireTest.php @@ -83,6 +83,14 @@ class DispositionRepositoryQuestionnaireTest extends KernelTestCase $this->assertSame([['disposition' => $disposition, 'draft' => false]], $this->pending()); } + public function testAPlacementWithoutBusEscortIsPendingToo(): void + { + $disposition = $this->escort(); + $disposition->getAssignment()->setPickup(0); + + $this->assertSame([['disposition' => $disposition, 'draft' => false]], $this->pending()); + } + public function testAnAssignmentEndingTodayIsPending(): void { $this->escort(dateTo: 'today'); @@ -108,7 +116,6 @@ class DispositionRepositoryQuestionnaireTest extends KernelTestCase public static function notPending(): array { return [ - 'no bus escort' => [static fn (Disposition $d) => $d->getAssignment()->setPickup(0)], 'not over yet' => [static fn (Disposition $d) => $d->getAssignment()->getDestination()->setDateTo(new \DateTimeImmutable('tomorrow'))], 'ended before go-live' => [static fn (Disposition $d) => $d->getAssignment()->getDestination()->setDateTo(new \DateTimeImmutable('2025-12-31'))], 'skip formalities' => [static fn (Disposition $d) => $d->getAssignment()->setSkipFormalities(true)], diff --git a/tests/Repository/QuestionnaireFixturesTrait.php b/tests/Repository/QuestionnaireFixturesTrait.php index 44d0073..43c5848 100644 --- a/tests/Repository/QuestionnaireFixturesTrait.php +++ b/tests/Repository/QuestionnaireFixturesTrait.php @@ -50,7 +50,7 @@ trait QuestionnaireFixturesTrait return $disposition; } - private function respond(Disposition $disposition, bool $submit, string $questionnaire = QuestionnaireCatalog::TEAMER_ESCORT): QuestionnaireResponse + private function respond(Disposition $disposition, bool $submit, string $questionnaire = QuestionnaireCatalog::TEAMER_ASSIGNMENT): QuestionnaireResponse { $definition = new Definition($questionnaire, 1, 'Beispiel', null, []); $response = QuestionnaireResponse::forDisposition($definition, $disposition); diff --git a/tests/Repository/QuestionnaireResponseRepositoryTest.php b/tests/Repository/QuestionnaireResponseRepositoryTest.php index ec02b81..8ac9259 100644 --- a/tests/Repository/QuestionnaireResponseRepositoryTest.php +++ b/tests/Repository/QuestionnaireResponseRepositoryTest.php @@ -94,6 +94,6 @@ class QuestionnaireResponseRepositoryTest extends KernelTestCase { $this->entityManager->flush(); - return $this->repository->getListQuery(QuestionnaireCatalog::TEAMER_ESCORT, $filter)->getResult(); + return $this->repository->getListQuery(QuestionnaireCatalog::TEAMER_ASSIGNMENT, $filter)->getResult(); } } diff --git a/tests/Security/Voter/DispositionVoterTest.php b/tests/Security/Voter/DispositionVoterTest.php index c072742..43e4b53 100644 --- a/tests/Security/Voter/DispositionVoterTest.php +++ b/tests/Security/Voter/DispositionVoterTest.php @@ -179,6 +179,17 @@ class DispositionVoterTest extends TestCase $this->assertQuestionnaire(true, $this->escortDisposition()); } + /** + * Every placement gets the questionnaire; the escort only adds the bus sections. + */ + public function testTheTeamerMayFillInTheQuestionnaireWithoutAnEscortToo(): void + { + $disposition = $this->escortDisposition(); + $disposition->getAssignment()->setPickup(0); + + $this->assertQuestionnaire(true, $disposition); + } + /** * Ending today already counts as over, as in DispositionRepository's feedback queries. */ @@ -205,7 +216,6 @@ class DispositionVoterTest extends TestCase public static function questionnaireDenials(): array { return [ - 'no bus escort' => [static fn (Disposition $d) => $d->getAssignment()->setPickup(0)], 'assignment not over yet' => [static fn (Disposition $d) => $d->getAssignment()->getDestination()->setDateTo(new \DateTimeImmutable('tomorrow'))], 'ended before go-live' => [static fn (Disposition $d) => $d->getAssignment()->getDestination()->setDateTo(new \DateTimeImmutable('2025-12-31'))], 'skip formalities' => [static fn (Disposition $d) => $d->getAssignment()->setSkipFormalities(true)], diff --git a/tests/Service/Questionnaire/DispositionContextTest.php b/tests/Service/Questionnaire/DispositionContextTest.php index c7712b8..9431f4d 100644 --- a/tests/Service/Questionnaire/DispositionContextTest.php +++ b/tests/Service/Questionnaire/DispositionContextTest.php @@ -10,6 +10,7 @@ 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; @@ -42,6 +43,12 @@ class DispositionContextTest extends TestCase $this->assertNull($context['escortPickupDate']); } + public function testOnlyABusEscortGetsTheBusSections(): void + { + $this->assertSame([QuestionnaireFlag::BUS_ESCORT], DispositionContext::flags($this->disposition(pickup: 4711)->getAssignment())); + $this->assertSame([], DispositionContext::flags($this->disposition(pickup: 0)->getAssignment())); + } + private function disposition(int $pickup): Disposition { $destination = (new Destination()) diff --git a/tests/Twig/QuestionnaireTemplateTest.php b/tests/Twig/QuestionnaireTemplateTest.php index cd8eab5..e05d175 100644 --- a/tests/Twig/QuestionnaireTemplateTest.php +++ b/tests/Twig/QuestionnaireTemplateTest.php @@ -96,6 +96,6 @@ class QuestionnaireTemplateTest extends KernelTestCase private function definition(): Definition { - return new QuestionnaireCatalog(__DIR__.'/../../config/questionnaires')->get(QuestionnaireCatalog::TEAMER_ESCORT); + return new QuestionnaireCatalog(__DIR__.'/../../config/questionnaires')->get(QuestionnaireCatalog::TEAMER_ASSIGNMENT); } }