+ {# 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 %}
+
+ {% if surveyForm.answerTouched is defined %}
+ {{ form_widget(surveyForm.answerTouched, {attr: {'data-survey-range-touched-field': '1'}}) }}
+ {% endif %}
{% else %}
- {{ 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"