feat: build the questionnaire form from its definition
This commit is contained in:
1 parent
d06b647dac
commit
8070fda64d
4 files changed
+435
No files matched your search
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Enum\ChoiceWidget;
|
||||
use App\Enum\QuestionType;
|
||||
use App\Model\Questionnaire\Definition;
|
||||
use App\Model\Questionnaire\Option;
|
||||
use App\Model\Questionnaire\Question;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\ClickableInterface;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\FormType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\Form\FormError;
|
||||
use Symfony\Component\Form\FormEvent;
|
||||
use Symfony\Component\Form\FormEvents;
|
||||
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
|
||||
* without nesting the answers.
|
||||
*
|
||||
* Two buttons: "draft" saves whatever is filled in, "submit" also demands every required question
|
||||
* that is shown. No field is HTML-required, or the browser would refuse to send a half-filled
|
||||
* draft. Whether a question is shown is Definition::isVisible(), the same rule the
|
||||
* questionnaire Stimulus controller applies on screen via the row's data-show-if.
|
||||
*/
|
||||
class QuestionnaireType extends AbstractType
|
||||
{
|
||||
public const string DRAFT = 'draft';
|
||||
public const string SUBMIT = 'submit';
|
||||
|
||||
private const int RATING_MAX = 5;
|
||||
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
/** @var Definition $definition */
|
||||
$definition = $options['definition'];
|
||||
|
||||
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($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<string, mixed>}
|
||||
*/
|
||||
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<string>, 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;
|
||||
}
|
||||
}
|
||||
@@ -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<string, mixed> $answers keyed by question key
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Form;
|
||||
|
||||
use App\Config\QuestionnaireCatalog;
|
||||
use App\Enum\QuestionType;
|
||||
use App\Form\QuestionnaireType;
|
||||
use App\Model\Questionnaire\Definition;
|
||||
use App\Model\Questionnaire\Option;
|
||||
use App\Model\Questionnaire\Question;
|
||||
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_escort 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
|
||||
* unchecked radio group is simply absent from the request, and without clearMissing it would keep
|
||||
* its old value and conditional validation would appear to pass.
|
||||
*/
|
||||
class QuestionnaireTypeTest extends KernelTestCase
|
||||
{
|
||||
public function testEmptyDraftIsValid(): void
|
||||
{
|
||||
$form = $this->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<string, array<string, mixed>>
|
||||
*/
|
||||
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];
|
||||
}
|
||||
}
|
||||
@@ -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')];
|
||||
|
||||
Reference in new issue
Block a user