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 <noreply@anthropic.com>
This commit is contained in:
2026-08-26 14:36:38 +02:00
co-authored by Claude Fable 5
parent 1ceb0ad28e
commit 6077832efc
17 changed files with 377 additions and 32 deletions
@@ -65,15 +65,19 @@
<h3>{{ question.question|striptags|trim }}</h3>
{% if question.description|survey_has_text %}<div class="survey-rich-text">{{ question.description|survey_rich }}</div>{% 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'}}) }}
<input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}">
{% if isPreview %}
<input type="hidden" name="preview_question" value="{{ question.id }}">
<input type="hidden" name="preview_history" value="{{ historyState }}">
{% 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' %}
<div class="survey-grid" style="grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));">
{% for child in surveyForm.answer %}
<div class="survey-grid" style="grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));" data-survey-answer-zone>
{% for child in answerChildren %}
<label class="survey-card" style="cursor: pointer;">
<div class="survey-meta"><strong>{{ child.vars.label|striptags|trim }}</strong>{{ form_widget(child) }}</div>
{% set jumpHint = jumpHints|default({})[child.vars.value]|default(null) %}
@@ -84,24 +88,48 @@
{% endfor %}
</div>
{% elseif question.type == 'choice' %}
<div class="survey-grid" style="grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));">
{% for child in surveyForm.answer %}
<div class="survey-grid" style="grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));" data-survey-answer-zone>
{% for child in answerChildren %}
<label class="survey-card" style="cursor: pointer;">
<div class="survey-meta"><strong>{{ child.vars.label|striptags|trim }}</strong>{{ form_widget(child) }}</div>
</label>
{% endfor %}
</div>
{% elseif question.type == 'range' %}
<div class="survey-grid survey-range-block">
{# 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 %}
<div class="survey-grid survey-range-block" data-survey-answer-zone>
<div class="survey-range-value-card">
<div class="survey-range-value-label">{{ 'survey.survey.current_value'|trans }}</div>
<div id="survey-range-output-{{ question.id }}" class="survey-range-output">{{ question.rangeMin }}</div>
<div id="survey-range-output-{{ question.id }}" class="survey-range-output">{{ rangeViewValue != '' ? rangeViewValue : '?' }}</div>
</div>
{{ form_widget(surveyForm.answer, {attr: {'data-survey-range': '1', 'data-survey-range-target': 'survey-range-output-' ~ question.id}}) }}
{{ form_widget(surveyForm.answer, {attr: rangeAttributes}) }}
<div class="survey-range-scale"><span class="survey-range-boundary">{{ question.rangeMin }}</span><span class="survey-range-boundary">{{ question.rangeMax }}</span></div>
</div>
{% if surveyForm.answerTouched is defined %}
{{ form_widget(surveyForm.answerTouched, {attr: {'data-survey-range-touched-field': '1'}}) }}
{% endif %}
{% else %}
{{ form_widget(surveyForm.answer) }}
<div data-survey-answer-zone>{{ form_widget(surveyForm.answer) }}</div>
{% endif %}
{% for child in noAnswerChildren %}
<label class="survey-card survey-no-answer-card" style="cursor: pointer;" data-survey-no-answer-zone>
<div class="survey-meta"><strong>{{ child.vars.label|striptags|trim }}</strong>{{ form_widget(child, {attr: {'data-survey-no-answer': '1'}}) }}</div>
<div class="survey-optional-hint">{{ 'survey.survey.optional_hint'|trans }}</div>
</label>
{% endfor %}
{% if surveyForm.noAnswer is defined %}
<label class="survey-card survey-no-answer-card" style="cursor: pointer;" data-survey-no-answer-zone>
<div class="survey-meta"><strong>{{ 'survey.survey.no_answer_skip'|trans }}</strong>{{ form_widget(surveyForm.noAnswer, {attr: {'data-survey-no-answer': '1'}}) }}</div>
<div class="survey-optional-hint">{{ 'survey.survey.optional_hint'|trans }}</div>
</label>
{% endif %}
{{ form_errors(surveyForm.answer) }}
@@ -55,6 +55,9 @@
<div class="survey-meta">
<h3>{{ question.question|striptags|trim }}</h3>
<span class="survey-badge neutral">{{ 'survey.results.answered'|trans({'%count%': question.totalAnswers, '%total%': completedSubmissionCount}) }}</span>
{% if question.noAnswerCount|default(0) > 0 %}
<span class="survey-badge neutral">{{ 'survey.results.no_answer'|trans({'%count%': question.noAnswerCount}) }}</span>
{% endif %}
{% if question.skippedCount > 0 %}
<span class="survey-badge neutral">{{ 'survey.results.skipped'|trans({'%count%': question.skippedCount}) }}</span>
{% endif %}
+55
View File
@@ -128,6 +128,19 @@
gap: 1.5rem;
}
/* Unbrechbare Inhalte (lange Wörter, URLs, &nbsp;-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;
+109 -1
View File
@@ -26,14 +26,122 @@ 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);
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) {
var wrapper = select.closest('[data-survey-question-editor]');
if (!wrapper) {
@@ -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);
+16
View File
@@ -7,4 +7,20 @@ namespace Mummert\SurveyBundle\Form\Model;
final class SurveyAnswerData
{
public mixed $answer = null;
/**
* "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 = '';
}
+10
View File
@@ -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
+28 -3
View File
@@ -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<string>
*/
@@ -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;
/**
+31 -1
View File
@@ -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;
}
}
+1
View File
@@ -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,
+21 -2
View File
@@ -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;
}
private function isMandatory(SurveyContentModel $question): bool
{
return '1' === (string) $question->mandatory;
}
}
@@ -273,6 +273,9 @@
<div class="meta-row">
<span class="meta-pill">{{ 'survey.pdf.answered'|trans({'%count%': question.totalAnswers, '%total%': completedSubmissionCount}) }}</span>
{% if question.noAnswerCount|default(0) > 0 %}
<span class="meta-pill">{{ 'survey.pdf.no_answer'|trans({'%count%': question.noAnswerCount}) }}</span>
{% endif %}
{% if question.skippedCount > 0 %}
<span class="meta-pill">{{ 'survey.pdf.skipped'|trans({'%count%': question.skippedCount}) }}</span>
{% endif %}
+9 -3
View File
@@ -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')) {
+21 -1
View File
@@ -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) {
+7 -1
View File
@@ -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"
+7 -1
View File
@@ -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"