4 Commits
Author SHA1 Message Date
admin.juergenandClaude Fable 5 965ce47d48 Redirect with a flash message instead of a 500 page on stale survey actions
Actions from outdated tabs (survey deleted meanwhile, assignment revoked,
session expired) hit guard clauses that threw RuntimeException and produced
an HTTP 500 error page - seen 11x during the customer pretest on 2026-08-27
(duplicate on a deleted survey, edit link to a deleted survey). All frontend
guards now add a clear flash message and redirect to a freshly loaded view:

- list actions: distinct messages for deleted survey, revoked assignment,
  expired session, unknown action
- editor: resolveSurvey redirects to the survey overview; editor POST with
  invalid token redirects back to the editor
- reader/preview back button: reload the current question instead of failing

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 10:57:00 +02:00
admin.juergenandClaude Fable 5 024c97fce5 Clearer lost-password errors and a request-new-link action
Every stale reset link produced a bare 'Ungültiges Token', which sent
users into a loop of requesting ever more e-mails. The overridden core
messages now explain that only the link from the newest e-mail counts,
the auth message template offers a direct 'Neuen Link anfordern' button,
and the change-password page asks for the 'Aktuelles Passwort' with a
hint covering freshly reset passwords.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 14:36:46 +02:00
admin.juergenandClaude Fable 5 6077832efc 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>
2026-08-26 14:36:38 +02:00
admin.juergenandClaude Fable 5 1ceb0ad28e Normalize Quill's non-breaking spaces so rich-text descriptions wrap
Quill 2's getSemanticHTML() serializes every space as U+00A0, turning a
whole paragraph into one unbreakable word that stretched the survey cards
far past the viewport. Normalize to plain spaces when sanitizing (which
also heals existing records on every output) and when the editor writes
the serialized HTML back into the textarea.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 14:36:28 +02:00
24 changed files with 486 additions and 58 deletions
+20
View File
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
/*
* Verständlichere Texte rund um "Passwort vergessen" für technische Laien.
*
* Hintergrund: Contao zeigt für jeden nicht (mehr) gültigen Reset-Link nur
* "Ungültiges Token". Das passiert im Alltag vor allem, wenn ein bereits
* benutzter Link erneut angeklickt wird oder ein Link aus einer älteren
* E-Mail geöffnet wird (beim erfolgreichen Zurücksetzen werden alle älteren
* Links ungültig). Die Texte erklären das und zeigen den Ausweg.
*/
$GLOBALS['TL_LANG']['MSC']['invalidToken'] = 'Dieser Link ist nicht mehr gültig. Er wurde bereits verwendet, ist abgelaufen oder wurde durch eine neuere E-Mail ersetzt. Bitte fordern Sie einen neuen Link an es gilt immer der Link aus der neuesten E-Mail.';
$GLOBALS['TL_LANG']['MSC']['tokenConfirmed'] = 'Dieser Link wurde bereits verwendet Ihr Passwort wurde damit schon geändert. Melden Sie sich mit Ihrem neuen Passwort an oder fordern Sie bei Bedarf einen neuen Link an.';
// "Altes Passwort" verwirrt direkt nach einem Passwort-Reset: Gemeint ist immer
// das Passwort der aktuellen Anmeldung.
$GLOBALS['TL_LANG']['MSC']['oldPassword'] = 'Aktuelles Passwort';
+13
View File
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
/*
* Clearer "lost password" wording for non-technical users.
* See contao/languages/de/default.php for the rationale.
*/
$GLOBALS['TL_LANG']['MSC']['invalidToken'] = 'This link is no longer valid. It has already been used, has expired or has been replaced by a newer e-mail. Please request a new link only the link from the most recent e-mail is valid.';
$GLOBALS['TL_LANG']['MSC']['tokenConfirmed'] = 'This link has already been used your password has already been changed with it. Sign in with your new password or request a new link if needed.';
$GLOBALS['TL_LANG']['MSC']['oldPassword'] = 'Current password';
@@ -16,9 +16,15 @@
{% block content %}
{% if surveyIsAuthMessage %}
{# Fehler bei einem Reset-Link (?token=pw-...): direkt den Weg zu einem
neuen Link anbieten - die Seite ohne Token zeigt das Anforderungsformular. #}
{% set surveyTokenError = type == 'error' and (app.request.query.get('token')|default('')) starts with 'pw-' %}
<div class="survey-auth-card">
<p class="{{ type }} message">{{ message }}</p>
<p class="survey-auth-actions">
{% if surveyTokenError %}
<a class="survey-auth-button" href="{{ app.request.baseUrl ~ app.request.pathInfo }}">{{ 'survey.auth.request_new_link'|trans }}</a>
{% endif %}
<a class="survey-auth-button" href="{{ app.request.baseUrl }}/">{{ 'survey.auth.back_to_login'|trans }}</a>
</p>
</div>
@@ -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;
+110 -2
View File
@@ -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) {
+4
View File
@@ -15,6 +15,10 @@
function toSemanticHtml(quill) {
var html = typeof quill.getSemanticHTML === 'function' ? quill.getSemanticHTML() : quill.root.innerHTML;
// Quill 2 serialisiert Leerzeichen als &nbsp; ohne echte Leerzeichen kann
// der Browser den Text nicht umbrechen und das Layout läuft über.
html = html.replace(/&nbsp;|\u00a0/g, ' ');
// Leerer Editor: kein "<p></p>" speichern.
if (/^\s*(<p>(\s|&nbsp;|<br\s*\/?>)*<\/p>\s*)*$/i.test(html)) {
return '';
@@ -75,7 +75,12 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController
return $template->getResponse();
}
$survey = $this->resolveSurvey($request, $memberId);
$survey = $this->resolveSurvey($request, $memberId, $model);
if ($survey instanceof Response) {
return $survey;
}
$createQuestionMode = $this->isCreateQuestionRequested($request);
if ($survey instanceof SurveyModel && $request->isMethod('POST') && $request->request->has('_survey_action')) {
@@ -225,7 +230,12 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController
return $template->getResponse();
}
private function resolveSurvey(Request $request, int $memberId): ?SurveyModel
/**
* Liefert die angeforderte Umfrage, null (kein survey-Parameter, z. B. Anlegen-Modus)
* oder einen Redirect zur Übersicht: Aufrufe aus veralteten Tabs oder Lesezeichen
* (Umfrage gelöscht, Zuweisung entzogen) dürfen keine 500er-Fehlerseite erzeugen.
*/
private function resolveSurvey(Request $request, int $memberId, ModuleModel $model): SurveyModel|Response|null
{
$surveyId = (int) $request->query->get('survey', 0);
@@ -236,11 +246,15 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController
$survey = $this->surveyRepository->findById($surveyId);
if (!$survey instanceof SurveyModel) {
throw new \RuntimeException('Die Umfrage wurde nicht gefunden.');
$this->addFlash('error', 'Die Umfrage wurde nicht gefunden sie wurde möglicherweise inzwischen gelöscht.');
return new RedirectResponse($this->resolveListUrl($model));
}
if (!$this->surveyEditorRepository->isEditor((int) $survey->id, $memberId)) {
throw new \RuntimeException('Sie dürfen diese Umfrage nicht bearbeiten.');
$this->addFlash('error', 'Sie sind dieser Umfrage nicht (mehr) zugewiesen.');
return new RedirectResponse($this->resolveListUrl($model));
}
return $survey;
@@ -301,7 +315,9 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController
$token = (string) $request->request->get('_token');
if (!$this->isCsrfTokenValid($action.'-'.$itemId, $token)) {
throw new \RuntimeException('Ungültiger CSRF-Token.');
$this->addFlash('error', 'Ihre Sitzung war zwischenzeitlich abgelaufen. Bitte versuchen Sie es erneut.');
return new RedirectResponse($this->buildEditUrl($model, $request, (int) $survey->id));
}
try {
@@ -100,18 +100,39 @@ final class MemberSurveyListController extends AbstractFrontendModuleController
$action = (string) $request->request->get('_survey_action');
$token = (string) $request->request->get('_token', '');
// Aktionen aus veralteten Tabs (Umfrage inzwischen gelöscht, Sitzung
// abgelaufen) dürfen keine 500er-Fehlerseite erzeugen: stattdessen
// Hinweis als Flash-Meldung und zurück zur frisch geladenen Übersicht.
$redirect = new RedirectResponse($request->getBaseUrl().$request->getPathInfo());
if ($surveyId <= 0 || '' === $action) {
throw new \RuntimeException('Ungültige Umfrage-Aktion.');
$this->addFlash('error', 'Die Aktion konnte nicht zugeordnet werden. Bitte versuchen Sie es erneut.');
return $redirect;
}
// Session-basierter Symfony-Token (wie im Editor): zuverlässig für
// eingeloggte Mitglieder. Der Contao-REQUEST_TOKEN ist cookie-gebunden
// und im Frontend-Fragment-Flow nicht stabil verfügbar.
if (!$this->isCsrfTokenValid($action.'-'.$surveyId, $token)) {
throw new \RuntimeException('Ungültiger CSRF-Token.');
$this->addFlash('error', 'Ihre Sitzung war zwischenzeitlich abgelaufen. Bitte versuchen Sie es erneut.');
return $redirect;
}
$survey = $this->resolveEditableSurvey($surveyId, $memberId);
$survey = $this->surveyRepository->findById($surveyId);
if (!$survey instanceof SurveyModel) {
$this->addFlash('error', 'Diese Umfrage existiert nicht mehr sie wurde möglicherweise inzwischen gelöscht. Die Übersicht wurde aktualisiert.');
return $redirect;
}
if (!$this->surveyEditorRepository->isEditor($surveyId, $memberId)) {
$this->addFlash('error', 'Sie sind dieser Umfrage nicht mehr zugewiesen. Die Übersicht wurde aktualisiert.');
return $redirect;
}
try {
if ('duplicate-survey' === $action) {
@@ -140,17 +161,6 @@ final class MemberSurveyListController extends AbstractFrontendModuleController
return new RedirectResponse($request->getBaseUrl().$request->getPathInfo());
}
private function resolveEditableSurvey(int $surveyId, int $memberId): SurveyModel
{
$survey = $this->surveyRepository->findById($surveyId);
if (!$survey instanceof SurveyModel || !$this->surveyEditorRepository->isEditor($surveyId, $memberId)) {
throw new \RuntimeException('Sie dürfen diese Umfrage nicht bearbeiten.');
}
return $survey;
}
private function resolvePage(int $pageId): ?PageModel
{
if ($pageId <= 0) {
@@ -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;
@@ -66,7 +65,11 @@ final class ShowSurveyController extends AbstractFrontendModuleController
$submission = $this->surveySubmissionService->resolveActiveSubmission($survey, $request->getSession(), null);
if ($request->isMethod('POST') && 'back' === (string) $request->request->get('_survey_navigation')) {
$this->assertNavigationTokenValid($request, 'survey-back-'.(int) $survey->id);
// Ungültiger Token (Sitzung abgelaufen, veralteter Tab): kein 500er,
// sondern die aktuelle Frage per Redirect neu laden.
if (!$this->isNavigationTokenValid($request, 'survey-back-'.(int) $survey->id)) {
return new RedirectResponse($request->getUri());
}
if ($this->surveySubmissionService->goBack($survey, $submission, $request->getSession())) {
return new RedirectResponse($request->getUri());
@@ -111,7 +114,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) {
@@ -169,7 +174,11 @@ final class ShowSurveyController extends AbstractFrontendModuleController
$question = $this->resolvePreviewQuestion($survey, (int) $request->request->get('preview_question', 0));
if ($request->isMethod('POST') && 'back' === (string) $request->request->get('_survey_navigation')) {
$this->assertNavigationTokenValid($request, 'survey-preview-back-'.(int) $survey->id);
// Ungültiger Token: Entwurfsansicht ohne Fehlerseite von vorn beginnen
// (der Zustand wandert nur durch POST-Felder, ein GET startet neu).
if (!$this->isNavigationTokenValid($request, 'survey-preview-back-'.(int) $survey->id)) {
return new RedirectResponse($request->getUri());
}
if ([] !== $history) {
$question = $this->resolvePreviewQuestion($survey, (int) array_pop($history));
@@ -193,18 +202,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);
@@ -283,13 +293,11 @@ final class ShowSurveyController extends AbstractFrontendModuleController
return implode(',', $history);
}
private function assertNavigationTokenValid(Request $request, string $tokenId): void
private function isNavigationTokenValid(Request $request, string $tokenId): bool
{
$token = (string) $request->request->get('_navigation_token', '');
if (!$this->isCsrfTokenValid($tokenId, $token)) {
throw new \RuntimeException('Ungültiger CSRF-Token.');
}
return $this->isCsrfTokenValid($tokenId, $token);
}
private function resolvePublicAlias(Request $request): string
+17 -1
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
+29 -4
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>
*/
@@ -76,4 +101,4 @@ final class ChoiceQuestionType implements QuestionTypeInterface
return $options;
}
}
}
@@ -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;
/**
+32 -2
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,
+22 -3
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 -1
View File
@@ -28,6 +28,11 @@ final class SurveyHtmlSanitizer
return '';
}
// Quill 2 serialisiert Leerzeichen als geschützte Leerzeichen (&nbsp;/U+00A0).
// Ohne echte Leerzeichen kann der Browser Absätze nicht umbrechen der Text
// sprengt dann das Kartenlayout. Geschützte Leerzeichen sind hier nie gewollt.
$html = str_replace(["\u{00A0}", '&nbsp;', '&#160;', '&#xa0;', '&#xA0;'], ' ', $html);
// Klartext ohne Tags (Altdaten aus dem bisherigen Textarea): Absätze aus
// Leerzeilen bilden, einfache Umbrüche als <br> erhalten.
if (!preg_match('#<[a-z/!]#i', $html)) {
@@ -70,6 +75,7 @@ final class SurveyHtmlSanitizer
$text = (string) preg_replace('#</(p|li|ul|ol)>#i', "\n", $text);
$text = (string) preg_replace('#<br\s*/?>#i', "\n", $text);
$text = html_entity_decode(strip_tags($text), ENT_QUOTES | ENT_HTML5, 'UTF-8');
$text = str_replace("\u{00A0}", ' ', $text);
$text = (string) preg_replace("/[ \t]+\n/", "\n", $text);
$text = (string) preg_replace("/\n{3,}/", "\n\n", $text);
@@ -78,7 +84,9 @@ final class SurveyHtmlSanitizer
public function isEmpty(?string $html): bool
{
return '' === trim(strip_tags((string) $html));
$text = str_replace(["\u{00A0}", '&nbsp;', '&#160;'], ' ', strip_tags((string) $html));
return '' === trim($text);
}
private function convertPlainTextToHtml(string $text): string
+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"