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"