Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
965ce47d48 | ||
|
|
024c97fce5 | ||
|
|
6077832efc | ||
|
|
1ceb0ad28e | ||
|
|
a4ca026e82 |
@@ -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';
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
function toSemanticHtml(quill) {
|
||||
var html = typeof quill.getSemanticHTML === 'function' ? quill.getSemanticHTML() : quill.root.innerHTML;
|
||||
|
||||
// Quill 2 serialisiert Leerzeichen als – ohne echte Leerzeichen kann
|
||||
// der Browser den Text nicht umbrechen und das Layout läuft über.
|
||||
html = html.replace(/ |\u00a0/g, ' ');
|
||||
|
||||
// Leerer Editor: kein "<p></p>" speichern.
|
||||
if (/^\s*(<p>(\s| |<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
|
||||
|
||||
@@ -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>
|
||||
*/
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -28,16 +28,28 @@ final class SurveyHtmlSanitizer
|
||||
return '';
|
||||
}
|
||||
|
||||
// Quill 2 serialisiert Leerzeichen als geschützte Leerzeichen ( /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}", ' ', ' ', ' ', ' '], ' ', $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)) {
|
||||
$html = $this->convertPlainTextToHtml($html);
|
||||
}
|
||||
|
||||
// Elemente samt Inhalt komplett entfernen, bevor der Parser sie sieht –
|
||||
// Word/Outlook liefern <style>- und <xml>-Blöcke mit, deren Text sonst
|
||||
// (je nach Parser-Kontext) als sichtbarer Inhalt übrig bleiben kann.
|
||||
$html = (string) preg_replace('#<(script|style|head|title|iframe|object|template|noscript|xml|svg|math)\b[^>]*>.*?</\1\s*>#is', ' ', $html);
|
||||
$html = (string) preg_replace('#<!--.*?-->#s', ' ', $html);
|
||||
|
||||
// Fremde Blockelemente (z. B. aus dem Backend-TinyMCE oder aus Word-Kopien)
|
||||
// in Absätze überführen, damit der Text nicht zusammenläuft.
|
||||
$html = (string) preg_replace('#<(div|h[1-6]|blockquote|section|article|header|footer|pre|address)\b[^>]*>#i', '<p>', $html);
|
||||
$html = (string) preg_replace('#</(div|h[1-6]|blockquote|section|article|header|footer|pre|address)>#i', '</p>', $html);
|
||||
$html = (string) preg_replace('#<(div|h[1-6]|blockquote|section|article|header|footer|pre|address|tr|dt|dd)\b[^>]*>#i', '<p>', $html);
|
||||
$html = (string) preg_replace('#</(div|h[1-6]|blockquote|section|article|header|footer|pre|address|tr|dt|dd)>#i', '</p>', $html);
|
||||
$html = (string) preg_replace('#</t[dh]>\s*<t[dh]\b[^>]*>#i', ' – ', $html);
|
||||
|
||||
$clean = $this->getSanitizer()->sanitize($html);
|
||||
|
||||
@@ -63,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);
|
||||
|
||||
@@ -71,7 +84,9 @@ final class SurveyHtmlSanitizer
|
||||
|
||||
public function isEmpty(?string $html): bool
|
||||
{
|
||||
return '' === trim(strip_tags((string) $html));
|
||||
$text = str_replace(["\u{00A0}", ' ', ' '], ' ', strip_tags((string) $html));
|
||||
|
||||
return '' === trim($text);
|
||||
}
|
||||
|
||||
private function convertPlainTextToHtml(string $text): string
|
||||
|
||||
@@ -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