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:
@@ -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 %}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = '';
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user