From 6077832efca4e7d7b76d7c305cf03d45073a20ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BCrgen=20Mummert?= Date: Wed, 26 Aug 2026 14:36:38 +0200 Subject: [PATCH] Honor the mandatory flag with explicit no-answer options Symfony's required default forced an answer to every question no matter what the mandatory checkbox said. Optional choice questions now offer a dedicated 'Keine Antwort' option, optional range and text questions a 'Keine Angaben machen' checkbox (preselected for ranges, because a slider always submits a value); the side that is not chosen gets dimmed but stays clickable. Mandatory ranges start their value display at '?' and reject the submit until the slider was actually used, so the browser's midpoint default no longer leaks into the results. Empty answers are stored as a deliberate 'no answer', kept out of counts, averages and charts, and reported separately on the results page and in the PDF export. Also gives the reader textarea its full width and field styling, adds grid overflow guards for unbreakable content and styles the form error box. Co-Authored-By: Claude Fable 5 --- .../templates/frontend/show_survey.html.twig | 46 +++++-- .../frontend/show_survey_results.html.twig | 3 + public/css/survey-frontend.css | 55 +++++++++ public/js/survey-frontend.js | 112 +++++++++++++++++- .../FrontendModule/ShowSurveyController.php | 12 +- src/Form/Model/SurveyAnswerData.php | 18 ++- src/Form/SurveyQuestionAnswerType.php | 10 ++ src/QuestionType/ChoiceQuestionType.php | 33 +++++- src/QuestionType/QuestionTypeInterface.php | 7 ++ src/QuestionType/RangeQuestionType.php | 34 +++++- src/QuestionType/TextQuestionType.php | 1 + src/QuestionType/YesNoMaybeQuestionType.php | 25 +++- .../views/pdf/survey_results.html.twig | 3 + src/Service/SurveyResultsViewService.php | 12 +- src/Service/SurveySubmissionService.php | 22 +++- translations/messages.de.yaml | 8 +- translations/messages.en.yaml | 8 +- 17 files changed, 377 insertions(+), 32 deletions(-) diff --git a/contao/templates/frontend/show_survey.html.twig b/contao/templates/frontend/show_survey.html.twig index 4f8eec5..a75e322 100644 --- a/contao/templates/frontend/show_survey.html.twig +++ b/contao/templates/frontend/show_survey.html.twig @@ -65,15 +65,19 @@

{{ question.question|striptags|trim }}

