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`
|
- Schaltet `isActive`
|
||||||
- Ist bewusst kein Auge mehr
|
- 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.
|
- Jedes Frontend-Aktionsformular (Liste **und** Editor) sendet bewusst **zwei** Tokens:
|
||||||
- Die zusätzliche Symfony-Aktions-CSRF-Prüfung wurde bewusst entfernt, weil sie in diesem Frontend-Flow zu ungültigen Tokens führte.
|
- `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
|
### 2. Frontend-Editor
|
||||||
|
|
||||||
@@ -191,6 +194,13 @@ Wichtige Regeln:
|
|||||||
- Entwurfsansicht arbeitet ohne Speicherung
|
- Entwurfsansicht arbeitet ohne Speicherung
|
||||||
- Öffentliche Ansicht speichert Antworten und erstellt/führt Submission fort
|
- Öffentliche Ansicht speichert Antworten und erstellt/führt Submission fort
|
||||||
- Rückwärtsnavigation ist implementiert
|
- 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
|
### 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.
|
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
|
## Rechtlicher Hinweis
|
||||||
|
|
||||||
Dieses Bundle ist proprietär und nicht zur öffentlichen Weitergabe bestimmt.
|
Dieses Bundle ist proprietär und nicht zur öffentlichen Weitergabe bestimmt.
|
||||||
|
|||||||
@@ -48,14 +48,14 @@ $GLOBALS['TL_DCA']['tl_survey'] = [
|
|||||||
],
|
],
|
||||||
'operations' => [
|
'operations' => [
|
||||||
'editheader' => [
|
'editheader' => [
|
||||||
'label' => ['Umfrage bearbeiten', 'Umfrage bearbeiten'],
|
'label' => &$GLOBALS['TL_LANG']['tl_survey']['editheader'],
|
||||||
'href' => 'act=edit',
|
'href' => 'act=edit',
|
||||||
'icon' => 'edit.svg',
|
'icon' => 'edit.svg',
|
||||||
'primary' => true,
|
'primary' => true,
|
||||||
'showInHeader' => false,
|
'showInHeader' => false,
|
||||||
],
|
],
|
||||||
'edit' => [
|
'edit' => [
|
||||||
'label' => ['Fragen bearbeiten', 'Fragen der Umfrage bearbeiten'],
|
'label' => &$GLOBALS['TL_LANG']['tl_survey']['edit'],
|
||||||
'href' => 'table=tl_survey_content',
|
'href' => 'table=tl_survey_content',
|
||||||
'icon' => 'children.svg',
|
'icon' => 'children.svg',
|
||||||
'primary' => true,
|
'primary' => true,
|
||||||
@@ -81,13 +81,13 @@ $GLOBALS['TL_DCA']['tl_survey'] = [
|
|||||||
'button_callback' => [SurveyDcaListener::class, 'generateDuplicateSurveyButton'],
|
'button_callback' => [SurveyDcaListener::class, 'generateDuplicateSurveyButton'],
|
||||||
],
|
],
|
||||||
'delete' => [
|
'delete' => [
|
||||||
'label' => ['Löschen', 'Eintrag wirklich löschen?'],
|
'label' => &$GLOBALS['TL_LANG']['tl_survey']['delete'],
|
||||||
'href' => 'act=delete',
|
'href' => 'act=delete',
|
||||||
'icon' => 'delete.svg',
|
'icon' => 'delete.svg',
|
||||||
'attributes' => 'onclick="if(!confirm(\'Eintrag wirklich löschen?\'))return false;Backend.getScrollOffset()"',
|
'attributes' => 'onclick="if(!confirm(\'Eintrag wirklich löschen?\'))return false;Backend.getScrollOffset()"',
|
||||||
],
|
],
|
||||||
'show' => [
|
'show' => [
|
||||||
'label' => ['Details', 'Details des Elements ID %s anzeigen'],
|
'label' => &$GLOBALS['TL_LANG']['tl_survey']['show'],
|
||||||
'href' => 'act=show',
|
'href' => 'act=show',
|
||||||
'icon' => 'show.svg',
|
'icon' => 'show.svg',
|
||||||
],
|
],
|
||||||
@@ -148,17 +148,6 @@ $GLOBALS['TL_DCA']['tl_survey'] = [
|
|||||||
'relation' => ['type' => 'hasMany', 'load' => 'lazy', 'table' => 'tl_survey_category', 'field' => 'id'],
|
'relation' => ['type' => 'hasMany', 'load' => 'lazy', 'table' => 'tl_survey_category', 'field' => 'id'],
|
||||||
'sql' => 'blob NULL',
|
'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' => [
|
'published' => [
|
||||||
'label' => &$GLOBALS['TL_LANG']['tl_survey']['published'],
|
'label' => &$GLOBALS['TL_LANG']['tl_survey']['published'],
|
||||||
'inputType' => 'checkbox',
|
'inputType' => 'checkbox',
|
||||||
@@ -253,7 +242,7 @@ if ('cli' !== PHP_SAPI) {
|
|||||||
System::getContainer()->get('database_connection')->fetchFirstColumn('SHOW COLUMNS FROM tl_survey'),
|
System::getContainer()->get('database_connection')->fetchFirstColumn('SHOW COLUMNS FROM tl_survey'),
|
||||||
);
|
);
|
||||||
$hasListMetadataColumns = !array_diff(
|
$hasListMetadataColumns = !array_diff(
|
||||||
['categoryfilter', 'categorysummary', 'templatefilter', 'templatesummary', 'assignedmembersfilter', 'assignedmemberssummary'],
|
['templatefilter', 'templatesummary', 'assignedmembersfilter', 'assignedmemberssummary'],
|
||||||
$columnNames,
|
$columnNames,
|
||||||
);
|
);
|
||||||
} catch (\Throwable) {
|
} catch (\Throwable) {
|
||||||
@@ -264,8 +253,6 @@ if ('cli' !== PHP_SAPI) {
|
|||||||
$GLOBALS['TL_DCA']['tl_survey']['list']['label']['fields'] = ['title', 'alias'];
|
$GLOBALS['TL_DCA']['tl_survey']['list']['label']['fields'] = ['title', 'alias'];
|
||||||
|
|
||||||
unset(
|
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']['templateFilter'],
|
||||||
$GLOBALS['TL_DCA']['tl_survey']['fields']['templateSummary'],
|
$GLOBALS['TL_DCA']['tl_survey']['fields']['templateSummary'],
|
||||||
$GLOBALS['TL_DCA']['tl_survey']['fields']['assignedMembersFilter'],
|
$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']['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']['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']['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']['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']['templateFilter'] = ['Vorlage', 'Filtert die Liste nach Vorlagenstatus.'];
|
||||||
$GLOBALS['TL_LANG']['tl_survey']['templateSummary'] = ['Vorlage', 'Zeigt an, ob die Umfrage als Vorlage markiert ist.'];
|
$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']['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']['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']['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']['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']['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.'];
|
$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']['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']['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']['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']['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']['updatedBy'] = ['Aktualisiert von', 'ID des zuletzt bearbeitenden Mitglieds.'];
|
||||||
$GLOBALS['TL_LANG']['tl_survey']['createdAt'] = ['Angelegt am', 'Zeitpunkt der Erstellung.'];
|
$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']['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']['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']['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']['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']['templateFilter'] = ['Template', 'Filters the list by template status.'];
|
||||||
$GLOBALS['TL_LANG']['tl_survey']['templateSummary'] = ['Template', 'Shows whether the survey is marked as a template.'];
|
$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']['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']['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']['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']['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']['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.'];
|
$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']['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']['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']['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']['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']['updatedBy'] = ['Updated by', 'ID of the member who last edited it.'];
|
||||||
$GLOBALS['TL_LANG']['tl_survey']['createdAt'] = ['Created on', 'Creation time.'];
|
$GLOBALS['TL_LANG']['tl_survey']['createdAt'] = ['Created on', 'Creation time.'];
|
||||||
|
|||||||
@@ -85,6 +85,12 @@
|
|||||||
margin-bottom: 4em;
|
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 {
|
.survey-shell--template {
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at top left, rgba(236, 124, 50, 0.18), transparent 32%),
|
radial-gradient(circle at top left, rgba(236, 124, 50, 0.18), transparent 32%),
|
||||||
@@ -489,6 +495,28 @@
|
|||||||
margin-left: auto;
|
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 {
|
.survey-publish-toggle-form {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
@@ -725,82 +753,6 @@
|
|||||||
text-align: left;
|
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,
|
||||||
.survey-accordion-item .handorgel__header__button:hover,
|
.survey-accordion-item .handorgel__header__button:hover,
|
||||||
.survey-accordion-item .handorgel__header__button:focus {
|
.survey-accordion-item .handorgel__header__button:focus {
|
||||||
@@ -1075,22 +1027,6 @@
|
|||||||
padding: 0.95rem 1rem;
|
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>
|
</style>
|
||||||
<script>
|
<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') {
|
if (typeof handorgel === 'function') {
|
||||||
document.querySelectorAll('[data-survey-handorgel]').forEach(function (element) {
|
document.querySelectorAll('[data-survey-handorgel]').forEach(function (element) {
|
||||||
var accordion = new handorgel(element, {
|
var accordion = new handorgel(element, {
|
||||||
@@ -1395,7 +1204,6 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
initializeQuestionSorting();
|
|
||||||
|
|
||||||
resetEditorViewport();
|
resetEditorViewport();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -70,17 +70,17 @@
|
|||||||
|
|
||||||
.survey-question-order-save-shell {
|
.survey-question-order-save-shell {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 50%;
|
bottom: 1.5rem;
|
||||||
right: 1rem;
|
right: 1.5rem;
|
||||||
z-index: 40;
|
z-index: 40;
|
||||||
transform: translate(120%, -50%);
|
transform: translate(120%, 0);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transition: transform 0.26s ease, opacity 0.26s ease;
|
transition: transform 0.26s ease, opacity 0.26s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.survey-question-order-save-shell.is-visible {
|
.survey-question-order-save-shell.is-visible {
|
||||||
transform: translate(0, -50%);
|
transform: translate(0, 0);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
@@ -115,5 +115,5 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</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>
|
<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">
|
<div class="survey-list-card__head-actions survey-list-card__head-actions--template">
|
||||||
<form method="post" class="survey-question-action-form">
|
<form method="post" class="survey-question-action-form">
|
||||||
<input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}">
|
<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="_survey_action" value="duplicate-survey">
|
||||||
<input type="hidden" name="item_id" value="{{ survey.id }}">
|
<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>
|
<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 %}
|
{% endif %}
|
||||||
<form method="post" class="survey-question-action-form">
|
<form method="post" class="survey-question-action-form">
|
||||||
<input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}">
|
<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="_survey_action" value="duplicate-survey">
|
||||||
<input type="hidden" name="item_id" value="{{ survey.id }}">
|
<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>
|
<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>
|
||||||
<form method="post" class="survey-question-action-form">
|
<form method="post" class="survey-question-action-form">
|
||||||
<input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}">
|
<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="_survey_action" value="delete-survey">
|
||||||
<input type="hidden" name="item_id" value="{{ survey.id }}">
|
<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>
|
<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">
|
<div class="survey-list-card__head-actions">
|
||||||
<form method="post" class="survey-list-publish-form">
|
<form method="post" class="survey-list-publish-form">
|
||||||
<input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}">
|
<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="_survey_action" value="publish-survey">
|
||||||
<input type="hidden" name="item_id" value="{{ survey.id }}">
|
<input type="hidden" name="item_id" value="{{ survey.id }}">
|
||||||
<button
|
<button
|
||||||
@@ -111,6 +115,7 @@
|
|||||||
|
|
||||||
<form method="post" class="survey-list-eye-form">
|
<form method="post" class="survey-list-eye-form">
|
||||||
<input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}">
|
<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="_survey_action" value="toggle-active">
|
||||||
<input type="hidden" name="item_id" value="{{ survey.id }}">
|
<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>
|
<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 %}
|
{% 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 %}
|
{% if moduleHeadline %}
|
||||||
<header><h2>{{ moduleHeadline }}</h2></header>
|
<header><h2>{{ moduleHeadline }}</h2></header>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -63,7 +63,10 @@
|
|||||||
{% for child in surveyForm.answer %}
|
{% for child in surveyForm.answer %}
|
||||||
<label class="survey-card" style="cursor: pointer;">
|
<label class="survey-card" style="cursor: pointer;">
|
||||||
<div class="survey-meta"><strong>{{ child.vars.label|striptags|trim }}</strong>{{ form_widget(child) }}</div>
|
<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>
|
</label>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ nav.mod_customnav.block:has(a[href*="/abmelden"]) {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
nav.mod_customnav.block:has(a[href*="/abmelden"]) .invisible {
|
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('DOMContentLoaded', normalizeSearchField);
|
||||||
document.addEventListener('turbo:load', 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();
|
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 });
|
observer.observe(document.documentElement, { childList: true, subtree: true });
|
||||||
})();
|
})();
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
style.textContent = [
|
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{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::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: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:nth-child(2){transform:translateY(-1.4px);transform-box:fill-box;transform-origin:center;}',
|
||||||
'.survey-sort-icon svg path[stroke]{stroke-width:2px;}'
|
'.survey-sort-icon svg path[stroke]{stroke-width:2px;}'
|
||||||
|
|||||||
@@ -68,9 +68,9 @@ final class MemberSurveyListController extends AbstractFrontendModuleController
|
|||||||
'questionCount' => (int) ($survey['questionCount'] ?? 0),
|
'questionCount' => (int) ($survey['questionCount'] ?? 0),
|
||||||
'participationCount' => (int) ($survey['participationCount'] ?? 0),
|
'participationCount' => (int) ($survey['participationCount'] ?? 0),
|
||||||
'editUrl' => $editPage instanceof PageModel ? $this->generateContentUrl($editPage, ['survey' => $surveyId]) : null,
|
'editUrl' => $editPage instanceof PageModel ? $this->generateContentUrl($editPage, ['survey' => $surveyId]) : null,
|
||||||
'publicUrl' => $readerPage instanceof PageModel ? $this->generateContentUrl($readerPage, ['auto_item' => $alias]) : null,
|
'publicUrl' => $readerPage instanceof PageModel ? $this->generateContentUrl($readerPage, ['parameters' => '/'.$alias]) : null,
|
||||||
'draftUrl' => $readerPage instanceof PageModel ? $this->generateContentUrl($readerPage, ['auto_item' => $alias, 'preview' => '1']) : null,
|
'draftUrl' => $readerPage instanceof PageModel ? $this->generateContentUrl($readerPage, ['parameters' => '/'.$alias, 'preview' => '1']) : null,
|
||||||
'resultsUrl' => $resultsPage instanceof PageModel ? $this->generateContentUrl($resultsPage, ['auto_item' => $alias]) : 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);
|
$surveyId = (int) $request->request->get('item_id', 0);
|
||||||
$action = (string) $request->request->get('_survey_action');
|
$action = (string) $request->request->get('_survey_action');
|
||||||
|
$token = (string) $request->request->get('_token', '');
|
||||||
|
|
||||||
if ($surveyId <= 0 || '' === $action) {
|
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.');
|
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\Controller\FrontendModule\AbstractFrontendModuleController;
|
||||||
use Contao\CoreBundle\DependencyInjection\Attribute\AsFrontendModule;
|
use Contao\CoreBundle\DependencyInjection\Attribute\AsFrontendModule;
|
||||||
use Contao\CoreBundle\Twig\FragmentTemplate;
|
use Contao\CoreBundle\Twig\FragmentTemplate;
|
||||||
|
use Contao\Input;
|
||||||
use Contao\ModuleModel;
|
use Contao\ModuleModel;
|
||||||
use Mummert\SurveyBundle\Model\SurveyContentModel;
|
use Mummert\SurveyBundle\Model\SurveyContentModel;
|
||||||
use Mummert\SurveyBundle\Model\SurveyModel;
|
use Mummert\SurveyBundle\Model\SurveyModel;
|
||||||
@@ -126,6 +127,7 @@ final class ShowSurveyController extends AbstractFrontendModuleController
|
|||||||
$template->set('survey', $survey);
|
$template->set('survey', $survey);
|
||||||
$template->set('progress', $this->surveyFlowService->getProgress($survey, $question));
|
$template->set('progress', $this->surveyFlowService->getProgress($survey, $question));
|
||||||
$template->set('question', $question);
|
$template->set('question', $question);
|
||||||
|
$template->set('jumpHints', $this->buildJumpHints($survey, $question));
|
||||||
$template->set('surveyForm', $form->createView());
|
$template->set('surveyForm', $form->createView());
|
||||||
$template->set('isComplete', false);
|
$template->set('isComplete', false);
|
||||||
$template->set('answers', []);
|
$template->set('answers', []);
|
||||||
@@ -136,6 +138,30 @@ final class ShowSurveyController extends AbstractFrontendModuleController
|
|||||||
return $template->getResponse();
|
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
|
private function buildPreviewResponse(FragmentTemplate $template, SurveyModel $survey, Request $request): Response
|
||||||
{
|
{
|
||||||
$history = $this->decodePreviewHistory((string) $request->request->get('preview_history', ''));
|
$history = $this->decodePreviewHistory((string) $request->request->get('preview_history', ''));
|
||||||
@@ -198,6 +224,7 @@ final class ShowSurveyController extends AbstractFrontendModuleController
|
|||||||
$template->set('survey', $survey);
|
$template->set('survey', $survey);
|
||||||
$template->set('progress', $this->surveyFlowService->getProgress($survey, $question));
|
$template->set('progress', $this->surveyFlowService->getProgress($survey, $question));
|
||||||
$template->set('question', $question);
|
$template->set('question', $question);
|
||||||
|
$template->set('jumpHints', $this->buildJumpHints($survey, $question));
|
||||||
$template->set('surveyForm', $form->createView());
|
$template->set('surveyForm', $form->createView());
|
||||||
$template->set('isComplete', false);
|
$template->set('isComplete', false);
|
||||||
$template->set('answers', []);
|
$template->set('answers', []);
|
||||||
@@ -265,7 +292,10 @@ final class ShowSurveyController extends AbstractFrontendModuleController
|
|||||||
|
|
||||||
private function resolvePublicAlias(Request $request): string
|
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)) {
|
if (\is_string($candidate) && '' !== trim($candidate)) {
|
||||||
return trim($candidate);
|
return trim($candidate);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use Contao\Config;
|
|||||||
use Contao\CoreBundle\Controller\FrontendModule\AbstractFrontendModuleController;
|
use Contao\CoreBundle\Controller\FrontendModule\AbstractFrontendModuleController;
|
||||||
use Contao\CoreBundle\DependencyInjection\Attribute\AsFrontendModule;
|
use Contao\CoreBundle\DependencyInjection\Attribute\AsFrontendModule;
|
||||||
use Contao\CoreBundle\Twig\FragmentTemplate;
|
use Contao\CoreBundle\Twig\FragmentTemplate;
|
||||||
|
use Contao\Input;
|
||||||
use Contao\ModuleModel;
|
use Contao\ModuleModel;
|
||||||
use Mummert\SurveyBundle\Repository\SurveyRepository;
|
use Mummert\SurveyBundle\Repository\SurveyRepository;
|
||||||
use Mummert\SurveyBundle\Service\SurveyResultsViewService;
|
use Mummert\SurveyBundle\Service\SurveyResultsViewService;
|
||||||
@@ -82,7 +83,9 @@ final class ShowSurveyResultsController extends AbstractFrontendModuleController
|
|||||||
|
|
||||||
private function resolvePublicAlias(Request $request): string
|
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)) {
|
if (\is_string($candidate) && '' !== trim($candidate)) {
|
||||||
return trim($candidate);
|
return trim($candidate);
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ use Contao\Image;
|
|||||||
use Contao\Input;
|
use Contao\Input;
|
||||||
use Contao\Message;
|
use Contao\Message;
|
||||||
use Contao\PageModel;
|
use Contao\PageModel;
|
||||||
use Contao\StringUtil;
|
|
||||||
use Contao\System;
|
use Contao\System;
|
||||||
use Doctrine\DBAL\Connection;
|
use Doctrine\DBAL\Connection;
|
||||||
use Mummert\SurveyBundle\Repository\SurveyEditorRepository;
|
use Mummert\SurveyBundle\Repository\SurveyEditorRepository;
|
||||||
@@ -213,54 +212,6 @@ final class SurveyDcaListener
|
|||||||
return $this->surveyEditorRepository->findMemberChoices();
|
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>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ final class SurveyEditorData
|
|||||||
{
|
{
|
||||||
$data = new self();
|
$data = new self();
|
||||||
$data->title = (string) $survey->title;
|
$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;
|
return $data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -199,8 +199,6 @@ final class SurveyRepository
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$categoryIds = $this->normalizeCategoryIds($survey->category);
|
|
||||||
$categoryTitles = $this->fetchCategoryTitles($categoryIds);
|
|
||||||
$assignedMembers = $this->fetchAssignedMembers($surveyId);
|
$assignedMembers = $this->fetchAssignedMembers($surveyId);
|
||||||
$memberIds = [];
|
$memberIds = [];
|
||||||
$memberNames = [];
|
$memberNames = [];
|
||||||
@@ -211,8 +209,6 @@ final class SurveyRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
$this->connection->update('tl_survey', [
|
$this->connection->update('tl_survey', [
|
||||||
'categoryFilter' => implode(',', $categoryIds),
|
|
||||||
'categorySummary' => implode(', ', $categoryTitles),
|
|
||||||
'templateFilter' => '1' === (string) $survey->isTemplate ? '1' : '0',
|
'templateFilter' => '1' === (string) $survey->isTemplate ? '1' : '0',
|
||||||
'templateSummary' => '1' === (string) $survey->isTemplate ? 'Ja' : 'Nein',
|
'templateSummary' => '1' === (string) $survey->isTemplate ? 'Ja' : 'Nein',
|
||||||
'assignedMembersFilter' => implode(',', $memberIds),
|
'assignedMembersFilter' => implode(',', $memberIds),
|
||||||
@@ -333,39 +329,6 @@ final class SurveyRepository
|
|||||||
return false !== $qb->executeQuery()->fetchOne();
|
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}>
|
* @return list<array{id:int,name:string}>
|
||||||
*/
|
*/
|
||||||
@@ -410,7 +373,7 @@ final class SurveyRepository
|
|||||||
);
|
);
|
||||||
|
|
||||||
return $this->hasListMetadataColumns = !array_diff(
|
return $this->hasListMetadataColumns = !array_diff(
|
||||||
['categoryfilter', 'categorysummary', 'templatefilter', 'templatesummary', 'assignedmembersfilter', 'assignedmemberssummary'],
|
['templatefilter', 'templatesummary', 'assignedmembersfilter', 'assignedmemberssummary'],
|
||||||
$columnNames,
|
$columnNames,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,16 +24,6 @@ final class SurveySubmissionRepository
|
|||||||
return $model instanceof SurveySubmissionModel ? $model : null;
|
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
|
public function findBySurveyAndToken(int $surveyId, string $token): ?SurveySubmissionModel
|
||||||
{
|
{
|
||||||
$id = $this->connection->fetchOne(
|
$id = $this->connection->fetchOne(
|
||||||
@@ -44,14 +34,6 @@ final class SurveySubmissionRepository
|
|||||||
return false === $id ? null : $this->findById((int) $id);
|
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
|
public function create(int $surveyId, string $token, ?int $currentQuestionId): SurveySubmissionModel
|
||||||
{
|
{
|
||||||
$now = time();
|
$now = time();
|
||||||
|
|||||||
@@ -264,7 +264,6 @@
|
|||||||
{% elseif question.type == 'range' %}
|
{% elseif question.type == 'range' %}
|
||||||
<div class="metric-row">
|
<div class="metric-row">
|
||||||
<div class="metric-box">
|
<div class="metric-box">
|
||||||
<span class="metric-label">Minimum</span>
|
|
||||||
<span class="metric-label">{{ 'survey.pdf.minimum'|trans({}, 'messages') }}</span>
|
<span class="metric-label">{{ 'survey.pdf.minimum'|trans({}, 'messages') }}</span>
|
||||||
<span class="metric-value">{{ question.minimum is not null ? question.minimum : '-' }}</span>
|
<span class="metric-value">{{ question.minimum is not null ? question.minimum : '-' }}</span>
|
||||||
</div>
|
</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>
|
* @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
|
private function buildChartLegend(array $questionResult): array
|
||||||
{
|
{
|
||||||
$chart = $questionResult['chart'] ?? null;
|
$chart = $questionResult['chart'] ?? null;
|
||||||
|
$chartType = is_array($chart) ? (string) ($chart['type'] ?? '') : '';
|
||||||
if (!is_array($chart)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$chartType = (string) ($chart['type'] ?? '');
|
|
||||||
$sourceRows = match ($chartType) {
|
$sourceRows = match ($chartType) {
|
||||||
'pie' => $questionResult['options'] ?? $questionResult['responseSummary'] ?? [],
|
'pie' => $questionResult['options'] ?? $questionResult['responseSummary'] ?? [],
|
||||||
'line' => $questionResult['distribution'] ?? [],
|
'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) {
|
if (!is_array($sourceRows) || [] === $sourceRows) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$dataset = $chart['data']['datasets'][0] ?? [];
|
$dataset = is_array($chart) ? ($chart['data']['datasets'][0] ?? []) : [];
|
||||||
$fillColors = match ($chartType) {
|
$fillColors = match ($chartType) {
|
||||||
'pie' => is_array($dataset['backgroundColor'] ?? null) ? $dataset['backgroundColor'] : [],
|
'pie' => is_array($dataset['backgroundColor'] ?? null) ? $dataset['backgroundColor'] : [],
|
||||||
'line' => is_array($dataset['pointBackgroundColor'] ?? null) ? $dataset['pointBackgroundColor'] : [],
|
'line' => is_array($dataset['pointBackgroundColor'] ?? null) ? $dataset['pointBackgroundColor'] : [],
|
||||||
|
|||||||
@@ -50,12 +50,7 @@ final class SurveyResultsViewService
|
|||||||
$results = [];
|
$results = [];
|
||||||
|
|
||||||
foreach ($questions as $question) {
|
foreach ($questions as $question) {
|
||||||
$key = $this->buildQuestionResultKey(
|
$key = $this->buildQuestionResultKey((int) $question->id, (string) $question->type);
|
||||||
(int) $question->id,
|
|
||||||
(string) $question->type,
|
|
||||||
(string) $question->question,
|
|
||||||
(string) ($question->description ?? ''),
|
|
||||||
);
|
|
||||||
|
|
||||||
$results[$key] = $this->buildResultSkeleton(
|
$results[$key] = $this->buildResultSkeleton(
|
||||||
(int) $question->id,
|
(int) $question->id,
|
||||||
@@ -65,6 +60,7 @@ final class SurveyResultsViewService
|
|||||||
(int) ($question->sorting ?? 0),
|
(int) ($question->sorting ?? 0),
|
||||||
);
|
);
|
||||||
$results[$key]['_choiceOptions'] = $this->getChoiceOptions($question);
|
$results[$key]['_choiceOptions'] = $this->getChoiceOptions($question);
|
||||||
|
$results[$key]['_allowMaybe'] = '1' === (string) $question->allowMaybe;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($answers as $answer) {
|
foreach ($answers as $answer) {
|
||||||
@@ -72,7 +68,7 @@ final class SurveyResultsViewService
|
|||||||
$questionType = (string) ($answer['questionType'] ?? 'text');
|
$questionType = (string) ($answer['questionType'] ?? 'text');
|
||||||
$questionLabel = trim((string) ($answer['questionLabel'] ?? '')) ?: 'Frage #'.$questionId;
|
$questionLabel = trim((string) ($answer['questionLabel'] ?? '')) ?: 'Frage #'.$questionId;
|
||||||
$questionDescription = (string) ($answer['questionDescription'] ?? '');
|
$questionDescription = (string) ($answer['questionDescription'] ?? '');
|
||||||
$key = $this->buildQuestionResultKey($questionId, $questionType, $questionLabel, $questionDescription);
|
$key = $this->buildQuestionResultKey($questionId, $questionType);
|
||||||
|
|
||||||
if (!isset($results[$key])) {
|
if (!isset($results[$key])) {
|
||||||
$results[$key] = $this->buildResultSkeleton(
|
$results[$key] = $this->buildResultSkeleton(
|
||||||
@@ -104,9 +100,11 @@ final class SurveyResultsViewService
|
|||||||
return array_values($results);
|
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,
|
'_sorting' => $sorting,
|
||||||
'_values' => [],
|
'_values' => [],
|
||||||
'_choiceOptions' => [],
|
'_choiceOptions' => [],
|
||||||
|
'_allowMaybe' => false,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,6 +145,12 @@ final class SurveyResultsViewService
|
|||||||
case 'yes_no_maybe':
|
case 'yes_no_maybe':
|
||||||
$optionCounts = ['Ja' => 0, 'Nein' => 0];
|
$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) {
|
foreach ($values as $value) {
|
||||||
$normalizedValue = mb_strtolower(trim($value));
|
$normalizedValue = mb_strtolower(trim($value));
|
||||||
|
|
||||||
@@ -205,7 +210,7 @@ final class SurveyResultsViewService
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
unset($result['_sorting'], $result['_values'], $result['_choiceOptions']);
|
unset($result['_sorting'], $result['_values'], $result['_choiceOptions'], $result['_allowMaybe']);
|
||||||
|
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ use Symfony\Component\Uid\Ulid;
|
|||||||
|
|
||||||
final class SurveySubmissionService
|
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(
|
public function __construct(
|
||||||
private readonly SurveySubmissionRepository $surveySubmissionRepository,
|
private readonly SurveySubmissionRepository $surveySubmissionRepository,
|
||||||
private readonly SurveyAnswerRepository $surveyAnswerRepository,
|
private readonly SurveyAnswerRepository $surveyAnswerRepository,
|
||||||
@@ -35,7 +41,9 @@ final class SurveySubmissionService
|
|||||||
if ('' !== $token) {
|
if ('' !== $token) {
|
||||||
$existing = $this->surveySubmissionRepository->findBySurveyAndToken((int) $survey->id, $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;
|
return $existing;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -48,6 +56,21 @@ final class SurveySubmissionService
|
|||||||
return $submission;
|
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
|
public function storeAnswer(SurveyModel $survey, SurveySubmissionModel $submission, SurveyContentModel $question, mixed $rawAnswer): string
|
||||||
{
|
{
|
||||||
$normalizedAnswer = $this->normalizeAnswerForQuestion($question, $rawAnswer);
|
$normalizedAnswer = $this->normalizeAnswerForQuestion($question, $rawAnswer);
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ survey:
|
|||||||
answer_no: "Nein"
|
answer_no: "Nein"
|
||||||
answer_maybe: "Vielleicht"
|
answer_maybe: "Vielleicht"
|
||||||
question_progress: "Frage %current%"
|
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"
|
current_value: "Aktueller Wert"
|
||||||
back: "Zurück"
|
back: "Zurück"
|
||||||
next: "Weiter"
|
next: "Weiter"
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ survey:
|
|||||||
answer_no: "No"
|
answer_no: "No"
|
||||||
answer_maybe: "Maybe"
|
answer_maybe: "Maybe"
|
||||||
question_progress: "Question %current%"
|
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"
|
current_value: "Current value"
|
||||||
back: "Back"
|
back: "Back"
|
||||||
next: "Next"
|
next: "Next"
|
||||||
|
|||||||
Reference in New Issue
Block a user