11 Commits

Author SHA1 Message Date
Jürgen Mummert 5f9a77d5bd Set tstamp on record inserts/updates
Questions, editor assignments and conditions were inserted without tstamp,
so Contao rendered them with tstamp=0 -> the backend "draft" styling
(opacity .5), making question rows look greyed out despite being published.

- SurveyQuestionRepository::insert()/update() (covers create, edit, duplicate)
- SurveyEditorRepository::insert()
- SurveyConditionRepository::insert()
SurveyRepository already set tstamp. Also widened the assigned-members
checkbox-wizard columns at smaller breakpoints.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:31:25 +02:00
Jürgen Mummert d6227d9f4e Remove dead condition-editing code
The frontend condition create/edit path was never wired (no caller, no UI).
Conditions stay live where used: the SurveyFlowService reads tl_survey_condition
for jumps, the editor shows the read-only overview, and delete still works.

Removed:
- SurveyConditionEditorType (zero references)
- SurveyEditorService::saveCondition() + its SurveyConditionData import
- SurveyConditionData form model
- SurveyConditionRepository::update() (only caller was saveCondition)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 13:23:11 +02:00
Jürgen Mummert db7094fd1e 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>
2026-06-14 13:00:05 +02:00
Jürgen Mummert 1b16675ae9 Add survey question drag-sort and backend assets
- Frontend editor: drag-to-sort questions with dedicated sort assets/JS
- Backend list/search refinements for tl_survey and tl_survey_content
- Repository helpers for survey/question/editor queries
- Translations and AI handover notes update
2026-06-14 09:13:07 +02:00
Jürgen Mummert 79c4402aee Fix survey reader back flow 2026-06-08 20:14:57 +02:00
Jürgen Mummert e3553e1108 Differentiate survey template shell 2026-06-08 20:05:58 +02:00
Jürgen Mummert 99caf51af9 Refine survey template module layout 2026-06-08 20:01:08 +02:00
Jürgen Mummert 7843fca003 Add survey template list module 2026-06-08 19:45:20 +02:00
Jürgen Mummert 9cc2f98456 Adjust survey action alignment 2026-06-08 19:23:14 +02:00
Jürgen Mummert d44f3be571 Add survey duplication actions 2026-06-08 14:06:32 +02:00
Jürgen Mummert 97a302a985 Document frontend/backend publication trade-off 2026-06-07 22:32:48 +02:00
45 changed files with 1362 additions and 408 deletions
+9
View File
@@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(git add *)",
"Bash(git commit -q -m ' *)",
"Bash(git push *)"
]
}
}
+99
View File
@@ -40,6 +40,9 @@ Die Umfrage arbeitet mit drei relevanten Zuständen in `tl_survey`:
- Keine Fragenbearbeitung nach Veröffentlichung - Keine Fragenbearbeitung nach Veröffentlichung
- Keine Metadatenbearbeitung nach Veröffentlichung - Keine Metadatenbearbeitung nach Veröffentlichung
- Keine Fragenbearbeitung nach ersten Teilnahmen - Keine Fragenbearbeitung nach ersten Teilnahmen
- Die Frontend-Sperre für veröffentlichte Umfragen soll bewusst nicht tief im System verankert sein.
- Für Redakteure genügt das Ausblenden bzw. Deaktivieren der relevanten Buttons und Formulare im Frontend.
- Es ist ausdrücklich keine zusätzliche harte Systembarriere gewünscht, solange die Redakteursoberfläche den Bearbeitungspfad sauber schließt.
- Backend ist bewusst toleranter: - Backend ist bewusst toleranter:
- Ein einzelner Admin darf im Notfall weiter arbeiten - Ein einzelner Admin darf im Notfall weiter arbeiten
@@ -47,12 +50,20 @@ Die Umfrage arbeitet mit drei relevanten Zuständen in `tl_survey`:
- `published` ist im Backend editierbar - `published` ist im Backend editierbar
- `isActive` ist im Backend per Contao-Toggle schaltbar - `isActive` ist im Backend per Contao-Toggle schaltbar
- Bewusster Trade-off zwischen Frontend und Backend:
- Ein veröffentlichter Survey soll für Frontend-Redakteure praktisch nicht mehr bearbeitbar sein.
- Im Backend darf der Admin trotz Veröffentlichung weiterhin Änderungen vornehmen, zum Beispiel für Rechtschreibfehler oder kleine Korrekturen.
- Dadurch bleibt bewusst das Risiko bestehen, dass der Admin auch Fragen oder Strukturen ändert, obwohl bereits Antworten vorliegen.
- Mögliche Folgen sind verwässerte oder fachlich falsche Ergebnisseiten sowie Inkonsistenzen, wenn bestehende Antworten nicht mehr sauber zur später geänderten Struktur passen.
- Dieses Risiko wird bewusst akzeptiert und organisatorisch über Briefing/Disziplin des Admins bzw. der zuständigen Redakteure abgefangen, nicht über eine harte technische Sperre im Backend.
Diese Entscheidung ist absichtlich so getroffen und kein Bug. Diese Entscheidung ist absichtlich so getroffen und kein Bug.
## Datenmodell und wichtige Tabellen ## Datenmodell und wichtige Tabellen
- `tl_survey` - `tl_survey`
- Umfrage-Stammdaten und Statusfelder - Umfrage-Stammdaten und Statusfelder
- Enthält zusätzlich die backend-only gepflegte `internalNote`
- `tl_survey_content` - `tl_survey_content`
- Fragen der Umfrage - Fragen der Umfrage
- `tl_survey_condition` - `tl_survey_condition`
@@ -105,10 +116,22 @@ Aktuelle Button- und Sichtbarkeitslogik pro Umfrage:
- `Umfrage veröffentlichen` - `Umfrage veröffentlichen`
- Eigene Aktion in der Liste - Eigene Aktion in der Liste
- Nach Veröffentlichung disabled und textlich als veröffentlicht markiert - Nach Veröffentlichung disabled und textlich als veröffentlicht markiert
- `Duplizieren`
- Legt eine Kopie der Umfrage für den Redakteur an
- Titelvergabe ist fortlaufend: `Titel - Kopie`, danach `Titel - Kopie 2`, `Titel - Kopie 3` usw.
- Bestehende `- Kopie`-Suffixe werden fachlich normalisiert, damit keine Titel wie `... - Kopie - Kopie` mehr neu entstehen
- Kreis grau/grün - Kreis grau/grün
- Schaltet `isActive` - Schaltet `isActive`
- Ist bewusst kein Auge mehr - Ist bewusst kein Auge mehr
Wichtiger technischer Punkt (CSRF):
- 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 ### 2. Frontend-Editor
Controller: Controller:
@@ -144,6 +167,9 @@ Wichtige Entscheidung:
- Typ, Pflicht usw. sollen nicht schon im Header gezeigt werden. - Typ, Pflicht usw. sollen nicht schon im Header gezeigt werden.
- Die Details sieht man erst nach dem Aufklappen. - Die Details sieht man erst nach dem Aufklappen.
- Die Sprungziel-Dropdowns im Frontend zeigen Positionsnummern der Umfrage (`1. ...`, `2. ...`) statt globaler Datenbank-IDs.
- Die aktuell bearbeitete Frage wird dort nicht als eigenes Sprungziel angeboten.
### 3. Öffentliche Umfrageansicht ### 3. Öffentliche Umfrageansicht
Controller: Controller:
@@ -168,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
@@ -222,6 +255,13 @@ Reihenfolge der Navigation:
Wichtiger Detailpunkt: Wichtiger Detailpunkt:
- In der Sprungregelübersicht im Frontend-Editor werden bewusst die Positionsnummern innerhalb der Umfrage angezeigt, nicht die globalen Datenbank-IDs der Fragen. - In der Sprungregelübersicht im Frontend-Editor werden bewusst die Positionsnummern innerhalb der Umfrage angezeigt, nicht die globalen Datenbank-IDs der Fragen.
- Gleiches gilt für die Sprungziel-Dropdowns in Backend und Frontend.
Wichtige technische Eigenschaft:
- `jumpOnYes`, `jumpOnNo` und generische Bedingungen referenzieren Ziel-Fragen intern immer über die Frage-ID, nicht über die Position.
- Dadurch bleiben Sprünge bei einer reinen Umordnung der Fragen fachlich stabil an dieselbe Frage gebunden.
- Wenn eine Ziel-Frage an eine andere Position verschoben wird, zeigt die Oberfläche danach automatisch die neue Positionsnummer an.
## Excel-Export ## Excel-Export
@@ -305,6 +345,26 @@ Wichtige Listenoperationen:
- `Link kopieren` - `Link kopieren`
- `Ergebnisse anzeigen` - `Ergebnisse anzeigen`
Aktuelle Listen- und Filterlogik:
- Die mehrspaltige Umfragenliste zeigt aktuell:
- `Titel`
- `Zugewiesene Benutzer`
- `Vorlage`
- `Interne Notiz`
- `Kategorien` wurden bewusst aus der mehrspaltigen Liste und aus der rechten Filterspalte entfernt.
- Die Spalte `Zugewiesene Benutzer` wird in der Listenmetadatenlogik gekürzt:
- maximal 3 Namen direkt sichtbar
- danach Zusatz wie `... und X weitere`
- `internalNote`
- backend-only Feld in `tl_survey`
- nur im Backend bearbeitbar
- in der mehrspaltigen Liste sichtbar
- bei Duplikaten wird der Wert mitkopiert
- In der rechten Suchspalte ist `Interne Notiz` suchbar.
- `ID` wurde für die Umfragenliste bewusst aus dem Suchfeld entfernt.
- Das geschieht über einen kleinen backend-spezifischen JS-Workaround, weil Contao `id` im Core für `DC_Table` hart ergänzt.
Aktuelle Backend-Entscheidung: Aktuelle Backend-Entscheidung:
- Das Warnsymbol genügt als Hinweis bei veröffentlichten Umfragen - Das Warnsymbol genügt als Hinweis bei veröffentlichten Umfragen
@@ -319,6 +379,21 @@ Aktuelle Backend-Entscheidung:
Hinweis: Hinweis:
- `isLocked` wird in den Child-Headerfeldern nicht mehr angezeigt, weil der Backend-Lock bewusst keine führende Benutzerfunktion mehr ist. - `isLocked` wird in den Child-Headerfeldern nicht mehr angezeigt, weil der Backend-Lock bewusst keine führende Benutzerfunktion mehr ist.
- In `tl_survey_content` ist der Fragenlisten-Header zusätzlich reduziert:
- nur noch `Titel` und `Veröffentlicht`
- kein öffentlicher Link-Schlüssel mehr
- die rechte Header-Leiste mit Aktionen ist im Normalzustand ausgeblendet
- im aktiven Verschiebe-/Clipboard-Modus wird sie wieder eingeblendet, damit `Neu oben` bzw. die Einfügepositionen erreichbar bleiben
Fragenliste `tl_survey_content`:
- Direkt sichtbare Primäraktion pro Frage:
- `Bearbeiten`-Stift
- Verschieben:
- erfolgt im Backend über den normalen Contao-Paste-/Move-Flow
- die Aktion `Verschieben` liegt bewusst nur unter den drei Punkten
- dort wird der Contao-Pfeil `mover.svg` verwendet, nicht die blaue Schere
- nach Aktivierung des Clipboard-Zustands erscheinen die üblichen Einfügeziele wie `Nach dem Element ... einfügen`
### Systemeinstellungen ### Systemeinstellungen
@@ -346,6 +421,7 @@ Wichtige Settings:
- `published` reversibel erlaubt - `published` reversibel erlaubt
- `isActive` per Standard-Contao-Toggle - `isActive` per Standard-Contao-Toggle
- nur Warnsymbol statt Backend-Sperre - nur Warnsymbol statt Backend-Sperre
- Admin darf Fragen auch nach Veröffentlichung oder nach vorhandenen Antworten umsortieren
## Wichtige Dateien nach Themengebiet ## Wichtige Dateien nach Themengebiet
@@ -388,12 +464,35 @@ Backend:
- `ddev exec php bin/console lint:yaml ...` - `ddev exec php bin/console lint:yaml ...`
- `ddev exec php bin/console cache:clear` - `ddev exec php bin/console cache:clear`
## Offene technische Punkte / Deployment-Hinweise
- Neue DB-Spalte in `tl_survey`:
- `internalNote`
- Typ aktuell: `varchar(255) NOT NULL default ''`
- Für andere Umgebungen muss diese Spalte per Migration/SQL vorhanden sein, sonst fehlen Backend-Feld, Listenanzeige und Suchfunktion fachlich bzw. führen zu Inkonsistenzen.
- Nach Änderungen an DCA, Backend-CSS oder Backend-JS ist in dieser Installation typischerweise nötig:
- `php vendor/bin/contao-console assets:install public --symlink --relative`
- `php vendor/bin/contao-console cache:clear --env=prod`
- Es gibt aktuell zwei bewusst eingebaute backend-spezifische UI-Workarounds über Bundle-Assets:
- In der Umfragenliste wird `ID` aus dem Suchfeld per JS entfernt, weil Contao das Feld im Core von `DC_Table` hart ergänzt.
- In der Fragenliste (`table=tl_survey_content`) wird die rechte Parent-Header-Leiste per CSS/JS im Normalzustand ausgeblendet und nur im aktiven Clipboard-/Verschiebe-Modus wieder eingeblendet.
- Lokale DDEV-Besonderheit, die bei Fehleranalyse relevant sein kann:
- Das Bundle läuft hier als Path-Repository/Symlink.
- Wenn Backend-Übersetzungen oder Assets trotz korrektem Code unerwartet fehlen, zuerst Bundle-Pfad, veröffentlichte Assets und Prod-Cache prüfen.
## Bekannte bewusste Trade-offs ## Bekannte bewusste Trade-offs
- Backend kann veröffentlichte Umfragen technisch weiterhin verändern. - Backend kann veröffentlichte Umfragen technisch weiterhin verändern.
- Das ist bewusst akzeptiert, weil nur ein Admin arbeitet. - Das ist bewusst akzeptiert, weil nur ein Admin arbeitet.
- `isLocked` bleibt als internes Logikfeld bestehen, obwohl der Backend-Lock nicht mehr durchgesetzt wird. - `isLocked` bleibt als internes Logikfeld bestehen, obwohl der Backend-Lock nicht mehr durchgesetzt wird.
- PDF-Export hängt von Gotenberg ab und ist absichtlich optional. - PDF-Export hängt von Gotenberg ab und ist absichtlich optional.
- Eine reine Umordnung von Fragen ist für Sprungziele logisch unkritisch, weil Sprünge an Frage-IDs hängen.
- Ergebnisse vorhandener, weiterhin existierender Fragen folgen bei der Anzeige jedoch der aktuellen Sortierung der Frage.
- Historische Antwort-Snapshots speichern zwar `questionSorting`, die Ergebnisansicht sortiert vorhandene Fragen aber primär nach der aktuellen Frage-Sortierung.
- Das ist im toleranten Backend-Modell ein bewusst akzeptierter Side-Effect.
## Empfohlene Einstiegspunkte für künftige Änderungen ## Empfohlene Einstiegspunkte für künftige Änderungen
+16
View File
@@ -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.
+2 -1
View File
@@ -2,8 +2,9 @@
declare(strict_types=1); declare(strict_types=1);
$GLOBALS['TL_CSS'][] = 'assets/survey-backend.css|static'; $GLOBALS['TL_CSS'][] = 'bundles/survey/css/survey-backend.css|static';
$GLOBALS['TL_CSS'][] = 'bundles/survey/css/auth-module.css|static'; $GLOBALS['TL_CSS'][] = 'bundles/survey/css/auth-module.css|static';
$GLOBALS['TL_JAVASCRIPT'][] = 'bundles/survey/js/survey-backend.js|static';
$GLOBALS['TL_MODELS']['tl_survey'] = Mummert\SurveyBundle\Model\SurveyModel::class; $GLOBALS['TL_MODELS']['tl_survey'] = Mummert\SurveyBundle\Model\SurveyModel::class;
$GLOBALS['TL_MODELS']['tl_survey_category'] = Mummert\SurveyBundle\Model\SurveyCategoryModel::class; $GLOBALS['TL_MODELS']['tl_survey_category'] = Mummert\SurveyBundle\Model\SurveyCategoryModel::class;
+1
View File
@@ -3,6 +3,7 @@
declare(strict_types=1); declare(strict_types=1);
$GLOBALS['TL_DCA']['tl_module']['palettes']['member_survey_list'] = '{title_legend},name,headline,type;{survey_navigation_legend},surveyEditPage;{protected_legend:hide},protected;{expert_legend:hide},guests,cssID'; $GLOBALS['TL_DCA']['tl_module']['palettes']['member_survey_list'] = '{title_legend},name,headline,type;{survey_navigation_legend},surveyEditPage;{protected_legend:hide},protected;{expert_legend:hide},guests,cssID';
$GLOBALS['TL_DCA']['tl_module']['palettes']['member_survey_template_list'] = '{title_legend},name,headline,type;{protected_legend:hide},protected;{expert_legend:hide},guests,cssID';
$GLOBALS['TL_DCA']['tl_module']['palettes']['member_survey_edit'] = '{title_legend},name,headline,type;{survey_navigation_legend},surveyListPage;{protected_legend:hide},protected;{expert_legend:hide},guests,cssID'; $GLOBALS['TL_DCA']['tl_module']['palettes']['member_survey_edit'] = '{title_legend},name,headline,type;{survey_navigation_legend},surveyListPage;{protected_legend:hide},protected;{expert_legend:hide},guests,cssID';
$GLOBALS['TL_DCA']['tl_module']['palettes']['show_survey'] = '{title_legend},name,headline,type;{expert_legend:hide},guests,cssID'; $GLOBALS['TL_DCA']['tl_module']['palettes']['show_survey'] = '{title_legend},name,headline,type;{expert_legend:hide},guests,cssID';
$GLOBALS['TL_DCA']['tl_module']['palettes']['survey_results'] = '{title_legend},name,headline,type;{expert_legend:hide},guests,cssID'; $GLOBALS['TL_DCA']['tl_module']['palettes']['survey_results'] = '{title_legend},name,headline,type;{expert_legend:hide},guests,cssID';
+32 -28
View File
@@ -12,6 +12,7 @@ $GLOBALS['TL_DCA']['tl_survey'] = [
'ctable' => ['tl_survey_content', 'tl_survey_condition', 'tl_survey_editor', 'tl_survey_submission'], 'ctable' => ['tl_survey_content', 'tl_survey_condition', 'tl_survey_editor', 'tl_survey_submission'],
'enableVersioning' => true, 'enableVersioning' => true,
'onload_callback' => [ 'onload_callback' => [
[SurveyDcaListener::class, 'handleDuplicateSurveyAction'],
[SurveyDcaListener::class, 'refreshSurveyBackendStateIndex'], [SurveyDcaListener::class, 'refreshSurveyBackendStateIndex'],
], ],
'onsubmit_callback' => [ 'onsubmit_callback' => [
@@ -35,7 +36,7 @@ $GLOBALS['TL_DCA']['tl_survey'] = [
'panelLayout' => 'filter;sort,search,limit', 'panelLayout' => 'filter;sort,search,limit',
], ],
'label' => [ 'label' => [
'fields' => ['title', 'categorySummary', 'assignedMembersSummary', 'templateSummary'], 'fields' => ['title', 'assignedMembersSummary', 'templateSummary', 'internalNote'],
'showColumns' => true, 'showColumns' => true,
], ],
'global_operations' => [ 'global_operations' => [
@@ -46,22 +47,26 @@ $GLOBALS['TL_DCA']['tl_survey'] = [
], ],
], ],
'operations' => [ 'operations' => [
'editheader' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey']['editheader'],
'href' => 'act=edit',
'icon' => 'edit.svg',
'primary' => true,
'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,
'editheader' => [ 'showInHeader' => false,
'label' => ['Umfrage bearbeiten', 'Umfrage bearbeiten'],
'href' => 'act=edit',
'icon' => 'header.svg',
], ],
'toggle' => [ 'toggle' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey']['toggleActive'], 'label' => &$GLOBALS['TL_LANG']['tl_survey']['toggleActive'],
'href' => 'act=toggle&amp;field=isActive', 'href' => 'act=toggle&amp;field=isActive',
'icon' => 'visible.svg', 'icon' => 'visible.svg',
'primary' => true, 'primary' => true,
'showInHeader' => true, 'showInHeader' => false,
], ],
'publishedWarning' => [ 'publishedWarning' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey']['publishedWarning'], 'label' => &$GLOBALS['TL_LANG']['tl_survey']['publishedWarning'],
@@ -69,14 +74,20 @@ $GLOBALS['TL_DCA']['tl_survey'] = [
'primary' => true, 'primary' => true,
'button_callback' => [SurveyDcaListener::class, 'generatePublishedWarningButton'], 'button_callback' => [SurveyDcaListener::class, 'generatePublishedWarningButton'],
], ],
'duplicateSurvey' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey']['duplicateSurvey'],
'href' => 'key=duplicateSurvey',
'icon' => 'copy.svg',
'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',
], ],
@@ -93,7 +104,7 @@ $GLOBALS['TL_DCA']['tl_survey'] = [
], ],
], ],
'palettes' => [ 'palettes' => [
'default' => '{title_legend},title,category,description;{assignment_legend},assignedMembers;{publishing_legend},published,isActive,isTemplate;{meta_legend},createdAt,updatedAt', 'default' => '{title_legend},title,category,description,internalNote;{assignment_legend},assignedMembers;{publishing_legend},published,isActive,isTemplate;{meta_legend},createdAt,updatedAt',
], ],
'fields' => [ 'fields' => [
'id' => [ 'id' => [
@@ -122,26 +133,21 @@ $GLOBALS['TL_DCA']['tl_survey'] = [
'eval' => ['rte' => 'tinyMCE', 'tl_class' => 'clr'], 'eval' => ['rte' => 'tinyMCE', 'tl_class' => 'clr'],
'sql' => 'text NULL', 'sql' => 'text NULL',
], ],
'internalNote' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey']['internalNote'],
'inputType' => 'text',
'search' => true,
'eval' => ['maxlength' => 255, 'tl_class' => 'clr w50'],
'sql' => "varchar(255) NOT NULL default ''",
],
'category' => [ 'category' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey']['category'], 'label' => &$GLOBALS['TL_LANG']['tl_survey']['category'],
'filter' => true,
'inputType' => 'select', 'inputType' => 'select',
'foreignKey' => 'tl_survey_category.title', 'foreignKey' => 'tl_survey_category.title',
'eval' => ['multiple' => true, 'chosen' => true, 'tl_class' => 'clr'], 'eval' => ['multiple' => true, 'chosen' => true, 'tl_class' => 'clr'],
'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',
@@ -181,9 +187,9 @@ $GLOBALS['TL_DCA']['tl_survey'] = [
], ],
'assignedMembers' => [ 'assignedMembers' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey']['assignedMembers'], 'label' => &$GLOBALS['TL_LANG']['tl_survey']['assignedMembers'],
'inputType' => 'select', 'inputType' => 'checkboxWizard',
'options_callback' => [Mummert\SurveyBundle\EventListener\SurveyDcaListener::class, 'getMemberOptions'], 'options_callback' => [Mummert\SurveyBundle\EventListener\SurveyDcaListener::class, 'getMemberOptions'],
'eval' => ['multiple' => true, 'chosen' => true, 'tl_class' => 'clr'], 'eval' => ['multiple' => true, 'class' => 'survey-assigned-members-checkbox-wizard', 'tl_class' => 'clr'],
'load_callback' => [[Mummert\SurveyBundle\EventListener\SurveyDcaListener::class, 'loadSurveyAssignedMembers']], 'load_callback' => [[Mummert\SurveyBundle\EventListener\SurveyDcaListener::class, 'loadSurveyAssignedMembers']],
'save_callback' => [[Mummert\SurveyBundle\EventListener\SurveyDcaListener::class, 'saveSurveyAssignedMembers']], 'save_callback' => [[Mummert\SurveyBundle\EventListener\SurveyDcaListener::class, 'saveSurveyAssignedMembers']],
'sql' => 'blob NULL', 'sql' => 'blob NULL',
@@ -236,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) {
@@ -247,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'],
+8 -1
View File
@@ -32,7 +32,7 @@ $GLOBALS['TL_DCA']['tl_survey_content'] = [
'sorting' => [ 'sorting' => [
'mode' => 4, 'mode' => 4,
'fields' => ['sorting'], 'fields' => ['sorting'],
'headerFields' => ['title', 'alias', 'published'], 'headerFields' => ['title', 'published'],
'child_record_class' => 'survey-question-record', 'child_record_class' => 'survey-question-record',
'panelLayout' => 'sort,search,limit', 'panelLayout' => 'sort,search,limit',
], ],
@@ -44,6 +44,13 @@ $GLOBALS['TL_DCA']['tl_survey_content'] = [
'edit' => [ 'edit' => [
'href' => 'act=edit', 'href' => 'act=edit',
'icon' => 'edit.svg', 'icon' => 'edit.svg',
'primary' => true,
],
'cut' => [
'href' => 'act=paste&amp;mode=cut',
'method' => 'POST',
'icon' => 'mover.svg',
'attributes' => 'data-action="contao--scroll-offset#store"',
], ],
'copy' => [ 'copy' => [
'href' => 'act=copy', 'href' => 'act=copy',
+1
View File
@@ -5,6 +5,7 @@ $GLOBALS['TL_LANG']['MOD']['survey_bundle_categories'] = ['Umfrage-Kategorien',
$GLOBALS['TL_LANG']['FMD']['survey'] = 'Umfragen'; $GLOBALS['TL_LANG']['FMD']['survey'] = 'Umfragen';
$GLOBALS['TL_LANG']['FMD']['member_survey_list'] = ['Meine Umfragen', 'Zeigt alle dem Mitglied zugewiesenen Umfragen an.']; $GLOBALS['TL_LANG']['FMD']['member_survey_list'] = ['Meine Umfragen', 'Zeigt alle dem Mitglied zugewiesenen Umfragen an.'];
$GLOBALS['TL_LANG']['FMD']['member_survey_template_list'] = ['Meine Umfrage-Vorlagen', 'Zeigt alle dem Mitglied zugewiesenen Umfrage-Vorlagen an und bietet nur das Duplizieren an.'];
$GLOBALS['TL_LANG']['FMD']['member_survey_edit'] = ['Umfrage bearbeiten', 'Bearbeitungsbereich für zugewiesene Umfragen im Frontend.']; $GLOBALS['TL_LANG']['FMD']['member_survey_edit'] = ['Umfrage bearbeiten', 'Bearbeitungsbereich für zugewiesene Umfragen im Frontend.'];
$GLOBALS['TL_LANG']['FMD']['show_survey'] = ['Umfrage ausfüllen', 'Öffentliche Umfrageansicht über den öffentlichen Link.']; $GLOBALS['TL_LANG']['FMD']['show_survey'] = ['Umfrage ausfüllen', 'Öffentliche Umfrageansicht über den öffentlichen Link.'];
$GLOBALS['TL_LANG']['FMD']['survey_results'] = ['Ergebnisse', 'Zeigt die Ergebnisse einer Umfrage über den konfigurierten Ergebnis-Link an.']; $GLOBALS['TL_LANG']['FMD']['survey_results'] = ['Ergebnisse', 'Zeigt die Ergebnisse einer Umfrage über den konfigurierten Ergebnis-Link an.'];
+8 -3
View File
@@ -3,14 +3,13 @@
$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.'];
$GLOBALS['TL_LANG']['tl_survey']['template_yes'] = 'Ja'; $GLOBALS['TL_LANG']['tl_survey']['template_yes'] = 'Ja';
$GLOBALS['TL_LANG']['tl_survey']['template_no'] = 'Nein'; $GLOBALS['TL_LANG']['tl_survey']['template_no'] = 'Nein';
$GLOBALS['TL_LANG']['tl_survey']['description'] = ['Beschreibung', 'Einleitung für den Editor und das Frontend.']; $GLOBALS['TL_LANG']['tl_survey']['description'] = ['Beschreibung', 'Einleitung für den Editor und das Frontend.'];
$GLOBALS['TL_LANG']['tl_survey']['internalNote'] = ['Interne Notiz', 'Nur im Backend sichtbare interne Notiz zur Umfrage.'];
$GLOBALS['TL_LANG']['tl_survey']['title_legend'] = 'Grunddaten'; $GLOBALS['TL_LANG']['tl_survey']['title_legend'] = 'Grunddaten';
$GLOBALS['TL_LANG']['tl_survey']['assignment_legend'] = 'Bearbeitende Mitglieder'; $GLOBALS['TL_LANG']['tl_survey']['assignment_legend'] = 'Bearbeitende Mitglieder';
$GLOBALS['TL_LANG']['tl_survey']['publishing_legend'] = 'Sichtbarkeit'; $GLOBALS['TL_LANG']['tl_survey']['publishing_legend'] = 'Sichtbarkeit';
@@ -18,14 +17,20 @@ $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.'];
$GLOBALS['TL_LANG']['tl_survey']['assignedMembersFilter'] = ['Zugewiesene Benutzer', 'Filtert die Liste nach einem zugewiesenen Benutzer.']; $GLOBALS['TL_LANG']['tl_survey']['assignedMembersFilter'] = ['Zugewiesene Benutzer', 'Filtert die Liste nach einem zugewiesenen Benutzer.'];
$GLOBALS['TL_LANG']['tl_survey']['assignedMembersSummary'] = ['Zugewiesene Benutzer', 'Kommagetrennte Liste der zugewiesenen Benutzer.']; $GLOBALS['TL_LANG']['tl_survey']['assignedMembersSummary'] = ['Zugewiesene Benutzer', 'Kommagetrennte Liste der zugewiesenen Benutzer.'];
$GLOBALS['TL_LANG']['tl_survey']['copyPublicLink'] = ['Link kopieren', 'Den öffentlichen Link in die Zwischenablage kopieren']; $GLOBALS['TL_LANG']['tl_survey']['copyPublicLink'] = ['Link kopieren', 'Den öffentlichen Link in die Zwischenablage kopieren'];
$GLOBALS['TL_LANG']['tl_survey']['duplicateSurvey'] = ['Duplizieren', 'Diese Umfrage mit allen Fragen duplizieren'];
$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']['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.'];
+1
View File
@@ -5,6 +5,7 @@ $GLOBALS['TL_LANG']['MOD']['survey_bundle_categories'] = ['Survey categories', '
$GLOBALS['TL_LANG']['FMD']['survey'] = 'Surveys'; $GLOBALS['TL_LANG']['FMD']['survey'] = 'Surveys';
$GLOBALS['TL_LANG']['FMD']['member_survey_list'] = ['My surveys', 'Displays all surveys assigned to the member.']; $GLOBALS['TL_LANG']['FMD']['member_survey_list'] = ['My surveys', 'Displays all surveys assigned to the member.'];
$GLOBALS['TL_LANG']['FMD']['member_survey_template_list'] = ['My survey templates', 'Displays all survey templates assigned to the member and only offers duplication.'];
$GLOBALS['TL_LANG']['FMD']['member_survey_edit'] = ['Edit survey', 'Frontend editing area for assigned surveys.']; $GLOBALS['TL_LANG']['FMD']['member_survey_edit'] = ['Edit survey', 'Frontend editing area for assigned surveys.'];
$GLOBALS['TL_LANG']['FMD']['show_survey'] = ['Fill out survey', 'Public survey view accessible via the public link.']; $GLOBALS['TL_LANG']['FMD']['show_survey'] = ['Fill out survey', 'Public survey view accessible via the public link.'];
$GLOBALS['TL_LANG']['FMD']['survey_results'] = ['Results', 'Displays the results of a survey via the configured results link.']; $GLOBALS['TL_LANG']['FMD']['survey_results'] = ['Results', 'Displays the results of a survey via the configured results link.'];
+8 -3
View File
@@ -3,14 +3,13 @@
$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.'];
$GLOBALS['TL_LANG']['tl_survey']['template_yes'] = 'Yes'; $GLOBALS['TL_LANG']['tl_survey']['template_yes'] = 'Yes';
$GLOBALS['TL_LANG']['tl_survey']['template_no'] = 'No'; $GLOBALS['TL_LANG']['tl_survey']['template_no'] = 'No';
$GLOBALS['TL_LANG']['tl_survey']['description'] = ['Description', 'Introduction for the editor and the frontend.']; $GLOBALS['TL_LANG']['tl_survey']['description'] = ['Description', 'Introduction for the editor and the frontend.'];
$GLOBALS['TL_LANG']['tl_survey']['internalNote'] = ['Internal note', 'Internal note for the survey, visible in the backend only.'];
$GLOBALS['TL_LANG']['tl_survey']['title_legend'] = 'Basic data'; $GLOBALS['TL_LANG']['tl_survey']['title_legend'] = 'Basic data';
$GLOBALS['TL_LANG']['tl_survey']['assignment_legend'] = 'Editing members'; $GLOBALS['TL_LANG']['tl_survey']['assignment_legend'] = 'Editing members';
$GLOBALS['TL_LANG']['tl_survey']['publishing_legend'] = 'Visibility'; $GLOBALS['TL_LANG']['tl_survey']['publishing_legend'] = 'Visibility';
@@ -18,14 +17,20 @@ $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.'];
$GLOBALS['TL_LANG']['tl_survey']['assignedMembersFilter'] = ['Assigned users', 'Filters the list by an assigned user.']; $GLOBALS['TL_LANG']['tl_survey']['assignedMembersFilter'] = ['Assigned users', 'Filters the list by an assigned user.'];
$GLOBALS['TL_LANG']['tl_survey']['assignedMembersSummary'] = ['Assigned users', 'Comma-separated list of assigned users.']; $GLOBALS['TL_LANG']['tl_survey']['assignedMembersSummary'] = ['Assigned users', 'Comma-separated list of assigned users.'];
$GLOBALS['TL_LANG']['tl_survey']['copyPublicLink'] = ['Copy link', 'Copy the public link to the clipboard']; $GLOBALS['TL_LANG']['tl_survey']['copyPublicLink'] = ['Copy link', 'Copy the public link to the clipboard'];
$GLOBALS['TL_LANG']['tl_survey']['duplicateSurvey'] = ['Duplicate', 'Duplicate this survey 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']['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.'];
@@ -82,6 +82,20 @@
border-radius: 2rem; border-radius: 2rem;
box-shadow: 0 28px 80px rgba(34, 34, 34, 0.08); box-shadow: 0 28px 80px rgba(34, 34, 34, 0.08);
padding: clamp(1.5rem, 2.2vw, 2.4rem); padding: clamp(1.5rem, 2.2vw, 2.4rem);
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%),
radial-gradient(circle at bottom right, rgba(157, 82, 118, 0.12), transparent 36%),
linear-gradient(180deg, rgba(252, 228, 214, 0.92) 0%, rgba(255, 250, 246, 0.98) 52%, rgba(252, 228, 214, 0.78) 100%);
} }
.survey-shell h2, .survey-shell h2,
@@ -287,6 +301,13 @@
align-items: center; align-items: center;
} }
.survey-list-card__head--template {
align-items: baseline;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.75rem;
}
.survey-list-card__headline { .survey-list-card__headline {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -296,6 +317,12 @@
flex: 1 1 auto; flex: 1 1 auto;
} }
.survey-list-card__headline--template {
align-items: baseline;
justify-content: flex-start;
flex: 1 1 auto;
}
.survey-list-card__title-wrap { .survey-list-card__title-wrap {
min-width: 0; min-width: 0;
} }
@@ -317,19 +344,24 @@
.survey-list-card__head-actions { .survey-list-card__head-actions {
display: flex; display: flex;
align-items: center;
justify-content: flex-end; justify-content: flex-end;
gap: 0.5rem; gap: 0.5rem;
flex: 0 0 auto; flex: 0 0 auto;
margin-left: 1rem; margin-left: 1rem;
} }
.survey-list-card__head-actions--template {
align-items: baseline;
margin-left: auto;
}
.survey-list-card__footer { .survey-list-card__footer {
display: flex; display: flex;
align-items: center; align-items: baseline;
justify-content: space-between; justify-content: space-between;
gap: 0.75rem; gap: 0.75rem;
flex-wrap: wrap; flex-wrap: wrap;
margin-top: .3em;
} }
.survey-list-card__stats { .survey-list-card__stats {
@@ -409,29 +441,25 @@
} }
.survey-visibility-dot { .survey-visibility-dot {
width: 2.2rem; aspect-ratio: 1;
height: 2.2rem; height: 42px;
border-radius: 999px; border-radius: 999px;
border: 1px solid rgba(162, 168, 180, 0.62); background: #a6adb7;
background: #7f8793;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
transition: background 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease; transition: background 0.18s ease;
} }
.survey-visibility-dot.is-on { .survey-visibility-dot.is-on {
background: #0a8378; background: #0a832e;
border-color: rgba(0, 174, 151, 0.68);
box-shadow: 0 0 0 0.18rem rgba(0, 174, 151, 0.14);
} }
.survey-button-row { .survey-button-row {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.5rem; gap: 0.5rem;
margin-top: 0.65rem;
} }
.survey-button-row--form-actions { .survey-button-row--form-actions {
@@ -444,6 +472,51 @@
align-items: center; align-items: center;
} }
.survey-button-row--navigation {
width: 100%;
justify-content: flex-end;
align-items: center;
margin-top: 1rem;
}
.survey-button-row--navigation.has-back {
justify-content: space-between;
}
.survey-navigation-form {
margin: 0;
}
.survey-navigation-form--back {
margin: 0;
}
.survey-navigation-submit {
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;
} }
@@ -663,6 +736,11 @@
margin: 0; margin: 0;
} }
.survey-accordion-header-row {
display: flex;
align-items: stretch;
}
.survey-accordion-toggle { .survey-accordion-toggle {
display: flex; display: flex;
width: 100%; width: 100%;
@@ -821,6 +899,56 @@
color: var(--survey-green); color: var(--survey-green);
} }
.survey-range-block {
gap: 1rem;
}
.survey-range-value-card {
display: grid;
place-items: center;
gap: 0.2rem;
width: min(100%, 8.5rem);
min-height: 8.5rem;
margin-inline: auto;
padding: 1rem;
border-radius: 50%;
background: radial-gradient(circle at top left, rgba(0, 100, 173, 0.12), rgba(255, 255, 255, 0.96) 62%, rgba(236, 124, 50, 0.16) 100%);
border: 1px solid rgba(0, 100, 173, 0.16);
box-shadow: 0 10px 24px rgba(34, 34, 34, 0.08);
text-align: center;
}
.survey-range-value-label {
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--survey-gray-dark);
}
.survey-range-scale {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
width: 100%;
}
.survey-range-boundary {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2.75rem;
min-height: 2.75rem;
padding: 0.45rem 0.7rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.82);
border: 1px solid rgba(0, 100, 173, 0.14);
color: var(--survey-blue);
font-weight: 700;
box-shadow: 0 6px 16px rgba(34, 34, 34, 0.05);
}
@media (min-width: 960px) { @media (min-width: 960px) {
.survey-grid-columns { .survey-grid-columns {
grid-template-columns: 0.95fr 1.05fr; grid-template-columns: 0.95fr 1.05fr;
@@ -858,6 +986,10 @@
align-items: flex-start; align-items: flex-start;
} }
.survey-button-row--navigation.has-back {
justify-content: space-between;
}
.survey-list-publish-help { .survey-list-publish-help {
left: 0; left: 0;
right: auto; right: auto;
@@ -1072,6 +1204,7 @@
}); });
} }
resetEditorViewport(); resetEditorViewport();
}); });
</script> </script>
@@ -0,0 +1,119 @@
<style>
.survey-accordion-header-row {
display: flex;
align-items: stretch;
}
.survey-accordion-sort-handle {
position: relative;
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;
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 {
position: relative;
display: inline-flex;
width: 0.9rem;
height: 1.2rem;
align-items: center;
justify-content: center;
color: #58a6da;
background-image:
linear-gradient(currentColor, currentColor),
linear-gradient(currentColor, currentColor),
linear-gradient(currentColor, currentColor);
background-repeat: no-repeat;
background-position: center 0.36rem, center center, center calc(100% - 0.36rem);
background-size: 0.78rem 2px, 0.78rem 2px, 0.78rem 2px;
}
.survey-sort-icon::before,
.survey-sort-icon::after {
content: '';
position: absolute;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 0.24rem solid transparent;
border-right: 0.24rem solid transparent;
}
.survey-sort-icon::before {
top: 0;
border-bottom: 0.24rem solid currentColor;
}
.survey-sort-icon::after {
bottom: 0;
border-top: 0.24rem solid currentColor;
}
.survey-question-order-save-shell {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
z-index: 40;
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, 0);
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;
}
@media (max-width: 767px) {
.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 src="{{ asset('bundles/survey/js/sortable.min.js') }}"></script>
<script src="{{ asset('bundles/survey/js/survey-frontend-editor-sort.js') }}"></script>
@@ -1,6 +1,7 @@
{% trans_default_domain 'messages' %} {% trans_default_domain 'messages' %}
{% include '@Survey/frontend/_survey_assets.html.twig' %} {% include '@Survey/frontend/_survey_assets.html.twig' %}
{% include '@Survey/frontend/_survey_question_sort_assets.html.twig' %}
<link rel="stylesheet" href="{{ asset('assets/handorgel/css/handorgel.min.css') }}"> <link rel="stylesheet" href="{{ asset('assets/handorgel/css/handorgel.min.css') }}">
{% include '@Survey/frontend/_survey_branding.html.twig' %} {% include '@Survey/frontend/_survey_branding.html.twig' %}
@@ -47,6 +48,7 @@
{% else %} {% else %}
{% set surveyMetaFormId = 'survey-meta-form' %} {% set surveyMetaFormId = 'survey-meta-form' %}
{% set newQuestionFormId = 'survey-question-form-new' %} {% set newQuestionFormId = 'survey-question-form-new' %}
{% set canQuestionReorder = canQuestionReorder|default(false) %}
<div class="survey-button-row survey-button-row--editor-actions"> <div class="survey-button-row survey-button-row--editor-actions">
{% if backUrl %}<a class="survey-button secondary" href="{{ backUrl }}">{{ 'survey.edit.back_to_list'|trans }}</a>{% endif %} {% if backUrl %}<a class="survey-button secondary" href="{{ backUrl }}">{{ 'survey.edit.back_to_list'|trans }}</a>{% endif %}
@@ -74,14 +76,25 @@
<div class="survey-grid-columns survey-editor-layout"> <div class="survey-grid-columns survey-editor-layout">
<div class="survey-question-editor-shell" data-survey-question-editor> <div class="survey-question-editor-shell" data-survey-question-editor>
<div class="survey-accordion-list handorgel" data-survey-handorgel> {% if canQuestionReorder %}
<form method="post" action="{{ metadataFormActionUrl }}" class="survey-question-order-save-shell" data-survey-question-order-shell hidden>
<input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}">
<input type="hidden" name="_survey_action" value="reorder-questions">
<input type="hidden" name="item_id" value="{{ survey.id }}">
<input type="hidden" name="_token" value="{{ csrf_token('reorder-questions-' ~ survey.id) }}">
<input type="hidden" name="question_order" value="" data-survey-question-order-input>
<button class="survey-button primary survey-question-order-save">{{ 'survey.edit.save_question_order'|trans }}</button>
</form>
{% endif %}
<div class="survey-accordion-list handorgel" data-survey-handorgel{% if canQuestionReorder %} data-survey-question-sortable="1"{% endif %}>
{% for question in questions %} {% for question in questions %}
{% set questionAnchor = 'survey-question-' ~ question.id %} {% set questionAnchor = 'survey-question-' ~ question.id %}
{% set isOpen = activeQuestionId == question.id %} {% set isOpen = activeQuestionId == question.id %}
{% set questionItemForm = questionForms[question.id]|default(null) %} {% set questionItemForm = questionForms[question.id]|default(null) %}
{% set questionFormId = 'survey-question-form-' ~ question.id %} {% set questionFormId = 'survey-question-form-' ~ question.id %}
<article id="{{ questionAnchor }}" class="survey-accordion-item"> <article id="{{ questionAnchor }}" class="survey-accordion-item"{% if canQuestionReorder %} data-survey-question-item data-question-id="{{ question.id }}"{% endif %}>
<h4 class="survey-accordion-header handorgel__header"> <h4 class="survey-accordion-header handorgel__header">
<div class="survey-accordion-header-row">
<button <button
type="button" type="button"
class="survey-accordion-toggle handorgel__header__button" class="survey-accordion-toggle handorgel__header__button"
@@ -93,6 +106,18 @@
</span> </span>
</span> </span>
</button> </button>
{% if canQuestionReorder %}
<button type="button" class="survey-accordion-sort-handle" data-survey-question-sort-handle aria-label="{{ 'survey.edit.reorder_question'|trans }}" title="{{ 'survey.edit.reorder_question'|trans }}">
<span class="survey-sort-icon" aria-hidden="true">
<svg viewBox="0 0 18 24" role="presentation" focusable="false">
<path d="M9 3 11.6 5.8H6.4z" fill="currentColor"></path>
<path d="M9 21 6.4 18.2h5.2z" fill="currentColor"></path>
<path d="M4.5 12h9" stroke="currentColor" stroke-linecap="round" stroke-width="1.8"></path>
</svg>
</span>
</button>
{% endif %}
</div>
</h4> </h4>
<div class="survey-accordion-panel handorgel__content"> <div class="survey-accordion-panel handorgel__content">
<div class="handorgel__content__inner" data-survey-question-editor> <div class="handorgel__content__inner" data-survey-question-editor>
@@ -1,11 +1,14 @@
{% trans_default_domain 'messages' %} {% trans_default_domain 'messages' %}
{% include '@Survey/frontend/_survey_assets.html.twig' %} {% include '@Survey/frontend/_survey_assets.html.twig' %}
{% include '@Survey/frontend/_survey_branding.html.twig' %} {% if not templatesOnly|default(false) %}
{% include '@Survey/frontend/_survey_branding.html.twig' %}
{% endif %}
{% set moduleHeadline = (headline is iterable ? headline.text|default('') : headline|default(''))|striptags|trim %} {% set moduleHeadline = (headline is iterable ? headline.text|default('') : headline|default(''))|striptags|trim %}
{% set templatesOnly = templatesOnly|default(false) %}
<section class="survey-shell survey-grid"> <section class="survey-shell survey-grid{{ templatesOnly ? ' survey-shell--template' : '' }}">
{% for message in app.flashes('success') %} {% for message in app.flashes('success') %}
<div class="survey-alert success">{{ message }}</div> <div class="survey-alert success">{{ message }}</div>
{% endfor %} {% endfor %}
@@ -32,17 +35,31 @@
<div class="survey-grid survey-list-grid"> <div class="survey-grid survey-list-grid">
{% for survey in surveys %} {% for survey in surveys %}
<article class="survey-card survey-list-card"> <article class="survey-card survey-list-card">
<div class="survey-list-card__head"> <div class="survey-list-card__head{{ templatesOnly ? ' survey-list-card__head--template' : '' }}">
<div class="survey-list-card__headline"> <div class="survey-list-card__headline{{ templatesOnly ? ' survey-list-card__headline--template' : '' }}">
<div class="survey-list-card__title-wrap"> <div class="survey-list-card__title-wrap">
<h3>{{ survey.title|striptags|trim }}</h3> <h3>{{ survey.title|striptags|trim }}</h3>
</div> </div>
<div class="survey-list-card__stats"> <div class="survey-list-card__stats">
<span class="survey-list-stat">{{ 'survey.list.questions'|trans({'%count%': survey.questionCount}) }}</span> <span class="survey-list-stat">{{ 'survey.list.questions'|trans({'%count%': survey.questionCount}) }}</span>
{% if not templatesOnly %}
<span class="survey-list-stat">{{ 'survey.list.participations'|trans({'%count%': survey.participationCount}) }}</span> <span class="survey-list-stat">{{ 'survey.list.participations'|trans({'%count%': survey.participationCount}) }}</span>
{% endif %}
</div> </div>
</div> </div>
{% if templatesOnly %}
<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>
</form>
</div> </div>
{% endif %}
</div>
{% if not templatesOnly %}
<div class="survey-list-card__footer"> <div class="survey-list-card__footer">
<div class="survey-button-row survey-list-card__actions"> <div class="survey-button-row survey-list-card__actions">
{% if survey.editUrl %} {% if survey.editUrl %}
@@ -63,9 +80,16 @@
{% 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="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="_survey_action" value="delete-survey">
<input type="hidden" name="item_id" value="{{ survey.id }}"> <input type="hidden" name="item_id" value="{{ survey.id }}">
<input type="hidden" name="_token" value="{{ csrf_token('delete-survey-' ~ 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>
</form> </form>
</div> </div>
@@ -73,9 +97,9 @@
<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 }}">
<input type="hidden" name="_token" value="{{ csrf_token('publish-survey-' ~ survey.id) }}">
<button <button
type="submit" type="submit"
class="survey-button primary survey-list-publish-button" class="survey-button primary survey-list-publish-button"
@@ -91,16 +115,19 @@
<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 }}">
<input type="hidden" name="_token" value="{{ csrf_token('toggle-active-' ~ 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>
</form> </form>
</div> </div>
</div> </div>
{% endif %}
</article> </article>
{% else %} {% else %}
{% if not templatesOnly %}
<div class="survey-card">{{ 'survey.list.empty'|trans }}</div> <div class="survey-card">{{ 'survey.list.empty'|trans }}</div>
{% endif %}
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% endif %}
+27 -21
View File
@@ -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 %}
@@ -52,20 +52,7 @@
<h3>{{ question.question|striptags|trim }}</h3> <h3>{{ question.question|striptags|trim }}</h3>
{% if question.description|striptags|trim %}<p>{{ question.description|striptags|trim }}</p>{% endif %} {% if question.description|striptags|trim %}<p>{{ question.description|striptags|trim }}</p>{% endif %}
{% if canGoBack %} {{ form_start(surveyForm, {attr: {class: 'survey-form-grid', id: 'survey-answer-form-' ~ question.id}}) }}
<form method="post" class="survey-button-row survey-button-row--navigation">
<input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}">
<input type="hidden" name="_survey_navigation" value="back">
<input type="hidden" name="_navigation_token" value="{{ csrf_token((isPreview ? 'survey-preview-back-' : 'survey-back-') ~ survey.id) }}">
{% if isPreview %}
<input type="hidden" name="preview_question" value="{{ question.id }}">
<input type="hidden" name="preview_history" value="{{ historyState }}">
{% endif %}
<button type="submit" class="survey-button secondary">{{ 'survey.survey.back'|trans }}</button>
</form>
{% endif %}
{{ form_start(surveyForm, {attr: {class: 'survey-form-grid'}}) }}
<input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}"> <input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}">
{% if isPreview %} {% if isPreview %}
<input type="hidden" name="preview_question" value="{{ question.id }}"> <input type="hidden" name="preview_question" value="{{ question.id }}">
@@ -76,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>
@@ -89,21 +79,37 @@
{% endfor %} {% endfor %}
</div> </div>
{% elseif question.type == 'range' %} {% elseif question.type == 'range' %}
<div class="survey-grid"> <div class="survey-grid survey-range-block">
<div class="survey-card"> <div class="survey-range-value-card">
<div>{{ 'survey.survey.current_value'|trans }}</div> <div class="survey-range-value-label">{{ 'survey.survey.current_value'|trans }}</div>
<div id="survey-range-output-{{ question.id }}" class="survey-range-output">{{ question.rangeMin }}</div> <div id="survey-range-output-{{ question.id }}" class="survey-range-output">{{ question.rangeMin }}</div>
</div> </div>
{{ form_widget(surveyForm.answer, {attr: {'data-survey-range': '1', 'data-survey-range-target': 'survey-range-output-' ~ question.id}}) }} {{ form_widget(surveyForm.answer, {attr: {'data-survey-range': '1', 'data-survey-range-target': 'survey-range-output-' ~ question.id}}) }}
<div class="survey-meta"><span>{{ question.rangeMin }}</span><span>{{ question.rangeMax }}</span></div> <div class="survey-range-scale"><span class="survey-range-boundary">{{ question.rangeMin }}</span><span class="survey-range-boundary">{{ question.rangeMax }}</span></div>
</div> </div>
{% else %} {% else %}
{{ form_widget(surveyForm.answer) }} {{ form_widget(surveyForm.answer) }}
{% endif %} {% endif %}
{{ form_errors(surveyForm.answer) }} {{ form_errors(surveyForm.answer) }}
<div class="survey-button-row"><button class="survey-button primary">{{ 'survey.survey.next'|trans }}</button></div>
{{ form_end(surveyForm) }} {{ form_end(surveyForm) }}
<div class="survey-button-row survey-button-row--navigation{{ canGoBack ? ' has-back' : '' }}">
{% if canGoBack %}
<form method="post" class="survey-navigation-form survey-navigation-form--back">
<input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}">
<input type="hidden" name="_survey_navigation" value="back">
<input type="hidden" name="_navigation_token" value="{{ csrf_token((isPreview ? 'survey-preview-back-' : 'survey-back-') ~ survey.id) }}">
{% if isPreview %}
<input type="hidden" name="preview_question" value="{{ question.id }}">
<input type="hidden" name="preview_history" value="{{ historyState }}">
{% endif %}
<button type="submit" class="survey-button secondary">{{ 'survey.survey.back'|trans }}</button>
</form>
{% endif %}
<button type="submit" form="{{ 'survey-answer-form-' ~ question.id }}" class="survey-button primary survey-navigation-submit">{{ 'survey.survey.next'|trans }}</button>
</div>
</article> </article>
{% endif %} {% endif %}
{% endif %} {% endif %}
+1
View File
@@ -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 {
+45
View File
@@ -0,0 +1,45 @@
.tl_checkbox_wizard.survey-assigned-members-checkbox-wizard {
column-count: 4;
column-gap: 1.5rem;
}
.tl_checkbox_wizard.survey-assigned-members-checkbox-wizard > span {
display: block;
break-inside: avoid;
margin-bottom: 0.35rem;
}
.tl_checkbox_wizard.survey-assigned-members-checkbox-wizard .drag-handle {
display: none;
}
.tl_checkbox_wizard.survey-assigned-members-checkbox-wizard label {
display: inline;
white-space: normal;
}
body.survey-question-list-page .parent_view > .tl_header .tl_content_right {
display: none;
}
body.survey-question-list-page.survey-question-list-clipboard-active .parent_view > .tl_header .tl_content_right {
display: block;
}
@media (max-width: 1400px) {
.tl_checkbox_wizard.survey-assigned-members-checkbox-wizard {
column-count: 6;
}
}
@media (max-width: 1100px) {
.tl_checkbox_wizard.survey-assigned-members-checkbox-wizard {
column-count: 4;
}
}
@media (max-width: 767px) {
.tl_checkbox_wizard.survey-assigned-members-checkbox-wizard {
column-count: 2;
}
}
+2
View File
File diff suppressed because one or more lines are too long
+136
View File
@@ -0,0 +1,136 @@
(function () {
function isSurveyListPage() {
var params = new URLSearchParams(window.location.search);
return params.get('do') === 'survey_bundle_surveys';
}
function isSurveyQuestionListPage() {
var params = new URLSearchParams(window.location.search);
return params.get('do') === 'survey_bundle_surveys' && params.get('table') === 'tl_survey_content' && !params.get('act');
}
function toggleQuestionListClass() {
document.body.classList.toggle('survey-question-list-page', isSurveyQuestionListPage());
document.body.classList.toggle('survey-question-list-clipboard-active', isSurveyQuestionListPage() && hasQuestionListClipboardState());
}
function hasQuestionListClipboardState() {
if (!isSurveyQuestionListPage()) {
return false;
}
if (document.querySelector('#paste_hint')) {
return true;
}
return Array.from(document.querySelectorAll('button')).some(function (button) {
return (button.textContent || '').trim() === 'Ablage leeren';
});
}
function getSearchSelect() {
return document.querySelector('select[name="tl_search"]');
}
function getSearchForm(select) {
return select ? select.closest('form') : null;
}
function removeIdOption(select) {
if (!select) {
return;
}
var idOption = select.querySelector('option[value="id"]');
if (!idOption) {
return;
}
var wasSelected = idOption.selected;
idOption.remove();
if (wasSelected) {
var fallback = select.querySelector('option[value="title"]') || select.options[0];
if (fallback) {
fallback.selected = true;
select.value = fallback.value;
select.dispatchEvent(new Event('change', { bubbles: true }));
}
}
}
function removeIdChoicesUi() {
document.querySelectorAll('.choices__item[data-value="id"]').forEach(function (node) {
node.remove();
});
document.querySelectorAll('.choices__item--choice[data-value="id"]').forEach(function (node) {
node.remove();
});
}
function normalizeSearchField() {
toggleQuestionListClass();
if (!isSurveyListPage()) {
return;
}
var select = getSearchSelect();
if (!select) {
return;
}
removeIdOption(select);
removeIdChoicesUi();
var form = getSearchForm(select);
if (form && !form.dataset.surveySearchNormalized) {
form.dataset.surveySearchNormalized = '1';
form.addEventListener('submit', function () {
if (select.value === 'id') {
var fallback = select.querySelector('option[value="title"]') || select.options[0];
if (fallback) {
select.value = fallback.value;
}
}
});
}
}
document.addEventListener('DOMContentLoaded', normalizeSearchField);
document.addEventListener('turbo:load', normalizeSearchField);
// 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 });
})();
+110
View File
@@ -0,0 +1,110 @@
(function () {
function ensureSortIconStyles() {
if (document.getElementById('survey-question-sort-icon-theme')) {
return;
}
var style = document.createElement('style');
style.id = 'survey-question-sort-icon-theme';
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(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;}'
].join('');
document.head.appendChild(style);
}
function initSurveyQuestionSorting() {
ensureSortIconStyles();
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 || 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();
sortableList._surveyQuestionSortable = 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();
}
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initSurveyQuestionSorting, { once: true });
} else {
initSurveyQuestionSorting();
}
window.addEventListener('load', initSurveyQuestionSorting, { once: true });
})();
@@ -188,6 +188,7 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController
$template->set('questionForms', $questionFormViews); $template->set('questionForms', $questionFormViews);
$template->set('newQuestionForm', $newQuestionFormView); $template->set('newQuestionForm', $newQuestionFormView);
$template->set('questions', $questions); $template->set('questions', $questions);
$template->set('canQuestionReorder', !$structureLocked && count($questions) > 1);
$conditionOverview = $this->surveyConditionRepository->findOverviewBySurvey((int) $survey->id); $conditionOverview = $this->surveyConditionRepository->findOverviewBySurvey((int) $survey->id);
$template->set('conditions', $this->buildJumpRuleOverview($questions, $conditionOverview)); $template->set('conditions', $this->buildJumpRuleOverview($questions, $conditionOverview));
@@ -279,6 +280,11 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController
$this->addFlash('success', 'Die Frage wurde gelöscht.'); $this->addFlash('success', 'Die Frage wurde gelöscht.');
} }
if ('reorder-questions' === $action) {
$this->surveyEditorService->reorderQuestions($survey, $this->parseQuestionOrder($request));
$this->addFlash('success', 'Die neue Reihenfolge der Fragen wurde gespeichert.');
}
if ('delete-condition' === $action) { if ('delete-condition' === $action) {
$this->surveyEditorService->deleteCondition($survey, $itemId); $this->surveyEditorService->deleteCondition($survey, $itemId);
$this->addFlash('success', 'Die Bedingung wurde gelöscht.'); $this->addFlash('success', 'Die Bedingung wurde gelöscht.');
@@ -290,6 +296,23 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController
return new RedirectResponse($this->buildEditUrl($model, $request, (int) $survey->id)); return new RedirectResponse($this->buildEditUrl($model, $request, (int) $survey->id));
} }
/**
* @return list<int>
*/
private function parseQuestionOrder(Request $request): array
{
$rawOrder = trim((string) $request->request->get('question_order', ''));
if ('' === $rawOrder) {
throw new \RuntimeException('Die neue Reihenfolge der Fragen fehlt.');
}
return array_values(array_filter(
array_map('intval', explode(',', $rawOrder)),
static fn (int $questionId): bool => $questionId > 0,
));
}
private function resolveActiveQuestion(Request $request, int $surveyId): mixed private function resolveActiveQuestion(Request $request, int $surveyId): mixed
{ {
$questionId = (int) $request->query->get('question', 0); $questionId = (int) $request->query->get('question', 0);
@@ -317,13 +340,15 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController
private function buildQuestionChoices(int $surveyId, int $excludeQuestionId = 0): array private function buildQuestionChoices(int $surveyId, int $excludeQuestionId = 0): array
{ {
$choices = []; $choices = [];
$position = 1;
foreach ($this->surveyQuestionRepository->findAllBySurvey($surveyId) as $question) { foreach ($this->surveyQuestionRepository->findAllBySurvey($surveyId) as $question) {
if ($excludeQuestionId > 0 && (int) $question->id === $excludeQuestionId) { if ($excludeQuestionId > 0 && (int) $question->id === $excludeQuestionId) {
++$position;
continue; continue;
} }
$choices[(int) $question->id] = sprintf('#%d %s', (int) $question->id, (string) $question->question); $choices[(int) $question->id] = sprintf('%d. %s', $position++, (string) $question->question);
} }
return $choices; return $choices;
@@ -20,6 +20,7 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
#[AsFrontendModule(type: 'member_survey_list', category: 'survey', template: 'member_survey_list')] #[AsFrontendModule(type: 'member_survey_list', category: 'survey', template: 'member_survey_list')]
#[AsFrontendModule(type: 'member_survey_template_list', category: 'survey', template: 'member_survey_list')]
final class MemberSurveyListController extends AbstractFrontendModuleController final class MemberSurveyListController extends AbstractFrontendModuleController
{ {
public function __construct( public function __construct(
@@ -31,12 +32,14 @@ final class MemberSurveyListController extends AbstractFrontendModuleController
protected function getResponse(FragmentTemplate $template, ModuleModel $model, Request $request): Response protected function getResponse(FragmentTemplate $template, ModuleModel $model, Request $request): Response
{ {
$templatesOnly = 'member_survey_template_list' === (string) ($model->type ?? '');
$user = $this->getUser(); $user = $this->getUser();
if (!$user instanceof FrontendUser) { if (!$user instanceof FrontendUser) {
$template->set('loginRequired', true); $template->set('loginRequired', true);
$template->set('surveys', []); $template->set('surveys', []);
$template->set('createUrl', null); $template->set('createUrl', null);
$template->set('templatesOnly', $templatesOnly);
return $template->getResponse(); return $template->getResponse();
} }
@@ -45,12 +48,12 @@ final class MemberSurveyListController extends AbstractFrontendModuleController
return $this->handleAction($request, (int) $user->id); return $this->handleAction($request, (int) $user->id);
} }
$editPage = $this->resolvePage((int) ($model->surveyEditPage ?? 0)); $editPage = $templatesOnly ? null : $this->resolvePage((int) ($model->surveyEditPage ?? 0));
$readerPage = $this->resolvePage((int) $this->getContaoAdapter(Config::class)->get('surveyReaderPage')); $readerPage = $this->resolvePage((int) $this->getContaoAdapter(Config::class)->get('surveyReaderPage'));
$resultsPage = $this->resolvePage((int) $this->getContaoAdapter(Config::class)->get('surveyResultsPage')); $resultsPage = $this->resolvePage((int) $this->getContaoAdapter(Config::class)->get('surveyResultsPage'));
$items = []; $items = [];
foreach ($this->surveyRepository->findEditableByMember((int) $user->id) as $survey) { foreach ($this->surveyRepository->findEditableByMember((int) $user->id, $templatesOnly) as $survey) {
$surveyId = (int) $survey['id']; $surveyId = (int) $survey['id'];
$alias = (string) $survey['alias']; $alias = (string) $survey['alias'];
@@ -58,21 +61,27 @@ final class MemberSurveyListController extends AbstractFrontendModuleController
'id' => $surveyId, 'id' => $surveyId,
'title' => (string) $survey['title'], 'title' => (string) $survey['title'],
'description' => (string) ($survey['description'] ?? ''), 'description' => (string) ($survey['description'] ?? ''),
'isTemplate' => !empty($survey['isTemplate']),
'isActive' => !empty($survey['isActive']), 'isActive' => !empty($survey['isActive']),
'isLocked' => !empty($survey['isLocked']), 'isLocked' => !empty($survey['isLocked']),
'published' => !empty($survey['published']), 'published' => !empty($survey['published']),
'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,
]; ];
} }
if ($templatesOnly && [] === $items) {
return new Response('');
}
$template->set('loginRequired', false); $template->set('loginRequired', false);
$template->set('surveys', $items); $template->set('surveys', $items);
$template->set('createUrl', $editPage instanceof PageModel ? $this->generateContentUrl($editPage, ['create' => '1']) : null); $template->set('createUrl', !$templatesOnly && $editPage instanceof PageModel ? $this->generateContentUrl($editPage, ['create' => '1']) : null);
$template->set('templatesOnly', $templatesOnly);
return $template->getResponse(); return $template->getResponse();
} }
@@ -81,15 +90,27 @@ 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'); $token = (string) $request->request->get('_token', '');
if ($surveyId <= 0 || !$this->isCsrfTokenValid($action.'-'.$surveyId, $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.'); throw new \RuntimeException('Ungültiger CSRF-Token.');
} }
$survey = $this->resolveEditableSurvey($surveyId, $memberId); $survey = $this->resolveEditableSurvey($surveyId, $memberId);
try { try {
if ('duplicate-survey' === $action) {
$duplicate = $this->surveyEditorService->duplicateSurvey($survey, $memberId);
$this->addFlash('success', sprintf('Die Umfrage "%s" wurde als Kopie angelegt.', (string) $duplicate->title));
}
if ('publish-survey' === $action) { if ('publish-survey' === $action) {
$this->surveyEditorService->publishSurvey($survey, $memberId); $this->surveyEditorService->publishSurvey($survey, $memberId);
$this->addFlash('success', 'Die Umfrage ist jetzt veröffentlicht.'); $this->addFlash('success', 'Die Umfrage ist jetzt veröffentlicht.');
@@ -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);
+109 -50
View File
@@ -6,22 +6,29 @@ namespace Mummert\SurveyBundle\EventListener;
use Contao\Config; use Contao\Config;
use Contao\CoreBundle\DataContainer\DataContainerOperation; use Contao\CoreBundle\DataContainer\DataContainerOperation;
use Contao\CoreBundle\Exception\RedirectResponseException;
use Contao\CoreBundle\Routing\ContentUrlGenerator; use Contao\CoreBundle\Routing\ContentUrlGenerator;
use Contao\CoreBundle\Framework\ContaoFramework; use Contao\CoreBundle\Framework\ContaoFramework;
use Contao\DataContainer; use Contao\DataContainer;
use Contao\Image; use Contao\Image;
use Contao\Input; use Contao\Input;
use Contao\Message;
use Contao\PageModel; use Contao\PageModel;
use Contao\StringUtil; use Contao\System;
use Doctrine\DBAL\Connection; use Doctrine\DBAL\Connection;
use Mummert\SurveyBundle\Repository\SurveyEditorRepository; use Mummert\SurveyBundle\Repository\SurveyEditorRepository;
use Mummert\SurveyBundle\Repository\SurveyQuestionRepository; use Mummert\SurveyBundle\Repository\SurveyQuestionRepository;
use Mummert\SurveyBundle\Repository\SurveyRepository; use Mummert\SurveyBundle\Repository\SurveyRepository;
use Mummert\SurveyBundle\Service\SurveyEditorService;
use Mummert\SurveyBundle\Service\SurveyCategoryAssignmentService; use Mummert\SurveyBundle\Service\SurveyCategoryAssignmentService;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
final class SurveyDcaListener final class SurveyDcaListener
{ {
private ?SurveyEditorService $surveyEditorService;
private ?RequestStack $requestStack;
public function __construct( public function __construct(
private readonly SurveyRepository $surveyRepository, private readonly SurveyRepository $surveyRepository,
private readonly SurveyQuestionRepository $surveyQuestionRepository, private readonly SurveyQuestionRepository $surveyQuestionRepository,
@@ -30,7 +37,11 @@ final class SurveyDcaListener
private readonly ContaoFramework $framework, private readonly ContaoFramework $framework,
private readonly Connection $connection, private readonly Connection $connection,
private readonly ContentUrlGenerator $contentUrlGenerator, private readonly ContentUrlGenerator $contentUrlGenerator,
?SurveyEditorService $surveyEditorService = null,
?RequestStack $requestStack = null,
) { ) {
$this->surveyEditorService = $surveyEditorService;
$this->requestStack = $requestStack;
} }
public function generateSurveyAlias(mixed $value, DataContainer $dataContainer): string public function generateSurveyAlias(mixed $value, DataContainer $dataContainer): string
@@ -114,6 +125,51 @@ final class SurveyDcaListener
)); ));
} }
public function generateDuplicateSurveyButton(DataContainerOperation $operation): void
{
$row = $operation->getRecord() ?? [];
$title = trim(strip_tags((string) ($row['title'] ?? '')));
$confirmText = sprintf(
$GLOBALS['TL_LANG']['tl_survey']['duplicateSurveyConfirm'] ?? 'Sind Sie sicher, dass Sie die Umfrage "%s" mit allen Fragen duplizieren wollen?',
'' !== $title ? $title : '#'.(string) ($row['id'] ?? 0),
);
$operation['label'] = $GLOBALS['TL_LANG']['tl_survey']['duplicateSurvey'][0] ?? 'Duplizieren';
$operation['title'] = $GLOBALS['TL_LANG']['tl_survey']['duplicateSurvey'][1] ?? 'Diese Umfrage mit allen Fragen duplizieren';
$operation['icon'] = 'copy.svg';
$operation['attributes']
->set('onclick', sprintf('if(!confirm(%s))return false;Backend.getScrollOffset()', json_encode($confirmText, JSON_THROW_ON_ERROR)))
->set('data-contao--tooltips-target', 'tooltip')
;
}
public function handleDuplicateSurveyAction(): void
{
if ('duplicateSurvey' !== (string) Input::get('key')) {
return;
}
$surveyId = (int) Input::get('id');
try {
$survey = $this->surveyRepository->findById($surveyId);
if (null === $survey) {
throw new \RuntimeException('Die Umfrage wurde nicht gefunden.');
}
$duplicate = $this->getSurveyEditorService()->duplicateSurvey($survey);
Message::addConfirmation(sprintf(
$GLOBALS['TL_LANG']['tl_survey']['duplicateSurveyDone'] ?? 'Die Umfrage "%s" wurde dupliziert.',
(string) $duplicate->title,
));
} catch (\Throwable $exception) {
Message::addError($exception->getMessage());
}
throw new RedirectResponseException($this->buildBackendListUrl());
}
/** /**
* @return array<int, string> * @return array<int, string>
*/ */
@@ -139,9 +195,10 @@ final class SurveyDcaListener
private function buildQuestionOptions(int $surveyId): array private function buildQuestionOptions(int $surveyId): array
{ {
$choices = []; $choices = [];
$position = 1;
foreach ($this->surveyQuestionRepository->findAllBySurvey($surveyId) as $question) { foreach ($this->surveyQuestionRepository->findAllBySurvey($surveyId) as $question) {
$choices[(int) $question->id] = sprintf('#%d %s', (int) $question->id, (string) $question->question); $choices[(int) $question->id] = sprintf('%d. %s', $position++, (string) $question->question);
} }
return $choices; return $choices;
@@ -155,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>
*/ */
@@ -496,4 +505,54 @@ final class SurveyDcaListener
{ {
return (int) $this->framework->getAdapter(Config::class)->get($settingKey); return (int) $this->framework->getAdapter(Config::class)->get($settingKey);
} }
private function buildBackendListUrl(): string
{
$request = $this->getRequestStack()->getCurrentRequest();
if (null === $request) {
return '/contao';
}
$query = $request->query->all();
unset($query['key'], $query['id']);
$url = $request->getBaseUrl().$request->getPathInfo();
if ([] !== $query) {
$url .= '?'.http_build_query($query);
}
return $url;
}
private function getSurveyEditorService(): SurveyEditorService
{
if ($this->surveyEditorService instanceof SurveyEditorService) {
return $this->surveyEditorService;
}
$service = System::getContainer()->get(SurveyEditorService::class);
if (!$service instanceof SurveyEditorService) {
throw new \RuntimeException('SurveyEditorService konnte nicht geladen werden.');
}
return $this->surveyEditorService = $service;
}
private function getRequestStack(): RequestStack
{
if ($this->requestStack instanceof RequestStack) {
return $this->requestStack;
}
$service = System::getContainer()->get('request_stack');
if (!$service instanceof RequestStack) {
throw new \RuntimeException('RequestStack konnte nicht geladen werden.');
}
return $this->requestStack = $service;
}
} }
-36
View File
@@ -1,36 +0,0 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Form\Model;
use Mummert\SurveyBundle\Model\SurveyConditionModel;
final class SurveyConditionData
{
public int $sourceQuestion = 0;
public string $answerValue = '';
public int $targetQuestion = 0;
public static function fromModel(SurveyConditionModel $condition): self
{
$data = new self();
$data->sourceQuestion = (int) $condition->sourceQuestion;
$data->answerValue = (string) $condition->answerValue;
$data->targetQuestion = (int) $condition->targetQuestion;
return $data;
}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'sourceQuestion' => $this->sourceQuestion,
'answerValue' => $this->answerValue,
'targetQuestion' => $this->targetQuestion,
];
}
}
+3 -1
View File
@@ -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;
} }
-48
View File
@@ -1,48 +0,0 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Form;
use Mummert\SurveyBundle\Form\Model\SurveyConditionData;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
final class SurveyConditionEditorType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$questionChoices = $options['question_choices'];
$builder
->add('sourceQuestion', ChoiceType::class, [
'label' => 'Ausgangsfrage',
'choices' => array_flip($questionChoices),
'constraints' => [new NotBlank()],
])
->add('answerValue', TextType::class, [
'label' => 'Antwort',
'help' => 'Bei Ja-Nein-Fragen bitte genau ja oder nein eintragen, bei aktivierter Vielleicht-Option auch vielleicht. Bei Choice-Fragen den exakten Antworttext verwenden, bei Multiple-Choice mehrere Werte mit | trennen.',
'constraints' => [new NotBlank(), new Length(['max' => 255])],
])
->add('targetQuestion', ChoiceType::class, [
'label' => 'Nächste Frage',
'choices' => array_flip($questionChoices),
'constraints' => [new NotBlank()],
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => SurveyConditionData::class,
'question_choices' => [],
]);
}
}
+2 -1
View File
@@ -6,6 +6,7 @@ namespace Mummert\SurveyBundle\Repository;
use Doctrine\DBAL\ArrayParameterType; use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Connection; use Doctrine\DBAL\Connection;
use Doctrine\DBAL\ParameterType;
final class SurveyAnswerRepository final class SurveyAnswerRepository
{ {
@@ -92,7 +93,7 @@ final class SurveyAnswerRepository
$this->connection->executeStatement( $this->connection->executeStatement(
'DELETE FROM tl_survey_answer WHERE submission = ? AND question NOT IN (?)', 'DELETE FROM tl_survey_answer WHERE submission = ? AND question NOT IN (?)',
[$submissionId, array_values(array_unique(array_map('intval', $questionIds)))], [$submissionId, array_values(array_unique(array_map('intval', $questionIds)))],
[\PDO::PARAM_INT, ArrayParameterType::INTEGER], [ParameterType::INTEGER, ArrayParameterType::INTEGER],
); );
} }
+1 -11
View File
@@ -62,6 +62,7 @@ final class SurveyConditionRepository
{ {
$this->connection->insert('tl_survey_condition', [ $this->connection->insert('tl_survey_condition', [
'pid' => $surveyId, 'pid' => $surveyId,
'tstamp' => time(),
'sourceQuestion' => (int) ($data['sourceQuestion'] ?? 0), 'sourceQuestion' => (int) ($data['sourceQuestion'] ?? 0),
'answerValue' => mb_strtolower(trim((string) ($data['answerValue'] ?? ''))), 'answerValue' => mb_strtolower(trim((string) ($data['answerValue'] ?? ''))),
'targetQuestion' => (int) ($data['targetQuestion'] ?? 0), 'targetQuestion' => (int) ($data['targetQuestion'] ?? 0),
@@ -72,17 +73,6 @@ final class SurveyConditionRepository
return $this->findById($id) ?? throw new \RuntimeException('Bedingung konnte nicht angelegt werden.'); return $this->findById($id) ?? throw new \RuntimeException('Bedingung konnte nicht angelegt werden.');
} }
public function update(SurveyConditionModel $condition, array $data): void
{
$this->connection->update('tl_survey_condition', [
'sourceQuestion' => (int) ($data['sourceQuestion'] ?? $condition->sourceQuestion),
'answerValue' => mb_strtolower(trim((string) ($data['answerValue'] ?? $condition->answerValue))),
'targetQuestion' => (int) ($data['targetQuestion'] ?? $condition->targetQuestion),
], [
'id' => (int) $condition->id,
]);
}
public function delete(int $surveyId, int $conditionId): void public function delete(int $surveyId, int $conditionId): void
{ {
$this->connection->delete('tl_survey_condition', [ $this->connection->delete('tl_survey_condition', [
+2 -1
View File
@@ -28,6 +28,7 @@ final class SurveyEditorRepository
$this->connection->insert('tl_survey_editor', [ $this->connection->insert('tl_survey_editor', [
'pid' => $surveyId, 'pid' => $surveyId,
'tstamp' => time(),
'survey' => $surveyId, 'survey' => $surveyId,
'member' => $memberId, 'member' => $memberId,
]); ]);
@@ -156,7 +157,7 @@ final class SurveyEditorRepository
foreach ($rows as $row) { foreach ($rows as $row) {
$name = trim(sprintf('%s %s', (string) $row['firstname'], (string) $row['lastname'])); $name = trim(sprintf('%s %s', (string) $row['firstname'], (string) $row['lastname']));
$choices[(int) $row['id']] = '' !== $name ? sprintf('%s <%s>', $name, (string) $row['email']) : (string) $row['email']; $choices[(int) $row['id']] = '' !== $name ? $name : (string) $row['email'];
} }
return $choices; return $choices;
@@ -64,6 +64,7 @@ final class SurveyQuestionRepository
$this->connection->insert('tl_survey_content', [ $this->connection->insert('tl_survey_content', [
'pid' => $surveyId, 'pid' => $surveyId,
'tstamp' => time(),
'sorting' => $sorting, 'sorting' => $sorting,
'type' => $type, 'type' => $type,
'question' => (string) ($data['question'] ?? ''), 'question' => (string) ($data['question'] ?? ''),
@@ -100,6 +101,7 @@ final class SurveyQuestionRepository
$isRangeType = 'range' === $type; $isRangeType = 'range' === $type;
$this->connection->update('tl_survey_content', [ $this->connection->update('tl_survey_content', [
'tstamp' => time(),
'type' => $type, 'type' => $type,
'question' => (string) ($data['question'] ?? $question->question), 'question' => (string) ($data['question'] ?? $question->question),
'description' => (string) ($data['description'] ?? $question->description), 'description' => (string) ($data['description'] ?? $question->description),
@@ -165,6 +167,46 @@ final class SurveyQuestionRepository
); );
} }
/**
* @param list<int> $orderedQuestionIds
*/
public function reorderQuestions(int $surveyId, array $orderedQuestionIds): void
{
$orderedQuestionIds = array_values(array_unique(array_filter(
array_map('intval', $orderedQuestionIds),
static fn (int $questionId): bool => $questionId > 0,
)));
$currentQuestionIds = array_map(static fn (SurveyContentModel $question): int => (int) $question->id, $this->findAllBySurvey($surveyId));
if ($orderedQuestionIds === $currentQuestionIds || count($orderedQuestionIds) < 2) {
return;
}
$sortedCurrentIds = $currentQuestionIds;
$sortedOrderedIds = $orderedQuestionIds;
sort($sortedCurrentIds);
sort($sortedOrderedIds);
if ($sortedCurrentIds !== $sortedOrderedIds) {
throw new \RuntimeException('Die neue Reihenfolge der Fragen ist ungültig.');
}
$this->connection->transactional(function () use ($surveyId, $orderedQuestionIds): void {
$sorting = 128;
foreach ($orderedQuestionIds as $questionId) {
$this->connection->update('tl_survey_content', [
'sorting' => $sorting,
], [
'id' => $questionId,
'pid' => $surveyId,
]);
$sorting += 128;
}
});
}
private function hydrate(array $ids): array private function hydrate(array $ids): array
{ {
$models = []; $models = [];
+52 -42
View File
@@ -53,10 +53,32 @@ final class SurveyRepository
return $this->findById((int) $id); return $this->findById((int) $id);
} }
public function titleExists(string $title, int $excludeId = 0): bool
{
$qb = $this->connection->createQueryBuilder()
->select('id')
->from('tl_survey')
->where('title = :title')
->setParameter('title', trim($title))
->setMaxResults(1)
;
if ($excludeId > 0) {
$qb
->andWhere('id != :excludeId')
->setParameter('excludeId', $excludeId)
;
}
$id = $qb->executeQuery()->fetchOne();
return false !== $id;
}
/** /**
* @return list<array<string, mixed>> * @return list<array<string, mixed>>
*/ */
public function findEditableByMember(int $memberId): array public function findEditableByMember(int $memberId, bool $templatesOnly = false): array
{ {
return $this->connection->fetchAllAssociative( return $this->connection->fetchAllAssociative(
<<<'SQL' <<<'SQL'
@@ -65,6 +87,7 @@ final class SurveyRepository
s.title, s.title,
s.alias, s.alias,
s.description, s.description,
s.isTemplate,
s.published, s.published,
s.isActive, s.isActive,
s.isLocked, s.isLocked,
@@ -75,10 +98,11 @@ final class SurveyRepository
INNER JOIN tl_survey_editor e ON COALESCE(NULLIF(e.survey, 0), e.pid) = s.id AND e.member = :member INNER JOIN tl_survey_editor e ON COALESCE(NULLIF(e.survey, 0), e.pid) = s.id AND e.member = :member
LEFT JOIN tl_survey_content q ON q.pid = s.id LEFT JOIN tl_survey_content q ON q.pid = s.id
LEFT JOIN tl_survey_submission sub ON sub.survey = s.id LEFT JOIN tl_survey_submission sub ON sub.survey = s.id
WHERE s.isTemplate = :isTemplate
GROUP BY s.id GROUP BY s.id
ORDER BY s.title ASC, s.id ASC ORDER BY s.title ASC, s.id ASC
SQL, SQL,
['member' => $memberId], ['member' => $memberId, 'isTemplate' => $templatesOnly ? '1' : ''],
); );
} }
@@ -93,9 +117,11 @@ final class SurveyRepository
'alias' => $this->ensurePublicAlias(null), 'alias' => $this->ensurePublicAlias(null),
'category' => serialize($this->normalizeCategoryIds($data['category'] ?? [])), 'category' => serialize($this->normalizeCategoryIds($data['category'] ?? [])),
'description' => (string) ($data['description'] ?? ''), 'description' => (string) ($data['description'] ?? ''),
'internalNote' => trim((string) ($data['internalNote'] ?? '')),
'published' => '', 'published' => '',
'isActive' => '', 'isActive' => '1',
'isLocked' => '', 'isLocked' => '',
'isTemplate' => !empty($data['isTemplate']) ? '1' : '',
'createdBy' => $memberId, 'createdBy' => $memberId,
'updatedBy' => $memberId, 'updatedBy' => $memberId,
'createdAt' => $now, 'createdAt' => $now,
@@ -118,6 +144,7 @@ final class SurveyRepository
'alias' => $this->ensurePublicAlias((string) $survey->alias, (int) $survey->id), 'alias' => $this->ensurePublicAlias((string) $survey->alias, (int) $survey->id),
'category' => serialize($this->normalizeCategoryIds($data['category'] ?? StringUtil::deserialize($survey->category, true))), 'category' => serialize($this->normalizeCategoryIds($data['category'] ?? StringUtil::deserialize($survey->category, true))),
'description' => (string) ($data['description'] ?? $survey->description), 'description' => (string) ($data['description'] ?? $survey->description),
'internalNote' => trim((string) ($data['internalNote'] ?? $survey->internalNote)),
'published' => array_key_exists('published', $data) ? (!empty($data['published']) ? '1' : '') : (string) $survey->published, 'published' => array_key_exists('published', $data) ? (!empty($data['published']) ? '1' : '') : (string) $survey->published,
'isActive' => array_key_exists('isActive', $data) ? (!empty($data['isActive']) ? '1' : '') : (string) ($survey->isActive ?? ''), 'isActive' => array_key_exists('isActive', $data) ? (!empty($data['isActive']) ? '1' : '') : (string) ($survey->isActive ?? ''),
'updatedBy' => $memberId, 'updatedBy' => $memberId,
@@ -172,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 = [];
@@ -184,12 +209,10 @@ 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),
'assignedMembersSummary' => implode(', ', $memberNames), 'assignedMembersSummary' => $this->buildAssignedMembersSummary($memberNames),
], [ ], [
'id' => $surveyId, 'id' => $surveyId,
]); ]);
@@ -306,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}>
*/ */
@@ -383,11 +373,31 @@ 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,
); );
} }
/**
* @param list<string> $memberNames
*/
private function buildAssignedMembersSummary(array $memberNames): string
{
if ([] === $memberNames) {
return '';
}
$visibleNames = array_slice($memberNames, 0, 3);
$remainingCount = count($memberNames) - count($visibleNames);
$summary = implode(', ', $visibleNames);
if ($remainingCount <= 0) {
return $summary;
}
return sprintf('%s, ... und %d weitere', $summary, $remainingCount);
}
private function isValidPublicAlias(string $alias): bool private function isValidPublicAlias(string $alias): bool
{ {
return 1 === preg_match('/^[a-z0-9]{20}$/', $alias); return 1 === preg_match('/^[a-z0-9]{20}$/', $alias);
@@ -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>
-36
View File
@@ -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);
}
}
+142 -12
View File
@@ -4,9 +4,10 @@ declare(strict_types=1);
namespace Mummert\SurveyBundle\Service; namespace Mummert\SurveyBundle\Service;
use Mummert\SurveyBundle\Form\Model\SurveyConditionData; use Doctrine\DBAL\Connection;
use Mummert\SurveyBundle\Form\Model\SurveyEditorData; use Mummert\SurveyBundle\Form\Model\SurveyEditorData;
use Mummert\SurveyBundle\Form\Model\SurveyQuestionData; use Mummert\SurveyBundle\Form\Model\SurveyQuestionData;
use Mummert\SurveyBundle\Model\SurveyContentModel;
use Mummert\SurveyBundle\Model\SurveyModel; use Mummert\SurveyBundle\Model\SurveyModel;
use Mummert\SurveyBundle\Repository\SurveyConditionRepository; use Mummert\SurveyBundle\Repository\SurveyConditionRepository;
use Mummert\SurveyBundle\Repository\SurveyEditorRepository; use Mummert\SurveyBundle\Repository\SurveyEditorRepository;
@@ -21,6 +22,7 @@ final class SurveyEditorService
private readonly SurveyConditionRepository $surveyConditionRepository, private readonly SurveyConditionRepository $surveyConditionRepository,
private readonly SurveyEditorRepository $surveyEditorRepository, private readonly SurveyEditorRepository $surveyEditorRepository,
private readonly SurveyCategoryAssignmentService $surveyCategoryAssignmentService, private readonly SurveyCategoryAssignmentService $surveyCategoryAssignmentService,
private readonly Connection $connection,
) { ) {
} }
@@ -94,30 +96,127 @@ final class SurveyEditorService
$this->surveyRepository->touch((int) $survey->id); $this->surveyRepository->touch((int) $survey->id);
} }
/**
* @param list<int> $orderedQuestionIds
*/
public function reorderQuestions(SurveyModel $survey, array $orderedQuestionIds): void
{
$this->assertUnlocked($survey);
$this->surveyQuestionRepository->reorderQuestions((int) $survey->id, $orderedQuestionIds);
$this->surveyRepository->touch((int) $survey->id);
}
public function deleteSurvey(SurveyModel $survey): void public function deleteSurvey(SurveyModel $survey): void
{ {
$this->surveyRepository->deleteCascade((int) $survey->id); $this->surveyRepository->deleteCascade((int) $survey->id);
} }
public function saveCondition(SurveyModel $survey, SurveyConditionData $data, int $conditionId = 0): void public function duplicateSurvey(SurveyModel $survey, ?int $actorMemberId = null): SurveyModel
{ {
$this->assertUnlocked($survey); return $this->connection->transactional(function () use ($survey, $actorMemberId): SurveyModel {
$sourceSurveyId = (int) $survey->id;
$memberIds = $this->surveyEditorRepository->findMemberIdsBySurvey($sourceSurveyId);
$duplicate = $this->surveyRepository->create($actorMemberId ?? (int) ($survey->createdBy ?? 0), [
'title' => $this->buildDuplicateTitle($survey),
'description' => (string) ($survey->description ?? ''),
'internalNote' => (string) ($survey->internalNote ?? ''),
'category' => $survey->category,
'isTemplate' => false,
]);
if ($conditionId > 0) { $duplicateSurveyId = (int) $duplicate->id;
$condition = $this->surveyConditionRepository->findById($conditionId);
if (null === $condition || (int) $condition->pid !== (int) $survey->id) { if ([] !== $memberIds) {
throw new \RuntimeException('Die Bedingung wurde nicht gefunden.'); $this->surveyEditorRepository->syncMembersForSurvey($duplicateSurveyId, $memberIds);
} }
$this->surveyConditionRepository->update($condition, $data->toArray()); $duplicateQuestions = [];
$this->surveyRepository->touch((int) $survey->id); $questionIdMap = [];
$sourceQuestions = $this->surveyQuestionRepository->findAllBySurvey($sourceSurveyId);
return; foreach ($sourceQuestions as $sourceQuestion) {
$newQuestion = $this->surveyQuestionRepository->create($duplicateSurveyId, [
'type' => (string) $sourceQuestion->type,
'question' => (string) $sourceQuestion->question,
'description' => (string) ($sourceQuestion->description ?? ''),
'mandatory' => '1' === (string) $sourceQuestion->mandatory,
'published' => '1' === (string) $sourceQuestion->published,
'allowMaybe' => '1' === (string) ($sourceQuestion->allowMaybe ?? ''),
'allowMultiple' => '1' === (string) ($sourceQuestion->allowMultiple ?? ''),
'answerOption1' => (string) ($sourceQuestion->answerOption1 ?? ''),
'answerOption2' => (string) ($sourceQuestion->answerOption2 ?? ''),
'answerOption3' => (string) ($sourceQuestion->answerOption3 ?? ''),
'answerOption4' => (string) ($sourceQuestion->answerOption4 ?? ''),
'answerOption5' => (string) ($sourceQuestion->answerOption5 ?? ''),
'answerOption6' => (string) ($sourceQuestion->answerOption6 ?? ''),
'answerOption7' => (string) ($sourceQuestion->answerOption7 ?? ''),
'answerOption8' => (string) ($sourceQuestion->answerOption8 ?? ''),
'answerOption9' => (string) ($sourceQuestion->answerOption9 ?? ''),
'answerOption10' => (string) ($sourceQuestion->answerOption10 ?? ''),
'rangeMin' => (int) ($sourceQuestion->rangeMin ?? 0),
'rangeMax' => (int) ($sourceQuestion->rangeMax ?? 10),
'rangeStep' => (int) ($sourceQuestion->rangeStep ?? 1),
'jumpOnYes' => 0,
'jumpOnNo' => 0,
]);
$sourceQuestionId = (int) $sourceQuestion->id;
$questionIdMap[$sourceQuestionId] = (int) $newQuestion->id;
$duplicateQuestions[$sourceQuestionId] = $newQuestion;
} }
$this->surveyConditionRepository->create((int) $survey->id, $data->toArray()); foreach ($sourceQuestions as $sourceQuestion) {
$this->surveyRepository->touch((int) $survey->id); $sourceQuestionId = (int) $sourceQuestion->id;
$duplicateQuestion = $duplicateQuestions[$sourceQuestionId] ?? null;
if (!$duplicateQuestion instanceof SurveyContentModel) {
throw new \RuntimeException('Fragenkopie konnte nicht vervollständigt werden.');
}
$this->surveyQuestionRepository->update($duplicateQuestion, [
'type' => (string) $sourceQuestion->type,
'question' => (string) $sourceQuestion->question,
'description' => (string) ($sourceQuestion->description ?? ''),
'mandatory' => '1' === (string) $sourceQuestion->mandatory,
'published' => '1' === (string) $sourceQuestion->published,
'allowMaybe' => '1' === (string) ($sourceQuestion->allowMaybe ?? ''),
'allowMultiple' => '1' === (string) ($sourceQuestion->allowMultiple ?? ''),
'answerOption1' => (string) ($sourceQuestion->answerOption1 ?? ''),
'answerOption2' => (string) ($sourceQuestion->answerOption2 ?? ''),
'answerOption3' => (string) ($sourceQuestion->answerOption3 ?? ''),
'answerOption4' => (string) ($sourceQuestion->answerOption4 ?? ''),
'answerOption5' => (string) ($sourceQuestion->answerOption5 ?? ''),
'answerOption6' => (string) ($sourceQuestion->answerOption6 ?? ''),
'answerOption7' => (string) ($sourceQuestion->answerOption7 ?? ''),
'answerOption8' => (string) ($sourceQuestion->answerOption8 ?? ''),
'answerOption9' => (string) ($sourceQuestion->answerOption9 ?? ''),
'answerOption10' => (string) ($sourceQuestion->answerOption10 ?? ''),
'rangeMin' => (int) ($sourceQuestion->rangeMin ?? 0),
'rangeMax' => (int) ($sourceQuestion->rangeMax ?? 10),
'rangeStep' => (int) ($sourceQuestion->rangeStep ?? 1),
'jumpOnYes' => $questionIdMap[(int) ($sourceQuestion->jumpOnYes ?? 0)] ?? 0,
'jumpOnNo' => $questionIdMap[(int) ($sourceQuestion->jumpOnNo ?? 0)] ?? 0,
]);
}
foreach ($this->surveyConditionRepository->findOverviewBySurvey($sourceSurveyId) as $condition) {
$sourceQuestionId = $questionIdMap[(int) ($condition['sourceQuestion'] ?? 0)] ?? 0;
if ($sourceQuestionId <= 0) {
continue;
}
$this->surveyConditionRepository->create($duplicateSurveyId, [
'sourceQuestion' => $sourceQuestionId,
'answerValue' => (string) ($condition['answerValue'] ?? ''),
'targetQuestion' => $questionIdMap[(int) ($condition['targetQuestion'] ?? 0)] ?? 0,
]);
}
$this->surveyRepository->refreshListMetadata($duplicateSurveyId);
return $this->surveyRepository->findById($duplicateSurveyId) ?? throw new \RuntimeException('Umfragekopie konnte nicht angelegt werden.');
});
} }
public function deleteCondition(SurveyModel $survey, int $conditionId): void public function deleteCondition(SurveyModel $survey, int $conditionId): void
@@ -133,4 +232,35 @@ final class SurveyEditorService
throw new \RuntimeException('Die Fragen dieser Umfrage können nach der Veröffentlichung oder nach ersten Teilnahmen nicht mehr geändert werden.'); throw new \RuntimeException('Die Fragen dieser Umfrage können nach der Veröffentlichung oder nach ersten Teilnahmen nicht mehr geändert werden.');
} }
} }
private function buildDuplicateTitle(SurveyModel $survey): string
{
$title = trim((string) $survey->title);
if ('' === $title) {
$title = 'Neue Umfrage';
}
$baseTitle = trim((string) preg_replace('/ - Kopie(?: \d+)?$/u', '', $title));
if ('' === $baseTitle) {
$baseTitle = 'Neue Umfrage';
}
for ($copyNumber = 1; ; ++$copyNumber) {
$suffix = 1 === $copyNumber ? ' - Kopie' : sprintf(' - Kopie %d', $copyNumber);
$candidate = $this->buildDuplicateTitleCandidate($baseTitle, $suffix);
if (!$this->surveyRepository->titleExists($candidate, (int) $survey->id)) {
return $candidate;
}
}
}
private function buildDuplicateTitleCandidate(string $baseTitle, string $suffix): string
{
$maxBaseLength = max(0, 255 - mb_strlen($suffix));
return mb_substr($baseTitle, 0, $maxBaseLength).$suffix;
}
} }
+23
View File
@@ -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>
*/ */
-29
View File
@@ -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.');
}
}
}
+4 -8
View File
@@ -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'] : [],
+15 -10
View File
@@ -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;
} }
+24 -1
View File
@@ -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);
+5 -1
View File
@@ -2,6 +2,8 @@ survey:
list: list:
login_required: "Dieser Bereich verwendet den normalen Contao-Mitgliederlogin. Bitte binden Sie das Modul auf einer geschützten Seite oder mit einer separaten Login-Seite ein." login_required: "Dieser Bereich verwendet den normalen Contao-Mitgliederlogin. Bitte binden Sie das Modul auf einer geschützten Seite oder mit einer separaten Login-Seite ein."
create: "Neue Umfrage anlegen" create: "Neue Umfrage anlegen"
duplicate: "Duplizieren"
duplicate_confirm: "Sind Sie sicher, dass Sie die Umfrage \"%title%\" mit allen Fragen duplizieren wollen?"
delete: "Löschen" delete: "Löschen"
delete_confirm: "Umfrage wirklich löschen? Damit werden auch alle Fragen, Antworten und Ergebnisse endgültig gelöscht. Alternativ können Sie die Umfrage auch einfach vorübergehend deaktivieren." delete_confirm: "Umfrage wirklich löschen? Damit werden auch alle Fragen, Antworten und Ergebnisse endgültig gelöscht. Alternativ können Sie die Umfrage auch einfach vorübergehend deaktivieren."
delete_confirm_second: "Wirklich sicher?" delete_confirm_second: "Wirklich sicher?"
@@ -78,6 +80,8 @@ survey:
delete: "Löschen" delete: "Löschen"
no_questions: "Noch keine Fragen angelegt." no_questions: "Noch keine Fragen angelegt."
new_question: "Neue Frage anlegen" new_question: "Neue Frage anlegen"
reorder_question: "Frage verschieben"
save_question_order: "Neue Reihenfolge speichern"
open_question: "Akkordeon öffnen" open_question: "Akkordeon öffnen"
close_question: "Akkordeon schließen" close_question: "Akkordeon schließen"
conditions_title: "Sprungregeln" conditions_title: "Sprungregeln"
@@ -104,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"
+5 -1
View File
@@ -2,6 +2,8 @@ survey:
list: list:
login_required: "This area uses the regular Contao member login. Please place the module on a protected page or provide a separate login page." login_required: "This area uses the regular Contao member login. Please place the module on a protected page or provide a separate login page."
create: "Create new survey" create: "Create new survey"
duplicate: "Duplicate"
duplicate_confirm: "Are you sure you want to duplicate the survey \"%title%\" with all questions?"
delete: "Delete" delete: "Delete"
delete_confirm: "Delete this survey? This will permanently remove all questions, answers, and results as well. Alternatively, you can simply deactivate the survey temporarily." delete_confirm: "Delete this survey? This will permanently remove all questions, answers, and results as well. Alternatively, you can simply deactivate the survey temporarily."
delete_confirm_second: "Are you really sure?" delete_confirm_second: "Are you really sure?"
@@ -78,6 +80,8 @@ survey:
delete: "Delete" delete: "Delete"
no_questions: "No questions created yet." no_questions: "No questions created yet."
new_question: "Create new question" new_question: "Create new question"
reorder_question: "Reorder question"
save_question_order: "Save new order"
open_question: "Open accordion" open_question: "Open accordion"
close_question: "Close accordion" close_question: "Close accordion"
conditions_title: "Jump rules" conditions_title: "Jump rules"
@@ -104,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"