Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fe142422a | |||
| 954a5102ac | |||
| c7439e4822 | |||
| 788f5ccde0 | |||
| 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 *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
+132
-2
@@ -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`
|
||||
@@ -98,16 +109,37 @@ Aktuelle Button- und Sichtbarkeitslogik pro Umfrage:
|
||||
- Nach Veröffentlichung disabled
|
||||
- `Öffentliche Ansicht`
|
||||
- Nur sichtbar, wenn `published = 1`
|
||||
- `Link kopieren`
|
||||
- Nur sichtbar, wenn `published = 1`
|
||||
- Kopiert die **absolute** öffentliche Reader-URL (`copyUrl`, erzeugt mit `UrlGeneratorInterface::ABSOLUTE_URL`) über den `data-copy-text`-Handler in `_survey_assets.html.twig`.
|
||||
- `Entwurfs-Ansicht`
|
||||
- Nur sichtbar, wenn `published = 0`
|
||||
- `Ergebnisse anzeigen`
|
||||
- Immer verfügbar, sofern Results-Seite konfiguriert ist
|
||||
- Tab-Verhalten (bewusste Produktentscheidung nach Kundenrückmeldung):
|
||||
- `Bearbeiten` öffnet **im selben Tab** (eigentlicher Arbeitsschritt).
|
||||
- `Öffentliche Ansicht`, `Entwurfs-Ansicht` und `Ergebnisse anzeigen` öffnen **im neuen Fenster** (`target="_blank"`), damit die Editor-/Listenansicht erhalten bleibt.
|
||||
- `Umfrage veröffentlichen`
|
||||
- Eigene Aktion in der Liste
|
||||
- Nach Veröffentlichung disabled und textlich als veröffentlicht markiert
|
||||
- Kreis grau/grün
|
||||
- `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
|
||||
- Wichtig: Die Titel-Kollisionsprüfung (`SurveyEditorService::buildDuplicateTitle`) prüft **ohne** Ausschluss der Quell-ID gegen `titleExists`. Sonst würde beim Duplizieren einer bereits `... - Kopie`-Umfrage erneut derselbe Titel vergeben statt `... - Kopie 2` (früherer Bug).
|
||||
- Aktiv-Umschalter (grauer/grüner Punkt **mit Textlabel** „Aktiv"/„Deaktiviert")
|
||||
- Schaltet `isActive`
|
||||
- Ist bewusst kein Auge mehr
|
||||
- Das Textlabel wurde ergänzt, weil der reine Punkt ohne Beschriftung für den Kunden unklar war (`survey-visibility-toggle` in `_survey_assets.html.twig`).
|
||||
- Bei `isActive = 0` ist der öffentliche Reader-Link deaktiviert: `SurveyRepository::findByPublicAlias` verlangt `published = 1` UND `isActive = 1`, sonst liefert der Reader die Leer-/Hinweisseite.
|
||||
|
||||
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
|
||||
|
||||
@@ -138,12 +170,23 @@ Aktueller Aufbau:
|
||||
- Fragen im Frontend nicht mehr änderbar
|
||||
- Metadaten im Frontend ebenfalls nicht mehr bearbeitbar
|
||||
|
||||
Ungespeicherte Änderungen (bewusst kein Auto-Save):
|
||||
|
||||
- Jedes Editor-Formular (`data-survey-dirty-form`: Metadaten, jede Frage, neue Frage) trackt clientseitig, ob sich der Inhalt gegenüber dem Ladezustand geändert hat, und blendet den roten „Ungespeicherte Änderungen"-Hinweis ein.
|
||||
- Zusätzlich gibt es einen `beforeunload`-Handler (`_survey_assets.html.twig`), der beim Verlassen der Seite (Link, Zurück, Tab schließen) warnt, sobald mindestens ein Formular ungespeicherte Änderungen hat.
|
||||
- Der Handler wird nur registriert, wenn dirty-getrackte Formulare existieren (also nur im Editor, nicht in Liste/Reader/Ergebnissen).
|
||||
- Ein bewusstes Absenden (Speichern oder eine Aktion) setzt ein `surveyIsSubmitting`-Flag über einen globalen `submit`-Listener, damit der normale Speicher-Flow **nicht** warnt.
|
||||
- Auto-Save wurde bewusst nicht umgesetzt (getrennte Formulare mit eigenen CSRF-Tokens, Struktur-Sperre nach Teilnahmen/Veröffentlichung). Der Dirty-Hinweis plus die `beforeunload`-Warnung sind der gewählte, risikoarme Kompromiss.
|
||||
|
||||
Wichtige Entscheidung:
|
||||
|
||||
- Die Header-Badges im Fragen-Akkordeon wurden bewusst entfernt.
|
||||
- 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 +211,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
|
||||
|
||||
@@ -196,9 +246,21 @@ Aktuelle Ergebnislogik:
|
||||
- Optionsstatistik plus Kreisdiagramm
|
||||
- `range`
|
||||
- Minimum, Maximum, Durchschnitt plus Verteilung und Linienchart
|
||||
- **Diagramm** (`distribution`): deckt die **komplette Skala** von `rangeMin` bis `rangeMax` in `rangeStep`-Schritten ab (inkl. Werte ohne Antworten), damit die X-Achse linear und vollständig ist. Antworten außerhalb der konfigurierten Grenzen erweitern die Skala. Siehe `SurveyResultsViewService::buildRangeScale`.
|
||||
- **Werte-Liste/Tabelle** (`distributionRows`): zeigt bewusst nur die **tatsächlich beantworteten** Werte (aufsteigend). So bläht sich die Ergebnisseite bei großen Skalen (z. B. Alter 1–100 mit Schrittweite 1) nicht mit hunderten Nullzeilen auf. Die vollständige Skala bleibt trotzdem im Diagramm sichtbar. Online-Liste, PDF-Tabelle und die SVG-/Chart.js-Diagramme greifen entsprechend auf `distributionRows` (Liste) bzw. `distribution` (Diagramm) zu.
|
||||
- Bei großen Skalen werden die X-Achsen-Beschriftungen ausgedünnt (Chart.js `autoSkip`/`maxTicksLimit`, SVG jede n-te Beschriftung) und die Punkt-Marker verkleinert bzw. ab >40 Punkten weggelassen. Die PDF-Range-Tabelle hat keine Farb-Swatch-Spalte mehr (nur Wert/Anzahl/Anteil), weil die Kopplung an ein Vollskalen-Diagramm nicht mehr sinnvoll ist.
|
||||
- `text`
|
||||
- Kein Pie-Chart
|
||||
- Stattdessen Response-Summary und Liste der Freitextantworten
|
||||
- Gruppierte Freitextantworten: jede unterschiedliche Antwort **einmal** mit exakter Häufigkeit (`responseSummary`, häufigste zuerst, kein Top-5-Limit). Prozentangaben und die frühere Doppel-Auflistung wurden bewusst entfernt.
|
||||
|
||||
Pro Frage werden statt einer reinen Prozent-Rücklaufquote absolute Zahlen ausgewiesen:
|
||||
|
||||
- `totalAnswers` und `completedSubmissionCount` als „X von Y beantwortet".
|
||||
- `skippedCount` (= `completedSubmissionCount - totalAnswers`) als „Z übersprungen", nur wenn > 0. So sind per Sprungregel übersprungene Fragen sichtbar, statt überall die volle Teilnehmerzahl zu zeigen.
|
||||
|
||||
Reihenfolge:
|
||||
|
||||
- Die Frageergebnisse werden **vor** dem Finalisieren nach `_sorting` sortiert. Wichtig: `finalizeQuestionResult` entfernt die internen `_sorting`-Werte – würde man erst danach sortieren (früherer Bug), fiele die Reihenfolge auf den alphabetischen Fallback zurück. Online-Ansicht, PDF und Excel folgen dadurch derselben Frage-Sortierung.
|
||||
|
||||
Wichtig:
|
||||
|
||||
@@ -222,6 +284,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
|
||||
|
||||
@@ -256,6 +325,8 @@ Wichtige Eigenschaft:
|
||||
|
||||
- Die Spalten orientieren sich an den aktuell vorhandenen Fragen im Survey.
|
||||
- Die Zeilen enthalten nur abgeschlossene Submissions.
|
||||
- Über den Antwortspalten steht eine eigene **Fragetext-Zeile** (`F1: <Fragetext>`, `F2: ...`). Je Frage werden die Zellen dieser Zeile über alle zugehörigen Antwortspalten zusammengeführt (`mergeCells`). Zuvor fehlte der Fragetext im Excel komplett – der Kunde sah nur `F1 ja`, `F1 nein` usw. Reihenfolge: Titel (Zeile 1), Fragetexte (Zeile 2), Spaltenköpfe (Zeile 3), Daten ab Zeile 4; Freeze-Pane auf `A4`.
|
||||
- Diagramme gibt es im Excel bewusst nicht (dafür ist der PDF-Export da).
|
||||
|
||||
## PDF-Export
|
||||
|
||||
@@ -305,6 +376,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 +410,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 +452,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 +495,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 {
|
||||
@@ -408,30 +440,51 @@
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.survey-visibility-dot {
|
||||
width: 2.2rem;
|
||||
height: 2.2rem;
|
||||
.survey-visibility-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
padding: 0.35rem 0.9rem 0.35rem 0.4rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(162, 168, 180, 0.62);
|
||||
background: #7f8793;
|
||||
border: 1px solid rgba(162, 168, 180, 0.5);
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
color: var(--survey-gray-dark);
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.18s ease, color 0.18s ease;
|
||||
}
|
||||
|
||||
.survey-visibility-toggle.is-on {
|
||||
border-color: rgba(10, 131, 46, 0.45);
|
||||
color: #0a6e28;
|
||||
}
|
||||
|
||||
.survey-visibility-dot {
|
||||
aspect-ratio: 1;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
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;
|
||||
flex-shrink: 0;
|
||||
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-visibility-label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.survey-button-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.65rem;
|
||||
}
|
||||
|
||||
.survey-button-row--form-actions {
|
||||
@@ -444,6 +497,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 +761,11 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.survey-accordion-header-row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.survey-accordion-toggle {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
@@ -821,6 +924,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 +1011,10 @@
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.survey-button-row--navigation.has-back {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.survey-list-publish-help {
|
||||
left: 0;
|
||||
right: auto;
|
||||
@@ -1009,6 +1166,9 @@
|
||||
});
|
||||
});
|
||||
|
||||
var surveyDirtyChecks = [];
|
||||
var surveyIsSubmitting = false;
|
||||
|
||||
document.querySelectorAll('[data-survey-dirty-form]').forEach(function (form) {
|
||||
var formId = form.getAttribute('id') || '';
|
||||
var hint = form.querySelector('[data-survey-dirty-hint]');
|
||||
@@ -1017,10 +1177,6 @@
|
||||
hint = document.querySelector('[data-survey-dirty-hint-for="' + formId + '"]');
|
||||
}
|
||||
|
||||
if (!hint) {
|
||||
return;
|
||||
}
|
||||
|
||||
var serializeForm = function () {
|
||||
var entries = [];
|
||||
var formData = new FormData(form);
|
||||
@@ -1034,25 +1190,61 @@
|
||||
|
||||
var initialState = serializeForm();
|
||||
|
||||
var isDirty = function () {
|
||||
return serializeForm() !== initialState;
|
||||
};
|
||||
|
||||
surveyDirtyChecks.push(isDirty);
|
||||
|
||||
var updateDirtyState = function () {
|
||||
hint.hidden = serializeForm() === initialState;
|
||||
};
|
||||
|
||||
var markDirty = function () {
|
||||
updateDirtyState();
|
||||
if (hint) {
|
||||
hint.hidden = !isDirty();
|
||||
}
|
||||
};
|
||||
|
||||
if (hint) {
|
||||
hint.hidden = true;
|
||||
}
|
||||
|
||||
form.addEventListener('input', markDirty);
|
||||
form.addEventListener('change', markDirty);
|
||||
form.addEventListener('input', updateDirtyState);
|
||||
form.addEventListener('change', updateDirtyState);
|
||||
form.addEventListener('reset', function () {
|
||||
window.setTimeout(function () {
|
||||
if (hint) {
|
||||
hint.hidden = true;
|
||||
}
|
||||
}, 0);
|
||||
});
|
||||
});
|
||||
|
||||
if (surveyDirtyChecks.length > 0) {
|
||||
// Ein bewusstes Absenden (Speichern oder eine Aktion) darf nicht warnen.
|
||||
document.addEventListener('submit', function () {
|
||||
surveyIsSubmitting = true;
|
||||
}, true);
|
||||
|
||||
window.addEventListener('beforeunload', function (event) {
|
||||
if (surveyIsSubmitting) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
var hasUnsavedChanges = surveyDirtyChecks.some(function (isDirty) {
|
||||
return isDirty();
|
||||
});
|
||||
|
||||
if (!hasUnsavedChanges) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Moderne Browser zeigen einen generischen Hinweis; der Text wird
|
||||
// ignoriert, muss aber gesetzt werden, damit der Dialog erscheint.
|
||||
event.preventDefault();
|
||||
event.returnValue = '';
|
||||
|
||||
return '';
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof handorgel === 'function') {
|
||||
document.querySelectorAll('[data-survey-handorgel]').forEach(function (element) {
|
||||
var accordion = new handorgel(element, {
|
||||
@@ -1072,6 +1264,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' %}
|
||||
{% include '@Survey/frontend/_survey_branding.html.twig' %}
|
||||
{% if not templatesOnly|default(false) %}
|
||||
{% include '@Survey/frontend/_survey_branding.html.twig' %}
|
||||
{% endif %}
|
||||
|
||||
{% set moduleHeadline = (headline is iterable ? headline.text|default('') : headline|default(''))|striptags|trim %}
|
||||
{% set 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 %}
|
||||
@@ -55,6 +72,15 @@
|
||||
{% if survey.publicUrl and survey.published %}
|
||||
<a class="survey-button secondary" href="{{ survey.publicUrl }}" target="_blank" rel="noreferrer">{{ 'survey.list.public_view'|trans }}</a>
|
||||
{% endif %}
|
||||
{% if survey.copyUrl and survey.published %}
|
||||
<button
|
||||
type="button"
|
||||
class="survey-button secondary"
|
||||
data-copy-text="{{ survey.copyUrl }}"
|
||||
data-copy-label="{{ 'survey.list.copy_link'|trans }}"
|
||||
data-copy-success="{{ 'survey.list.copy_link_done'|trans }}"
|
||||
>{{ 'survey.list.copy_link'|trans }}</button>
|
||||
{% endif %}
|
||||
{% if survey.draftUrl and not survey.published %}
|
||||
<a class="survey-button secondary" href="{{ survey.draftUrl }}" target="_blank" rel="noreferrer">{{ 'survey.list.draft_view'|trans }}</a>
|
||||
{% endif %}
|
||||
@@ -63,9 +89,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 +106,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 +124,22 @@
|
||||
|
||||
<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>
|
||||
<button type="submit" class="survey-visibility-toggle{{ 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 }}">
|
||||
<span class="survey-visibility-dot{{ survey.isActive ? ' is-on' : '' }}" aria-hidden="true"></span>
|
||||
<span class="survey-visibility-label">{{ survey.isActive ? 'survey.list.active_state'|trans : 'survey.list.inactive_state'|trans }}</span>
|
||||
</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 %}
|
||||
|
||||
@@ -41,8 +41,10 @@
|
||||
<article class="survey-card survey-results-card">
|
||||
<div class="survey-meta">
|
||||
<h3>{{ question.question|striptags|trim }}</h3>
|
||||
<span class="survey-badge neutral">{{ 'survey.results.participations'|trans({'%count%': completedSubmissionCount}) }}</span>
|
||||
<span class="survey-badge neutral">{{ 'survey.results.response_rate'|trans({'%rate%': question.responseRate}) }}</span>
|
||||
<span class="survey-badge neutral">{{ 'survey.results.answered'|trans({'%count%': question.totalAnswers, '%total%': completedSubmissionCount}) }}</span>
|
||||
{% if question.skippedCount > 0 %}
|
||||
<span class="survey-badge neutral">{{ 'survey.results.skipped'|trans({'%count%': question.skippedCount}) }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if question.description|striptags|trim %}
|
||||
@@ -91,11 +93,11 @@
|
||||
</div>
|
||||
|
||||
<div class="survey-results-list">
|
||||
{% for option in question.distribution %}
|
||||
{% for option in question.distributionRows|default([]) %}
|
||||
<div class="survey-results-row">
|
||||
<div class="survey-meta">
|
||||
<strong>{{ option.label|striptags|trim }}</strong>
|
||||
<span class="survey-badge neutral">{{ option.count }}</span>
|
||||
<span class="survey-badge neutral">{{ 'survey.results.answer_count'|trans({'%count%': option.count}) }}</span>
|
||||
<span class="survey-badge neutral">{{ option.percentage }}%</span>
|
||||
</div>
|
||||
<div class="survey-progress">
|
||||
@@ -107,25 +109,13 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
{% if question.responseSummary is defined and question.responseSummary %}
|
||||
<div class="survey-text-summary-grid">
|
||||
{% for option in question.responseSummary %}
|
||||
<article class="survey-text-summary-card">
|
||||
<strong>{{ option.label|striptags|trim }}</strong>
|
||||
<div class="survey-meta">
|
||||
<span class="survey-badge neutral">{{ option.count }}</span>
|
||||
<span class="survey-badge neutral">{{ option.percentage }}%</span>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Eine Liste statt Doppel-Auflistung: jede Antwort einmal,
|
||||
mit exakter Anzahl statt Prozentwerten. #}
|
||||
<div class="survey-text-response-grid survey-results-responses">
|
||||
{% for response in question.responses %}
|
||||
{% for option in question.responseSummary|default([]) %}
|
||||
<article class="survey-card survey-response-card">
|
||||
<span class="survey-response-card__index">{{ loop.index }}</span>
|
||||
<blockquote>{{ response|striptags|trim|nl2br }}</blockquote>
|
||||
<span class="survey-badge neutral">{{ 'survey.results.answer_count'|trans({'%count%': option.count}) }}</span>
|
||||
<blockquote>{{ option.label|striptags|trim|nl2br }}</blockquote>
|
||||
</article>
|
||||
{% else %}
|
||||
<p>{{ 'survey.results.no_text_responses'|trans }}</p>
|
||||
|
||||
@@ -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: 6;
|
||||
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;
|
||||
|
||||
@@ -18,8 +18,10 @@ use Mummert\SurveyBundle\Service\SurveyEditorService;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
|
||||
#[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 +33,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 +49,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 +62,30 @@ 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,
|
||||
// Absolute URL für den "Link kopieren"-Button, damit der geteilte
|
||||
// Link auch außerhalb der Seite funktioniert.
|
||||
'copyUrl' => $readerPage instanceof PageModel ? $this->generateContentUrl($readerPage, ['parameters' => '/'.$alias], UrlGeneratorInterface::ABSOLUTE_URL) : 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 +94,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();
|
||||
|
||||
@@ -223,16 +223,15 @@
|
||||
{% endif %}
|
||||
|
||||
<div class="meta-row">
|
||||
<span class="meta-pill">{{ 'survey.pdf.participations'|trans({'%count%': completedSubmissionCount}) }}</span>
|
||||
<span class="meta-pill">{{ 'survey.pdf.response_rate'|trans({'%rate%': question.responseRate}) }}</span>
|
||||
<span class="meta-pill">{{ 'survey.pdf.answered'|trans({'%count%': question.totalAnswers, '%total%': completedSubmissionCount}) }}</span>
|
||||
{% if question.skippedCount > 0 %}
|
||||
<span class="meta-pill">{{ 'survey.pdf.skipped'|trans({'%count%': question.skippedCount}) }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if question.pdfChartDataUri %}
|
||||
<div class="chart-box chart-{{ question.pdfChartType ?: 'default' }}">
|
||||
<img src="{{ question.pdfChartDataUri }}" alt="Diagramm zu {{ question.question|striptags|trim }}">
|
||||
{% if question.type == 'range' and question.pdfChartLegend %}
|
||||
<div class="chart-hint">{{ 'survey.pdf.chart_hint'|trans }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -264,7 +263,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>
|
||||
@@ -278,61 +276,51 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if question.distributionRows|default([]) %}
|
||||
<table class="stats-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="swatch-col">{{ 'survey.pdf.color'|trans }}</th>
|
||||
<th>{{ 'survey.pdf.value'|trans }}</th>
|
||||
<th class="numeric">{{ 'survey.pdf.count'|trans }}</th>
|
||||
<th class="numeric">{{ 'survey.pdf.share'|trans }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for option in question.pdfChartLegend %}
|
||||
{% for option in question.distributionRows %}
|
||||
<tr>
|
||||
<td><span class="swatch" style="background: {{ option.fillColor }}; border-color: {{ option.borderColor }};"></span></td>
|
||||
<td>{{ option.label|striptags|trim }}</td>
|
||||
<td class="numeric">{{ option.count }}</td>
|
||||
<td class="numeric">{{ option.percentage }}%</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4">{{ 'survey.pdf.no_ratings'|trans }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
{% if question.pdfChartLegend %}
|
||||
<p>{{ 'survey.pdf.no_ratings'|trans }}</p>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{# Eine Tabelle statt Doppel-Auflistung: jede Antwort einmal, mit
|
||||
exakter Anzahl statt Prozentwerten. #}
|
||||
{% if question.responseSummary|default([]) %}
|
||||
<table class="stats-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="swatch-col">{{ 'survey.pdf.color'|trans }}</th>
|
||||
<th>{{ 'survey.pdf.answer_group'|trans }}</th>
|
||||
<th class="numeric">{{ 'survey.pdf.count'|trans }}</th>
|
||||
<th class="numeric">{{ 'survey.pdf.share'|trans }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for option in question.pdfChartLegend %}
|
||||
{% for option in question.responseSummary %}
|
||||
<tr>
|
||||
<td><span class="swatch" style="background: {{ option.fillColor }}; border-color: {{ option.borderColor }};"></span></td>
|
||||
<td>{{ option.label|striptags|trim }}</td>
|
||||
<td class="numeric">{{ option.count }}</td>
|
||||
<td class="numeric">{{ option.percentage }}%</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
<ol class="response-list">
|
||||
{% for response in question.responses %}
|
||||
<li>{{ response|striptags|trim }}</li>
|
||||
{% else %}
|
||||
<li>{{ 'survey.pdf.no_text_responses'|trans }}</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
<p>{{ 'survey.pdf.no_text_responses'|trans }}</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</section>
|
||||
{% else %}
|
||||
|
||||
@@ -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,38 @@ 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);
|
||||
|
||||
// Bewusst ohne Ausschluss der Quelle: Beim Duplizieren einer bereits
|
||||
// "... - Kopie"-Umfrage muss der Quelltitel selbst als vergeben gelten,
|
||||
// sonst entsteht erneut derselbe Titel statt "... - Kopie 2".
|
||||
if (!$this->surveyRepository->titleExists($candidate)) {
|
||||
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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,9 +29,19 @@ final class SurveyResultsExportService
|
||||
*/
|
||||
public function createExcelExportResponse(SurveyModel $survey, array $finishedSubmissions): Response
|
||||
{
|
||||
[$headers, $columns] = $this->buildExportColumns($this->surveyQuestionRepository->findAllBySurvey((int) $survey->id));
|
||||
[$questionSpans, $headers, $columns] = $this->buildExportColumns($this->surveyQuestionRepository->findAllBySurvey((int) $survey->id));
|
||||
|
||||
// Eigene Zeile mit dem Fragetext oberhalb der Antwortspalten; die Zellen
|
||||
// werden je Frage über alle zugehörigen Spalten zusammengeführt.
|
||||
$questionRow = array_fill(0, max(1, count($headers)), '');
|
||||
|
||||
foreach ($questionSpans as $span) {
|
||||
$questionRow[$span['start'] - 1] = $span['label'];
|
||||
}
|
||||
|
||||
$rows = [
|
||||
[(string) $survey->title],
|
||||
$questionRow,
|
||||
$headers,
|
||||
];
|
||||
|
||||
@@ -66,7 +76,7 @@ final class SurveyResultsExportService
|
||||
$rows[] = $row;
|
||||
}
|
||||
|
||||
$response = new Response($this->buildSpreadsheetContent($rows));
|
||||
$response = new Response($this->buildSpreadsheetContent($rows, $questionSpans));
|
||||
$disposition = $response->headers->makeDisposition(
|
||||
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
|
||||
$this->buildExportFilename((string) $survey->title)
|
||||
@@ -84,16 +94,18 @@ final class SurveyResultsExportService
|
||||
/**
|
||||
* @param list<SurveyContentModel> $questions
|
||||
*
|
||||
* @return array{0:list<string>,1:list<array{questionId:int,kind:string,value:string}>}
|
||||
* @return array{0:list<array{label:string,start:int,end:int}>,1:list<string>,2:list<array{questionId:int,kind:string,value:string}>}
|
||||
*/
|
||||
private function buildExportColumns(array $questions): array
|
||||
{
|
||||
$questionSpans = [];
|
||||
$headers = [];
|
||||
$columns = [];
|
||||
|
||||
foreach (array_values($questions) as $index => $question) {
|
||||
$questionNumber = $index + 1;
|
||||
$prefix = 'F'.$questionNumber;
|
||||
$firstColumnIndex = count($headers) + 1;
|
||||
|
||||
switch ((string) $question->type) {
|
||||
case 'yes_no_maybe':
|
||||
@@ -137,9 +149,17 @@ final class SurveyResultsExportService
|
||||
];
|
||||
break;
|
||||
}
|
||||
|
||||
$questionLabel = trim((string) $question->question);
|
||||
|
||||
$questionSpans[] = [
|
||||
'label' => sprintf('%s: %s', $prefix, '' !== $questionLabel ? $questionLabel : 'Frage #'.(int) $question->id),
|
||||
'start' => $firstColumnIndex,
|
||||
'end' => count($headers),
|
||||
];
|
||||
}
|
||||
|
||||
return [$headers, $columns];
|
||||
return [$questionSpans, $headers, $columns];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -179,8 +199,9 @@ final class SurveyResultsExportService
|
||||
|
||||
/**
|
||||
* @param list<list<int|string|float>> $rows
|
||||
* @param list<array{label:string,start:int,end:int}> $questionSpans
|
||||
*/
|
||||
private function buildSpreadsheetContent(array $rows): string
|
||||
private function buildSpreadsheetContent(array $rows, array $questionSpans): string
|
||||
{
|
||||
$spreadsheet = new Spreadsheet();
|
||||
$worksheet = $spreadsheet->getActiveSheet();
|
||||
@@ -200,12 +221,27 @@ final class SurveyResultsExportService
|
||||
}
|
||||
}
|
||||
|
||||
$lastColumn = Coordinate::stringFromColumnIndex(max(1, count($rows[1] ?? $rows[0] ?? [])));
|
||||
// Fragetext-Zellen je Frage über die zugehörigen Antwortspalten zusammenführen.
|
||||
foreach ($questionSpans as $span) {
|
||||
if ($span['end'] > $span['start']) {
|
||||
$worksheet->mergeCells(sprintf(
|
||||
'%s2:%s2',
|
||||
Coordinate::stringFromColumnIndex($span['start']),
|
||||
Coordinate::stringFromColumnIndex($span['end']),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
$columnCount = max(1, count($rows[2] ?? $rows[0] ?? []));
|
||||
$lastColumn = Coordinate::stringFromColumnIndex($columnCount);
|
||||
$worksheet->getStyle('A1')->getFont()->setBold(true)->setSize(14);
|
||||
$worksheet->getStyle('A2:'.$lastColumn.'2')->getFont()->setBold(true);
|
||||
$worksheet->freezePane('A3');
|
||||
$worksheet->getStyle('A2:'.$lastColumn.'2')->getAlignment()->setWrapText(true)->setVertical(\PhpOffice\PhpSpreadsheet\Style\Alignment::VERTICAL_TOP);
|
||||
$worksheet->getRowDimension(2)->setRowHeight(42);
|
||||
$worksheet->getStyle('A3:'.$lastColumn.'3')->getFont()->setBold(true);
|
||||
$worksheet->freezePane('A4');
|
||||
|
||||
for ($column = 1; $column <= max(1, count($rows[1] ?? $rows[0] ?? [])); ++$column) {
|
||||
for ($column = 1; $column <= $columnCount; ++$column) {
|
||||
$worksheet->getColumnDimension(Coordinate::stringFromColumnIndex($column))->setAutoSize(true);
|
||||
}
|
||||
|
||||
|
||||
@@ -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'] : [],
|
||||
@@ -365,7 +361,16 @@ final class SurveyResultsPdfService
|
||||
$xLabels = [];
|
||||
$yGrid = [];
|
||||
|
||||
for ($tick = 0; $tick <= $maxValue; ++$tick) {
|
||||
// Große Skalen (z. B. Alter 1–100): X-Beschriftung ausdünnen und die
|
||||
// Punkt-Marker verkleinern bzw. weglassen, damit das Diagramm lesbar bleibt.
|
||||
$pointCount = count($values);
|
||||
$labelStep = max(1, (int) ceil($pointCount / 12));
|
||||
$lastIndex = $pointCount - 1;
|
||||
$pointRadius = $pointCount > 40 ? 0.0 : ($pointCount > 20 ? 3.0 : 5.5);
|
||||
|
||||
$yStep = max(1, (int) ceil($maxValue / 6));
|
||||
|
||||
for ($tick = 0; $tick <= $maxValue; $tick += $yStep) {
|
||||
$ratio = $maxValue > 0 ? $tick / $maxValue : 0;
|
||||
$y = $paddingTop + $plotHeight - ($ratio * $plotHeight);
|
||||
$yGrid[] = sprintf('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#d7dee8" stroke-width="1" />', $paddingLeft, $y, $width - $paddingRight, $y);
|
||||
@@ -377,9 +382,15 @@ final class SurveyResultsPdfService
|
||||
$y = $paddingTop + $plotHeight - (($value / $maxValue) * $plotHeight);
|
||||
$polylinePoints[] = sprintf('%.2f,%.2f', $x, $y);
|
||||
$areaPolylinePoints[] = sprintf('%.2f,%.2f', $x, $y);
|
||||
$pointCircles[] = sprintf('<circle cx="%.2f" cy="%.2f" r="5.5" fill="%s" stroke="#ffffff" stroke-width="2" />', $x, $y, $this->escapeXml((string) ($pointColors[$index] ?? '#EC7C32')));
|
||||
|
||||
if ($pointRadius > 0.0) {
|
||||
$pointCircles[] = sprintf('<circle cx="%.2f" cy="%.2f" r="%.1f" fill="%s" stroke="#ffffff" stroke-width="2" />', $x, $y, $pointRadius, $this->escapeXml((string) ($pointColors[$index] ?? '#EC7C32')));
|
||||
}
|
||||
|
||||
if (0 === $index % $labelStep || $index === $lastIndex) {
|
||||
$xLabels[] = sprintf('<text x="%.2f" y="%.2f" text-anchor="middle" font-size="11" font-family="DejaVu Sans" fill="#555e68">%s</text>', $x, $height - 16, $this->escapeXml($labels[$index]));
|
||||
}
|
||||
}
|
||||
|
||||
$areaPoints = sprintf(
|
||||
'%.2f,%.2f %s %.2f,%.2f',
|
||||
|
||||
@@ -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,12 @@ final class SurveyResultsViewService
|
||||
(int) ($question->sorting ?? 0),
|
||||
);
|
||||
$results[$key]['_choiceOptions'] = $this->getChoiceOptions($question);
|
||||
$results[$key]['_allowMaybe'] = '1' === (string) $question->allowMaybe;
|
||||
$results[$key]['_rangeConfig'] = 'range' === (string) $question->type ? [
|
||||
'min' => (int) $question->rangeMin,
|
||||
'max' => (int) $question->rangeMax,
|
||||
'step' => max(1, (int) $question->rangeStep),
|
||||
] : null;
|
||||
}
|
||||
|
||||
foreach ($answers as $answer) {
|
||||
@@ -72,7 +73,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(
|
||||
@@ -87,10 +88,9 @@ final class SurveyResultsViewService
|
||||
$results[$key]['_values'][] = (string) ($answer['value'] ?? '');
|
||||
}
|
||||
|
||||
foreach ($results as $key => $result) {
|
||||
$results[$key] = $this->finalizeQuestionResult($result, $completedSubmissionCount);
|
||||
}
|
||||
|
||||
// Erst sortieren, dann finalisieren: finalizeQuestionResult entfernt die
|
||||
// internen _sorting-Werte – ein späteres Sortieren fiele sonst immer auf
|
||||
// den alphabetischen Fallback zurück.
|
||||
uasort($results, static function (array $left, array $right): int {
|
||||
$sortingComparison = ((int) ($left['_sorting'] ?? 0)) <=> ((int) ($right['_sorting'] ?? 0));
|
||||
|
||||
@@ -101,12 +101,18 @@ final class SurveyResultsViewService
|
||||
return strcasecmp((string) ($left['question'] ?? ''), (string) ($right['question'] ?? ''));
|
||||
});
|
||||
|
||||
foreach ($results as $key => $result) {
|
||||
$results[$key] = $this->finalizeQuestionResult($result, $completedSubmissionCount);
|
||||
}
|
||||
|
||||
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 +131,8 @@ final class SurveyResultsViewService
|
||||
'_sorting' => $sorting,
|
||||
'_values' => [],
|
||||
'_choiceOptions' => [],
|
||||
'_allowMaybe' => false,
|
||||
'_rangeConfig' => null,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -140,12 +148,19 @@ final class SurveyResultsViewService
|
||||
$totalAnswers = count($values);
|
||||
|
||||
$result['totalAnswers'] = $totalAnswers;
|
||||
$result['skippedCount'] = max(0, $completedSubmissionCount - $totalAnswers);
|
||||
$result['responseRate'] = $completedSubmissionCount > 0 ? (int) round(($totalAnswers / $completedSubmissionCount) * 100) : 0;
|
||||
|
||||
switch ((string) ($result['type'] ?? 'text')) {
|
||||
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));
|
||||
|
||||
@@ -181,19 +196,36 @@ final class SurveyResultsViewService
|
||||
|
||||
case 'range':
|
||||
$numericValues = array_values(array_map(static fn (string $value): int => (int) $value, $values));
|
||||
$distribution = [];
|
||||
$valueCounts = [];
|
||||
|
||||
foreach ($numericValues as $numericValue) {
|
||||
$label = (string) $numericValue;
|
||||
$distribution[$label] = ($distribution[$label] ?? 0) + 1;
|
||||
$valueCounts[$numericValue] = ($valueCounts[$numericValue] ?? 0) + 1;
|
||||
}
|
||||
|
||||
ksort($distribution, SORT_NATURAL);
|
||||
// Diagramm: die komplette Skala abbilden (inklusive Werte ohne
|
||||
// Antworten), damit die X-Achse linear und vollständig ist.
|
||||
$fullScale = [];
|
||||
|
||||
foreach ($this->buildRangeScale($result['_rangeConfig'] ?? null, $valueCounts) as $scalePoint) {
|
||||
$fullScale[(string) $scalePoint] = $valueCounts[$scalePoint] ?? 0;
|
||||
}
|
||||
|
||||
// Liste/Tabelle: nur tatsächlich beantwortete Werte, aufsteigend.
|
||||
// So bläht sich die Ergebnisseite bei großen Skalen (z. B. Alter
|
||||
// 1–100) nicht mit hunderten Nullzeilen auf. Die vollständige Skala
|
||||
// bleibt weiterhin im Diagramm sichtbar.
|
||||
ksort($valueCounts, SORT_NUMERIC);
|
||||
$answeredRows = [];
|
||||
|
||||
foreach ($valueCounts as $value => $count) {
|
||||
$answeredRows[(string) $value] = $count;
|
||||
}
|
||||
|
||||
$result['minimum'] = [] !== $numericValues ? min($numericValues) : null;
|
||||
$result['maximum'] = [] !== $numericValues ? max($numericValues) : null;
|
||||
$result['average'] = [] !== $numericValues ? round(array_sum($numericValues) / count($numericValues), 2) : null;
|
||||
$result['distribution'] = $this->buildOptionStats($distribution);
|
||||
$result['distribution'] = $this->buildOptionStats($fullScale);
|
||||
$result['distributionRows'] = $this->buildOptionStats($answeredRows);
|
||||
$result['chart'] = $this->buildRangeChartConfig($result['distribution']);
|
||||
break;
|
||||
|
||||
@@ -205,11 +237,71 @@ final class SurveyResultsViewService
|
||||
break;
|
||||
}
|
||||
|
||||
unset($result['_sorting'], $result['_values'], $result['_choiceOptions']);
|
||||
unset($result['_sorting'], $result['_values'], $result['_choiceOptions'], $result['_allowMaybe'], $result['_rangeConfig']);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert alle Skalenpunkte einer Bewertungsfrage. Ohne Skalen-Konfiguration
|
||||
* (z. B. bei gelöschten Fragen, die nur noch als Antwort-Snapshot existieren)
|
||||
* wird auf die tatsächlich vorkommenden Werte zurückgegriffen.
|
||||
*
|
||||
* @param array{min:int,max:int,step:int}|null $rangeConfig
|
||||
* @param array<int, int> $valueCounts
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function buildRangeScale(?array $rangeConfig, array $valueCounts): array
|
||||
{
|
||||
$observedValues = array_keys($valueCounts);
|
||||
$min = $rangeConfig['min'] ?? ([] !== $observedValues ? min($observedValues) : null);
|
||||
$max = $rangeConfig['max'] ?? ([] !== $observedValues ? max($observedValues) : null);
|
||||
$step = max(1, (int) ($rangeConfig['step'] ?? 1));
|
||||
|
||||
if (null === $min || null === $max) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Antworten außerhalb der konfigurierten Grenzen (nachträglich geänderte
|
||||
// Skala) nicht verschlucken, sondern die Skala entsprechend erweitern.
|
||||
if ([] !== $observedValues) {
|
||||
$min = min($min, min($observedValues));
|
||||
$max = max($max, max($observedValues));
|
||||
}
|
||||
|
||||
if ($max < $min) {
|
||||
[$min, $max] = [$max, $min];
|
||||
}
|
||||
|
||||
// Schutz vor unbrauchbar dichten Achsen bei extremen Skalen.
|
||||
if (($max - $min) / $step > 100) {
|
||||
sort($observedValues);
|
||||
|
||||
return array_values($observedValues);
|
||||
}
|
||||
|
||||
$scale = [];
|
||||
|
||||
for ($point = $min; $point <= $max; $point += $step) {
|
||||
$scale[] = $point;
|
||||
}
|
||||
|
||||
if ([] === $scale || $max !== end($scale)) {
|
||||
$scale[] = $max;
|
||||
}
|
||||
|
||||
foreach ($observedValues as $observedValue) {
|
||||
if (!\in_array($observedValue, $scale, true)) {
|
||||
$scale[] = $observedValue;
|
||||
}
|
||||
}
|
||||
|
||||
sort($scale);
|
||||
|
||||
return array_values($scale);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
@@ -321,6 +413,11 @@ final class SurveyResultsViewService
|
||||
|
||||
$pointStyles = ['circle', 'rectRounded', 'triangle', 'star', 'rectRot', 'crossRot'];
|
||||
$palette = $this->buildChartPalette(count($distribution));
|
||||
$pointCount = count($distribution);
|
||||
|
||||
// Bei großen Skalen (z. B. Alter 1–100) die Punkt-Marker verkleinern bzw.
|
||||
// ausblenden, damit sich die Linie nicht in überlappenden Punkten verliert.
|
||||
$pointRadius = $pointCount > 40 ? 0 : ($pointCount > 20 ? 3 : 7);
|
||||
|
||||
return [
|
||||
'type' => 'line',
|
||||
@@ -333,10 +430,10 @@ final class SurveyResultsViewService
|
||||
'backgroundColor' => 'rgba(236, 124, 50, 0.12)',
|
||||
'pointBackgroundColor' => array_column($palette, 'border'),
|
||||
'pointBorderColor' => '#ffffff',
|
||||
'pointBorderWidth' => 3,
|
||||
'pointBorderWidth' => $pointRadius > 0 ? 3 : 0,
|
||||
'pointHoverBorderWidth' => 3,
|
||||
'pointRadius' => 7,
|
||||
'pointHoverRadius' => 9,
|
||||
'pointRadius' => $pointRadius,
|
||||
'pointHoverRadius' => $pointRadius > 0 ? $pointRadius + 2 : 4,
|
||||
'pointStyle' => array_map(
|
||||
static fn (int $index): string => $pointStyles[$index % count($pointStyles)],
|
||||
array_keys($distribution)
|
||||
@@ -371,6 +468,11 @@ final class SurveyResultsViewService
|
||||
],
|
||||
'ticks' => [
|
||||
'color' => '#555E68',
|
||||
// Achsenbeschriftung bei großen Skalen ausdünnen, damit
|
||||
// sich die Zahlen nicht überlappen.
|
||||
'autoSkip' => true,
|
||||
'maxTicksLimit' => 12,
|
||||
'maxRotation' => 0,
|
||||
'font' => [
|
||||
'family' => 'PT Sans Narrow',
|
||||
'weight' => '600',
|
||||
@@ -419,26 +521,11 @@ final class SurveyResultsViewService
|
||||
$counts[$label] = ($counts[$label] ?? 0) + 1;
|
||||
}
|
||||
|
||||
// Alle unterschiedlichen Antworten mit exakter Häufigkeit zeigen (häufigste
|
||||
// zuerst) – kein Top-5-Limit mit Sammelposten mehr.
|
||||
arsort($counts);
|
||||
$summary = [];
|
||||
$remainingCount = 0;
|
||||
$index = 0;
|
||||
|
||||
foreach ($counts as $label => $count) {
|
||||
if ($index < 5) {
|
||||
$summary[$label] = $count;
|
||||
} else {
|
||||
$remainingCount += $count;
|
||||
}
|
||||
|
||||
++$index;
|
||||
}
|
||||
|
||||
if ($remainingCount > 0) {
|
||||
$summary['Weitere Antworten'] = $remainingCount;
|
||||
}
|
||||
|
||||
return $this->buildOptionStats($summary);
|
||||
return $this->buildOptionStats($counts);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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?"
|
||||
@@ -14,6 +16,10 @@ survey:
|
||||
participations: "%count% Teilnahmen"
|
||||
edit: "Bearbeiten"
|
||||
public_view: "Öffentliche Ansicht"
|
||||
copy_link: "Link kopieren"
|
||||
copy_link_done: "Link kopiert!"
|
||||
active_state: "Aktiv"
|
||||
inactive_state: "Deaktiviert"
|
||||
draft_view: "Entwurfs-Ansicht"
|
||||
show_results: "Ergebnisse anzeigen"
|
||||
publish: "Umfrage veröffentlichen"
|
||||
@@ -78,6 +84,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 +112,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"
|
||||
@@ -119,6 +127,9 @@ survey:
|
||||
download_excel: "Ergebnisse als Excel herunterladen"
|
||||
download_pdf: "Ergebnisse als PDF herunterladen"
|
||||
answers: "%count% Antworten"
|
||||
answered: "%count% von %total% beantwortet"
|
||||
skipped: "%count% übersprungen"
|
||||
answer_count: "%count%×"
|
||||
response_rate: "%rate%% Rücklauf"
|
||||
minimum: "Minimum"
|
||||
maximum: "Maximum"
|
||||
@@ -133,6 +144,8 @@ survey:
|
||||
questions: "%count% Fragen"
|
||||
export_prefix: "Export %date%"
|
||||
answers: "%count% Antworten"
|
||||
answered: "%count% von %total% beantwortet"
|
||||
skipped: "%count% übersprungen"
|
||||
response_rate: "%rate%% Rücklauf"
|
||||
minimum: "Minimum"
|
||||
maximum: "Maximum"
|
||||
|
||||
@@ -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?"
|
||||
@@ -14,6 +16,10 @@ survey:
|
||||
participations: "%count% participations"
|
||||
edit: "Edit"
|
||||
public_view: "Public view"
|
||||
copy_link: "Copy link"
|
||||
copy_link_done: "Link copied!"
|
||||
active_state: "Active"
|
||||
inactive_state: "Deactivated"
|
||||
draft_view: "Draft view"
|
||||
show_results: "Show results"
|
||||
publish: "Publish survey"
|
||||
@@ -78,6 +84,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 +112,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"
|
||||
@@ -119,6 +127,9 @@ survey:
|
||||
download_excel: "Download results as Excel"
|
||||
download_pdf: "Download results as PDF"
|
||||
answers: "%count% answers"
|
||||
answered: "%count% of %total% answered"
|
||||
skipped: "%count% skipped"
|
||||
answer_count: "%count%×"
|
||||
response_rate: "%rate%% response rate"
|
||||
minimum: "Minimum"
|
||||
maximum: "Maximum"
|
||||
@@ -133,6 +144,8 @@ survey:
|
||||
questions: "%count% questions"
|
||||
export_prefix: "Export %date%"
|
||||
answers: "%count% answers"
|
||||
answered: "%count% of %total% answered"
|
||||
skipped: "%count% skipped"
|
||||
response_rate: "%rate%% response rate"
|
||||
minimum: "Minimum"
|
||||
maximum: "Maximum"
|
||||
|
||||
Reference in New Issue
Block a user