Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f9a77d5bd | |||
| d6227d9f4e | |||
| db7094fd1e | |||
| 1b16675ae9 | |||
| 79c4402aee | |||
| e3553e1108 | |||
| 99caf51af9 | |||
| 7843fca003 | |||
| 9cc2f98456 | |||
| d44f3be571 | |||
| 97a302a985 |
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(git add *)",
|
||||
"Bash(git commit -q -m ' *)",
|
||||
"Bash(git push *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,9 @@ Die Umfrage arbeitet mit drei relevanten Zuständen in `tl_survey`:
|
||||
- Keine Fragenbearbeitung nach Veröffentlichung
|
||||
- Keine Metadatenbearbeitung nach Veröffentlichung
|
||||
- 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:
|
||||
- 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
|
||||
- `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.
|
||||
|
||||
## Datenmodell und wichtige Tabellen
|
||||
|
||||
- `tl_survey`
|
||||
- Umfrage-Stammdaten und Statusfelder
|
||||
- Enthält zusätzlich die backend-only gepflegte `internalNote`
|
||||
- `tl_survey_content`
|
||||
- Fragen der Umfrage
|
||||
- `tl_survey_condition`
|
||||
@@ -105,10 +116,22 @@ Aktuelle Button- und Sichtbarkeitslogik pro Umfrage:
|
||||
- `Umfrage veröffentlichen`
|
||||
- Eigene Aktion in der Liste
|
||||
- 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
|
||||
- Schaltet `isActive`
|
||||
- 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
|
||||
|
||||
Controller:
|
||||
@@ -144,6 +167,9 @@ Wichtige Entscheidung:
|
||||
- Typ, Pflicht usw. sollen nicht schon im Header gezeigt werden.
|
||||
- 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
|
||||
|
||||
Controller:
|
||||
@@ -168,6 +194,13 @@ Wichtige Regeln:
|
||||
- Entwurfsansicht arbeitet ohne Speicherung
|
||||
- Öffentliche Ansicht speichert Antworten und erstellt/führt Submission fort
|
||||
- Rückwärtsnavigation ist implementiert
|
||||
- Nach Abschluss zeigt der gleiche Browser-Token für eine kurze Cooldown-Zeit (`RESTART_COOLDOWN_SECONDS = 60`) die Danke-Seite; danach darf dieselbe Session erneut teilnehmen (`SurveySubmissionService::isRestartAllowed`).
|
||||
|
||||
Saubere Link-URLs (Reader und Ergebnisseite):
|
||||
|
||||
- Die Umfrage wird über den Alias als **Pfad-Segment** adressiert: `/<reader-seite>/<alias>` statt `?auto_item=<alias>`.
|
||||
- URL-Erzeugung: per Route-Platzhalter `['parameters' => '/'.$alias]` (Frontend-Liste `MemberSurveyListController`, Backend `SurveyDcaListener::buildSurveyPageUrl`), nicht `['auto_item' => $alias]`.
|
||||
- Auslesen im Controller: `$this->getContaoAdapter(Input::class)->get('auto_item')`. Wichtig: `Input::get` markiert den Route-Parameter als **benutzt** – sonst wirft Contaos `LegacyRouteParametersListener` eine 404 wegen „unbenutzter Parameter". Ein reines `$request->query->get('auto_item')` reicht für Pfad-URLs nicht.
|
||||
|
||||
### 4. Ergebnisseite
|
||||
|
||||
@@ -222,6 +255,13 @@ Reihenfolge der Navigation:
|
||||
Wichtiger Detailpunkt:
|
||||
|
||||
- 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
|
||||
|
||||
@@ -305,6 +345,26 @@ Wichtige Listenoperationen:
|
||||
- `Link kopieren`
|
||||
- `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:
|
||||
|
||||
- Das Warnsymbol genügt als Hinweis bei veröffentlichten Umfragen
|
||||
@@ -319,6 +379,21 @@ Aktuelle Backend-Entscheidung:
|
||||
Hinweis:
|
||||
|
||||
- `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
|
||||
|
||||
@@ -346,6 +421,7 @@ Wichtige Settings:
|
||||
- `published` reversibel erlaubt
|
||||
- `isActive` per Standard-Contao-Toggle
|
||||
- nur Warnsymbol statt Backend-Sperre
|
||||
- Admin darf Fragen auch nach Veröffentlichung oder nach vorhandenen Antworten umsortieren
|
||||
|
||||
## Wichtige Dateien nach Themengebiet
|
||||
|
||||
@@ -388,12 +464,35 @@ Backend:
|
||||
- `ddev exec php bin/console lint:yaml ...`
|
||||
- `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
|
||||
|
||||
- Backend kann veröffentlichte Umfragen technisch weiterhin verändern.
|
||||
- Das ist bewusst akzeptiert, weil nur ein Admin arbeitet.
|
||||
- `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.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -6,6 +6,22 @@ Das Bundle stellt Frontend- und Backend-Funktionen für die Erstellung, Verwaltu
|
||||
|
||||
Ziel des Bundles ist eine möglichst native Integration in Contao, sodass Umfragen sich wie ein regulärer Bestandteil der bestehenden System- und Redaktionsumgebung verhalten.
|
||||
|
||||
## Dokumentation
|
||||
|
||||
Architektur, Zustandslogik, bewusste Produktentscheidungen und Einstiegspunkte sind ausführlich in [`AI_HANDOVER.md`](AI_HANDOVER.md) beschrieben.
|
||||
|
||||
Zwei wiederkehrend relevante technische Konventionen:
|
||||
|
||||
- **CSRF in Frontend-Aktionsformularen:** Jedes POST-Formular sendet **zwei** Tokens – `REQUEST_TOKEN` (Contaos globaler Frontend-Gate) und `_token` (`csrf_token('<aktion>-' ~ id)`, im Controller via `isCsrfTokenValid` geprüft). Beide sind nötig.
|
||||
- **Saubere Link-URLs:** Reader und Ergebnisseite werden als Pfad adressiert (`/<seite>/<alias>`). Erzeugung über `['parameters' => '/'.$alias]`, Auslesen über `Input::get('auto_item')` (markiert den Route-Parameter als benutzt – sonst Contao-404).
|
||||
|
||||
Nach Änderungen an DCA, Backend-CSS oder JS:
|
||||
|
||||
```bash
|
||||
php vendor/bin/contao-console assets:install public --symlink --relative
|
||||
php vendor/bin/contao-console cache:clear --env=prod
|
||||
```
|
||||
|
||||
## Rechtlicher Hinweis
|
||||
|
||||
Dieses Bundle ist proprietär und nicht zur öffentlichen Weitergabe bestimmt.
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
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_JAVASCRIPT'][] = 'bundles/survey/js/survey-backend.js|static';
|
||||
|
||||
$GLOBALS['TL_MODELS']['tl_survey'] = Mummert\SurveyBundle\Model\SurveyModel::class;
|
||||
$GLOBALS['TL_MODELS']['tl_survey_category'] = Mummert\SurveyBundle\Model\SurveyCategoryModel::class;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
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_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']['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';
|
||||
|
||||
+32
-28
@@ -12,6 +12,7 @@ $GLOBALS['TL_DCA']['tl_survey'] = [
|
||||
'ctable' => ['tl_survey_content', 'tl_survey_condition', 'tl_survey_editor', 'tl_survey_submission'],
|
||||
'enableVersioning' => true,
|
||||
'onload_callback' => [
|
||||
[SurveyDcaListener::class, 'handleDuplicateSurveyAction'],
|
||||
[SurveyDcaListener::class, 'refreshSurveyBackendStateIndex'],
|
||||
],
|
||||
'onsubmit_callback' => [
|
||||
@@ -35,7 +36,7 @@ $GLOBALS['TL_DCA']['tl_survey'] = [
|
||||
'panelLayout' => 'filter;sort,search,limit',
|
||||
],
|
||||
'label' => [
|
||||
'fields' => ['title', 'categorySummary', 'assignedMembersSummary', 'templateSummary'],
|
||||
'fields' => ['title', 'assignedMembersSummary', 'templateSummary', 'internalNote'],
|
||||
'showColumns' => true,
|
||||
],
|
||||
'global_operations' => [
|
||||
@@ -46,22 +47,26 @@ $GLOBALS['TL_DCA']['tl_survey'] = [
|
||||
],
|
||||
],
|
||||
'operations' => [
|
||||
'editheader' => [
|
||||
'label' => &$GLOBALS['TL_LANG']['tl_survey']['editheader'],
|
||||
'href' => 'act=edit',
|
||||
'icon' => 'edit.svg',
|
||||
'primary' => true,
|
||||
'showInHeader' => false,
|
||||
],
|
||||
'edit' => [
|
||||
'label' => ['Fragen bearbeiten', 'Fragen der Umfrage bearbeiten'],
|
||||
'label' => &$GLOBALS['TL_LANG']['tl_survey']['edit'],
|
||||
'href' => 'table=tl_survey_content',
|
||||
'icon' => 'children.svg',
|
||||
],
|
||||
'editheader' => [
|
||||
'label' => ['Umfrage bearbeiten', 'Umfrage bearbeiten'],
|
||||
'href' => 'act=edit',
|
||||
'icon' => 'header.svg',
|
||||
'primary' => true,
|
||||
'showInHeader' => false,
|
||||
],
|
||||
'toggle' => [
|
||||
'label' => &$GLOBALS['TL_LANG']['tl_survey']['toggleActive'],
|
||||
'href' => 'act=toggle&field=isActive',
|
||||
'icon' => 'visible.svg',
|
||||
'primary' => true,
|
||||
'showInHeader' => true,
|
||||
'showInHeader' => false,
|
||||
],
|
||||
'publishedWarning' => [
|
||||
'label' => &$GLOBALS['TL_LANG']['tl_survey']['publishedWarning'],
|
||||
@@ -69,14 +74,20 @@ $GLOBALS['TL_DCA']['tl_survey'] = [
|
||||
'primary' => true,
|
||||
'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' => [
|
||||
'label' => ['Löschen', 'Eintrag wirklich löschen?'],
|
||||
'label' => &$GLOBALS['TL_LANG']['tl_survey']['delete'],
|
||||
'href' => 'act=delete',
|
||||
'icon' => 'delete.svg',
|
||||
'attributes' => 'onclick="if(!confirm(\'Eintrag wirklich löschen?\'))return false;Backend.getScrollOffset()"',
|
||||
],
|
||||
'show' => [
|
||||
'label' => ['Details', 'Details des Elements ID %s anzeigen'],
|
||||
'label' => &$GLOBALS['TL_LANG']['tl_survey']['show'],
|
||||
'href' => 'act=show',
|
||||
'icon' => 'show.svg',
|
||||
],
|
||||
@@ -93,7 +104,7 @@ $GLOBALS['TL_DCA']['tl_survey'] = [
|
||||
],
|
||||
],
|
||||
'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' => [
|
||||
'id' => [
|
||||
@@ -122,26 +133,21 @@ $GLOBALS['TL_DCA']['tl_survey'] = [
|
||||
'eval' => ['rte' => 'tinyMCE', 'tl_class' => 'clr'],
|
||||
'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' => [
|
||||
'label' => &$GLOBALS['TL_LANG']['tl_survey']['category'],
|
||||
'filter' => true,
|
||||
'inputType' => 'select',
|
||||
'foreignKey' => 'tl_survey_category.title',
|
||||
'eval' => ['multiple' => true, 'chosen' => true, 'tl_class' => 'clr'],
|
||||
'relation' => ['type' => 'hasMany', 'load' => 'lazy', 'table' => 'tl_survey_category', 'field' => 'id'],
|
||||
'sql' => 'blob NULL',
|
||||
],
|
||||
'categoryFilter' => [
|
||||
'label' => &$GLOBALS['TL_LANG']['tl_survey']['categoryFilter'],
|
||||
'inputType' => 'select',
|
||||
'options_callback' => [SurveyDcaListener::class, 'getUsedCategoryFilterOptions'],
|
||||
'eval' => ['includeBlankOption' => true, 'chosen' => true, 'findInSet' => true],
|
||||
'sql' => "varchar(255) NOT NULL default ''",
|
||||
],
|
||||
'categorySummary' => [
|
||||
'label' => &$GLOBALS['TL_LANG']['tl_survey']['categorySummary'],
|
||||
'sql' => 'text NULL',
|
||||
],
|
||||
'published' => [
|
||||
'label' => &$GLOBALS['TL_LANG']['tl_survey']['published'],
|
||||
'inputType' => 'checkbox',
|
||||
@@ -181,9 +187,9 @@ $GLOBALS['TL_DCA']['tl_survey'] = [
|
||||
],
|
||||
'assignedMembers' => [
|
||||
'label' => &$GLOBALS['TL_LANG']['tl_survey']['assignedMembers'],
|
||||
'inputType' => 'select',
|
||||
'inputType' => 'checkboxWizard',
|
||||
'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']],
|
||||
'save_callback' => [[Mummert\SurveyBundle\EventListener\SurveyDcaListener::class, 'saveSurveyAssignedMembers']],
|
||||
'sql' => 'blob NULL',
|
||||
@@ -236,7 +242,7 @@ if ('cli' !== PHP_SAPI) {
|
||||
System::getContainer()->get('database_connection')->fetchFirstColumn('SHOW COLUMNS FROM tl_survey'),
|
||||
);
|
||||
$hasListMetadataColumns = !array_diff(
|
||||
['categoryfilter', 'categorysummary', 'templatefilter', 'templatesummary', 'assignedmembersfilter', 'assignedmemberssummary'],
|
||||
['templatefilter', 'templatesummary', 'assignedmembersfilter', 'assignedmemberssummary'],
|
||||
$columnNames,
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
@@ -247,8 +253,6 @@ if ('cli' !== PHP_SAPI) {
|
||||
$GLOBALS['TL_DCA']['tl_survey']['list']['label']['fields'] = ['title', 'alias'];
|
||||
|
||||
unset(
|
||||
$GLOBALS['TL_DCA']['tl_survey']['fields']['categoryFilter'],
|
||||
$GLOBALS['TL_DCA']['tl_survey']['fields']['categorySummary'],
|
||||
$GLOBALS['TL_DCA']['tl_survey']['fields']['templateFilter'],
|
||||
$GLOBALS['TL_DCA']['tl_survey']['fields']['templateSummary'],
|
||||
$GLOBALS['TL_DCA']['tl_survey']['fields']['assignedMembersFilter'],
|
||||
|
||||
@@ -32,7 +32,7 @@ $GLOBALS['TL_DCA']['tl_survey_content'] = [
|
||||
'sorting' => [
|
||||
'mode' => 4,
|
||||
'fields' => ['sorting'],
|
||||
'headerFields' => ['title', 'alias', 'published'],
|
||||
'headerFields' => ['title', 'published'],
|
||||
'child_record_class' => 'survey-question-record',
|
||||
'panelLayout' => 'sort,search,limit',
|
||||
],
|
||||
@@ -44,6 +44,13 @@ $GLOBALS['TL_DCA']['tl_survey_content'] = [
|
||||
'edit' => [
|
||||
'href' => 'act=edit',
|
||||
'icon' => 'edit.svg',
|
||||
'primary' => true,
|
||||
],
|
||||
'cut' => [
|
||||
'href' => 'act=paste&mode=cut',
|
||||
'method' => 'POST',
|
||||
'icon' => 'mover.svg',
|
||||
'attributes' => 'data-action="contao--scroll-offset#store"',
|
||||
],
|
||||
'copy' => [
|
||||
'href' => 'act=copy',
|
||||
|
||||
@@ -5,6 +5,7 @@ $GLOBALS['TL_LANG']['MOD']['survey_bundle_categories'] = ['Umfrage-Kategorien',
|
||||
|
||||
$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_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']['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.'];
|
||||
@@ -3,14 +3,13 @@
|
||||
$GLOBALS['TL_LANG']['tl_survey']['title'] = ['Titel', 'Titel der Umfrage.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['alias'] = ['Öffentlicher Link-Schlüssel', 'Wird automatisch als 20-stelliger Link-Schlüssel erzeugt.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['category'] = ['Kategorien', 'Ordnet die Umfrage einer oder mehreren Kategorien zu.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['categoryFilter'] = ['Kategorien', 'Filtert die Liste nach einer Umfrage-Kategorie.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['categorySummary'] = ['Kategorien', 'Zugeordnete Kategorien der Umfrage.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['isTemplate'] = ['Umfrage ist eine Vorlage', 'Wird den zugewiesenen Nutzern als Vorlage angezeigt.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['templateFilter'] = ['Vorlage', 'Filtert die Liste nach Vorlagenstatus.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['templateSummary'] = ['Vorlage', 'Zeigt an, ob die Umfrage als Vorlage markiert ist.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['template_yes'] = 'Ja';
|
||||
$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']['internalNote'] = ['Interne Notiz', 'Nur im Backend sichtbare interne Notiz zur Umfrage.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['title_legend'] = 'Grunddaten';
|
||||
$GLOBALS['TL_LANG']['tl_survey']['assignment_legend'] = 'Bearbeitende Mitglieder';
|
||||
$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']['isActive'] = ['Vorübergehend aktiv', 'Schaltet eine bereits veröffentlichte Umfrage öffentlich an oder aus.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['isLocked'] = ['Gesperrt', 'Struktur ist wegen vorhandener Antworten gesperrt.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['publishSurvey'] = ['Umfrage veröffentlichen', 'Veröffentlicht die Umfrage endgültig.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['publishedWarning'] = ['Veröffentlichungshinweis', 'Zeigt einen Warnhinweis bei veröffentlichten Umfragen.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['toggleActive'] = ['Sichtbarkeit umschalten', 'Schaltet die veröffentlichte Umfrage vorübergehend an oder aus.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['assignedMembers'] = ['Zugewiesene Mitglieder', 'Wählen Sie die Mitglieder aus, die diese Umfrage bearbeiten dürfen.'];
|
||||
$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']['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']['editheader'] = ['Umfrage bearbeiten', 'Umfrage bearbeiten'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['edit'] = ['Fragen bearbeiten', 'Fragen der Umfrage bearbeiten'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['delete'] = ['Löschen', 'Eintrag wirklich löschen?'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['show'] = ['Details', 'Details des Elements ID %s anzeigen'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['createdBy'] = ['Angelegt von', 'ID des erstellenden Mitglieds.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['updatedBy'] = ['Aktualisiert von', 'ID des zuletzt bearbeitenden Mitglieds.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['createdAt'] = ['Angelegt am', 'Zeitpunkt der Erstellung.'];
|
||||
|
||||
@@ -5,6 +5,7 @@ $GLOBALS['TL_LANG']['MOD']['survey_bundle_categories'] = ['Survey categories', '
|
||||
|
||||
$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_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']['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.'];
|
||||
@@ -3,14 +3,13 @@
|
||||
$GLOBALS['TL_LANG']['tl_survey']['title'] = ['Title', 'Title of the survey.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['alias'] = ['Public link key', 'Automatically generated as a 20-character link key.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['category'] = ['Categories', 'Assigns the survey to one or more categories.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['categoryFilter'] = ['Categories', 'Filters the list by a survey category.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['categorySummary'] = ['Categories', 'Assigned categories of the survey.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['isTemplate'] = ['Survey is a template', 'Shown to assigned users as a template.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['templateFilter'] = ['Template', 'Filters the list by template status.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['templateSummary'] = ['Template', 'Shows whether the survey is marked as a template.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['template_yes'] = 'Yes';
|
||||
$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']['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']['assignment_legend'] = 'Editing members';
|
||||
$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']['isActive'] = ['Temporarily active', 'Turns an already published survey on or off publicly.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['isLocked'] = ['Locked', 'Structure is locked because answers already exist.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['publishSurvey'] = ['Publish survey', 'Publishes the survey permanently.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['publishedWarning'] = ['Publication warning', 'Shows a warning hint for already published surveys.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['toggleActive'] = ['Toggle visibility', 'Temporarily switches the published survey on or off.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['assignedMembers'] = ['Assigned members', 'Choose the members who are allowed to edit this survey.'];
|
||||
$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']['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']['editheader'] = ['Edit survey', 'Edit survey'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['edit'] = ['Edit questions', 'Edit the survey questions'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['delete'] = ['Delete', 'Really delete the entry?'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['show'] = ['Details', 'Show the details of entry ID %s'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['createdBy'] = ['Created by', 'ID of the creating member.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['updatedBy'] = ['Updated by', 'ID of the member who last edited it.'];
|
||||
$GLOBALS['TL_LANG']['tl_survey']['createdAt'] = ['Created on', 'Creation time.'];
|
||||
|
||||
@@ -82,6 +82,20 @@
|
||||
border-radius: 2rem;
|
||||
box-shadow: 0 28px 80px rgba(34, 34, 34, 0.08);
|
||||
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,
|
||||
@@ -287,6 +301,13 @@
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -296,6 +317,12 @@
|
||||
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 {
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -317,19 +344,24 @@
|
||||
|
||||
.survey-list-card__head-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
flex: 0 0 auto;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
.survey-list-card__head-actions--template {
|
||||
align-items: baseline;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.survey-list-card__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: .3em;
|
||||
}
|
||||
|
||||
.survey-list-card__stats {
|
||||
@@ -409,29 +441,25 @@
|
||||
}
|
||||
|
||||
.survey-visibility-dot {
|
||||
width: 2.2rem;
|
||||
height: 2.2rem;
|
||||
aspect-ratio: 1;
|
||||
height: 42px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(162, 168, 180, 0.62);
|
||||
background: #7f8793;
|
||||
background: #a6adb7;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
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 {
|
||||
background: #0a8378;
|
||||
border-color: rgba(0, 174, 151, 0.68);
|
||||
box-shadow: 0 0 0 0.18rem rgba(0, 174, 151, 0.14);
|
||||
background: #0a832e;
|
||||
}
|
||||
|
||||
.survey-button-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.65rem;
|
||||
}
|
||||
|
||||
.survey-button-row--form-actions {
|
||||
@@ -444,6 +472,51 @@
|
||||
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 {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -663,6 +736,11 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.survey-accordion-header-row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.survey-accordion-toggle {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
@@ -821,6 +899,56 @@
|
||||
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) {
|
||||
.survey-grid-columns {
|
||||
grid-template-columns: 0.95fr 1.05fr;
|
||||
@@ -858,6 +986,10 @@
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.survey-button-row--navigation.has-back {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.survey-list-publish-help {
|
||||
left: 0;
|
||||
right: auto;
|
||||
@@ -1072,6 +1204,7 @@
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
resetEditorViewport();
|
||||
});
|
||||
</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' %}
|
||||
|
||||
{% 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') }}">
|
||||
{% include '@Survey/frontend/_survey_branding.html.twig' %}
|
||||
|
||||
@@ -47,6 +48,7 @@
|
||||
{% else %}
|
||||
{% set surveyMetaFormId = 'survey-meta-form' %}
|
||||
{% set newQuestionFormId = 'survey-question-form-new' %}
|
||||
{% set canQuestionReorder = canQuestionReorder|default(false) %}
|
||||
|
||||
<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 %}
|
||||
@@ -74,14 +76,25 @@
|
||||
|
||||
<div class="survey-grid-columns survey-editor-layout">
|
||||
<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 %}
|
||||
{% set questionAnchor = 'survey-question-' ~ question.id %}
|
||||
{% set isOpen = activeQuestionId == question.id %}
|
||||
{% set questionItemForm = questionForms[question.id]|default(null) %}
|
||||
{% 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">
|
||||
<div class="survey-accordion-header-row">
|
||||
<button
|
||||
type="button"
|
||||
class="survey-accordion-toggle handorgel__header__button"
|
||||
@@ -93,6 +106,18 @@
|
||||
</span>
|
||||
</span>
|
||||
</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>
|
||||
<div class="survey-accordion-panel handorgel__content">
|
||||
<div class="handorgel__content__inner" data-survey-question-editor>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
{% trans_default_domain 'messages' %}
|
||||
|
||||
{% include '@Survey/frontend/_survey_assets.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 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') %}
|
||||
<div class="survey-alert success">{{ message }}</div>
|
||||
{% endfor %}
|
||||
@@ -32,17 +35,31 @@
|
||||
<div class="survey-grid survey-list-grid">
|
||||
{% for survey in surveys %}
|
||||
<article class="survey-card survey-list-card">
|
||||
<div class="survey-list-card__head">
|
||||
<div class="survey-list-card__headline">
|
||||
<div class="survey-list-card__head{{ templatesOnly ? ' survey-list-card__head--template' : '' }}">
|
||||
<div class="survey-list-card__headline{{ templatesOnly ? ' survey-list-card__headline--template' : '' }}">
|
||||
<div class="survey-list-card__title-wrap">
|
||||
<h3>{{ survey.title|striptags|trim }}</h3>
|
||||
</div>
|
||||
<div class="survey-list-card__stats">
|
||||
<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>
|
||||
{% endif %}
|
||||
</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>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if not templatesOnly %}
|
||||
<div class="survey-list-card__footer">
|
||||
<div class="survey-button-row survey-list-card__actions">
|
||||
{% if survey.editUrl %}
|
||||
@@ -63,9 +80,16 @@
|
||||
{% endif %}
|
||||
<form method="post" class="survey-question-action-form">
|
||||
<input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('duplicate-survey-' ~ survey.id) }}">
|
||||
<input type="hidden" name="_survey_action" value="duplicate-survey">
|
||||
<input type="hidden" name="item_id" value="{{ survey.id }}">
|
||||
<button class="survey-button secondary" onclick="return confirm('{{ 'survey.list.duplicate_confirm'|trans({'%title%': survey.title|striptags|trim})|e('js') }}');">{{ 'survey.list.duplicate'|trans }}</button>
|
||||
</form>
|
||||
<form method="post" class="survey-question-action-form">
|
||||
<input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('delete-survey-' ~ survey.id) }}">
|
||||
<input type="hidden" name="_survey_action" value="delete-survey">
|
||||
<input type="hidden" name="item_id" value="{{ survey.id }}">
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
@@ -73,9 +97,9 @@
|
||||
<div class="survey-list-card__head-actions">
|
||||
<form method="post" class="survey-list-publish-form">
|
||||
<input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('publish-survey-' ~ survey.id) }}">
|
||||
<input type="hidden" name="_survey_action" value="publish-survey">
|
||||
<input type="hidden" name="item_id" value="{{ survey.id }}">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('publish-survey-' ~ survey.id) }}">
|
||||
<button
|
||||
type="submit"
|
||||
class="survey-button primary survey-list-publish-button"
|
||||
@@ -91,16 +115,19 @@
|
||||
|
||||
<form method="post" class="survey-list-eye-form">
|
||||
<input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('toggle-active-' ~ survey.id) }}">
|
||||
<input type="hidden" name="_survey_action" value="toggle-active">
|
||||
<input type="hidden" name="item_id" value="{{ survey.id }}">
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</article>
|
||||
{% else %}
|
||||
{% if not templatesOnly %}
|
||||
<div class="survey-card">{{ 'survey.list.empty'|trans }}</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{% set moduleHeadline = (headline is iterable ? headline.text|default('') : headline|default(''))|striptags|trim %}
|
||||
|
||||
<section class="survey-shell survey-grid">
|
||||
<section class="survey-shell survey-shell--reader survey-grid">
|
||||
{% if moduleHeadline %}
|
||||
<header><h2>{{ moduleHeadline }}</h2></header>
|
||||
{% endif %}
|
||||
@@ -52,20 +52,7 @@
|
||||
<h3>{{ question.question|striptags|trim }}</h3>
|
||||
{% if question.description|striptags|trim %}<p>{{ question.description|striptags|trim }}</p>{% endif %}
|
||||
|
||||
{% if canGoBack %}
|
||||
<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'}}) }}
|
||||
{{ form_start(surveyForm, {attr: {class: 'survey-form-grid', id: 'survey-answer-form-' ~ question.id}}) }}
|
||||
<input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}">
|
||||
{% if isPreview %}
|
||||
<input type="hidden" name="preview_question" value="{{ question.id }}">
|
||||
@@ -76,7 +63,10 @@
|
||||
{% for child in surveyForm.answer %}
|
||||
<label class="survey-card" style="cursor: pointer;">
|
||||
<div class="survey-meta"><strong>{{ child.vars.label|striptags|trim }}</strong>{{ form_widget(child) }}</div>
|
||||
<div>{{ 'survey.survey.answer_flow_hint'|trans }}</div>
|
||||
{% set jumpHint = jumpHints|default({})[child.vars.value]|default(null) %}
|
||||
{% if jumpHint %}
|
||||
<div class="survey-jump-hint">{{ 'survey.survey.jump_to'|trans({'%position%': jumpHint.position, '%label%': jumpHint.label|striptags|trim}) }}</div>
|
||||
{% endif %}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -89,21 +79,37 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% elseif question.type == 'range' %}
|
||||
<div class="survey-grid">
|
||||
<div class="survey-card">
|
||||
<div>{{ 'survey.survey.current_value'|trans }}</div>
|
||||
<div class="survey-grid survey-range-block">
|
||||
<div class="survey-range-value-card">
|
||||
<div class="survey-range-value-label">{{ 'survey.survey.current_value'|trans }}</div>
|
||||
<div id="survey-range-output-{{ question.id }}" class="survey-range-output">{{ question.rangeMin }}</div>
|
||||
</div>
|
||||
{{ 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>
|
||||
{% else %}
|
||||
{{ form_widget(surveyForm.answer) }}
|
||||
{% endif %}
|
||||
|
||||
{{ form_errors(surveyForm.answer) }}
|
||||
<div class="survey-button-row"><button class="survey-button primary">{{ 'survey.survey.next'|trans }}</button></div>
|
||||
{{ 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>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
@@ -170,6 +170,7 @@ nav.mod_customnav.block:has(a[href*="/abmelden"]) {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
box-sizing: border-box;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
nav.mod_customnav.block:has(a[href*="/abmelden"]) .invisible {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Vendored
+2
File diff suppressed because one or more lines are too long
@@ -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 });
|
||||
})();
|
||||
@@ -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('newQuestionForm', $newQuestionFormView);
|
||||
$template->set('questions', $questions);
|
||||
$template->set('canQuestionReorder', !$structureLocked && count($questions) > 1);
|
||||
$conditionOverview = $this->surveyConditionRepository->findOverviewBySurvey((int) $survey->id);
|
||||
|
||||
$template->set('conditions', $this->buildJumpRuleOverview($questions, $conditionOverview));
|
||||
@@ -279,6 +280,11 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController
|
||||
$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) {
|
||||
$this->surveyEditorService->deleteCondition($survey, $itemId);
|
||||
$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 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
|
||||
{
|
||||
$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
|
||||
{
|
||||
$choices = [];
|
||||
$position = 1;
|
||||
|
||||
foreach ($this->surveyQuestionRepository->findAllBySurvey($surveyId) as $question) {
|
||||
if ($excludeQuestionId > 0 && (int) $question->id === $excludeQuestionId) {
|
||||
++$position;
|
||||
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;
|
||||
|
||||
@@ -20,6 +20,7 @@ use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
#[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
|
||||
{
|
||||
public function __construct(
|
||||
@@ -31,12 +32,14 @@ final class MemberSurveyListController extends AbstractFrontendModuleController
|
||||
|
||||
protected function getResponse(FragmentTemplate $template, ModuleModel $model, Request $request): Response
|
||||
{
|
||||
$templatesOnly = 'member_survey_template_list' === (string) ($model->type ?? '');
|
||||
$user = $this->getUser();
|
||||
|
||||
if (!$user instanceof FrontendUser) {
|
||||
$template->set('loginRequired', true);
|
||||
$template->set('surveys', []);
|
||||
$template->set('createUrl', null);
|
||||
$template->set('templatesOnly', $templatesOnly);
|
||||
|
||||
return $template->getResponse();
|
||||
}
|
||||
@@ -45,12 +48,12 @@ final class MemberSurveyListController extends AbstractFrontendModuleController
|
||||
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'));
|
||||
$resultsPage = $this->resolvePage((int) $this->getContaoAdapter(Config::class)->get('surveyResultsPage'));
|
||||
$items = [];
|
||||
|
||||
foreach ($this->surveyRepository->findEditableByMember((int) $user->id) as $survey) {
|
||||
foreach ($this->surveyRepository->findEditableByMember((int) $user->id, $templatesOnly) as $survey) {
|
||||
$surveyId = (int) $survey['id'];
|
||||
$alias = (string) $survey['alias'];
|
||||
|
||||
@@ -58,21 +61,27 @@ final class MemberSurveyListController extends AbstractFrontendModuleController
|
||||
'id' => $surveyId,
|
||||
'title' => (string) $survey['title'],
|
||||
'description' => (string) ($survey['description'] ?? ''),
|
||||
'isTemplate' => !empty($survey['isTemplate']),
|
||||
'isActive' => !empty($survey['isActive']),
|
||||
'isLocked' => !empty($survey['isLocked']),
|
||||
'published' => !empty($survey['published']),
|
||||
'questionCount' => (int) ($survey['questionCount'] ?? 0),
|
||||
'participationCount' => (int) ($survey['participationCount'] ?? 0),
|
||||
'editUrl' => $editPage instanceof PageModel ? $this->generateContentUrl($editPage, ['survey' => $surveyId]) : null,
|
||||
'publicUrl' => $readerPage instanceof PageModel ? $this->generateContentUrl($readerPage, ['auto_item' => $alias]) : null,
|
||||
'draftUrl' => $readerPage instanceof PageModel ? $this->generateContentUrl($readerPage, ['auto_item' => $alias, 'preview' => '1']) : null,
|
||||
'resultsUrl' => $resultsPage instanceof PageModel ? $this->generateContentUrl($resultsPage, ['auto_item' => $alias]) : null,
|
||||
'publicUrl' => $readerPage instanceof PageModel ? $this->generateContentUrl($readerPage, ['parameters' => '/'.$alias]) : null,
|
||||
'draftUrl' => $readerPage instanceof PageModel ? $this->generateContentUrl($readerPage, ['parameters' => '/'.$alias, 'preview' => '1']) : null,
|
||||
'resultsUrl' => $resultsPage instanceof PageModel ? $this->generateContentUrl($resultsPage, ['parameters' => '/'.$alias]) : null,
|
||||
];
|
||||
}
|
||||
|
||||
if ($templatesOnly && [] === $items) {
|
||||
return new Response('');
|
||||
}
|
||||
|
||||
$template->set('loginRequired', false);
|
||||
$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();
|
||||
}
|
||||
@@ -81,15 +90,27 @@ final class MemberSurveyListController extends AbstractFrontendModuleController
|
||||
{
|
||||
$surveyId = (int) $request->request->get('item_id', 0);
|
||||
$action = (string) $request->request->get('_survey_action');
|
||||
$token = (string) $request->request->get('_token');
|
||||
$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.');
|
||||
}
|
||||
|
||||
$survey = $this->resolveEditableSurvey($surveyId, $memberId);
|
||||
|
||||
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) {
|
||||
$this->surveyEditorService->publishSurvey($survey, $memberId);
|
||||
$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\DependencyInjection\Attribute\AsFrontendModule;
|
||||
use Contao\CoreBundle\Twig\FragmentTemplate;
|
||||
use Contao\Input;
|
||||
use Contao\ModuleModel;
|
||||
use Mummert\SurveyBundle\Model\SurveyContentModel;
|
||||
use Mummert\SurveyBundle\Model\SurveyModel;
|
||||
@@ -126,6 +127,7 @@ final class ShowSurveyController extends AbstractFrontendModuleController
|
||||
$template->set('survey', $survey);
|
||||
$template->set('progress', $this->surveyFlowService->getProgress($survey, $question));
|
||||
$template->set('question', $question);
|
||||
$template->set('jumpHints', $this->buildJumpHints($survey, $question));
|
||||
$template->set('surveyForm', $form->createView());
|
||||
$template->set('isComplete', false);
|
||||
$template->set('answers', []);
|
||||
@@ -136,6 +138,30 @@ final class ShowSurveyController extends AbstractFrontendModuleController
|
||||
return $template->getResponse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sprung-Hinweise je Ja/Nein-Option für die aktuelle Frage (nur bei konfigurierter Sprunglogik).
|
||||
*
|
||||
* @return array<string, array{position:int,label:string}>
|
||||
*/
|
||||
private function buildJumpHints(SurveyModel $survey, SurveyContentModel $question): array
|
||||
{
|
||||
if ('yes_no_maybe' !== (string) $question->type) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$hints = [];
|
||||
|
||||
if (null !== $yes = $this->surveyFlowService->describeJumpTarget($survey, (int) ($question->jumpOnYes ?? 0))) {
|
||||
$hints['yes'] = $yes;
|
||||
}
|
||||
|
||||
if (null !== $no = $this->surveyFlowService->describeJumpTarget($survey, (int) ($question->jumpOnNo ?? 0))) {
|
||||
$hints['no'] = $no;
|
||||
}
|
||||
|
||||
return $hints;
|
||||
}
|
||||
|
||||
private function buildPreviewResponse(FragmentTemplate $template, SurveyModel $survey, Request $request): Response
|
||||
{
|
||||
$history = $this->decodePreviewHistory((string) $request->request->get('preview_history', ''));
|
||||
@@ -198,6 +224,7 @@ final class ShowSurveyController extends AbstractFrontendModuleController
|
||||
$template->set('survey', $survey);
|
||||
$template->set('progress', $this->surveyFlowService->getProgress($survey, $question));
|
||||
$template->set('question', $question);
|
||||
$template->set('jumpHints', $this->buildJumpHints($survey, $question));
|
||||
$template->set('surveyForm', $form->createView());
|
||||
$template->set('isComplete', false);
|
||||
$template->set('answers', []);
|
||||
@@ -265,7 +292,10 @@ final class ShowSurveyController extends AbstractFrontendModuleController
|
||||
|
||||
private function resolvePublicAlias(Request $request): string
|
||||
{
|
||||
$candidate = $request->attributes->get('auto_item');
|
||||
// Bei sauberen Pfad-URLs (/seite/<alias>) legt Contao den Wert im Input-Adapter
|
||||
// ab und markiert ihn als Route-Parameter. Input::get liest ihn aus UND markiert
|
||||
// ihn als benutzt – sonst wirft Contao 404 (unbenutzter Route-Parameter).
|
||||
$candidate = $this->getContaoAdapter(Input::class)->get('auto_item');
|
||||
|
||||
if (\is_string($candidate) && '' !== trim($candidate)) {
|
||||
return trim($candidate);
|
||||
|
||||
@@ -8,6 +8,7 @@ use Contao\Config;
|
||||
use Contao\CoreBundle\Controller\FrontendModule\AbstractFrontendModuleController;
|
||||
use Contao\CoreBundle\DependencyInjection\Attribute\AsFrontendModule;
|
||||
use Contao\CoreBundle\Twig\FragmentTemplate;
|
||||
use Contao\Input;
|
||||
use Contao\ModuleModel;
|
||||
use Mummert\SurveyBundle\Repository\SurveyRepository;
|
||||
use Mummert\SurveyBundle\Service\SurveyResultsViewService;
|
||||
@@ -82,7 +83,9 @@ final class ShowSurveyResultsController extends AbstractFrontendModuleController
|
||||
|
||||
private function resolvePublicAlias(Request $request): string
|
||||
{
|
||||
$candidate = $request->attributes->get('auto_item');
|
||||
// Bei sauberen Pfad-URLs (/seite/<alias>) liegt der Wert im Contao-Input-Adapter;
|
||||
// Input::get liest ihn aus und markiert ihn als benutzt (sonst Contao-404).
|
||||
$candidate = $this->getContaoAdapter(Input::class)->get('auto_item');
|
||||
|
||||
if (\is_string($candidate) && '' !== trim($candidate)) {
|
||||
return trim($candidate);
|
||||
|
||||
@@ -6,22 +6,29 @@ namespace Mummert\SurveyBundle\EventListener;
|
||||
|
||||
use Contao\Config;
|
||||
use Contao\CoreBundle\DataContainer\DataContainerOperation;
|
||||
use Contao\CoreBundle\Exception\RedirectResponseException;
|
||||
use Contao\CoreBundle\Routing\ContentUrlGenerator;
|
||||
use Contao\CoreBundle\Framework\ContaoFramework;
|
||||
use Contao\DataContainer;
|
||||
use Contao\Image;
|
||||
use Contao\Input;
|
||||
use Contao\Message;
|
||||
use Contao\PageModel;
|
||||
use Contao\StringUtil;
|
||||
use Contao\System;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Mummert\SurveyBundle\Repository\SurveyEditorRepository;
|
||||
use Mummert\SurveyBundle\Repository\SurveyQuestionRepository;
|
||||
use Mummert\SurveyBundle\Repository\SurveyRepository;
|
||||
use Mummert\SurveyBundle\Service\SurveyEditorService;
|
||||
use Mummert\SurveyBundle\Service\SurveyCategoryAssignmentService;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
|
||||
final class SurveyDcaListener
|
||||
{
|
||||
private ?SurveyEditorService $surveyEditorService;
|
||||
private ?RequestStack $requestStack;
|
||||
|
||||
public function __construct(
|
||||
private readonly SurveyRepository $surveyRepository,
|
||||
private readonly SurveyQuestionRepository $surveyQuestionRepository,
|
||||
@@ -30,7 +37,11 @@ final class SurveyDcaListener
|
||||
private readonly ContaoFramework $framework,
|
||||
private readonly Connection $connection,
|
||||
private readonly ContentUrlGenerator $contentUrlGenerator,
|
||||
?SurveyEditorService $surveyEditorService = null,
|
||||
?RequestStack $requestStack = null,
|
||||
) {
|
||||
$this->surveyEditorService = $surveyEditorService;
|
||||
$this->requestStack = $requestStack;
|
||||
}
|
||||
|
||||
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>
|
||||
*/
|
||||
@@ -139,9 +195,10 @@ final class SurveyDcaListener
|
||||
private function buildQuestionOptions(int $surveyId): array
|
||||
{
|
||||
$choices = [];
|
||||
$position = 1;
|
||||
|
||||
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;
|
||||
@@ -155,54 +212,6 @@ final class SurveyDcaListener
|
||||
return $this->surveyEditorRepository->findMemberChoices();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function getCategoryOptions(): array
|
||||
{
|
||||
$rows = $this->connection->fetchAllAssociative('SELECT id, title FROM tl_survey_category ORDER BY sorting ASC, title ASC');
|
||||
$options = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$options[(int) $row['id']] = (string) $row['title'];
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function getUsedCategoryFilterOptions(): array
|
||||
{
|
||||
$usedCategoryIds = [];
|
||||
|
||||
foreach ($this->connection->fetchFirstColumn('SELECT category FROM tl_survey') as $rawCategoryValue) {
|
||||
foreach (StringUtil::deserialize($rawCategoryValue, true) as $categoryId) {
|
||||
$categoryId = (int) $categoryId;
|
||||
|
||||
if ($categoryId > 0) {
|
||||
$usedCategoryIds[$categoryId] = $categoryId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ([] === $usedCategoryIds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$allCategoryOptions = $this->getCategoryOptions();
|
||||
$options = [];
|
||||
|
||||
foreach (array_values($usedCategoryIds) as $categoryId) {
|
||||
if (isset($allCategoryOptions[$categoryId])) {
|
||||
$options[$categoryId] = $allCategoryOptions[$categoryId];
|
||||
}
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
@@ -496,4 +505,54 @@ final class SurveyDcaListener
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,9 @@ final class SurveyEditorData
|
||||
{
|
||||
$data = new self();
|
||||
$data->title = (string) $survey->title;
|
||||
$data->description = (string) $survey->description;
|
||||
// Beschreibung ist ein Klartextfeld; evtl. gespeicherte HTML-Tags (z. B. <p>)
|
||||
// entfernen, damit das Textarea reinen Text zeigt.
|
||||
$data->description = trim(strip_tags((string) $survey->description));
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
@@ -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' => [],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ namespace Mummert\SurveyBundle\Repository;
|
||||
|
||||
use Doctrine\DBAL\ArrayParameterType;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\DBAL\ParameterType;
|
||||
|
||||
final class SurveyAnswerRepository
|
||||
{
|
||||
@@ -92,7 +93,7 @@ final class SurveyAnswerRepository
|
||||
$this->connection->executeStatement(
|
||||
'DELETE FROM tl_survey_answer WHERE submission = ? AND question NOT IN (?)',
|
||||
[$submissionId, array_values(array_unique(array_map('intval', $questionIds)))],
|
||||
[\PDO::PARAM_INT, ArrayParameterType::INTEGER],
|
||||
[ParameterType::INTEGER, ArrayParameterType::INTEGER],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ final class SurveyConditionRepository
|
||||
{
|
||||
$this->connection->insert('tl_survey_condition', [
|
||||
'pid' => $surveyId,
|
||||
'tstamp' => time(),
|
||||
'sourceQuestion' => (int) ($data['sourceQuestion'] ?? 0),
|
||||
'answerValue' => mb_strtolower(trim((string) ($data['answerValue'] ?? ''))),
|
||||
'targetQuestion' => (int) ($data['targetQuestion'] ?? 0),
|
||||
@@ -72,17 +73,6 @@ final class SurveyConditionRepository
|
||||
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
|
||||
{
|
||||
$this->connection->delete('tl_survey_condition', [
|
||||
|
||||
@@ -28,6 +28,7 @@ final class SurveyEditorRepository
|
||||
|
||||
$this->connection->insert('tl_survey_editor', [
|
||||
'pid' => $surveyId,
|
||||
'tstamp' => time(),
|
||||
'survey' => $surveyId,
|
||||
'member' => $memberId,
|
||||
]);
|
||||
@@ -156,7 +157,7 @@ final class SurveyEditorRepository
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$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;
|
||||
|
||||
@@ -64,6 +64,7 @@ final class SurveyQuestionRepository
|
||||
|
||||
$this->connection->insert('tl_survey_content', [
|
||||
'pid' => $surveyId,
|
||||
'tstamp' => time(),
|
||||
'sorting' => $sorting,
|
||||
'type' => $type,
|
||||
'question' => (string) ($data['question'] ?? ''),
|
||||
@@ -100,6 +101,7 @@ final class SurveyQuestionRepository
|
||||
$isRangeType = 'range' === $type;
|
||||
|
||||
$this->connection->update('tl_survey_content', [
|
||||
'tstamp' => time(),
|
||||
'type' => $type,
|
||||
'question' => (string) ($data['question'] ?? $question->question),
|
||||
'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
|
||||
{
|
||||
$models = [];
|
||||
|
||||
@@ -53,10 +53,32 @@ final class SurveyRepository
|
||||
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>>
|
||||
*/
|
||||
public function findEditableByMember(int $memberId): array
|
||||
public function findEditableByMember(int $memberId, bool $templatesOnly = false): array
|
||||
{
|
||||
return $this->connection->fetchAllAssociative(
|
||||
<<<'SQL'
|
||||
@@ -65,6 +87,7 @@ final class SurveyRepository
|
||||
s.title,
|
||||
s.alias,
|
||||
s.description,
|
||||
s.isTemplate,
|
||||
s.published,
|
||||
s.isActive,
|
||||
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
|
||||
LEFT JOIN tl_survey_content q ON q.pid = s.id
|
||||
LEFT JOIN tl_survey_submission sub ON sub.survey = s.id
|
||||
WHERE s.isTemplate = :isTemplate
|
||||
GROUP BY s.id
|
||||
ORDER BY s.title ASC, s.id ASC
|
||||
SQL,
|
||||
['member' => $memberId],
|
||||
['member' => $memberId, 'isTemplate' => $templatesOnly ? '1' : ''],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -93,9 +117,11 @@ final class SurveyRepository
|
||||
'alias' => $this->ensurePublicAlias(null),
|
||||
'category' => serialize($this->normalizeCategoryIds($data['category'] ?? [])),
|
||||
'description' => (string) ($data['description'] ?? ''),
|
||||
'internalNote' => trim((string) ($data['internalNote'] ?? '')),
|
||||
'published' => '',
|
||||
'isActive' => '',
|
||||
'isActive' => '1',
|
||||
'isLocked' => '',
|
||||
'isTemplate' => !empty($data['isTemplate']) ? '1' : '',
|
||||
'createdBy' => $memberId,
|
||||
'updatedBy' => $memberId,
|
||||
'createdAt' => $now,
|
||||
@@ -118,6 +144,7 @@ final class SurveyRepository
|
||||
'alias' => $this->ensurePublicAlias((string) $survey->alias, (int) $survey->id),
|
||||
'category' => serialize($this->normalizeCategoryIds($data['category'] ?? StringUtil::deserialize($survey->category, true))),
|
||||
'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,
|
||||
'isActive' => array_key_exists('isActive', $data) ? (!empty($data['isActive']) ? '1' : '') : (string) ($survey->isActive ?? ''),
|
||||
'updatedBy' => $memberId,
|
||||
@@ -172,8 +199,6 @@ final class SurveyRepository
|
||||
return;
|
||||
}
|
||||
|
||||
$categoryIds = $this->normalizeCategoryIds($survey->category);
|
||||
$categoryTitles = $this->fetchCategoryTitles($categoryIds);
|
||||
$assignedMembers = $this->fetchAssignedMembers($surveyId);
|
||||
$memberIds = [];
|
||||
$memberNames = [];
|
||||
@@ -184,12 +209,10 @@ final class SurveyRepository
|
||||
}
|
||||
|
||||
$this->connection->update('tl_survey', [
|
||||
'categoryFilter' => implode(',', $categoryIds),
|
||||
'categorySummary' => implode(', ', $categoryTitles),
|
||||
'templateFilter' => '1' === (string) $survey->isTemplate ? '1' : '0',
|
||||
'templateSummary' => '1' === (string) $survey->isTemplate ? 'Ja' : 'Nein',
|
||||
'assignedMembersFilter' => implode(',', $memberIds),
|
||||
'assignedMembersSummary' => implode(', ', $memberNames),
|
||||
'assignedMembersSummary' => $this->buildAssignedMembersSummary($memberNames),
|
||||
], [
|
||||
'id' => $surveyId,
|
||||
]);
|
||||
@@ -306,39 +329,6 @@ final class SurveyRepository
|
||||
return false !== $qb->executeQuery()->fetchOne();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $categoryIds
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function fetchCategoryTitles(array $categoryIds): array
|
||||
{
|
||||
if ([] === $categoryIds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($categoryIds), '?'));
|
||||
$rows = $this->connection->fetchAllAssociative(
|
||||
sprintf('SELECT id, title FROM tl_survey_category WHERE id IN (%s)', $placeholders),
|
||||
$categoryIds,
|
||||
);
|
||||
$titlesById = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$titlesById[(int) $row['id']] = (string) $row['title'];
|
||||
}
|
||||
|
||||
$titles = [];
|
||||
|
||||
foreach ($categoryIds as $categoryId) {
|
||||
if (isset($titlesById[$categoryId])) {
|
||||
$titles[] = $titlesById[$categoryId];
|
||||
}
|
||||
}
|
||||
|
||||
return $titles;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{id:int,name:string}>
|
||||
*/
|
||||
@@ -383,11 +373,31 @@ final class SurveyRepository
|
||||
);
|
||||
|
||||
return $this->hasListMetadataColumns = !array_diff(
|
||||
['categoryfilter', 'categorysummary', 'templatefilter', 'templatesummary', 'assignedmembersfilter', 'assignedmemberssummary'],
|
||||
['templatefilter', 'templatesummary', 'assignedmembersfilter', 'assignedmemberssummary'],
|
||||
$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
|
||||
{
|
||||
return 1 === preg_match('/^[a-z0-9]{20}$/', $alias);
|
||||
|
||||
@@ -24,16 +24,6 @@ final class SurveySubmissionRepository
|
||||
return $model instanceof SurveySubmissionModel ? $model : null;
|
||||
}
|
||||
|
||||
public function findOpenBySurveyAndToken(int $surveyId, string $token): ?SurveySubmissionModel
|
||||
{
|
||||
$id = $this->connection->fetchOne(
|
||||
'SELECT id FROM tl_survey_submission WHERE survey = ? AND token = ? AND isFinished != 1 ORDER BY id DESC LIMIT 1',
|
||||
[$surveyId, $token],
|
||||
);
|
||||
|
||||
return false === $id ? null : $this->findById((int) $id);
|
||||
}
|
||||
|
||||
public function findBySurveyAndToken(int $surveyId, string $token): ?SurveySubmissionModel
|
||||
{
|
||||
$id = $this->connection->fetchOne(
|
||||
@@ -44,14 +34,6 @@ final class SurveySubmissionRepository
|
||||
return false === $id ? null : $this->findById((int) $id);
|
||||
}
|
||||
|
||||
public function hasFinishedBySurveyAndToken(int $surveyId, string $token): bool
|
||||
{
|
||||
return false !== $this->connection->fetchOne(
|
||||
'SELECT id FROM tl_survey_submission WHERE survey = ? AND token = ? AND isFinished = 1 ORDER BY id DESC LIMIT 1',
|
||||
[$surveyId, $token],
|
||||
);
|
||||
}
|
||||
|
||||
public function create(int $surveyId, string $token, ?int $currentQuestionId): SurveySubmissionModel
|
||||
{
|
||||
$now = time();
|
||||
|
||||
@@ -264,7 +264,6 @@
|
||||
{% elseif question.type == 'range' %}
|
||||
<div class="metric-row">
|
||||
<div class="metric-box">
|
||||
<span class="metric-label">Minimum</span>
|
||||
<span class="metric-label">{{ 'survey.pdf.minimum'|trans({}, 'messages') }}</span>
|
||||
<span class="metric-value">{{ question.minimum is not null ? question.minimum : '-' }}</span>
|
||||
</div>
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\SurveyBundle\Security;
|
||||
|
||||
use Contao\FrontendUser;
|
||||
use Mummert\SurveyBundle\Model\SurveyModel;
|
||||
use Mummert\SurveyBundle\Service\SurveyPermissionService;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
|
||||
final class SurveyEditorVoter extends Voter
|
||||
{
|
||||
public const string EDIT = 'SURVEY_EDIT';
|
||||
|
||||
public function __construct(private readonly SurveyPermissionService $surveyPermissionService)
|
||||
{
|
||||
}
|
||||
|
||||
protected function supports(string $attribute, mixed $subject): bool
|
||||
{
|
||||
return self::EDIT === $attribute && $subject instanceof SurveyModel;
|
||||
}
|
||||
|
||||
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
|
||||
{
|
||||
$user = $token->getUser();
|
||||
|
||||
if (!$user instanceof FrontendUser || !$subject instanceof SurveyModel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->surveyPermissionService->canEditSurvey($subject, $user);
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
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\SurveyQuestionData;
|
||||
use Mummert\SurveyBundle\Model\SurveyContentModel;
|
||||
use Mummert\SurveyBundle\Model\SurveyModel;
|
||||
use Mummert\SurveyBundle\Repository\SurveyConditionRepository;
|
||||
use Mummert\SurveyBundle\Repository\SurveyEditorRepository;
|
||||
@@ -21,6 +22,7 @@ final class SurveyEditorService
|
||||
private readonly SurveyConditionRepository $surveyConditionRepository,
|
||||
private readonly SurveyEditorRepository $surveyEditorRepository,
|
||||
private readonly SurveyCategoryAssignmentService $surveyCategoryAssignmentService,
|
||||
private readonly Connection $connection,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -94,30 +96,127 @@ final class SurveyEditorService
|
||||
$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
|
||||
{
|
||||
$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) {
|
||||
$condition = $this->surveyConditionRepository->findById($conditionId);
|
||||
$duplicateSurveyId = (int) $duplicate->id;
|
||||
|
||||
if (null === $condition || (int) $condition->pid !== (int) $survey->id) {
|
||||
throw new \RuntimeException('Die Bedingung wurde nicht gefunden.');
|
||||
if ([] !== $memberIds) {
|
||||
$this->surveyEditorRepository->syncMembersForSurvey($duplicateSurveyId, $memberIds);
|
||||
}
|
||||
|
||||
$this->surveyConditionRepository->update($condition, $data->toArray());
|
||||
$this->surveyRepository->touch((int) $survey->id);
|
||||
$duplicateQuestions = [];
|
||||
$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());
|
||||
$this->surveyRepository->touch((int) $survey->id);
|
||||
foreach ($sourceQuestions as $sourceQuestion) {
|
||||
$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
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -112,6 +112,29 @@ final class SurveyFlowService
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Beschreibt das Sprungziel einer Ja/Nein-Antwort über Positionsnummer und Label.
|
||||
*
|
||||
* @return array{position:int,label:string}|null
|
||||
*/
|
||||
public function describeJumpTarget(SurveyModel $survey, int $targetQuestionId): ?array
|
||||
{
|
||||
if ($targetQuestionId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($this->getOrderedQuestions($survey) as $index => $question) {
|
||||
if ((int) $question->id === $targetQuestionId) {
|
||||
return [
|
||||
'position' => $index + 1,
|
||||
'label' => trim((string) $question->question),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<SurveyContentModel>
|
||||
*/
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\SurveyBundle\Service;
|
||||
|
||||
use Contao\FrontendUser;
|
||||
use Mummert\SurveyBundle\Model\SurveyModel;
|
||||
use Mummert\SurveyBundle\Repository\SurveyEditorRepository;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
|
||||
final class SurveyPermissionService
|
||||
{
|
||||
public function __construct(private readonly SurveyEditorRepository $surveyEditorRepository)
|
||||
{
|
||||
}
|
||||
|
||||
public function canEditSurvey(SurveyModel $survey, FrontendUser $member): bool
|
||||
{
|
||||
return $this->surveyEditorRepository->isEditor((int) $survey->id, (int) $member->id);
|
||||
}
|
||||
|
||||
public function assertEditorAccess(SurveyModel $survey, FrontendUser $member): void
|
||||
{
|
||||
if (!$this->canEditSurvey($survey, $member)) {
|
||||
throw new AccessDeniedHttpException('Sie dürfen diese Umfrage nicht bearbeiten.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -136,23 +136,19 @@ final class SurveyResultsPdfService
|
||||
private function buildChartLegend(array $questionResult): array
|
||||
{
|
||||
$chart = $questionResult['chart'] ?? null;
|
||||
|
||||
if (!is_array($chart)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$chartType = (string) ($chart['type'] ?? '');
|
||||
$chartType = is_array($chart) ? (string) ($chart['type'] ?? '') : '';
|
||||
$sourceRows = match ($chartType) {
|
||||
'pie' => $questionResult['options'] ?? $questionResult['responseSummary'] ?? [],
|
||||
'line' => $questionResult['distribution'] ?? [],
|
||||
default => [],
|
||||
// Freitext hat kein Diagramm: Antwort-Häufigkeiten wie in der Online-Ansicht zeigen.
|
||||
default => $questionResult['responseSummary'] ?? [],
|
||||
};
|
||||
|
||||
if (!is_array($sourceRows) || [] === $sourceRows) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$dataset = $chart['data']['datasets'][0] ?? [];
|
||||
$dataset = is_array($chart) ? ($chart['data']['datasets'][0] ?? []) : [];
|
||||
$fillColors = match ($chartType) {
|
||||
'pie' => is_array($dataset['backgroundColor'] ?? null) ? $dataset['backgroundColor'] : [],
|
||||
'line' => is_array($dataset['pointBackgroundColor'] ?? null) ? $dataset['pointBackgroundColor'] : [],
|
||||
|
||||
@@ -50,12 +50,7 @@ final class SurveyResultsViewService
|
||||
$results = [];
|
||||
|
||||
foreach ($questions as $question) {
|
||||
$key = $this->buildQuestionResultKey(
|
||||
(int) $question->id,
|
||||
(string) $question->type,
|
||||
(string) $question->question,
|
||||
(string) ($question->description ?? ''),
|
||||
);
|
||||
$key = $this->buildQuestionResultKey((int) $question->id, (string) $question->type);
|
||||
|
||||
$results[$key] = $this->buildResultSkeleton(
|
||||
(int) $question->id,
|
||||
@@ -65,6 +60,7 @@ final class SurveyResultsViewService
|
||||
(int) ($question->sorting ?? 0),
|
||||
);
|
||||
$results[$key]['_choiceOptions'] = $this->getChoiceOptions($question);
|
||||
$results[$key]['_allowMaybe'] = '1' === (string) $question->allowMaybe;
|
||||
}
|
||||
|
||||
foreach ($answers as $answer) {
|
||||
@@ -72,7 +68,7 @@ final class SurveyResultsViewService
|
||||
$questionType = (string) ($answer['questionType'] ?? 'text');
|
||||
$questionLabel = trim((string) ($answer['questionLabel'] ?? '')) ?: 'Frage #'.$questionId;
|
||||
$questionDescription = (string) ($answer['questionDescription'] ?? '');
|
||||
$key = $this->buildQuestionResultKey($questionId, $questionType, $questionLabel, $questionDescription);
|
||||
$key = $this->buildQuestionResultKey($questionId, $questionType);
|
||||
|
||||
if (!isset($results[$key])) {
|
||||
$results[$key] = $this->buildResultSkeleton(
|
||||
@@ -104,9 +100,11 @@ final class SurveyResultsViewService
|
||||
return array_values($results);
|
||||
}
|
||||
|
||||
private function buildQuestionResultKey(int $questionId, string $questionType, string $questionLabel, string $questionDescription): string
|
||||
private function buildQuestionResultKey(int $questionId, string $questionType): string
|
||||
{
|
||||
return implode('|', [$questionId, $questionType, md5($questionLabel), md5($questionDescription)]);
|
||||
// Bewusst nur Frage-ID + Typ: So werden Antwort-Snapshots und die aktuelle
|
||||
// Frage auch dann zusammengeführt, wenn das Label nachträglich geändert wurde.
|
||||
return implode('|', [$questionId, $questionType]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,6 +123,7 @@ final class SurveyResultsViewService
|
||||
'_sorting' => $sorting,
|
||||
'_values' => [],
|
||||
'_choiceOptions' => [],
|
||||
'_allowMaybe' => false,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -146,6 +145,12 @@ final class SurveyResultsViewService
|
||||
case 'yes_no_maybe':
|
||||
$optionCounts = ['Ja' => 0, 'Nein' => 0];
|
||||
|
||||
// Wie beim Excel-Export: Bei erlaubtem "Vielleicht" die Zeile auch dann
|
||||
// zeigen, wenn (noch) niemand "Vielleicht" gewählt hat.
|
||||
if (!empty($result['_allowMaybe'])) {
|
||||
$optionCounts['Vielleicht'] = 0;
|
||||
}
|
||||
|
||||
foreach ($values as $value) {
|
||||
$normalizedValue = mb_strtolower(trim($value));
|
||||
|
||||
@@ -205,7 +210,7 @@ final class SurveyResultsViewService
|
||||
break;
|
||||
}
|
||||
|
||||
unset($result['_sorting'], $result['_values'], $result['_choiceOptions']);
|
||||
unset($result['_sorting'], $result['_values'], $result['_choiceOptions'], $result['_allowMaybe']);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,12 @@ use Symfony\Component\Uid\Ulid;
|
||||
|
||||
final class SurveySubmissionService
|
||||
{
|
||||
/**
|
||||
* Eine abgeschlossene Teilnahme bleibt für diese Dauer auf der Danke-Seite.
|
||||
* Danach darf dieselbe Browser-Session erneut an der Umfrage teilnehmen.
|
||||
*/
|
||||
private const RESTART_COOLDOWN_SECONDS = 60;
|
||||
|
||||
public function __construct(
|
||||
private readonly SurveySubmissionRepository $surveySubmissionRepository,
|
||||
private readonly SurveyAnswerRepository $surveyAnswerRepository,
|
||||
@@ -35,7 +41,9 @@ final class SurveySubmissionService
|
||||
if ('' !== $token) {
|
||||
$existing = $this->surveySubmissionRepository->findBySurveyAndToken((int) $survey->id, $token);
|
||||
|
||||
if ($existing instanceof SurveySubmissionModel) {
|
||||
// Offene Durchläufe werden fortgesetzt; abgeschlossene Durchläufe halten
|
||||
// die Danke-Seite, bis der Cooldown abgelaufen ist (dann neuer Durchlauf).
|
||||
if ($existing instanceof SurveySubmissionModel && !$this->isRestartAllowed($existing)) {
|
||||
return $existing;
|
||||
}
|
||||
}
|
||||
@@ -48,6 +56,21 @@ final class SurveySubmissionService
|
||||
return $submission;
|
||||
}
|
||||
|
||||
private function isRestartAllowed(SurveySubmissionModel $submission): bool
|
||||
{
|
||||
if ('1' !== (string) $submission->isFinished) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$completedAt = (int) ($submission->completedAt ?? 0);
|
||||
|
||||
if ($completedAt <= 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (time() - $completedAt) >= self::RESTART_COOLDOWN_SECONDS;
|
||||
}
|
||||
|
||||
public function storeAnswer(SurveyModel $survey, SurveySubmissionModel $submission, SurveyContentModel $question, mixed $rawAnswer): string
|
||||
{
|
||||
$normalizedAnswer = $this->normalizeAnswerForQuestion($question, $rawAnswer);
|
||||
|
||||
@@ -2,6 +2,8 @@ survey:
|
||||
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."
|
||||
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_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?"
|
||||
@@ -78,6 +80,8 @@ survey:
|
||||
delete: "Löschen"
|
||||
no_questions: "Noch keine Fragen angelegt."
|
||||
new_question: "Neue Frage anlegen"
|
||||
reorder_question: "Frage verschieben"
|
||||
save_question_order: "Neue Reihenfolge speichern"
|
||||
open_question: "Akkordeon öffnen"
|
||||
close_question: "Akkordeon schließen"
|
||||
conditions_title: "Sprungregeln"
|
||||
@@ -104,7 +108,7 @@ survey:
|
||||
answer_no: "Nein"
|
||||
answer_maybe: "Vielleicht"
|
||||
question_progress: "Frage %current%"
|
||||
answer_flow_hint: "Diese Antwort wird direkt für den weiteren Ablauf verwendet."
|
||||
jump_to: "weiter zu Frage %position%: %label%"
|
||||
current_value: "Aktueller Wert"
|
||||
back: "Zurück"
|
||||
next: "Weiter"
|
||||
|
||||
@@ -2,6 +2,8 @@ survey:
|
||||
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."
|
||||
create: "Create new survey"
|
||||
duplicate: "Duplicate"
|
||||
duplicate_confirm: "Are you sure you want to duplicate the survey \"%title%\" with all questions?"
|
||||
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_second: "Are you really sure?"
|
||||
@@ -78,6 +80,8 @@ survey:
|
||||
delete: "Delete"
|
||||
no_questions: "No questions created yet."
|
||||
new_question: "Create new question"
|
||||
reorder_question: "Reorder question"
|
||||
save_question_order: "Save new order"
|
||||
open_question: "Open accordion"
|
||||
close_question: "Close accordion"
|
||||
conditions_title: "Jump rules"
|
||||
@@ -104,7 +108,7 @@ survey:
|
||||
answer_no: "No"
|
||||
answer_maybe: "Maybe"
|
||||
question_progress: "Question %current%"
|
||||
answer_flow_hint: "This answer is used directly for the further flow."
|
||||
jump_to: "continue to question %position%: %label%"
|
||||
current_value: "Current value"
|
||||
back: "Back"
|
||||
next: "Next"
|
||||
|
||||
Reference in New Issue
Block a user