{% if question.description|survey_has_text %}
{{ question.description|survey_rich }}
{% endif %} - {{ form_start(surveyForm, {attr: {class: 'survey-form-grid', id: 'survey-answer-form-' ~ question.id}}) }} + {{ form_start(surveyForm, {attr: {class: 'survey-form-grid', id: 'survey-answer-form-' ~ question.id, 'data-survey-answer-group': '1'}}) }} {% if isPreview %} {% endif %} + {# Die Option "Keine Antwort" (nur bei optionalen Auswahlfragen) wird + als eigene Karte unterhalb der echten Antworten dargestellt. #} + {% set answerChildren = surveyForm.answer is defined ? surveyForm.answer|filter(child => child.vars.value is not defined or child.vars.value != '__no_answer__') : [] %} + {% set noAnswerChildren = surveyForm.answer is defined ? surveyForm.answer|filter(child => child.vars.value is defined and child.vars.value == '__no_answer__') : [] %} {% if question.type == 'yes_no_maybe' %} -
- {% for child in surveyForm.answer %} +
+ {% for child in answerChildren %}
{% elseif question.type == 'choice' %} -
- {% for child in surveyForm.answer %} +
+ {% for child in answerChildren %} {% endfor %}
{% elseif question.type == 'range' %} -
+ {# Ohne bestätigten Wert zeigt die Anzeige "?" - der Slider selbst hat + technisch immer einen Wert (Browser-Vorgabe: Skalenmitte). #} + {% set rangeViewValue = surveyForm.answer.vars.value|default('') %} + {% set rangeAttributes = {'data-survey-range': '1', 'data-survey-range-target': 'survey-range-output-' ~ question.id} %} + {% if rangeViewValue == '' %} + {% set rangeAttributes = rangeAttributes|merge({'data-survey-range-untouched': '1'}) %} + {% endif %} +
{{ 'survey.survey.current_value'|trans }}
-
{{ question.rangeMin }}
+
{{ rangeViewValue != '' ? rangeViewValue : '?' }}
- {{ form_widget(surveyForm.answer, {attr: {'data-survey-range': '1', 'data-survey-range-target': 'survey-range-output-' ~ question.id}}) }} + {{ form_widget(surveyForm.answer, {attr: rangeAttributes}) }}
{{ question.rangeMin }}{{ question.rangeMax }}
+ {% if surveyForm.answerTouched is defined %} + {{ form_widget(surveyForm.answerTouched, {attr: {'data-survey-range-touched-field': '1'}}) }} + {% endif %} {% else %} - {{ form_widget(surveyForm.answer) }} +
{{ form_widget(surveyForm.answer) }}
+ {% endif %} + + {% for child in noAnswerChildren %} + + {% endfor %} + + {% if surveyForm.noAnswer is defined %} + {% endif %} {{ form_errors(surveyForm.answer) }} diff --git a/contao/templates/frontend/show_survey_results.html.twig b/contao/templates/frontend/show_survey_results.html.twig index 4f40f2c..39c0fc7 100644 --- a/contao/templates/frontend/show_survey_results.html.twig +++ b/contao/templates/frontend/show_survey_results.html.twig @@ -55,6 +55,9 @@

{{ question.question|striptags|trim }}

{{ 'survey.results.answered'|trans({'%count%': question.totalAnswers, '%total%': completedSubmissionCount}) }} + {% if question.noAnswerCount|default(0) > 0 %} + {{ 'survey.results.no_answer'|trans({'%count%': question.noAnswerCount}) }} + {% endif %} {% if question.skippedCount > 0 %} {{ 'survey.results.skipped'|trans({'%count%': question.skippedCount}) }} {% endif %} diff --git a/public/css/survey-frontend.css b/public/css/survey-frontend.css index 3cd4a94..500f704 100644 --- a/public/css/survey-frontend.css +++ b/public/css/survey-frontend.css @@ -128,6 +128,19 @@ gap: 1.5rem; } +/* Unbrechbare Inhalte (lange Wörter, URLs,  -Altdaten) dürfen das Layout + nicht sprengen: Grid-Zellen nicht über ihre Spur hinauswachsen lassen und + Wörter notfalls umbrechen (overflow-wrap wird an alle Texte vererbt). */ +.survey-grid > *, +.survey-grid-columns > *, +.survey-form-grid > * { + min-width: 0; +} + +.survey-shell { + overflow-wrap: break-word; +} + .survey-results-card { overflow: hidden; } @@ -1284,6 +1297,48 @@ margin-bottom: 0; } +/* --- Reader: Antwortbereich --- */ +[data-survey-answer-zone] textarea { + width: 100%; + box-sizing: border-box; + border-radius: 1rem; + border: 1px solid rgba(162, 168, 180, 0.5); + padding: 0.8rem 0.9rem; + background: rgba(255, 255, 255, 0.98); + color: var(--survey-black); +} + +/* Validierungsfehler (z. B. Pflicht-Bewertung ohne gewählten Wert) */ +[data-survey-answer-group] ul { + margin: 0; + padding: 0.8rem 1.1rem; + list-style: none; + border-radius: 1.2rem; + border: 1px solid rgba(157, 82, 118, 0.28); + background: rgba(157, 82, 118, 0.1); + color: var(--survey-purple); + font-weight: 600; +} + +/* --- Reader: "Keine Antwort" bei optionalen Fragen --- */ +.survey-no-answer-card { + background: rgba(233, 233, 235, 0.55); + border-style: dashed; +} + +.survey-optional-hint { + margin-top: 0.35rem; + font-size: 0.9rem; + color: var(--survey-gray-dark); +} + +/* Die nicht gewählte Seite wird nur gedimmt, bleibt aber bedienbar - + ein Klick wechselt jederzeit zurück. */ +.survey-dimmed { + opacity: 0.45; + transition: opacity 0.2s ease; +} + /* --- Reader: Themenbereich-Kopfzeile --- */ .survey-section-context { display: flex; diff --git a/public/js/survey-frontend.js b/public/js/survey-frontend.js index a430308..a9b20ec 100644 --- a/public/js/survey-frontend.js +++ b/public/js/survey-frontend.js @@ -26,12 +26,120 @@ document.addEventListener('DOMContentLoaded', function () { return; } + var form = input.closest('form'); + var touchedField = form ? form.querySelector('[data-survey-range-touched-field]') : null; + var update = function () { output.textContent = input.value; }; - input.addEventListener('input', update); - update(); + input.addEventListener('input', function () { + // Erst eine bewusste Bedienung macht aus dem "?" einen Wert und + // markiert die Pflicht-Bewertung serverseitig als beantwortet. + input.removeAttribute('data-survey-range-untouched'); + + if (touchedField) { + touchedField.value = '1'; + } + + update(); + }); + + // Ohne bestätigten Wert bleibt das "?" aus dem Template stehen. + if (!input.hasAttribute('data-survey-range-untouched')) { + update(); + } + }); + + // "Keine Antwort" / "Keine Angaben machen" bei optionalen Fragen: Die jeweils + // nicht gewählte Seite wird gedimmt (bleibt aber klickbar, damit der Wechsel + // jederzeit möglich ist). Serverseitig gewinnt das "Keine Angabe"-Feld. + document.querySelectorAll('[data-survey-answer-group]').forEach(function (group) { + var noAnswerInput = group.querySelector('[data-survey-no-answer]'); + var answerZone = group.querySelector('[data-survey-answer-zone]'); + var noAnswerZone = group.querySelector('[data-survey-no-answer-zone]'); + + if (!noAnswerInput || !answerZone || !noAnswerZone) { + return; + } + + var realInputs = Array.prototype.filter.call( + answerZone.querySelectorAll('input, textarea, select'), + function (input) { return input !== noAnswerInput; } + ); + + var hasRealAnswer = function () { + return realInputs.some(function (input) { + if (input.type === 'radio' || input.type === 'checkbox') { + return input.checked; + } + + if (input.type === 'range') { + // Der Slider hat technisch immer einen Wert - er zählt als + // Antwort, sobald "Keine Angaben machen" abgewählt ist. + return !noAnswerInput.checked; + } + + return String(input.value || '').trim() !== ''; + }); + }; + + var sync = function () { + answerZone.classList.toggle('survey-dimmed', noAnswerInput.checked); + noAnswerZone.classList.toggle('survey-dimmed', !noAnswerInput.checked && hasRealAnswer()); + + // Bewertungsfragen: Solange "Keine Angaben machen" gewählt ist, zeigt + // die Wertanzeige "?" statt des technischen Slider-Werts. + var rangeInput = answerZone.querySelector('input[type=range]'); + + if (rangeInput) { + var rangeTargetId = rangeInput.getAttribute('data-survey-range-target'); + var rangeOutput = rangeTargetId ? document.getElementById(rangeTargetId) : null; + + if (rangeOutput) { + rangeOutput.textContent = noAnswerInput.checked ? '?' : rangeInput.value; + } + } + }; + + noAnswerInput.addEventListener('change', function () { + if (noAnswerInput.checked) { + realInputs.forEach(function (input) { + if (input.type === 'radio' || input.type === 'checkbox') { + input.checked = false; + } + }); + } + + sync(); + }); + + var chooseAnswer = function () { + if (noAnswerInput.checked) { + noAnswerInput.checked = false; + } + + sync(); + }; + + realInputs.forEach(function (input) { + input.addEventListener('change', chooseAnswer); + input.addEventListener('input', chooseAnswer); + + if (input.type === 'range' || input.tagName === 'TEXTAREA') { + input.addEventListener('focus', chooseAnswer); + } + }); + + // Ein Klick irgendwo in den gedimmten Antwortbereich (z. B. auf die + // Wertanzeige des Sliders) reaktiviert ihn ebenfalls. + answerZone.addEventListener('pointerdown', function () { + if (noAnswerInput.checked) { + chooseAnswer(); + } + }); + + sync(); }); document.querySelectorAll('[data-survey-question-type]').forEach(function (select) { diff --git a/src/Controller/FrontendModule/ShowSurveyController.php b/src/Controller/FrontendModule/ShowSurveyController.php index 851d204..fe6e490 100644 --- a/src/Controller/FrontendModule/ShowSurveyController.php +++ b/src/Controller/FrontendModule/ShowSurveyController.php @@ -11,7 +11,6 @@ use Contao\Input; use Contao\ModuleModel; use Mummert\SurveyBundle\Model\SurveyContentModel; use Mummert\SurveyBundle\Model\SurveyModel; -use Mummert\SurveyBundle\Form\Model\SurveyAnswerData; use Mummert\SurveyBundle\Form\SurveyQuestionAnswerType; use Mummert\SurveyBundle\Repository\SurveyRepository; use Mummert\SurveyBundle\Service\SurveyFlowService; @@ -111,7 +110,9 @@ final class ShowSurveyController extends AbstractFrontendModuleController $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { - $normalizedAnswer = $this->surveySubmissionService->storeAnswer($survey, $submission, $question, $answerData->answer); + // "Keine Angaben machen" gewinnt: Der Slider sendet technisch immer einen Wert mit. + $rawAnswer = $answerData->noAnswer ? null : $answerData->answer; + $normalizedAnswer = $this->surveySubmissionService->storeAnswer($survey, $submission, $question, $rawAnswer); $nextQuestion = $this->surveyFlowService->determineNextQuestion($survey, $question, $normalizedAnswer); if ($nextQuestion instanceof SurveyContentModel) { @@ -193,18 +194,19 @@ final class ShowSurveyController extends AbstractFrontendModuleController return $template->getResponse(); } - $answerData = new SurveyAnswerData(); + $answerData = $this->surveySubmissionService->createAnswerData($question); $form = $this->createForm(SurveyQuestionAnswerType::class, $answerData, ['question' => $question]); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { - $normalizedAnswer = $this->surveySubmissionService->normalizeAnswerForQuestion($question, $answerData->answer); + $rawAnswer = $answerData->noAnswer ? null : $answerData->answer; + $normalizedAnswer = $this->surveySubmissionService->normalizeAnswerForQuestion($question, $rawAnswer); $nextQuestion = $this->surveyFlowService->determineNextQuestion($survey, $question, $normalizedAnswer); if ($nextQuestion instanceof SurveyContentModel) { $history[] = (int) $question->id; $question = $nextQuestion; - $form = $this->createForm(SurveyQuestionAnswerType::class, new SurveyAnswerData(), ['question' => $question]); + $form = $this->createForm(SurveyQuestionAnswerType::class, $this->surveySubmissionService->createAnswerData($question), ['question' => $question]); } else { $template->set('errorMessage', null); $template->set('survey', $survey); diff --git a/src/Form/Model/SurveyAnswerData.php b/src/Form/Model/SurveyAnswerData.php index 16f5455..672b397 100644 --- a/src/Form/Model/SurveyAnswerData.php +++ b/src/Form/Model/SurveyAnswerData.php @@ -7,4 +7,20 @@ namespace Mummert\SurveyBundle\Form\Model; final class SurveyAnswerData { public mixed $answer = null; -} \ No newline at end of file + + /** + * "Keine Angaben machen" bei optionalen Bewertungs- und Freitextfragen. + * Ist das Feld gesetzt, gewinnt es über einen gleichzeitig übermittelten + * Antwortwert (der Slider sendet technisch immer einen Wert mit). + */ + public bool $noAnswer = false; + + /** + * Pflicht-Bewertungsfragen: '1', sobald der Slider bewusst bedient wurde. + * Der Slider sendet technisch immer einen Wert mit (Browser-Vorgabe ist die + * Skalenmitte) - ohne dieses Flag ließe sich "nicht beantwortet" nicht von + * "Mittelwert gewählt" unterscheiden. Nullable, weil Symfony ein leer + * übermitteltes Hidden-Feld zu null transformiert. + */ + public ?string $answerTouched = ''; +} diff --git a/src/Form/SurveyQuestionAnswerType.php b/src/Form/SurveyQuestionAnswerType.php index 68fc62a..23ba121 100644 --- a/src/Form/SurveyQuestionAnswerType.php +++ b/src/Form/SurveyQuestionAnswerType.php @@ -8,6 +8,7 @@ use Mummert\SurveyBundle\Form\Model\SurveyAnswerData; use Mummert\SurveyBundle\Model\SurveyContentModel; use Mummert\SurveyBundle\QuestionType\QuestionTypeRegistry; use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -26,6 +27,15 @@ final class SurveyQuestionAnswerType extends AbstractType } $this->questionTypeRegistry->get((string) $question->type)->buildField($builder, $question); + + // Optionale Bewertungs- und Freitextfragen erhalten "Keine Angaben machen". + // Auswahlfragen lösen das stattdessen über eine eigene Option "Keine Antwort". + if ('1' !== (string) $question->mandatory && \in_array((string) $question->type, ['range', 'text'], true)) { + $builder->add('noAnswer', CheckboxType::class, [ + 'label' => false, + 'required' => false, + ]); + } } public function configureOptions(OptionsResolver $resolver): void diff --git a/src/QuestionType/ChoiceQuestionType.php b/src/QuestionType/ChoiceQuestionType.php index 4e90b94..8572621 100644 --- a/src/QuestionType/ChoiceQuestionType.php +++ b/src/QuestionType/ChoiceQuestionType.php @@ -22,9 +22,14 @@ final class ChoiceQuestionType implements QuestionTypeInterface { $choices = $this->getAnswerOptions($question); $multiple = '1' === (string) $question->allowMultiple; + + if (!$this->isMandatory($question)) { + $choices[] = self::NO_ANSWER; + } + $constraints = [new Choice(['choices' => $choices, 'multiple' => $multiple])]; - if ('1' === (string) $question->mandatory) { + if ($this->isMandatory($question)) { $constraints[] = $multiple ? new Count(['min' => 1]) : new NotBlank(); } @@ -39,10 +44,18 @@ final class ChoiceQuestionType implements QuestionTypeInterface $choices[$answerOption] = $answerOption; } + if (!$this->isMandatory($question)) { + $choices['Keine Antwort'] = self::NO_ANSWER; + } + $builder->add($fieldName, ChoiceType::class, [ 'label' => false, 'expanded' => true, 'multiple' => '1' === (string) $question->allowMultiple, + 'required' => $this->isMandatory($question), + // Symfony würde bei optionalen Radios sonst eine "None"-Option einfügen - + // die Rolle übernimmt hier die eigene Option "Keine Antwort". + 'placeholder' => false, 'choices' => $choices, 'constraints' => $this->getConstraints($question), ]); @@ -51,14 +64,26 @@ final class ChoiceQuestionType implements QuestionTypeInterface public function normalizeAnswer(mixed $value, SurveyContentModel $question): string { if (!\is_array($value)) { - return trim((string) $value); + $answer = trim((string) $value); + + return self::NO_ANSWER === $answer ? '' : $answer; } - $answers = array_values(array_filter(array_map(static fn (mixed $entry): string => trim((string) $entry), $value), static fn (string $entry): bool => '' !== $entry)); + // "Keine Antwort" fällt beim Normalisieren weg: Kombiniert mit echten + // Optionen (nur ohne JavaScript möglich) zählen die echten Antworten. + $answers = array_values(array_filter( + array_map(static fn (mixed $entry): string => trim((string) $entry), $value), + static fn (string $entry): bool => '' !== $entry && self::NO_ANSWER !== $entry, + )); return implode(' | ', $answers); } + private function isMandatory(SurveyContentModel $question): bool + { + return '1' === (string) $question->mandatory; + } + /** * @return list */ @@ -76,4 +101,4 @@ final class ChoiceQuestionType implements QuestionTypeInterface return $options; } -} \ No newline at end of file +} diff --git a/src/QuestionType/QuestionTypeInterface.php b/src/QuestionType/QuestionTypeInterface.php index dc0306f..44a8f53 100644 --- a/src/QuestionType/QuestionTypeInterface.php +++ b/src/QuestionType/QuestionTypeInterface.php @@ -9,6 +9,13 @@ use Symfony\Component\Form\FormBuilderInterface; interface QuestionTypeInterface { + /** + * Interner Wert der Option "Keine Antwort" bei optionalen Fragen. Wird beim + * Normalisieren in eine leere Antwort ("keine Angabe") überführt und darf + * deshalb nie mit einer echten Antwortoption kollidieren. + */ + public const NO_ANSWER = '__no_answer__'; + public function getName(): string; /** diff --git a/src/QuestionType/RangeQuestionType.php b/src/QuestionType/RangeQuestionType.php index 03893da..ba2c01e 100644 --- a/src/QuestionType/RangeQuestionType.php +++ b/src/QuestionType/RangeQuestionType.php @@ -5,8 +5,11 @@ declare(strict_types=1); namespace Mummert\SurveyBundle\QuestionType; use Mummert\SurveyBundle\Model\SurveyContentModel; +use Symfony\Component\Form\Extension\Core\Type\HiddenType; use Symfony\Component\Form\Extension\Core\Type\RangeType; use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormEvent; +use Symfony\Component\Form\FormEvents; use Symfony\Component\Validator\Constraints\NotBlank; use Symfony\Component\Validator\Constraints\Range; @@ -27,7 +30,7 @@ final class RangeQuestionType implements QuestionTypeInterface ]; if ('1' === (string) $question->mandatory) { - $constraints[] = new NotBlank(); + $constraints[] = new NotBlank(['message' => 'Bitte wählen Sie einen Wert auf der Skala aus – bei dieser Frage ist eine Angabe erforderlich.']); } return $constraints; @@ -35,8 +38,11 @@ final class RangeQuestionType implements QuestionTypeInterface public function buildField(FormBuilderInterface $builder, SurveyContentModel $question, string $fieldName = 'answer'): void { + $mandatory = '1' === (string) $question->mandatory; + $builder->add($fieldName, RangeType::class, [ 'label' => false, + 'required' => $mandatory, 'constraints' => $this->getConstraints($question), 'attr' => [ 'min' => (int) $question->rangeMin, @@ -47,10 +53,34 @@ final class RangeQuestionType implements QuestionTypeInterface 'data-action' => 'input->range-preview#update', ], ]); + + if ($mandatory) { + // Der Slider sendet immer einen Wert mit (Browser-Vorgabe: Skalenmitte). + // Erst eine bewusste Bedienung setzt das Flag; ohne Flag wird der + // mitgesendete Wert verworfen und die NotBlank-Meldung angezeigt. + $builder->add('answerTouched', HiddenType::class, [ + 'required' => false, + ]); + + $builder->addEventListener(FormEvents::PRE_SUBMIT, static function (FormEvent $event) use ($fieldName): void { + $data = $event->getData(); + + if (\is_array($data) && '1' !== (string) ($data['answerTouched'] ?? '')) { + $data[$fieldName] = ''; + $event->setData($data); + } + }); + } } public function normalizeAnswer(mixed $value, SurveyContentModel $question): string { + // "Keine Angabe" (null bzw. leer) darf nicht zu "0" werden – das wäre ein + // Wert außerhalb der Skala und würde Durchschnitt/Minimum verfälschen. + if (null === $value || '' === trim((string) $value)) { + return ''; + } + return (string) (int) $value; } -} \ No newline at end of file +} diff --git a/src/QuestionType/TextQuestionType.php b/src/QuestionType/TextQuestionType.php index 85f77b6..c3feb66 100644 --- a/src/QuestionType/TextQuestionType.php +++ b/src/QuestionType/TextQuestionType.php @@ -32,6 +32,7 @@ final class TextQuestionType implements QuestionTypeInterface { $builder->add($fieldName, TextareaType::class, [ 'label' => false, + 'required' => '1' === (string) $question->mandatory, 'constraints' => $this->getConstraints($question), 'attr' => [ 'rows' => 6, diff --git a/src/QuestionType/YesNoMaybeQuestionType.php b/src/QuestionType/YesNoMaybeQuestionType.php index 862fa60..7f05100 100644 --- a/src/QuestionType/YesNoMaybeQuestionType.php +++ b/src/QuestionType/YesNoMaybeQuestionType.php @@ -25,9 +25,13 @@ final class YesNoMaybeQuestionType implements QuestionTypeInterface $choices[] = 'maybe'; } + if (!$this->isMandatory($question)) { + $choices[] = self::NO_ANSWER; + } + $constraints = [new Choice(['choices' => $choices])]; - if ('1' === (string) $question->mandatory) { + if ($this->isMandatory($question)) { $constraints[] = new NotBlank(); } @@ -45,10 +49,18 @@ final class YesNoMaybeQuestionType implements QuestionTypeInterface $choices['Vielleicht'] = 'maybe'; } + if (!$this->isMandatory($question)) { + $choices['Keine Antwort'] = self::NO_ANSWER; + } + $builder->add($fieldName, ChoiceType::class, [ 'label' => false, 'expanded' => true, 'multiple' => false, + 'required' => $this->isMandatory($question), + // Symfony würde bei optionalen Radios sonst eine "None"-Option einfügen - + // die Rolle übernimmt hier die eigene Option "Keine Antwort". + 'placeholder' => false, 'choices' => $choices, 'constraints' => $this->getConstraints($question), ]); @@ -56,6 +68,13 @@ final class YesNoMaybeQuestionType implements QuestionTypeInterface public function normalizeAnswer(mixed $value, SurveyContentModel $question): string { - return mb_strtolower(trim((string) $value)); + $normalized = mb_strtolower(trim((string) $value)); + + return self::NO_ANSWER === $normalized ? '' : $normalized; } -} \ No newline at end of file + + private function isMandatory(SurveyContentModel $question): bool + { + return '1' === (string) $question->mandatory; + } +} diff --git a/src/Resources/views/pdf/survey_results.html.twig b/src/Resources/views/pdf/survey_results.html.twig index f1338e2..b6eb2c5 100644 --- a/src/Resources/views/pdf/survey_results.html.twig +++ b/src/Resources/views/pdf/survey_results.html.twig @@ -273,6 +273,9 @@
{{ 'survey.pdf.answered'|trans({'%count%': question.totalAnswers, '%total%': completedSubmissionCount}) }} + {% if question.noAnswerCount|default(0) > 0 %} + {{ 'survey.pdf.no_answer'|trans({'%count%': question.noAnswerCount}) }} + {% endif %} {% if question.skippedCount > 0 %} {{ 'survey.pdf.skipped'|trans({'%count%': question.skippedCount}) }} {% endif %} diff --git a/src/Service/SurveyResultsViewService.php b/src/Service/SurveyResultsViewService.php index 7d22a0b..954e3f1 100644 --- a/src/Service/SurveyResultsViewService.php +++ b/src/Service/SurveyResultsViewService.php @@ -164,12 +164,18 @@ final class SurveyResultsViewService */ private function finalizeQuestionResult(array $result, int $completedSubmissionCount): array { - $values = $result['_values'] ?? []; - $values = \is_array($values) ? array_values(array_map('strval', $values)) : []; + $rawValues = $result['_values'] ?? []; + $rawValues = \is_array($rawValues) ? array_values(array_map('strval', $rawValues)) : []; + + // Leere Antworten sind ein bewusstes "keine Angabe" (optionale Frage) und + // fließen nicht in die inhaltliche Auswertung ein. "Übersprungen" bleibt + // dagegen: Frage wurde (z. B. wegen Sprunglogik) gar nicht gestellt. + $values = array_values(array_filter($rawValues, static fn (string $value): bool => '' !== trim($value))); $totalAnswers = count($values); $result['totalAnswers'] = $totalAnswers; - $result['skippedCount'] = max(0, $completedSubmissionCount - $totalAnswers); + $result['noAnswerCount'] = count($rawValues) - $totalAnswers; + $result['skippedCount'] = max(0, $completedSubmissionCount - count($rawValues)); $result['responseRate'] = $completedSubmissionCount > 0 ? (int) round(($totalAnswers / $completedSubmissionCount) * 100) : 0; switch ((string) ($result['type'] ?? 'text')) { diff --git a/src/Service/SurveySubmissionService.php b/src/Service/SurveySubmissionService.php index 5f45cc7..4980923 100644 --- a/src/Service/SurveySubmissionService.php +++ b/src/Service/SurveySubmissionService.php @@ -109,6 +109,20 @@ final class SurveySubmissionService $answerData = new SurveyAnswerData(); if (null === $storedValue || '' === trim((string) $storedValue)) { + // Der Slider einer Bewertungsfrage steht technisch immer auf einem Wert + // (Browser-Vorgabe: Skalenmitte). Damit ohne bewusste Eingabe kein Wert + // in die Statistik läuft, startet die optionale Bewertungsfrage mit + // vorausgewähltem "Keine Angaben machen"; bei Freitext ist eine leer + // gespeicherte Antwort ein bewusst gewähltes "keine Angabe". + if ('1' !== (string) $question->mandatory) { + $answerData->noAnswer = match ((string) $question->type) { + 'range' => true, + // null = nie beantwortet, '' = bewusst ohne Angabe gespeichert. + 'text' => null !== $storedValue, + default => false, + }; + } + return $answerData; } @@ -122,6 +136,12 @@ final class SurveySubmissionService default => $normalizedValue, }; + // Ein gespeicherter Bewertungswert (Zurück-Navigation) gilt als bewusst + // gewählt - sonst würde der erneute Submit die Antwort verwerfen. + if ('range' === (string) $question->type) { + $answerData->answerTouched = '1'; + } + return $answerData; } @@ -243,7 +263,7 @@ final class SurveySubmissionService private function translateAnswerValue(string $questionType, string $value): string { if ('' === $value) { - return $value; + return $this->translator->trans('survey.survey.no_answer_display', [], 'messages'); } if ('yes_no_maybe' === $questionType) { diff --git a/translations/messages.de.yaml b/translations/messages.de.yaml index 791452f..7e11823 100644 --- a/translations/messages.de.yaml +++ b/translations/messages.de.yaml @@ -135,6 +135,9 @@ survey: answer_yes: "Ja" answer_no: "Nein" answer_maybe: "Vielleicht" + optional_hint: "Diese Frage ist optional – Sie können auch ohne eine Antwort weitermachen." + no_answer_skip: "Keine Angaben machen" + no_answer_display: "Keine Angabe" question_progress: "Frage %current%" jump_to: "weiter zu Frage %position%: %label%" current_value: "Aktueller Wert" @@ -155,6 +158,7 @@ survey: answers: "%count% Antworten" answered: "%count% von %total% beantwortet" skipped: "%count% übersprungen" + no_answer: "%count%× keine Angabe" answer_count: "%count%×" response_rate: "%rate%% Rücklauf" minimum: "Minimum" @@ -174,6 +178,7 @@ survey: answers: "%count% Antworten" answered: "%count% von %total% beantwortet" skipped: "%count% übersprungen" + no_answer: "%count%× keine Angabe" response_rate: "%rate%% Rücklauf" minimum: "Minimum" maximum: "Maximum" @@ -203,7 +208,8 @@ survey: change_password: headline: "Neues Passwort vergeben" intro: "Bitte vergeben Sie ein neues Passwort für Ihren Zugang zur Umfrageplattform. Geben Sie dazu zuerst Ihr aktuelles Passwort ein und anschließend das neue." - hint: "Wählen Sie ein Passwort, das Sie nicht bereits an anderer Stelle verwenden." + hint: "Ihr aktuelles Passwort ist das Passwort, mit dem Sie sich soeben angemeldet haben. Haben Sie es gerade erst über den E-Mail-Link neu gesetzt, ist genau dieses neue Passwort gemeint. Wählen Sie als neues Passwort eines, das Sie nicht bereits an anderer Stelle verwenden." + request_new_link: "Neuen Link anfordern" back_to_login: "Zurück zur Anmeldung" email: cta_password: "Neues Passwort festlegen" diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index 5899a0c..65974af 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -135,6 +135,9 @@ survey: answer_yes: "Yes" answer_no: "No" answer_maybe: "Maybe" + optional_hint: "This question is optional – you can continue without an answer." + no_answer_skip: "No answer" + no_answer_display: "No answer" question_progress: "Question %current%" jump_to: "continue to question %position%: %label%" current_value: "Current value" @@ -155,6 +158,7 @@ survey: answers: "%count% answers" answered: "%count% of %total% answered" skipped: "%count% skipped" + no_answer: "%count%× no answer" answer_count: "%count%×" response_rate: "%rate%% response rate" minimum: "Minimum" @@ -174,6 +178,7 @@ survey: answers: "%count% answers" answered: "%count% of %total% answered" skipped: "%count% skipped" + no_answer: "%count%× no answer" response_rate: "%rate%% response rate" minimum: "Minimum" maximum: "Maximum" @@ -203,7 +208,8 @@ survey: change_password: headline: "Set a new password" intro: "Please choose a new password for your survey platform account. Enter your current password first, then the new one." - hint: "Please pick a password that you do not already use elsewhere." + hint: "Your current password is the one you just signed in with. If you have just set it via the e-mail link, that new password is the one to enter. Please pick a new password that you do not already use elsewhere." + request_new_link: "Request a new link" back_to_login: "Back to login" email: cta_password: "Set a new password"