feat: build the questionnaire form from its definition

This commit is contained in:
fromme committed 2026-10-27 16:47:38 +01:00
1 parent d06b647dac
commit 8070fda64d
4 files changed
+435

No files matched your search

+175
View File
@@ -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;
}
}
+24
View File
@@ -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