Fix frontend CSRF, clean reader URLs, and review findings
CSRF / actions: - List action forms now send both REQUEST_TOKEN (Contao gate) and _token (Symfony, validated via isCsrfTokenValid) — mirrors the editor; fixes the 400/500 "Ungültiger CSRF-Token" on publish/toggle/duplicate/delete Public reader / URLs: - Reader & results read auto_item via Input::get (marks the route param used) so clean path URLs /<page>/<alias> no longer 404 - List controller generates path URLs via ['parameters' => '/'.$alias] - Allow re-participation in the same session after a 60s cooldown Review fixes: - Results: merge answer snapshots by questionId+type (no more split cards on label edits); seed "Vielleicht" when allowMaybe so screen matches Excel - Reader: replace generic answer hint with per-option "weiter zu Frage X" only when a jump is configured; strip HTML tags from survey description - PDF: render free-text frequency summary; drop duplicate "Minimum" label - Drag-sort: bundle SortableJS locally, drop the duplicated inline sort CSS/JS from _survey_assets, throttle the backend MutationObserver - Backend: move hardcoded tl_survey operation labels to TL_LANG (de+en) - Remove dead code: SurveyEditorVoter, SurveyPermissionService, categoryFilter/categorySummary + helpers, orphaned publishSurvey lang key Reader layout: - max-width 1000px (other views keep 1400px); back/next buttons fill the width as equal, card-sized halves; sort handle icon offset tweak Docs: update AI_HANDOVER (dual-token CSRF, clean URLs) and README Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+13
-3
@@ -124,10 +124,13 @@ Aktuelle Button- und Sichtbarkeitslogik pro Umfrage:
|
||||
- Schaltet `isActive`
|
||||
- Ist bewusst kein Auge mehr
|
||||
|
||||
Wichtiger technischer Punkt:
|
||||
Wichtiger technischer Punkt (CSRF):
|
||||
|
||||
- Die Frontend-Listenaktionen arbeiten nur noch mit dem Contao-`REQUEST_TOKEN` plus Berechtigungsprüfung.
|
||||
- Die zusätzliche Symfony-Aktions-CSRF-Prüfung wurde bewusst entfernt, weil sie in diesem Frontend-Flow zu ungültigen Tokens führte.
|
||||
- Jedes Frontend-Aktionsformular (Liste **und** Editor) sendet bewusst **zwei** Tokens:
|
||||
- `REQUEST_TOKEN` (`{{ contao.request_token }}`) – nötig für Contaos globalen Frontend-POST-Gate; fehlt er, lehnt Contao den POST schon vor dem Controller mit HTTP 400 ab.
|
||||
- `_token` (`{{ csrf_token('<aktion>-' ~ id) }}`) – wird im Controller mit `isCsrfTokenValid('<aktion>-'.$id, $token)` geprüft (session-basierter Symfony-Token).
|
||||
- Beide sind erforderlich. Frühere Stände hatten nur einen davon – das führte zu „Ungültiger CSRF-Token" (500) bzw. Bad Request (400). Der Listen-Controller spiegelt jetzt exakt die Token-Mechanik des Editors.
|
||||
- Der Contao-`REQUEST_TOKEN` allein reicht **nicht** als Controller-Prüfung: Er ist cookie-gebunden und bei anonymen/fragmentierten Frontend-Requests nicht stabil (Contao setzt das CSRF-Cookie dort teils gar nicht). Deshalb die zusätzliche session-basierte `_token`-Prüfung.
|
||||
|
||||
### 2. Frontend-Editor
|
||||
|
||||
@@ -191,6 +194,13 @@ Wichtige Regeln:
|
||||
- Entwurfsansicht arbeitet ohne Speicherung
|
||||
- Öffentliche Ansicht speichert Antworten und erstellt/führt Submission fort
|
||||
- Rückwärtsnavigation ist implementiert
|
||||
- Nach Abschluss zeigt der gleiche Browser-Token für eine kurze Cooldown-Zeit (`RESTART_COOLDOWN_SECONDS = 60`) die Danke-Seite; danach darf dieselbe Session erneut teilnehmen (`SurveySubmissionService::isRestartAllowed`).
|
||||
|
||||
Saubere Link-URLs (Reader und Ergebnisseite):
|
||||
|
||||
- Die Umfrage wird über den Alias als **Pfad-Segment** adressiert: `/<reader-seite>/<alias>` statt `?auto_item=<alias>`.
|
||||
- URL-Erzeugung: per Route-Platzhalter `['parameters' => '/'.$alias]` (Frontend-Liste `MemberSurveyListController`, Backend `SurveyDcaListener::buildSurveyPageUrl`), nicht `['auto_item' => $alias]`.
|
||||
- Auslesen im Controller: `$this->getContaoAdapter(Input::class)->get('auto_item')`. Wichtig: `Input::get` markiert den Route-Parameter als **benutzt** – sonst wirft Contaos `LegacyRouteParametersListener` eine 404 wegen „unbenutzter Parameter". Ein reines `$request->query->get('auto_item')` reicht für Pfad-URLs nicht.
|
||||
|
||||
### 4. Ergebnisseite
|
||||
|
||||
|
||||
@@ -6,6 +6,22 @@ Das Bundle stellt Frontend- und Backend-Funktionen für die Erstellung, Verwaltu
|
||||
|
||||
Ziel des Bundles ist eine möglichst native Integration in Contao, sodass Umfragen sich wie ein regulärer Bestandteil der bestehenden System- und Redaktionsumgebung verhalten.
|
||||
|
||||
## Dokumentation
|
||||
|
||||
Architektur, Zustandslogik, bewusste Produktentscheidungen und Einstiegspunkte sind ausführlich in [`AI_HANDOVER.md`](AI_HANDOVER.md) beschrieben.
|
||||
|
||||
Zwei wiederkehrend relevante technische Konventionen:
|
||||
|
||||
- **CSRF in Frontend-Aktionsformularen:** Jedes POST-Formular sendet **zwei** Tokens – `REQUEST_TOKEN` (Contaos globaler Frontend-Gate) und `_token` (`csrf_token('<aktion>-' ~ id)`, im Controller via `isCsrfTokenValid` geprüft). Beide sind nötig.
|
||||
- **Saubere Link-URLs:** Reader und Ergebnisseite werden als Pfad adressiert (`/<seite>/<alias>`). Erzeugung über `['parameters' => '/'.$alias]`, Auslesen über `Input::get('auto_item')` (markiert den Route-Parameter als benutzt – sonst Contao-404).
|
||||
|
||||
Nach Änderungen an DCA, Backend-CSS oder JS:
|
||||
|
||||
```bash
|
||||
php vendor/bin/contao-console assets:install public --symlink --relative
|
||||
php vendor/bin/contao-console cache:clear --env=prod
|
||||
```
|
||||
|
||||
## Rechtlicher Hinweis
|
||||
|
||||
Dieses Bundle ist proprietär und nicht zur öffentlichen Weitergabe bestimmt.
|
||||
|
||||
@@ -48,14 +48,14 @@ $GLOBALS['TL_DCA']['tl_survey'] = [
|
||||
],
|
||||
'operations' => [
|
||||
'editheader' => [
|
||||
'label' => ['Umfrage bearbeiten', 'Umfrage bearbeiten'],
|
||||
'label' => &$GLOBALS['TL_LANG']['tl_survey']['editheader'],
|
||||
'href' => 'act=edit',
|
||||
'icon' => 'edit.svg',
|
||||
'primary' => true,
|
||||
'showInHeader' => false,
|
||||
],
|
||||
'edit' => [
|
||||
'label' => ['Fragen bearbeiten', 'Fragen der Umfrage bearbeiten'],
|
||||
'label' => &$GLOBALS['TL_LANG']['tl_survey']['edit'],
|
||||
'href' => 'table=tl_survey_content',
|
||||
'icon' => 'children.svg',
|
||||
'primary' => true,
|
||||
@@ -81,13 +81,13 @@ $GLOBALS['TL_DCA']['tl_survey'] = [
|
||||
'button_callback' => [SurveyDcaListener::class, 'generateDuplicateSurveyButton'],
|
||||
],
|
||||
'delete' => [
|
||||
'label' => ['Löschen', 'Eintrag wirklich löschen?'],
|
||||
'label' => &$GLOBALS['TL_LANG']['tl_survey']['delete'],
|
||||
'href' => 'act=delete',
|
||||
'icon' => 'delete.svg',
|
||||
'attributes' => 'onclick="if(!confirm(\'Eintrag wirklich löschen?\'))return false;Backend.getScrollOffset()"',
|
||||
],
|
||||
'show' => [
|
||||
'label' => ['Details', 'Details des Elements ID %s anzeigen'],
|
||||
'label' => &$GLOBALS['TL_LANG']['tl_survey']['show'],
|
||||
'href' => 'act=show',
|
||||
'icon' => 'show.svg',
|
||||
],
|
||||
@@ -148,17 +148,6 @@ $GLOBALS['TL_DCA']['tl_survey'] = [
|
||||
'relation' => ['type' => 'hasMany', 'load' => 'lazy', 'table' => 'tl_survey_category', 'field' => 'id'],
|
||||
'sql' => 'blob NULL',
|
||||
],
|
||||
'categoryFilter' => [
|
||||
'label' => &$GLOBALS['TL_LANG']['tl_survey']['categoryFilter'],
|
||||
'inputType' => 'select',
|
||||
'options_callback' => [SurveyDcaListener::class, 'getUsedCategoryFilterOptions'],
|
||||
'eval' => ['includeBlankOption' => true, 'chosen' => true, 'findInSet' => true],
|
||||
'sql' => "varchar(255) NOT NULL default ''",
|
||||
],
|
||||
'categorySummary' => [
|
||||
'label' => &$GLOBALS['TL_LANG']['tl_survey']['categorySummary'],
|
||||
'sql' => 'text NULL',
|
||||
],
|
||||
'published' => [
|
||||
'label' => &$GLOBALS['TL_LANG']['tl_survey']['published'],
|
||||
'inputType' => 'checkbox',
|
||||
@@ -253,7 +242,7 @@ if ('cli' !== PHP_SAPI) {
|
||||
System::getContainer()->get('database_connection')->fetchFirstColumn('SHOW COLUMNS FROM tl_survey'),
|
||||
);
|
||||
$hasListMetadataColumns = !array_diff(
|
||||
['categoryfilter', 'categorysummary', 'templatefilter', 'templatesummary', 'assignedmembersfilter', 'assignedmemberssummary'],
|
||||
['templatefilter', 'templatesummary', 'assignedmembersfilter', 'assignedmemberssummary'],
|
||||
$columnNames,
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
@@ -264,8 +253,6 @@ if ('cli' !== PHP_SAPI) {
|
||||
$GLOBALS['TL_DCA']['tl_survey']['list']['label']['fields'] = ['title', 'alias'];
|
||||
|
||||
unset(
|
||||
$GLOBALS['TL_DCA']['tl_survey']['fields']['categoryFilter'],
|
||||
$GLOBALS['TL_DCA']['tl_survey']['fields']['categorySummary'],
|
||||
$GLOBALS['TL_DCA']['tl_survey']['fields']['templateFilter'],
|
||||
$GLOBALS['TL_DCA']['tl_survey']['fields']['templateSummary'],
|
||||
$GLOBALS['TL_DCA']['tl_survey']['fields']['assignedMembersFilter'],
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
$GLOBALS['TL_LANG']['tl_survey']['title'] = ['Titel', 'Titel der Umfrage.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['alias'] = ['Öffentlicher Link-Schlüssel', 'Wird automatisch als 20-stelliger Link-Schlüssel erzeugt.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['category'] = ['Kategorien', 'Ordnet die Umfrage einer oder mehreren Kategorien zu.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['categoryFilter'] = ['Kategorien', 'Filtert die Liste nach einer Umfrage-Kategorie.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['categorySummary'] = ['Kategorien', 'Zugeordnete Kategorien der Umfrage.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['isTemplate'] = ['Umfrage ist eine Vorlage', 'Wird den zugewiesenen Nutzern als Vorlage angezeigt.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['templateFilter'] = ['Vorlage', 'Filtert die Liste nach Vorlagenstatus.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['templateSummary'] = ['Vorlage', 'Zeigt an, ob die Umfrage als Vorlage markiert ist.'];
|
||||
@@ -19,7 +17,6 @@ $GLOBALS['TL_LANG']['tl_survey']['meta_legend'] = 'Verwaltungsdaten';
|
||||
$GLOBALS['TL_LANG']['tl_survey']['published'] = ['Veröffentlicht', 'Die Umfrage öffentlich freischalten.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['isActive'] = ['Vorübergehend aktiv', 'Schaltet eine bereits veröffentlichte Umfrage öffentlich an oder aus.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['isLocked'] = ['Gesperrt', 'Struktur ist wegen vorhandener Antworten gesperrt.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['publishSurvey'] = ['Umfrage veröffentlichen', 'Veröffentlicht die Umfrage endgültig.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['publishedWarning'] = ['Veröffentlichungshinweis', 'Zeigt einen Warnhinweis bei veröffentlichten Umfragen.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['toggleActive'] = ['Sichtbarkeit umschalten', 'Schaltet die veröffentlichte Umfrage vorübergehend an oder aus.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['assignedMembers'] = ['Zugewiesene Mitglieder', 'Wählen Sie die Mitglieder aus, die diese Umfrage bearbeiten dürfen.'];
|
||||
@@ -30,6 +27,10 @@ $GLOBALS['TL_LANG']['tl_survey']['duplicateSurvey'] = ['Duplizieren', 'Diese Umf
|
||||
$GLOBALS['TL_LANG']['tl_survey']['duplicateSurveyConfirm'] = 'Sind Sie sicher, dass Sie die Umfrage "%s" mit allen Fragen duplizieren wollen?';
|
||||
$GLOBALS['TL_LANG']['tl_survey']['duplicateSurveyDone'] = 'Die Umfrage "%s" wurde dupliziert.';
|
||||
$GLOBALS['TL_LANG']['tl_survey']['showResults'] = ['Ergebnisse anzeigen', 'Die Ergebnisse dieser Umfrage im Frontend anzeigen'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['editheader'] = ['Umfrage bearbeiten', 'Umfrage bearbeiten'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['edit'] = ['Fragen bearbeiten', 'Fragen der Umfrage bearbeiten'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['delete'] = ['Löschen', 'Eintrag wirklich löschen?'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['show'] = ['Details', 'Details des Elements ID %s anzeigen'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['createdBy'] = ['Angelegt von', 'ID des erstellenden Mitglieds.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['updatedBy'] = ['Aktualisiert von', 'ID des zuletzt bearbeitenden Mitglieds.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['createdAt'] = ['Angelegt am', 'Zeitpunkt der Erstellung.'];
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
$GLOBALS['TL_LANG']['tl_survey']['title'] = ['Title', 'Title of the survey.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['alias'] = ['Public link key', 'Automatically generated as a 20-character link key.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['category'] = ['Categories', 'Assigns the survey to one or more categories.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['categoryFilter'] = ['Categories', 'Filters the list by a survey category.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['categorySummary'] = ['Categories', 'Assigned categories of the survey.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['isTemplate'] = ['Survey is a template', 'Shown to assigned users as a template.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['templateFilter'] = ['Template', 'Filters the list by template status.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['templateSummary'] = ['Template', 'Shows whether the survey is marked as a template.'];
|
||||
@@ -19,7 +17,6 @@ $GLOBALS['TL_LANG']['tl_survey']['meta_legend'] = 'Administrative data';
|
||||
$GLOBALS['TL_LANG']['tl_survey']['published'] = ['Published', 'Make the survey publicly accessible.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['isActive'] = ['Temporarily active', 'Turns an already published survey on or off publicly.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['isLocked'] = ['Locked', 'Structure is locked because answers already exist.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['publishSurvey'] = ['Publish survey', 'Publishes the survey permanently.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['publishedWarning'] = ['Publication warning', 'Shows a warning hint for already published surveys.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['toggleActive'] = ['Toggle visibility', 'Temporarily switches the published survey on or off.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['assignedMembers'] = ['Assigned members', 'Choose the members who are allowed to edit this survey.'];
|
||||
@@ -30,6 +27,10 @@ $GLOBALS['TL_LANG']['tl_survey']['duplicateSurvey'] = ['Duplicate', 'Duplicate t
|
||||
$GLOBALS['TL_LANG']['tl_survey']['duplicateSurveyConfirm'] = 'Are you sure you want to duplicate the survey "%s" with all questions?';
|
||||
$GLOBALS['TL_LANG']['tl_survey']['duplicateSurveyDone'] = 'The survey "%s" has been duplicated.';
|
||||
$GLOBALS['TL_LANG']['tl_survey']['showResults'] = ['Show results', 'Display the results of this survey in the frontend'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['editheader'] = ['Edit survey', 'Edit survey'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['edit'] = ['Edit questions', 'Edit the survey questions'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['delete'] = ['Delete', 'Really delete the entry?'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['show'] = ['Details', 'Show the details of entry ID %s'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['createdBy'] = ['Created by', 'ID of the creating member.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['updatedBy'] = ['Updated by', 'ID of the member who last edited it.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['createdAt'] = ['Created on', 'Creation time.'];
|
||||
|
||||
@@ -85,6 +85,12 @@
|
||||
margin-bottom: 4em;
|
||||
}
|
||||
|
||||
.survey-shell--reader {
|
||||
max-width: 1000px;
|
||||
margin-left: max(var(--survey-page-gutter), calc((100% - 1000px) / 2));
|
||||
margin-right: max(var(--survey-page-gutter), calc((100% - 1000px) / 2));
|
||||
}
|
||||
|
||||
.survey-shell--template {
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(236, 124, 50, 0.18), transparent 32%),
|
||||
@@ -489,6 +495,28 @@
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Reader: Zurück/Weiter füllen die volle Breite und sind so groß wie die Ja/Nein-Karten */
|
||||
.survey-shell--reader .survey-button-row--navigation {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.survey-shell--reader .survey-button-row--navigation.has-back {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.survey-shell--reader .survey-navigation-form--back {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.survey-shell--reader .survey-button-row--navigation .survey-button {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 1.25rem;
|
||||
border-radius: 1.5rem;
|
||||
}
|
||||
|
||||
.survey-publish-toggle-form {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -725,82 +753,6 @@
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.survey-accordion-sort-handle {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
min-width: 3.5rem;
|
||||
padding: 0 1rem;
|
||||
border: 0;
|
||||
border-left: 1px solid rgba(162, 168, 180, 0.18);
|
||||
background: transparent;
|
||||
cursor: grab;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-self: stretch;
|
||||
transition: background 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
.survey-accordion-sort-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.survey-accordion-item .handorgel__header__button:hover + .survey-accordion-sort-handle,
|
||||
.survey-accordion-item .handorgel__header__button:focus + .survey-accordion-sort-handle,
|
||||
.survey-accordion-item .handorgel__header--opened .survey-accordion-sort-handle,
|
||||
.survey-accordion-sort-handle:hover,
|
||||
.survey-accordion-sort-handle:focus {
|
||||
background: var(--survey-blue);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.survey-sort-icon {
|
||||
display: inline-flex;
|
||||
width: 0.95rem;
|
||||
height: 1.28rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #58a6da;
|
||||
line-height: 0;
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.survey-sort-icon svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
|
||||
.survey-question-order-save-shell {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
right: 1rem;
|
||||
z-index: 40;
|
||||
transform: translate(120%, -50%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: transform 0.26s ease, opacity 0.26s ease;
|
||||
}
|
||||
|
||||
.survey-question-order-save-shell.is-visible {
|
||||
transform: translate(0, -50%);
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.survey-question-order-save {
|
||||
box-shadow: 0 18px 40px rgba(0, 59, 102, 0.24);
|
||||
}
|
||||
|
||||
.survey-accordion-item.is-reordering {
|
||||
box-shadow: 0 20px 44px rgba(0, 59, 102, 0.18);
|
||||
}
|
||||
|
||||
.survey-accordion-item.sortable-ghost {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.survey-accordion-item .handorgel__header__button,
|
||||
.survey-accordion-item .handorgel__header__button:hover,
|
||||
.survey-accordion-item .handorgel__header__button:focus {
|
||||
@@ -1075,22 +1027,6 @@
|
||||
padding: 0.95rem 1rem;
|
||||
}
|
||||
|
||||
.survey-accordion-sort-handle {
|
||||
min-width: 3.1rem;
|
||||
padding: 0 0.8rem;
|
||||
}
|
||||
|
||||
.survey-question-order-save-shell {
|
||||
top: auto;
|
||||
right: 0.85rem;
|
||||
bottom: 0.85rem;
|
||||
transform: translate(0, 130%);
|
||||
}
|
||||
|
||||
.survey-question-order-save-shell.is-visible {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
@@ -1249,133 +1185,6 @@
|
||||
});
|
||||
});
|
||||
|
||||
var initializeQuestionSorting = function () {
|
||||
var styleId = 'survey-question-sort-icon-runtime';
|
||||
|
||||
if (!document.getElementById(styleId)) {
|
||||
var runtimeStyle = document.createElement('style');
|
||||
runtimeStyle.id = styleId;
|
||||
runtimeStyle.textContent = '' +
|
||||
'.survey-sort-icon{' +
|
||||
'width:1.14rem !important;' +
|
||||
'height:1.52rem !important;' +
|
||||
'color:#58a6da !important;' +
|
||||
'background:none !important;' +
|
||||
'transform:translateY(1px) !important;' +
|
||||
'}' +
|
||||
'.survey-sort-icon::before,.survey-sort-icon::after{' +
|
||||
'content:none !important;' +
|
||||
'}' +
|
||||
'.survey-sort-icon svg{' +
|
||||
'display:block !important;' +
|
||||
'width:100% !important;' +
|
||||
'height:100% !important;' +
|
||||
'transform:translateY(4px);' +
|
||||
'}' +
|
||||
'.survey-sort-icon svg path:first-child{' +
|
||||
'transform:translateY(1.4px);' +
|
||||
'transform-box:fill-box;' +
|
||||
'transform-origin:center;' +
|
||||
'}' +
|
||||
'.survey-sort-icon svg path:nth-child(2){' +
|
||||
'transform:translateY(-1.4px);' +
|
||||
'transform-box:fill-box;' +
|
||||
'transform-origin:center;' +
|
||||
'}' +
|
||||
'.survey-sort-icon svg path[stroke]{' +
|
||||
'stroke-width:2px;' +
|
||||
'}';
|
||||
|
||||
document.head.appendChild(runtimeStyle);
|
||||
}
|
||||
|
||||
if (typeof Sortable !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
var sortableList = document.querySelector('[data-survey-question-sortable]');
|
||||
var orderForm = document.querySelector('[data-survey-question-order-shell]');
|
||||
var orderInput = document.querySelector('[data-survey-question-order-input]');
|
||||
|
||||
if (!sortableList || !orderForm || !orderInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sortableList._surveyQuestionSortable) {
|
||||
return;
|
||||
}
|
||||
|
||||
var getOrderedIds = function () {
|
||||
return Array.from(sortableList.querySelectorAll('[data-survey-question-item]')).map(function (item) {
|
||||
return item.getAttribute('data-question-id') || '';
|
||||
}).filter(function (value) {
|
||||
return value !== '';
|
||||
});
|
||||
};
|
||||
|
||||
var initialOrder = getOrderedIds().join(',');
|
||||
|
||||
var updateOrderField = function () {
|
||||
orderInput.value = getOrderedIds().join(',');
|
||||
};
|
||||
|
||||
var updateQuestionIndices = function () {
|
||||
sortableList.querySelectorAll('[data-survey-question-item]').forEach(function (item, index) {
|
||||
var indexNode = item.querySelector('.survey-accordion-index');
|
||||
|
||||
if (indexNode) {
|
||||
indexNode.textContent = (index + 1) + '.';
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
var updateSaveVisibility = function () {
|
||||
var currentOrder = getOrderedIds().join(',');
|
||||
var isDirty = currentOrder !== initialOrder;
|
||||
|
||||
orderForm.hidden = !isDirty;
|
||||
orderForm.classList.toggle('is-visible', isDirty);
|
||||
updateOrderField();
|
||||
};
|
||||
|
||||
sortableList.querySelectorAll('[data-survey-question-sort-handle]').forEach(function (handle) {
|
||||
handle.addEventListener('click', function (event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
});
|
||||
});
|
||||
|
||||
updateQuestionIndices();
|
||||
updateOrderField();
|
||||
updateSaveVisibility();
|
||||
|
||||
var sortableInstance = Sortable.create(sortableList, {
|
||||
animation: 180,
|
||||
draggable: '[data-survey-question-item]',
|
||||
handle: '[data-survey-question-sort-handle]',
|
||||
ghostClass: 'sortable-ghost',
|
||||
forceFallback: true,
|
||||
fallbackTolerance: 3,
|
||||
onStart: function (event) {
|
||||
if (event.item) {
|
||||
event.item.classList.add('is-reordering');
|
||||
}
|
||||
},
|
||||
onEnd: function (event) {
|
||||
if (event.item) {
|
||||
event.item.classList.remove('is-reordering');
|
||||
}
|
||||
|
||||
updateQuestionIndices();
|
||||
updateSaveVisibility();
|
||||
}
|
||||
});
|
||||
|
||||
sortableList._surveyQuestionSortable = sortableInstance;
|
||||
};
|
||||
|
||||
window.initializeSurveyQuestionSorting = initializeQuestionSorting;
|
||||
|
||||
if (typeof handorgel === 'function') {
|
||||
document.querySelectorAll('[data-survey-handorgel]').forEach(function (element) {
|
||||
var accordion = new handorgel(element, {
|
||||
@@ -1395,7 +1204,6 @@
|
||||
});
|
||||
}
|
||||
|
||||
initializeQuestionSorting();
|
||||
|
||||
resetEditorViewport();
|
||||
});
|
||||
|
||||
@@ -70,17 +70,17 @@
|
||||
|
||||
.survey-question-order-save-shell {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
right: 1rem;
|
||||
bottom: 1.5rem;
|
||||
right: 1.5rem;
|
||||
z-index: 40;
|
||||
transform: translate(120%, -50%);
|
||||
transform: translate(120%, 0);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: transform 0.26s ease, opacity 0.26s ease;
|
||||
}
|
||||
|
||||
.survey-question-order-save-shell.is-visible {
|
||||
transform: translate(0, -50%);
|
||||
transform: translate(0, 0);
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
@@ -115,5 +115,5 @@
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.6/Sortable.min.js"></script>
|
||||
<script src="{{ asset('bundles/survey/js/sortable.min.js') }}"></script>
|
||||
<script src="{{ asset('bundles/survey/js/survey-frontend-editor-sort.js') }}"></script>
|
||||
@@ -51,6 +51,7 @@
|
||||
<div class="survey-list-card__head-actions survey-list-card__head-actions--template">
|
||||
<form method="post" class="survey-question-action-form">
|
||||
<input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('duplicate-survey-' ~ survey.id) }}">
|
||||
<input type="hidden" name="_survey_action" value="duplicate-survey">
|
||||
<input type="hidden" name="item_id" value="{{ survey.id }}">
|
||||
<button class="survey-button secondary" onclick="return confirm('{{ 'survey.list.duplicate_confirm'|trans({'%title%': survey.title|striptags|trim})|e('js') }}');">{{ 'survey.list.duplicate'|trans }}</button>
|
||||
@@ -79,12 +80,14 @@
|
||||
{% endif %}
|
||||
<form method="post" class="survey-question-action-form">
|
||||
<input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('duplicate-survey-' ~ survey.id) }}">
|
||||
<input type="hidden" name="_survey_action" value="duplicate-survey">
|
||||
<input type="hidden" name="item_id" value="{{ survey.id }}">
|
||||
<button class="survey-button secondary" onclick="return confirm('{{ 'survey.list.duplicate_confirm'|trans({'%title%': survey.title|striptags|trim})|e('js') }}');">{{ 'survey.list.duplicate'|trans }}</button>
|
||||
</form>
|
||||
<form method="post" class="survey-question-action-form">
|
||||
<input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('delete-survey-' ~ survey.id) }}">
|
||||
<input type="hidden" name="_survey_action" value="delete-survey">
|
||||
<input type="hidden" name="item_id" value="{{ survey.id }}">
|
||||
<button class="survey-button danger" onclick="return confirm('{{ 'survey.list.delete_confirm'|trans|e('js') }}') && confirm('{{ 'survey.list.delete_confirm_second'|trans|e('js') }}');">{{ 'survey.list.delete'|trans }}</button>
|
||||
@@ -94,6 +97,7 @@
|
||||
<div class="survey-list-card__head-actions">
|
||||
<form method="post" class="survey-list-publish-form">
|
||||
<input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('publish-survey-' ~ survey.id) }}">
|
||||
<input type="hidden" name="_survey_action" value="publish-survey">
|
||||
<input type="hidden" name="item_id" value="{{ survey.id }}">
|
||||
<button
|
||||
@@ -111,6 +115,7 @@
|
||||
|
||||
<form method="post" class="survey-list-eye-form">
|
||||
<input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('toggle-active-' ~ survey.id) }}">
|
||||
<input type="hidden" name="_survey_action" value="toggle-active">
|
||||
<input type="hidden" name="item_id" value="{{ survey.id }}">
|
||||
<button type="submit" class="survey-visibility-dot{{ survey.isActive ? ' is-on' : '' }}" title="{{ survey.isActive ? 'survey.edit.deactivate_survey'|trans : 'survey.edit.activate_survey'|trans }}" aria-label="{{ survey.isActive ? 'survey.edit.deactivate_survey'|trans : 'survey.edit.activate_survey'|trans }}"></button>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{% set moduleHeadline = (headline is iterable ? headline.text|default('') : headline|default(''))|striptags|trim %}
|
||||
|
||||
<section class="survey-shell survey-grid">
|
||||
<section class="survey-shell survey-shell--reader survey-grid">
|
||||
{% if moduleHeadline %}
|
||||
<header><h2>{{ moduleHeadline }}</h2></header>
|
||||
{% endif %}
|
||||
@@ -63,7 +63,10 @@
|
||||
{% for child in surveyForm.answer %}
|
||||
<label class="survey-card" style="cursor: pointer;">
|
||||
<div class="survey-meta"><strong>{{ child.vars.label|striptags|trim }}</strong>{{ form_widget(child) }}</div>
|
||||
<div>{{ 'survey.survey.answer_flow_hint'|trans }}</div>
|
||||
{% set jumpHint = jumpHints|default({})[child.vars.value]|default(null) %}
|
||||
{% if jumpHint %}
|
||||
<div class="survey-jump-hint">{{ 'survey.survey.jump_to'|trans({'%position%': jumpHint.position, '%label%': jumpHint.label|striptags|trim}) }}</div>
|
||||
{% endif %}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
@@ -170,6 +170,7 @@ nav.mod_customnav.block:has(a[href*="/abmelden"]) {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
box-sizing: border-box;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
nav.mod_customnav.block:has(a[href*="/abmelden"]) .invisible {
|
||||
|
||||
Vendored
+2
File diff suppressed because one or more lines are too long
@@ -108,9 +108,29 @@
|
||||
document.addEventListener('DOMContentLoaded', normalizeSearchField);
|
||||
document.addEventListener('turbo:load', normalizeSearchField);
|
||||
|
||||
var observer = new MutationObserver(function () {
|
||||
// Mutationen werden gebündelt: viele DOM-Änderungen lösen nur einen
|
||||
// normalizeSearchField-Lauf pro Frame aus statt einen pro Mutation.
|
||||
var scheduled = false;
|
||||
var scheduleNormalize = function () {
|
||||
if (scheduled) {
|
||||
return;
|
||||
}
|
||||
|
||||
scheduled = true;
|
||||
|
||||
var run = function () {
|
||||
scheduled = false;
|
||||
normalizeSearchField();
|
||||
});
|
||||
};
|
||||
|
||||
if (typeof window.requestAnimationFrame === 'function') {
|
||||
window.requestAnimationFrame(run);
|
||||
} else {
|
||||
window.setTimeout(run, 100);
|
||||
}
|
||||
};
|
||||
|
||||
var observer = new MutationObserver(scheduleNormalize);
|
||||
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true });
|
||||
})();
|
||||
@@ -9,7 +9,7 @@
|
||||
style.textContent = [
|
||||
'.survey-sort-icon{width:1.14rem !important;height:1.52rem !important;color:#58a6da !important;background:none !important;transform:translateY(1px) !important;}',
|
||||
'.survey-sort-icon::before,.survey-sort-icon::after{content:none !important;}',
|
||||
'.survey-sort-icon svg{display:block !important;width:100% !important;height:100% !important;transform:translateY(4px);}',
|
||||
'.survey-sort-icon svg{display:block !important;width:100% !important;height:100% !important;transform:translateY(5px);}',
|
||||
'.survey-sort-icon svg path:first-child{transform:translateY(1.4px);transform-box:fill-box;transform-origin:center;}',
|
||||
'.survey-sort-icon svg path:nth-child(2){transform:translateY(-1.4px);transform-box:fill-box;transform-origin:center;}',
|
||||
'.survey-sort-icon svg path[stroke]{stroke-width:2px;}'
|
||||
|
||||
@@ -68,9 +68,9 @@ final class MemberSurveyListController extends AbstractFrontendModuleController
|
||||
'questionCount' => (int) ($survey['questionCount'] ?? 0),
|
||||
'participationCount' => (int) ($survey['participationCount'] ?? 0),
|
||||
'editUrl' => $editPage instanceof PageModel ? $this->generateContentUrl($editPage, ['survey' => $surveyId]) : null,
|
||||
'publicUrl' => $readerPage instanceof PageModel ? $this->generateContentUrl($readerPage, ['auto_item' => $alias]) : null,
|
||||
'draftUrl' => $readerPage instanceof PageModel ? $this->generateContentUrl($readerPage, ['auto_item' => $alias, 'preview' => '1']) : null,
|
||||
'resultsUrl' => $resultsPage instanceof PageModel ? $this->generateContentUrl($resultsPage, ['auto_item' => $alias]) : null,
|
||||
'publicUrl' => $readerPage instanceof PageModel ? $this->generateContentUrl($readerPage, ['parameters' => '/'.$alias]) : null,
|
||||
'draftUrl' => $readerPage instanceof PageModel ? $this->generateContentUrl($readerPage, ['parameters' => '/'.$alias, 'preview' => '1']) : null,
|
||||
'resultsUrl' => $resultsPage instanceof PageModel ? $this->generateContentUrl($resultsPage, ['parameters' => '/'.$alias]) : null,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -90,8 +90,16 @@ final class MemberSurveyListController extends AbstractFrontendModuleController
|
||||
{
|
||||
$surveyId = (int) $request->request->get('item_id', 0);
|
||||
$action = (string) $request->request->get('_survey_action');
|
||||
$token = (string) $request->request->get('_token', '');
|
||||
|
||||
if ($surveyId <= 0 || '' === $action) {
|
||||
throw new \RuntimeException('Ungültige Umfrage-Aktion.');
|
||||
}
|
||||
|
||||
// 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.');
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace Mummert\SurveyBundle\Controller\FrontendModule;
|
||||
use Contao\CoreBundle\Controller\FrontendModule\AbstractFrontendModuleController;
|
||||
use Contao\CoreBundle\DependencyInjection\Attribute\AsFrontendModule;
|
||||
use Contao\CoreBundle\Twig\FragmentTemplate;
|
||||
use Contao\Input;
|
||||
use Contao\ModuleModel;
|
||||
use Mummert\SurveyBundle\Model\SurveyContentModel;
|
||||
use Mummert\SurveyBundle\Model\SurveyModel;
|
||||
@@ -126,6 +127,7 @@ final class ShowSurveyController extends AbstractFrontendModuleController
|
||||
$template->set('survey', $survey);
|
||||
$template->set('progress', $this->surveyFlowService->getProgress($survey, $question));
|
||||
$template->set('question', $question);
|
||||
$template->set('jumpHints', $this->buildJumpHints($survey, $question));
|
||||
$template->set('surveyForm', $form->createView());
|
||||
$template->set('isComplete', false);
|
||||
$template->set('answers', []);
|
||||
@@ -136,6 +138,30 @@ final class ShowSurveyController extends AbstractFrontendModuleController
|
||||
return $template->getResponse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sprung-Hinweise je Ja/Nein-Option für die aktuelle Frage (nur bei konfigurierter Sprunglogik).
|
||||
*
|
||||
* @return array<string, array{position:int,label:string}>
|
||||
*/
|
||||
private function buildJumpHints(SurveyModel $survey, SurveyContentModel $question): array
|
||||
{
|
||||
if ('yes_no_maybe' !== (string) $question->type) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$hints = [];
|
||||
|
||||
if (null !== $yes = $this->surveyFlowService->describeJumpTarget($survey, (int) ($question->jumpOnYes ?? 0))) {
|
||||
$hints['yes'] = $yes;
|
||||
}
|
||||
|
||||
if (null !== $no = $this->surveyFlowService->describeJumpTarget($survey, (int) ($question->jumpOnNo ?? 0))) {
|
||||
$hints['no'] = $no;
|
||||
}
|
||||
|
||||
return $hints;
|
||||
}
|
||||
|
||||
private function buildPreviewResponse(FragmentTemplate $template, SurveyModel $survey, Request $request): Response
|
||||
{
|
||||
$history = $this->decodePreviewHistory((string) $request->request->get('preview_history', ''));
|
||||
@@ -198,6 +224,7 @@ final class ShowSurveyController extends AbstractFrontendModuleController
|
||||
$template->set('survey', $survey);
|
||||
$template->set('progress', $this->surveyFlowService->getProgress($survey, $question));
|
||||
$template->set('question', $question);
|
||||
$template->set('jumpHints', $this->buildJumpHints($survey, $question));
|
||||
$template->set('surveyForm', $form->createView());
|
||||
$template->set('isComplete', false);
|
||||
$template->set('answers', []);
|
||||
@@ -265,7 +292,10 @@ final class ShowSurveyController extends AbstractFrontendModuleController
|
||||
|
||||
private function resolvePublicAlias(Request $request): string
|
||||
{
|
||||
$candidate = $request->attributes->get('auto_item');
|
||||
// Bei sauberen Pfad-URLs (/seite/<alias>) legt Contao den Wert im Input-Adapter
|
||||
// ab und markiert ihn als Route-Parameter. Input::get liest ihn aus UND markiert
|
||||
// ihn als benutzt – sonst wirft Contao 404 (unbenutzter Route-Parameter).
|
||||
$candidate = $this->getContaoAdapter(Input::class)->get('auto_item');
|
||||
|
||||
if (\is_string($candidate) && '' !== trim($candidate)) {
|
||||
return trim($candidate);
|
||||
|
||||
@@ -8,6 +8,7 @@ use Contao\Config;
|
||||
use Contao\CoreBundle\Controller\FrontendModule\AbstractFrontendModuleController;
|
||||
use Contao\CoreBundle\DependencyInjection\Attribute\AsFrontendModule;
|
||||
use Contao\CoreBundle\Twig\FragmentTemplate;
|
||||
use Contao\Input;
|
||||
use Contao\ModuleModel;
|
||||
use Mummert\SurveyBundle\Repository\SurveyRepository;
|
||||
use Mummert\SurveyBundle\Service\SurveyResultsViewService;
|
||||
@@ -82,7 +83,9 @@ final class ShowSurveyResultsController extends AbstractFrontendModuleController
|
||||
|
||||
private function resolvePublicAlias(Request $request): string
|
||||
{
|
||||
$candidate = $request->attributes->get('auto_item');
|
||||
// Bei sauberen Pfad-URLs (/seite/<alias>) liegt der Wert im Contao-Input-Adapter;
|
||||
// Input::get liest ihn aus und markiert ihn als benutzt (sonst Contao-404).
|
||||
$candidate = $this->getContaoAdapter(Input::class)->get('auto_item');
|
||||
|
||||
if (\is_string($candidate) && '' !== trim($candidate)) {
|
||||
return trim($candidate);
|
||||
|
||||
@@ -14,7 +14,6 @@ use Contao\Image;
|
||||
use Contao\Input;
|
||||
use Contao\Message;
|
||||
use Contao\PageModel;
|
||||
use Contao\StringUtil;
|
||||
use Contao\System;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Mummert\SurveyBundle\Repository\SurveyEditorRepository;
|
||||
@@ -213,54 +212,6 @@ final class SurveyDcaListener
|
||||
return $this->surveyEditorRepository->findMemberChoices();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function getCategoryOptions(): array
|
||||
{
|
||||
$rows = $this->connection->fetchAllAssociative('SELECT id, title FROM tl_survey_category ORDER BY sorting ASC, title ASC');
|
||||
$options = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$options[(int) $row['id']] = (string) $row['title'];
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function getUsedCategoryFilterOptions(): array
|
||||
{
|
||||
$usedCategoryIds = [];
|
||||
|
||||
foreach ($this->connection->fetchFirstColumn('SELECT category FROM tl_survey') as $rawCategoryValue) {
|
||||
foreach (StringUtil::deserialize($rawCategoryValue, true) as $categoryId) {
|
||||
$categoryId = (int) $categoryId;
|
||||
|
||||
if ($categoryId > 0) {
|
||||
$usedCategoryIds[$categoryId] = $categoryId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ([] === $usedCategoryIds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$allCategoryOptions = $this->getCategoryOptions();
|
||||
$options = [];
|
||||
|
||||
foreach (array_values($usedCategoryIds) as $categoryId) {
|
||||
if (isset($allCategoryOptions[$categoryId])) {
|
||||
$options[$categoryId] = $allCategoryOptions[$categoryId];
|
||||
}
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
|
||||
@@ -15,7 +15,9 @@ final class SurveyEditorData
|
||||
{
|
||||
$data = new self();
|
||||
$data->title = (string) $survey->title;
|
||||
$data->description = (string) $survey->description;
|
||||
// Beschreibung ist ein Klartextfeld; evtl. gespeicherte HTML-Tags (z. B. <p>)
|
||||
// entfernen, damit das Textarea reinen Text zeigt.
|
||||
$data->description = trim(strip_tags((string) $survey->description));
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
@@ -199,8 +199,6 @@ final class SurveyRepository
|
||||
return;
|
||||
}
|
||||
|
||||
$categoryIds = $this->normalizeCategoryIds($survey->category);
|
||||
$categoryTitles = $this->fetchCategoryTitles($categoryIds);
|
||||
$assignedMembers = $this->fetchAssignedMembers($surveyId);
|
||||
$memberIds = [];
|
||||
$memberNames = [];
|
||||
@@ -211,8 +209,6 @@ final class SurveyRepository
|
||||
}
|
||||
|
||||
$this->connection->update('tl_survey', [
|
||||
'categoryFilter' => implode(',', $categoryIds),
|
||||
'categorySummary' => implode(', ', $categoryTitles),
|
||||
'templateFilter' => '1' === (string) $survey->isTemplate ? '1' : '0',
|
||||
'templateSummary' => '1' === (string) $survey->isTemplate ? 'Ja' : 'Nein',
|
||||
'assignedMembersFilter' => implode(',', $memberIds),
|
||||
@@ -333,39 +329,6 @@ final class SurveyRepository
|
||||
return false !== $qb->executeQuery()->fetchOne();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $categoryIds
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function fetchCategoryTitles(array $categoryIds): array
|
||||
{
|
||||
if ([] === $categoryIds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($categoryIds), '?'));
|
||||
$rows = $this->connection->fetchAllAssociative(
|
||||
sprintf('SELECT id, title FROM tl_survey_category WHERE id IN (%s)', $placeholders),
|
||||
$categoryIds,
|
||||
);
|
||||
$titlesById = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$titlesById[(int) $row['id']] = (string) $row['title'];
|
||||
}
|
||||
|
||||
$titles = [];
|
||||
|
||||
foreach ($categoryIds as $categoryId) {
|
||||
if (isset($titlesById[$categoryId])) {
|
||||
$titles[] = $titlesById[$categoryId];
|
||||
}
|
||||
}
|
||||
|
||||
return $titles;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{id:int,name:string}>
|
||||
*/
|
||||
@@ -410,7 +373,7 @@ final class SurveyRepository
|
||||
);
|
||||
|
||||
return $this->hasListMetadataColumns = !array_diff(
|
||||
['categoryfilter', 'categorysummary', 'templatefilter', 'templatesummary', 'assignedmembersfilter', 'assignedmemberssummary'],
|
||||
['templatefilter', 'templatesummary', 'assignedmembersfilter', 'assignedmemberssummary'],
|
||||
$columnNames,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,16 +24,6 @@ final class SurveySubmissionRepository
|
||||
return $model instanceof SurveySubmissionModel ? $model : null;
|
||||
}
|
||||
|
||||
public function findOpenBySurveyAndToken(int $surveyId, string $token): ?SurveySubmissionModel
|
||||
{
|
||||
$id = $this->connection->fetchOne(
|
||||
'SELECT id FROM tl_survey_submission WHERE survey = ? AND token = ? AND isFinished != 1 ORDER BY id DESC LIMIT 1',
|
||||
[$surveyId, $token],
|
||||
);
|
||||
|
||||
return false === $id ? null : $this->findById((int) $id);
|
||||
}
|
||||
|
||||
public function findBySurveyAndToken(int $surveyId, string $token): ?SurveySubmissionModel
|
||||
{
|
||||
$id = $this->connection->fetchOne(
|
||||
@@ -44,14 +34,6 @@ final class SurveySubmissionRepository
|
||||
return false === $id ? null : $this->findById((int) $id);
|
||||
}
|
||||
|
||||
public function hasFinishedBySurveyAndToken(int $surveyId, string $token): bool
|
||||
{
|
||||
return false !== $this->connection->fetchOne(
|
||||
'SELECT id FROM tl_survey_submission WHERE survey = ? AND token = ? AND isFinished = 1 ORDER BY id DESC LIMIT 1',
|
||||
[$surveyId, $token],
|
||||
);
|
||||
}
|
||||
|
||||
public function create(int $surveyId, string $token, ?int $currentQuestionId): SurveySubmissionModel
|
||||
{
|
||||
$now = time();
|
||||
|
||||
@@ -264,7 +264,6 @@
|
||||
{% elseif question.type == 'range' %}
|
||||
<div class="metric-row">
|
||||
<div class="metric-box">
|
||||
<span class="metric-label">Minimum</span>
|
||||
<span class="metric-label">{{ 'survey.pdf.minimum'|trans({}, 'messages') }}</span>
|
||||
<span class="metric-value">{{ question.minimum is not null ? question.minimum : '-' }}</span>
|
||||
</div>
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\SurveyBundle\Security;
|
||||
|
||||
use Contao\FrontendUser;
|
||||
use Mummert\SurveyBundle\Model\SurveyModel;
|
||||
use Mummert\SurveyBundle\Service\SurveyPermissionService;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
|
||||
final class SurveyEditorVoter extends Voter
|
||||
{
|
||||
public const string EDIT = 'SURVEY_EDIT';
|
||||
|
||||
public function __construct(private readonly SurveyPermissionService $surveyPermissionService)
|
||||
{
|
||||
}
|
||||
|
||||
protected function supports(string $attribute, mixed $subject): bool
|
||||
{
|
||||
return self::EDIT === $attribute && $subject instanceof SurveyModel;
|
||||
}
|
||||
|
||||
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
|
||||
{
|
||||
$user = $token->getUser();
|
||||
|
||||
if (!$user instanceof FrontendUser || !$subject instanceof SurveyModel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->surveyPermissionService->canEditSurvey($subject, $user);
|
||||
}
|
||||
}
|
||||
@@ -112,6 +112,29 @@ final class SurveyFlowService
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Beschreibt das Sprungziel einer Ja/Nein-Antwort über Positionsnummer und Label.
|
||||
*
|
||||
* @return array{position:int,label:string}|null
|
||||
*/
|
||||
public function describeJumpTarget(SurveyModel $survey, int $targetQuestionId): ?array
|
||||
{
|
||||
if ($targetQuestionId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($this->getOrderedQuestions($survey) as $index => $question) {
|
||||
if ((int) $question->id === $targetQuestionId) {
|
||||
return [
|
||||
'position' => $index + 1,
|
||||
'label' => trim((string) $question->question),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<SurveyContentModel>
|
||||
*/
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\SurveyBundle\Service;
|
||||
|
||||
use Contao\FrontendUser;
|
||||
use Mummert\SurveyBundle\Model\SurveyModel;
|
||||
use Mummert\SurveyBundle\Repository\SurveyEditorRepository;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
|
||||
final class SurveyPermissionService
|
||||
{
|
||||
public function __construct(private readonly SurveyEditorRepository $surveyEditorRepository)
|
||||
{
|
||||
}
|
||||
|
||||
public function canEditSurvey(SurveyModel $survey, FrontendUser $member): bool
|
||||
{
|
||||
return $this->surveyEditorRepository->isEditor((int) $survey->id, (int) $member->id);
|
||||
}
|
||||
|
||||
public function assertEditorAccess(SurveyModel $survey, FrontendUser $member): void
|
||||
{
|
||||
if (!$this->canEditSurvey($survey, $member)) {
|
||||
throw new AccessDeniedHttpException('Sie dürfen diese Umfrage nicht bearbeiten.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -136,23 +136,19 @@ final class SurveyResultsPdfService
|
||||
private function buildChartLegend(array $questionResult): array
|
||||
{
|
||||
$chart = $questionResult['chart'] ?? null;
|
||||
|
||||
if (!is_array($chart)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$chartType = (string) ($chart['type'] ?? '');
|
||||
$chartType = is_array($chart) ? (string) ($chart['type'] ?? '') : '';
|
||||
$sourceRows = match ($chartType) {
|
||||
'pie' => $questionResult['options'] ?? $questionResult['responseSummary'] ?? [],
|
||||
'line' => $questionResult['distribution'] ?? [],
|
||||
default => [],
|
||||
// Freitext hat kein Diagramm: Antwort-Häufigkeiten wie in der Online-Ansicht zeigen.
|
||||
default => $questionResult['responseSummary'] ?? [],
|
||||
};
|
||||
|
||||
if (!is_array($sourceRows) || [] === $sourceRows) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$dataset = $chart['data']['datasets'][0] ?? [];
|
||||
$dataset = is_array($chart) ? ($chart['data']['datasets'][0] ?? []) : [];
|
||||
$fillColors = match ($chartType) {
|
||||
'pie' => is_array($dataset['backgroundColor'] ?? null) ? $dataset['backgroundColor'] : [],
|
||||
'line' => is_array($dataset['pointBackgroundColor'] ?? null) ? $dataset['pointBackgroundColor'] : [],
|
||||
|
||||
@@ -50,12 +50,7 @@ final class SurveyResultsViewService
|
||||
$results = [];
|
||||
|
||||
foreach ($questions as $question) {
|
||||
$key = $this->buildQuestionResultKey(
|
||||
(int) $question->id,
|
||||
(string) $question->type,
|
||||
(string) $question->question,
|
||||
(string) ($question->description ?? ''),
|
||||
);
|
||||
$key = $this->buildQuestionResultKey((int) $question->id, (string) $question->type);
|
||||
|
||||
$results[$key] = $this->buildResultSkeleton(
|
||||
(int) $question->id,
|
||||
@@ -65,6 +60,7 @@ final class SurveyResultsViewService
|
||||
(int) ($question->sorting ?? 0),
|
||||
);
|
||||
$results[$key]['_choiceOptions'] = $this->getChoiceOptions($question);
|
||||
$results[$key]['_allowMaybe'] = '1' === (string) $question->allowMaybe;
|
||||
}
|
||||
|
||||
foreach ($answers as $answer) {
|
||||
@@ -72,7 +68,7 @@ final class SurveyResultsViewService
|
||||
$questionType = (string) ($answer['questionType'] ?? 'text');
|
||||
$questionLabel = trim((string) ($answer['questionLabel'] ?? '')) ?: 'Frage #'.$questionId;
|
||||
$questionDescription = (string) ($answer['questionDescription'] ?? '');
|
||||
$key = $this->buildQuestionResultKey($questionId, $questionType, $questionLabel, $questionDescription);
|
||||
$key = $this->buildQuestionResultKey($questionId, $questionType);
|
||||
|
||||
if (!isset($results[$key])) {
|
||||
$results[$key] = $this->buildResultSkeleton(
|
||||
@@ -104,9 +100,11 @@ final class SurveyResultsViewService
|
||||
return array_values($results);
|
||||
}
|
||||
|
||||
private function buildQuestionResultKey(int $questionId, string $questionType, string $questionLabel, string $questionDescription): string
|
||||
private function buildQuestionResultKey(int $questionId, string $questionType): string
|
||||
{
|
||||
return implode('|', [$questionId, $questionType, md5($questionLabel), md5($questionDescription)]);
|
||||
// Bewusst nur Frage-ID + Typ: So werden Antwort-Snapshots und die aktuelle
|
||||
// Frage auch dann zusammengeführt, wenn das Label nachträglich geändert wurde.
|
||||
return implode('|', [$questionId, $questionType]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,6 +123,7 @@ final class SurveyResultsViewService
|
||||
'_sorting' => $sorting,
|
||||
'_values' => [],
|
||||
'_choiceOptions' => [],
|
||||
'_allowMaybe' => false,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -146,6 +145,12 @@ final class SurveyResultsViewService
|
||||
case 'yes_no_maybe':
|
||||
$optionCounts = ['Ja' => 0, 'Nein' => 0];
|
||||
|
||||
// Wie beim Excel-Export: Bei erlaubtem "Vielleicht" die Zeile auch dann
|
||||
// zeigen, wenn (noch) niemand "Vielleicht" gewählt hat.
|
||||
if (!empty($result['_allowMaybe'])) {
|
||||
$optionCounts['Vielleicht'] = 0;
|
||||
}
|
||||
|
||||
foreach ($values as $value) {
|
||||
$normalizedValue = mb_strtolower(trim($value));
|
||||
|
||||
@@ -205,7 +210,7 @@ final class SurveyResultsViewService
|
||||
break;
|
||||
}
|
||||
|
||||
unset($result['_sorting'], $result['_values'], $result['_choiceOptions']);
|
||||
unset($result['_sorting'], $result['_values'], $result['_choiceOptions'], $result['_allowMaybe']);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,12 @@ use Symfony\Component\Uid\Ulid;
|
||||
|
||||
final class SurveySubmissionService
|
||||
{
|
||||
/**
|
||||
* Eine abgeschlossene Teilnahme bleibt für diese Dauer auf der Danke-Seite.
|
||||
* Danach darf dieselbe Browser-Session erneut an der Umfrage teilnehmen.
|
||||
*/
|
||||
private const RESTART_COOLDOWN_SECONDS = 60;
|
||||
|
||||
public function __construct(
|
||||
private readonly SurveySubmissionRepository $surveySubmissionRepository,
|
||||
private readonly SurveyAnswerRepository $surveyAnswerRepository,
|
||||
@@ -35,7 +41,9 @@ final class SurveySubmissionService
|
||||
if ('' !== $token) {
|
||||
$existing = $this->surveySubmissionRepository->findBySurveyAndToken((int) $survey->id, $token);
|
||||
|
||||
if ($existing instanceof SurveySubmissionModel) {
|
||||
// Offene Durchläufe werden fortgesetzt; abgeschlossene Durchläufe halten
|
||||
// die Danke-Seite, bis der Cooldown abgelaufen ist (dann neuer Durchlauf).
|
||||
if ($existing instanceof SurveySubmissionModel && !$this->isRestartAllowed($existing)) {
|
||||
return $existing;
|
||||
}
|
||||
}
|
||||
@@ -48,6 +56,21 @@ final class SurveySubmissionService
|
||||
return $submission;
|
||||
}
|
||||
|
||||
private function isRestartAllowed(SurveySubmissionModel $submission): bool
|
||||
{
|
||||
if ('1' !== (string) $submission->isFinished) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$completedAt = (int) ($submission->completedAt ?? 0);
|
||||
|
||||
if ($completedAt <= 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (time() - $completedAt) >= self::RESTART_COOLDOWN_SECONDS;
|
||||
}
|
||||
|
||||
public function storeAnswer(SurveyModel $survey, SurveySubmissionModel $submission, SurveyContentModel $question, mixed $rawAnswer): string
|
||||
{
|
||||
$normalizedAnswer = $this->normalizeAnswerForQuestion($question, $rawAnswer);
|
||||
|
||||
@@ -108,7 +108,7 @@ survey:
|
||||
answer_no: "Nein"
|
||||
answer_maybe: "Vielleicht"
|
||||
question_progress: "Frage %current%"
|
||||
answer_flow_hint: "Diese Antwort wird direkt für den weiteren Ablauf verwendet."
|
||||
jump_to: "weiter zu Frage %position%: %label%"
|
||||
current_value: "Aktueller Wert"
|
||||
back: "Zurück"
|
||||
next: "Weiter"
|
||||
|
||||
@@ -108,7 +108,7 @@ survey:
|
||||
answer_no: "No"
|
||||
answer_maybe: "Maybe"
|
||||
question_progress: "Question %current%"
|
||||
answer_flow_hint: "This answer is used directly for the further flow."
|
||||
jump_to: "continue to question %position%: %label%"
|
||||
current_value: "Current value"
|
||||
back: "Back"
|
||||
next: "Next"
|
||||
|
||||
Reference in New Issue
Block a user