From 8070fda64daf6673a804fcaddfe8b1fe7cf8bc76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Tue, 27 Oct 2026 16:47:38 +0100 Subject: [PATCH] feat: build the questionnaire form from its definition --- src/Form/QuestionnaireType.php | 175 +++++++++++++++ src/Model/Questionnaire/Definition.php | 24 ++ tests/Form/QuestionnaireTypeTest.php | 220 +++++++++++++++++++ tests/Model/Questionnaire/DefinitionTest.php | 16 ++ 4 files changed, 435 insertions(+) create mode 100644 src/Form/QuestionnaireType.php create mode 100644 tests/Form/QuestionnaireTypeTest.php diff --git a/src/Form/QuestionnaireType.php b/src/Form/QuestionnaireType.php new file mode 100644 index 0000000..5604fc6 --- /dev/null +++ b/src/Form/QuestionnaireType.php @@ -0,0 +1,175 @@ +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($question)); + } + + $builder->add($sectionBuilder); + } + + $builder + ->add(self::DRAFT, SubmitType::class, [ + 'label' => 'Entwurf speichern', + ]) + ->add(self::SUBMIT, SubmitType::class, [ + 'label' => 'Absenden', + ]) + ->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event) use ($definition): void { + $form = $event->getForm(); + + if (!$this->isFinal($form)) { + return; + } + + $answers = $form->getData(); + + 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.') + ); + } + } + } + }) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver + ->setRequired('definition') + ->setAllowedTypes('definition', Definition::class) + ->setDefaults([ + 'data_class' => null, + 'empty_data' => [], + ]) + ; + } + + /** + * Whether the form was sent with the submit button rather than as a draft. A form sent without + * any button (Enter in a text field) counts as a draft: losing nothing beats submitting early. + */ + public static function isFinal(FormInterface $form): bool + { + $button = $form->get(self::SUBMIT); + + return $button instanceof ClickableInterface && $button->isClicked(); + } + + /** + * @return array{0: class-string, 1: array} + */ + private function field(Question $question): array + { + $common = [ + 'label' => $question->label, + 'help' => $question->help, + 'required' => false, + 'row_attr' => null === $question->showIf ? [] : [ + 'data-show-if' => json_encode([ + 'question' => $question->showIf->question, + 'operator' => $question->showIf->operator->value, + 'value' => $question->showIf->value, + ], JSON_THROW_ON_ERROR), + ], + ]; + + return match ($question->type) { + QuestionType::TEXT => [TextType::class, $common], + QuestionType::TEXTAREA => [AutosizeTextareaType::class, $common + ['min_rows' => 3]], + QuestionType::CHOICE => [ChoiceType::class, $common + $this->choices($question) + [ + 'expanded' => ChoiceWidget::RADIO === $question->widget, + // a radio list needs no "none" entry, a select needs an empty first line + 'placeholder' => ChoiceWidget::RADIO === $question->widget ? false : 'Bitte wählen', + ]], + QuestionType::MULTI => [ChoiceType::class, $common + $this->choices($question) + [ + 'expanded' => true, + 'multiple' => true, + ]], + QuestionType::DATE => [DatepickerType::class, $common + [ + 'input' => 'string', + 'input_format' => 'Y-m-d', + ]], + QuestionType::RATING => [ChoiceType::class, $common + [ + 'choices' => array_combine(range(1, self::RATING_MAX), range(1, self::RATING_MAX)), + 'expanded' => true, + 'placeholder' => false, + 'block_prefix' => 'questionnaire_rating', + ]], + }; + } + + /** + * The option keys as choices, labelled through a callback: keyed by label, two options that + * share one would silently collapse into a single choice. + * + * @return array{choices: list, choice_label: \Closure(string): string} + */ + private function choices(Question $question): array + { + return [ + 'choices' => array_map(static fn (Option $option): string => $option->key, $question->options), + 'choice_label' => static fn (string $key): string => $question->getOption($key)->label, + ]; + } + + private function isEmpty(mixed $answer): bool + { + return null === $answer || '' === $answer || [] === $answer; + } +} diff --git a/src/Model/Questionnaire/Definition.php b/src/Model/Questionnaire/Definition.php index 0764cf9..efeec31 100644 --- a/src/Model/Questionnaire/Definition.php +++ b/src/Model/Questionnaire/Definition.php @@ -41,6 +41,30 @@ readonly class Definition return $this->getQuestions()[$key] ?? null; } + /** + * The answers worth keeping once the questionnaire is submitted: those to questions that are + * shown, and not empty. A draft keeps everything, so switching an answer back restores the + * follow-up the respondent had already written. + * + * @param array $answers keyed by question key + * + * @return array + */ + public function withoutHiddenAnswers(array $answers): array + { + $kept = []; + + foreach ($this->getQuestions() as $key => $question) { + $answer = $answers[$key] ?? null; + + if (null !== $answer && '' !== $answer && [] !== $answer && $this->isVisible($question, $answers)) { + $kept[$key] = $answer; + } + } + + return $kept; + } + /** * Whether a question is shown for these answers: it has no show_if, or its condition is met * and the question it depends on is shown in turn. A hidden question's answer counts for diff --git a/tests/Form/QuestionnaireTypeTest.php b/tests/Form/QuestionnaireTypeTest.php new file mode 100644 index 0000000..70fcf20 --- /dev/null +++ b/tests/Form/QuestionnaireTypeTest.php @@ -0,0 +1,220 @@ +createForm($this->teamerEscort()); + $form->submit([QuestionnaireType::DRAFT => '']); + + $this->assertTrue($form->isValid()); + $this->assertFalse(QuestionnaireType::isFinal($form)); + } + + /** + * Enter in a text field sends the form without any button; that must not submit it for good. + */ + public function testFormSentWithoutAButtonIsADraft(): void + { + $form = $this->createForm($this->teamerEscort()); + $form->submit([]); + + $this->assertTrue($form->isValid()); + $this->assertFalse(QuestionnaireType::isFinal($form)); + } + + public function testCompleteSubmitIsValidAndFlat(): void + { + $definition = $this->teamerEscort(); + $form = $this->createForm($definition); + $form->submit($this->complete($definition) + [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->teamerEscort(); + $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()); + } + + public function testLowRatingDemandsAReason(): void + { + $definition = $this->teamerEscort(); + $data = $this->complete($definition); + $data['organisation']['office_briefing_rating'] = '3'; + + $form = $this->createForm($definition); + $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->getErrors(true)); + } + + public function testOtherBusCompanyDemandsItsName(): void + { + $definition = $this->teamerEscort(); + $data = $this->complete($definition); + $data['bus_trip']['bus_company'] = 'other'; + + $form = $this->createForm($definition); + $form->submit($data + [QuestionnaireType::SUBMIT => '']); + + $this->assertCount(1, $this->field($form, 'bus_trip', 'bus_company_other')->getErrors()); + } + + /** + * A draft must be sendable from the browser at any stage, so nothing may carry the HTML + * required attribute. + */ + public function testNoFieldIsHtmlRequired(): void + { + $view = $this->createForm($this->teamerEscort())->createView(); + + foreach ($view as $section) { + foreach ($section as $field) { + $this->assertFalse($field->vars['required'], $field->vars['full_name']); + } + } + } + + public function testConditionalQuestionCarriesItsConditionForTheBrowser(): void + { + $view = $this->createForm($this->teamerEscort())->createView(); + + $row = $this->view($view, 'organisation', 'office_briefing_low_reason'); + $this->assertSame( + ['question' => '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 testMultiAndDateAnswersRoundTrip(): void + { + $definition = new Definition('sample', 1, 'Beispiel', null, [ + new Section('main', null, [ + new Question('extras', 'Extras', QuestionType::MULTI, options: [ + new Option('trailer', 'Anhänger'), + new Option('ski_box', 'Skikoffer'), + ]), + new Question('travel_date', 'Reisedatum', QuestionType::DATE), + ]), + ]); + + $form = $this->createForm($definition, ['extras' => ['ski_box'], 'travel_date' => '2026-01-10']); + $view = $form->createView(); + $this->assertTrue($this->view($view, 'main', 'extras')->children[1]->vars['checked']); + + $form = $this->createForm($definition); + $form->submit([ + 'main' => ['extras' => ['trailer', 'ski_box'], 'travel_date' => $this->view($view, 'main', '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']); + } + + private function teamerEscort(): Definition + { + return new QuestionnaireCatalog(__DIR__.'/../../config/questionnaires')->get('teamer_escort'); + } + + /** + * A submission answering every question that is shown when each choice takes its first option + * and each rating is 5. + * + * @return array> + */ + private function complete(Definition $definition): 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; + + if ($definition->isVisible($question, $answers)) { + $data[$section->key][$question->key] = is_int($answer) ? (string) $answer : $answer; + } + } + } + + return $data; + } + + private function createForm(Definition $definition, array $answers = []): FormInterface + { + self::bootKernel(); + + /** @var FormFactoryInterface $formFactory */ + $formFactory = self::getContainer()->get(FormFactoryInterface::class); + + return $formFactory->create(QuestionnaireType::class, $answers, [ + 'definition' => $definition, + '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]; + } +} diff --git a/tests/Model/Questionnaire/DefinitionTest.php b/tests/Model/Questionnaire/DefinitionTest.php index cd1a068..40cb0a0 100644 --- a/tests/Model/Questionnaire/DefinitionTest.php +++ b/tests/Model/Questionnaire/DefinitionTest.php @@ -44,6 +44,22 @@ class DefinitionTest extends TestCase $this->assertFalse($definition->isVisible($details, ['damage' => 'no', 'claim' => 'expected'])); } + public function testWithoutHiddenAnswersDropsHiddenEmptyAndUnknownAnswers(): void + { + $answers = [ + 'damage' => 'no', + 'claim' => 'expected', // hidden now that damage is "no" + 'details' => 'Spiegel ab', // hidden in turn + 'unknown' => 'x', // not a question of this definition + ]; + + $this->assertSame(['damage' => 'no'], $this->definition()->withoutHiddenAnswers($answers)); + $this->assertSame( + ['damage' => 'yes', 'claim' => 'settled'], + $this->definition()->withoutHiddenAnswers(['damage' => 'yes', 'claim' => 'settled', 'details' => '']), + ); + } + private function definition(): Definition { $yesNo = [new Option('yes', 'Ja'), new Option('no', 'Nein')];