11 Commits

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 13:23:11 +02:00
Jürgen Mummert db7094fd1e Fix frontend CSRF, clean reader URLs, and review findings
CSRF / actions:
- List action forms now send both REQUEST_TOKEN (Contao gate) and _token
  (Symfony, validated via isCsrfTokenValid) — mirrors the editor; fixes
  the 400/500 "Ungültiger CSRF-Token" on publish/toggle/duplicate/delete

Public reader / URLs:
- Reader & results read auto_item via Input::get (marks the route param used)
  so clean path URLs /<page>/<alias> no longer 404
- List controller generates path URLs via ['parameters' => '/'.$alias]
- Allow re-participation in the same session after a 60s cooldown

Review fixes:
- Results: merge answer snapshots by questionId+type (no more split cards on
  label edits); seed "Vielleicht" when allowMaybe so screen matches Excel
- Reader: replace generic answer hint with per-option "weiter zu Frage X"
  only when a jump is configured; strip HTML tags from survey description
- PDF: render free-text frequency summary; drop duplicate "Minimum" label
- Drag-sort: bundle SortableJS locally, drop the duplicated inline sort CSS/JS
  from _survey_assets, throttle the backend MutationObserver
- Backend: move hardcoded tl_survey operation labels to TL_LANG (de+en)
- Remove dead code: SurveyEditorVoter, SurveyPermissionService,
  categoryFilter/categorySummary + helpers, orphaned publishSurvey lang key

Reader layout:
- max-width 1000px (other views keep 1400px); back/next buttons fill the
  width as equal, card-sized halves; sort handle icon offset tweak

Docs: update AI_HANDOVER (dual-token CSRF, clean URLs) and README

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 13:00:05 +02:00
Jürgen Mummert 1b16675ae9 Add survey question drag-sort and backend assets
- Frontend editor: drag-to-sort questions with dedicated sort assets/JS
- Backend list/search refinements for tl_survey and tl_survey_content
- Repository helpers for survey/question/editor queries
- Translations and AI handover notes update
2026-06-14 09:13:07 +02:00
Jürgen Mummert 79c4402aee Fix survey reader back flow 2026-06-08 20:14:57 +02:00
Jürgen Mummert e3553e1108 Differentiate survey template shell 2026-06-08 20:05:58 +02:00
Jürgen Mummert 99caf51af9 Refine survey template module layout 2026-06-08 20:01:08 +02:00
Jürgen Mummert 7843fca003 Add survey template list module 2026-06-08 19:45:20 +02:00
Jürgen Mummert 9cc2f98456 Adjust survey action alignment 2026-06-08 19:23:14 +02:00
Jürgen Mummert d44f3be571 Add survey duplication actions 2026-06-08 14:06:32 +02:00
Jürgen Mummert 97a302a985 Document frontend/backend publication trade-off 2026-06-07 22:32:48 +02:00
45 changed files with 1362 additions and 408 deletions
+9
View File
@@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(git add *)",
"Bash(git commit -q -m ' *)",
"Bash(git push *)"
]
}
}
+99
View File
@@ -40,6 +40,9 @@ Die Umfrage arbeitet mit drei relevanten Zuständen in `tl_survey`:
- Keine Fragenbearbeitung nach Veröffentlichung
- Keine Metadatenbearbeitung nach Veröffentlichung
- Keine Fragenbearbeitung nach ersten Teilnahmen
- Die Frontend-Sperre für veröffentlichte Umfragen soll bewusst nicht tief im System verankert sein.
- Für Redakteure genügt das Ausblenden bzw. Deaktivieren der relevanten Buttons und Formulare im Frontend.
- Es ist ausdrücklich keine zusätzliche harte Systembarriere gewünscht, solange die Redakteursoberfläche den Bearbeitungspfad sauber schließt.
- Backend ist bewusst toleranter:
- Ein einzelner Admin darf im Notfall weiter arbeiten
@@ -47,12 +50,20 @@ Die Umfrage arbeitet mit drei relevanten Zuständen in `tl_survey`:
- `published` ist im Backend editierbar
- `isActive` ist im Backend per Contao-Toggle schaltbar
- Bewusster Trade-off zwischen Frontend und Backend:
- Ein veröffentlichter Survey soll für Frontend-Redakteure praktisch nicht mehr bearbeitbar sein.
- Im Backend darf der Admin trotz Veröffentlichung weiterhin Änderungen vornehmen, zum Beispiel für Rechtschreibfehler oder kleine Korrekturen.
- Dadurch bleibt bewusst das Risiko bestehen, dass der Admin auch Fragen oder Strukturen ändert, obwohl bereits Antworten vorliegen.
- Mögliche Folgen sind verwässerte oder fachlich falsche Ergebnisseiten sowie Inkonsistenzen, wenn bestehende Antworten nicht mehr sauber zur später geänderten Struktur passen.
- Dieses Risiko wird bewusst akzeptiert und organisatorisch über Briefing/Disziplin des Admins bzw. der zuständigen Redakteure abgefangen, nicht über eine harte technische Sperre im Backend.
Diese Entscheidung ist absichtlich so getroffen und kein Bug.
## Datenmodell und wichtige Tabellen
- `tl_survey`
- Umfrage-Stammdaten und Statusfelder
- Enthält zusätzlich die backend-only gepflegte `internalNote`
- `tl_survey_content`
- Fragen der Umfrage
- `tl_survey_condition`
@@ -105,10 +116,22 @@ Aktuelle Button- und Sichtbarkeitslogik pro Umfrage:
- `Umfrage veröffentlichen`
- Eigene Aktion in der Liste
- Nach Veröffentlichung disabled und textlich als veröffentlicht markiert
- `Duplizieren`
- Legt eine Kopie der Umfrage für den Redakteur an
- Titelvergabe ist fortlaufend: `Titel - Kopie`, danach `Titel - Kopie 2`, `Titel - Kopie 3` usw.
- Bestehende `- Kopie`-Suffixe werden fachlich normalisiert, damit keine Titel wie `... - Kopie - Kopie` mehr neu entstehen
- Kreis grau/grün
- Schaltet `isActive`
- Ist bewusst kein Auge mehr
Wichtiger technischer Punkt (CSRF):
- Jedes Frontend-Aktionsformular (Liste **und** Editor) sendet bewusst **zwei** Tokens:
- `REQUEST_TOKEN` (`{{ contao.request_token }}`) nötig für Contaos globalen Frontend-POST-Gate; fehlt er, lehnt Contao den POST schon vor dem Controller mit HTTP 400 ab.
- `_token` (`{{ csrf_token('<aktion>-' ~ id) }}`) wird im Controller mit `isCsrfTokenValid('<aktion>-'.$id, $token)` geprüft (session-basierter Symfony-Token).
- Beide sind erforderlich. Frühere Stände hatten nur einen davon das führte zu „Ungültiger CSRF-Token" (500) bzw. Bad Request (400). Der Listen-Controller spiegelt jetzt exakt die Token-Mechanik des Editors.
- Der Contao-`REQUEST_TOKEN` allein reicht **nicht** als Controller-Prüfung: Er ist cookie-gebunden und bei anonymen/fragmentierten Frontend-Requests nicht stabil (Contao setzt das CSRF-Cookie dort teils gar nicht). Deshalb die zusätzliche session-basierte `_token`-Prüfung.
### 2. Frontend-Editor
Controller:
@@ -144,6 +167,9 @@ Wichtige Entscheidung:
- Typ, Pflicht usw. sollen nicht schon im Header gezeigt werden.
- Die Details sieht man erst nach dem Aufklappen.
- Die Sprungziel-Dropdowns im Frontend zeigen Positionsnummern der Umfrage (`1. ...`, `2. ...`) statt globaler Datenbank-IDs.
- Die aktuell bearbeitete Frage wird dort nicht als eigenes Sprungziel angeboten.
### 3. Öffentliche Umfrageansicht
Controller:
@@ -168,6 +194,13 @@ Wichtige Regeln:
- Entwurfsansicht arbeitet ohne Speicherung
- Öffentliche Ansicht speichert Antworten und erstellt/führt Submission fort
- Rückwärtsnavigation ist implementiert
- Nach Abschluss zeigt der gleiche Browser-Token für eine kurze Cooldown-Zeit (`RESTART_COOLDOWN_SECONDS = 60`) die Danke-Seite; danach darf dieselbe Session erneut teilnehmen (`SurveySubmissionService::isRestartAllowed`).
Saubere Link-URLs (Reader und Ergebnisseite):
- Die Umfrage wird über den Alias als **Pfad-Segment** adressiert: `/<reader-seite>/<alias>` statt `?auto_item=<alias>`.
- URL-Erzeugung: per Route-Platzhalter `['parameters' => '/'.$alias]` (Frontend-Liste `MemberSurveyListController`, Backend `SurveyDcaListener::buildSurveyPageUrl`), nicht `['auto_item' => $alias]`.
- Auslesen im Controller: `$this->getContaoAdapter(Input::class)->get('auto_item')`. Wichtig: `Input::get` markiert den Route-Parameter als **benutzt** sonst wirft Contaos `LegacyRouteParametersListener` eine 404 wegen „unbenutzter Parameter". Ein reines `$request->query->get('auto_item')` reicht für Pfad-URLs nicht.
### 4. Ergebnisseite
@@ -222,6 +255,13 @@ Reihenfolge der Navigation:
Wichtiger Detailpunkt:
- In der Sprungregelübersicht im Frontend-Editor werden bewusst die Positionsnummern innerhalb der Umfrage angezeigt, nicht die globalen Datenbank-IDs der Fragen.
- Gleiches gilt für die Sprungziel-Dropdowns in Backend und Frontend.
Wichtige technische Eigenschaft:
- `jumpOnYes`, `jumpOnNo` und generische Bedingungen referenzieren Ziel-Fragen intern immer über die Frage-ID, nicht über die Position.
- Dadurch bleiben Sprünge bei einer reinen Umordnung der Fragen fachlich stabil an dieselbe Frage gebunden.
- Wenn eine Ziel-Frage an eine andere Position verschoben wird, zeigt die Oberfläche danach automatisch die neue Positionsnummer an.
## Excel-Export
@@ -305,6 +345,26 @@ Wichtige Listenoperationen:
- `Link kopieren`
- `Ergebnisse anzeigen`
Aktuelle Listen- und Filterlogik:
- Die mehrspaltige Umfragenliste zeigt aktuell:
- `Titel`
- `Zugewiesene Benutzer`
- `Vorlage`
- `Interne Notiz`
- `Kategorien` wurden bewusst aus der mehrspaltigen Liste und aus der rechten Filterspalte entfernt.
- Die Spalte `Zugewiesene Benutzer` wird in der Listenmetadatenlogik gekürzt:
- maximal 3 Namen direkt sichtbar
- danach Zusatz wie `... und X weitere`
- `internalNote`
- backend-only Feld in `tl_survey`
- nur im Backend bearbeitbar
- in der mehrspaltigen Liste sichtbar
- bei Duplikaten wird der Wert mitkopiert
- In der rechten Suchspalte ist `Interne Notiz` suchbar.
- `ID` wurde für die Umfragenliste bewusst aus dem Suchfeld entfernt.
- Das geschieht über einen kleinen backend-spezifischen JS-Workaround, weil Contao `id` im Core für `DC_Table` hart ergänzt.
Aktuelle Backend-Entscheidung:
- Das Warnsymbol genügt als Hinweis bei veröffentlichten Umfragen
@@ -319,6 +379,21 @@ Aktuelle Backend-Entscheidung:
Hinweis:
- `isLocked` wird in den Child-Headerfeldern nicht mehr angezeigt, weil der Backend-Lock bewusst keine führende Benutzerfunktion mehr ist.
- In `tl_survey_content` ist der Fragenlisten-Header zusätzlich reduziert:
- nur noch `Titel` und `Veröffentlicht`
- kein öffentlicher Link-Schlüssel mehr
- die rechte Header-Leiste mit Aktionen ist im Normalzustand ausgeblendet
- im aktiven Verschiebe-/Clipboard-Modus wird sie wieder eingeblendet, damit `Neu oben` bzw. die Einfügepositionen erreichbar bleiben
Fragenliste `tl_survey_content`:
- Direkt sichtbare Primäraktion pro Frage:
- `Bearbeiten`-Stift
- Verschieben:
- erfolgt im Backend über den normalen Contao-Paste-/Move-Flow
- die Aktion `Verschieben` liegt bewusst nur unter den drei Punkten
- dort wird der Contao-Pfeil `mover.svg` verwendet, nicht die blaue Schere
- nach Aktivierung des Clipboard-Zustands erscheinen die üblichen Einfügeziele wie `Nach dem Element ... einfügen`
### Systemeinstellungen
@@ -346,6 +421,7 @@ Wichtige Settings:
- `published` reversibel erlaubt
- `isActive` per Standard-Contao-Toggle
- nur Warnsymbol statt Backend-Sperre
- Admin darf Fragen auch nach Veröffentlichung oder nach vorhandenen Antworten umsortieren
## Wichtige Dateien nach Themengebiet
@@ -388,12 +464,35 @@ Backend:
- `ddev exec php bin/console lint:yaml ...`
- `ddev exec php bin/console cache:clear`
## Offene technische Punkte / Deployment-Hinweise
- Neue DB-Spalte in `tl_survey`:
- `internalNote`
- Typ aktuell: `varchar(255) NOT NULL default ''`
- Für andere Umgebungen muss diese Spalte per Migration/SQL vorhanden sein, sonst fehlen Backend-Feld, Listenanzeige und Suchfunktion fachlich bzw. führen zu Inkonsistenzen.
- Nach Änderungen an DCA, Backend-CSS oder Backend-JS ist in dieser Installation typischerweise nötig:
- `php vendor/bin/contao-console assets:install public --symlink --relative`
- `php vendor/bin/contao-console cache:clear --env=prod`
- Es gibt aktuell zwei bewusst eingebaute backend-spezifische UI-Workarounds über Bundle-Assets:
- In der Umfragenliste wird `ID` aus dem Suchfeld per JS entfernt, weil Contao das Feld im Core von `DC_Table` hart ergänzt.
- In der Fragenliste (`table=tl_survey_content`) wird die rechte Parent-Header-Leiste per CSS/JS im Normalzustand ausgeblendet und nur im aktiven Clipboard-/Verschiebe-Modus wieder eingeblendet.
- Lokale DDEV-Besonderheit, die bei Fehleranalyse relevant sein kann:
- Das Bundle läuft hier als Path-Repository/Symlink.
- Wenn Backend-Übersetzungen oder Assets trotz korrektem Code unerwartet fehlen, zuerst Bundle-Pfad, veröffentlichte Assets und Prod-Cache prüfen.
## Bekannte bewusste Trade-offs
- Backend kann veröffentlichte Umfragen technisch weiterhin verändern.
- Das ist bewusst akzeptiert, weil nur ein Admin arbeitet.
- `isLocked` bleibt als internes Logikfeld bestehen, obwohl der Backend-Lock nicht mehr durchgesetzt wird.
- PDF-Export hängt von Gotenberg ab und ist absichtlich optional.
- Eine reine Umordnung von Fragen ist für Sprungziele logisch unkritisch, weil Sprünge an Frage-IDs hängen.
- Ergebnisse vorhandener, weiterhin existierender Fragen folgen bei der Anzeige jedoch der aktuellen Sortierung der Frage.
- Historische Antwort-Snapshots speichern zwar `questionSorting`, die Ergebnisansicht sortiert vorhandene Fragen aber primär nach der aktuellen Frage-Sortierung.
- Das ist im toleranten Backend-Modell ein bewusst akzeptierter Side-Effect.
## Empfohlene Einstiegspunkte für künftige Änderungen
+16
View File
@@ -6,6 +6,22 @@ Das Bundle stellt Frontend- und Backend-Funktionen für die Erstellung, Verwaltu
Ziel des Bundles ist eine möglichst native Integration in Contao, sodass Umfragen sich wie ein regulärer Bestandteil der bestehenden System- und Redaktionsumgebung verhalten.
## 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 -1
View File
@@ -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;
+1
View File
@@ -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
View File
@@ -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&amp;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'],
+8 -1
View File
@@ -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&amp;mode=cut',
'method' => 'POST',
'icon' => 'mover.svg',
'attributes' => 'data-action="contao--scroll-offset#store"',
],
'copy' => [
'href' => 'act=copy',
+1
View File
@@ -5,6 +5,7 @@ $GLOBALS['TL_LANG']['MOD']['survey_bundle_categories'] = ['Umfrage-Kategorien',
$GLOBALS['TL_LANG']['FMD']['survey'] = 'Umfragen';
$GLOBALS['TL_LANG']['FMD']['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.'];
+8 -3
View File
@@ -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.'];
+1
View File
@@ -5,6 +5,7 @@ $GLOBALS['TL_LANG']['MOD']['survey_bundle_categories'] = ['Survey categories', '
$GLOBALS['TL_LANG']['FMD']['survey'] = 'Surveys';
$GLOBALS['TL_LANG']['FMD']['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.'];
+8 -3
View File
@@ -3,14 +3,13 @@
$GLOBALS['TL_LANG']['tl_survey']['title'] = ['Title', 'Title of the survey.'];
$GLOBALS['TL_LANG']['tl_survey']['alias'] = ['Public link key', 'Automatically generated as a 20-character link key.'];
$GLOBALS['TL_LANG']['tl_survey']['category'] = ['Categories', 'Assigns the survey to one or more categories.'];
$GLOBALS['TL_LANG']['tl_survey']['categoryFilter'] = ['Categories', 'Filters the list by a survey category.'];
$GLOBALS['TL_LANG']['tl_survey']['categorySummary'] = ['Categories', 'Assigned categories of the survey.'];
$GLOBALS['TL_LANG']['tl_survey']['isTemplate'] = ['Survey is a template', 'Shown to assigned users as a template.'];
$GLOBALS['TL_LANG']['tl_survey']['templateFilter'] = ['Template', 'Filters the list by template status.'];
$GLOBALS['TL_LANG']['tl_survey']['templateSummary'] = ['Template', 'Shows whether the survey is marked as a template.'];
$GLOBALS['TL_LANG']['tl_survey']['template_yes'] = 'Yes';
$GLOBALS['TL_LANG']['tl_survey']['template_no'] = 'No';
$GLOBALS['TL_LANG']['tl_survey']['description'] = ['Description', 'Introduction for the editor and the frontend.'];
$GLOBALS['TL_LANG']['tl_survey']['internalNote'] = ['Internal note', 'Internal note for the survey, visible in the backend only.'];
$GLOBALS['TL_LANG']['tl_survey']['title_legend'] = 'Basic data';
$GLOBALS['TL_LANG']['tl_survey']['assignment_legend'] = 'Editing members';
$GLOBALS['TL_LANG']['tl_survey']['publishing_legend'] = 'Visibility';
@@ -18,14 +17,20 @@ $GLOBALS['TL_LANG']['tl_survey']['meta_legend'] = 'Administrative data';
$GLOBALS['TL_LANG']['tl_survey']['published'] = ['Published', 'Make the survey publicly accessible.'];
$GLOBALS['TL_LANG']['tl_survey']['isActive'] = ['Temporarily active', 'Turns an already published survey on or off publicly.'];
$GLOBALS['TL_LANG']['tl_survey']['isLocked'] = ['Locked', 'Structure is locked because answers already exist.'];
$GLOBALS['TL_LANG']['tl_survey']['publishSurvey'] = ['Publish survey', 'Publishes the survey permanently.'];
$GLOBALS['TL_LANG']['tl_survey']['publishedWarning'] = ['Publication warning', 'Shows a warning hint for already published surveys.'];
$GLOBALS['TL_LANG']['tl_survey']['toggleActive'] = ['Toggle visibility', 'Temporarily switches the published survey on or off.'];
$GLOBALS['TL_LANG']['tl_survey']['assignedMembers'] = ['Assigned members', 'Choose the members who are allowed to edit this survey.'];
$GLOBALS['TL_LANG']['tl_survey']['assignedMembersFilter'] = ['Assigned users', 'Filters the list by an assigned user.'];
$GLOBALS['TL_LANG']['tl_survey']['assignedMembersSummary'] = ['Assigned users', 'Comma-separated list of assigned users.'];
$GLOBALS['TL_LANG']['tl_survey']['copyPublicLink'] = ['Copy link', 'Copy the public link to the clipboard'];
$GLOBALS['TL_LANG']['tl_survey']['duplicateSurvey'] = ['Duplicate', 'Duplicate this survey with all questions'];
$GLOBALS['TL_LANG']['tl_survey']['duplicateSurveyConfirm'] = 'Are you sure you want to duplicate the survey "%s" with all questions?';
$GLOBALS['TL_LANG']['tl_survey']['duplicateSurveyDone'] = 'The survey "%s" has been duplicated.';
$GLOBALS['TL_LANG']['tl_survey']['showResults'] = ['Show results', 'Display the results of this survey in the frontend'];
$GLOBALS['TL_LANG']['tl_survey']['editheader'] = ['Edit survey', 'Edit survey'];
$GLOBALS['TL_LANG']['tl_survey']['edit'] = ['Edit questions', 'Edit the survey questions'];
$GLOBALS['TL_LANG']['tl_survey']['delete'] = ['Delete', 'Really delete the entry?'];
$GLOBALS['TL_LANG']['tl_survey']['show'] = ['Details', 'Show the details of entry ID %s'];
$GLOBALS['TL_LANG']['tl_survey']['createdBy'] = ['Created by', 'ID of the creating member.'];
$GLOBALS['TL_LANG']['tl_survey']['updatedBy'] = ['Updated by', 'ID of the member who last edited it.'];
$GLOBALS['TL_LANG']['tl_survey']['createdAt'] = ['Created on', 'Creation time.'];
@@ -82,6 +82,20 @@
border-radius: 2rem;
box-shadow: 0 28px 80px rgba(34, 34, 34, 0.08);
padding: clamp(1.5rem, 2.2vw, 2.4rem);
margin-bottom: 4em;
}
.survey-shell--reader {
max-width: 1000px;
margin-left: max(var(--survey-page-gutter), calc((100% - 1000px) / 2));
margin-right: max(var(--survey-page-gutter), calc((100% - 1000px) / 2));
}
.survey-shell--template {
background:
radial-gradient(circle at top left, rgba(236, 124, 50, 0.18), transparent 32%),
radial-gradient(circle at bottom right, rgba(157, 82, 118, 0.12), transparent 36%),
linear-gradient(180deg, rgba(252, 228, 214, 0.92) 0%, rgba(255, 250, 246, 0.98) 52%, rgba(252, 228, 214, 0.78) 100%);
}
.survey-shell h2,
@@ -287,6 +301,13 @@
align-items: center;
}
.survey-list-card__head--template {
align-items: baseline;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.75rem;
}
.survey-list-card__headline {
display: flex;
align-items: center;
@@ -296,6 +317,12 @@
flex: 1 1 auto;
}
.survey-list-card__headline--template {
align-items: baseline;
justify-content: flex-start;
flex: 1 1 auto;
}
.survey-list-card__title-wrap {
min-width: 0;
}
@@ -317,19 +344,24 @@
.survey-list-card__head-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
flex: 0 0 auto;
margin-left: 1rem;
}
.survey-list-card__head-actions--template {
align-items: baseline;
margin-left: auto;
}
.survey-list-card__footer {
display: flex;
align-items: center;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
margin-top: .3em;
}
.survey-list-card__stats {
@@ -409,29 +441,25 @@
}
.survey-visibility-dot {
width: 2.2rem;
height: 2.2rem;
aspect-ratio: 1;
height: 42px;
border-radius: 999px;
border: 1px solid rgba(162, 168, 180, 0.62);
background: #7f8793;
background: #a6adb7;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease;
transition: background 0.18s ease;
}
.survey-visibility-dot.is-on {
background: #0a8378;
border-color: rgba(0, 174, 151, 0.68);
box-shadow: 0 0 0 0.18rem rgba(0, 174, 151, 0.14);
background: #0a832e;
}
.survey-button-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.65rem;
}
.survey-button-row--form-actions {
@@ -444,6 +472,51 @@
align-items: center;
}
.survey-button-row--navigation {
width: 100%;
justify-content: flex-end;
align-items: center;
margin-top: 1rem;
}
.survey-button-row--navigation.has-back {
justify-content: space-between;
}
.survey-navigation-form {
margin: 0;
}
.survey-navigation-form--back {
margin: 0;
}
.survey-navigation-submit {
margin-left: auto;
}
/* Reader: Zurück/Weiter füllen die volle Breite und sind so groß wie die Ja/Nein-Karten */
.survey-shell--reader .survey-button-row--navigation {
display: grid;
grid-template-columns: 1fr;
gap: 1.5rem;
}
.survey-shell--reader .survey-button-row--navigation.has-back {
grid-template-columns: 1fr 1fr;
}
.survey-shell--reader .survey-navigation-form--back {
width: 100%;
}
.survey-shell--reader .survey-button-row--navigation .survey-button {
width: 100%;
margin: 0;
padding: 1.25rem;
border-radius: 1.5rem;
}
.survey-publish-toggle-form {
margin: 0;
}
@@ -663,6 +736,11 @@
margin: 0;
}
.survey-accordion-header-row {
display: flex;
align-items: stretch;
}
.survey-accordion-toggle {
display: flex;
width: 100%;
@@ -821,6 +899,56 @@
color: var(--survey-green);
}
.survey-range-block {
gap: 1rem;
}
.survey-range-value-card {
display: grid;
place-items: center;
gap: 0.2rem;
width: min(100%, 8.5rem);
min-height: 8.5rem;
margin-inline: auto;
padding: 1rem;
border-radius: 50%;
background: radial-gradient(circle at top left, rgba(0, 100, 173, 0.12), rgba(255, 255, 255, 0.96) 62%, rgba(236, 124, 50, 0.16) 100%);
border: 1px solid rgba(0, 100, 173, 0.16);
box-shadow: 0 10px 24px rgba(34, 34, 34, 0.08);
text-align: center;
}
.survey-range-value-label {
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--survey-gray-dark);
}
.survey-range-scale {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
width: 100%;
}
.survey-range-boundary {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2.75rem;
min-height: 2.75rem;
padding: 0.45rem 0.7rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.82);
border: 1px solid rgba(0, 100, 173, 0.14);
color: var(--survey-blue);
font-weight: 700;
box-shadow: 0 6px 16px rgba(34, 34, 34, 0.05);
}
@media (min-width: 960px) {
.survey-grid-columns {
grid-template-columns: 0.95fr 1.05fr;
@@ -858,6 +986,10 @@
align-items: flex-start;
}
.survey-button-row--navigation.has-back {
justify-content: space-between;
}
.survey-list-publish-help {
left: 0;
right: auto;
@@ -1072,6 +1204,7 @@
});
}
resetEditorViewport();
});
</script>
@@ -0,0 +1,119 @@
<style>
.survey-accordion-header-row {
display: flex;
align-items: stretch;
}
.survey-accordion-sort-handle {
position: relative;
flex: 0 0 auto;
min-width: 3.5rem;
padding: 0 1rem;
border: 0;
border-left: 1px solid rgba(162, 168, 180, 0.18);
background: transparent;
cursor: grab;
transition: background 0.2s ease, color 0.2s ease;
}
.survey-accordion-sort-handle:active {
cursor: grabbing;
}
.survey-accordion-item .handorgel__header__button:hover + .survey-accordion-sort-handle,
.survey-accordion-item .handorgel__header__button:focus + .survey-accordion-sort-handle,
.survey-accordion-item .handorgel__header--opened .survey-accordion-sort-handle,
.survey-accordion-sort-handle:hover,
.survey-accordion-sort-handle:focus {
background: var(--survey-blue);
color: #fff;
}
.survey-sort-icon {
position: relative;
display: inline-flex;
width: 0.9rem;
height: 1.2rem;
align-items: center;
justify-content: center;
color: #58a6da;
background-image:
linear-gradient(currentColor, currentColor),
linear-gradient(currentColor, currentColor),
linear-gradient(currentColor, currentColor);
background-repeat: no-repeat;
background-position: center 0.36rem, center center, center calc(100% - 0.36rem);
background-size: 0.78rem 2px, 0.78rem 2px, 0.78rem 2px;
}
.survey-sort-icon::before,
.survey-sort-icon::after {
content: '';
position: absolute;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 0.24rem solid transparent;
border-right: 0.24rem solid transparent;
}
.survey-sort-icon::before {
top: 0;
border-bottom: 0.24rem solid currentColor;
}
.survey-sort-icon::after {
bottom: 0;
border-top: 0.24rem solid currentColor;
}
.survey-question-order-save-shell {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
z-index: 40;
transform: translate(120%, 0);
opacity: 0;
pointer-events: none;
transition: transform 0.26s ease, opacity 0.26s ease;
}
.survey-question-order-save-shell.is-visible {
transform: translate(0, 0);
opacity: 1;
pointer-events: auto;
}
.survey-question-order-save {
box-shadow: 0 18px 40px rgba(0, 59, 102, 0.24);
}
.survey-accordion-item.is-reordering {
box-shadow: 0 20px 44px rgba(0, 59, 102, 0.18);
}
.survey-accordion-item.sortable-ghost {
opacity: 0.55;
}
@media (max-width: 767px) {
.survey-accordion-sort-handle {
min-width: 3.1rem;
padding: 0 0.8rem;
}
.survey-question-order-save-shell {
top: auto;
right: 0.85rem;
bottom: 0.85rem;
transform: translate(0, 130%);
}
.survey-question-order-save-shell.is-visible {
transform: translate(0, 0);
}
}
</style>
<script src="{{ asset('bundles/survey/js/sortable.min.js') }}"></script>
<script src="{{ asset('bundles/survey/js/survey-frontend-editor-sort.js') }}"></script>
@@ -1,6 +1,7 @@
{% trans_default_domain 'messages' %}
{% include '@Survey/frontend/_survey_assets.html.twig' %}
{% include '@Survey/frontend/_survey_question_sort_assets.html.twig' %}
<link rel="stylesheet" href="{{ asset('assets/handorgel/css/handorgel.min.css') }}">
{% include '@Survey/frontend/_survey_branding.html.twig' %}
@@ -47,6 +48,7 @@
{% else %}
{% set surveyMetaFormId = 'survey-meta-form' %}
{% set newQuestionFormId = 'survey-question-form-new' %}
{% set canQuestionReorder = canQuestionReorder|default(false) %}
<div class="survey-button-row survey-button-row--editor-actions">
{% if backUrl %}<a class="survey-button secondary" href="{{ backUrl }}">{{ 'survey.edit.back_to_list'|trans }}</a>{% endif %}
@@ -74,14 +76,25 @@
<div class="survey-grid-columns survey-editor-layout">
<div class="survey-question-editor-shell" data-survey-question-editor>
<div class="survey-accordion-list handorgel" data-survey-handorgel>
{% if canQuestionReorder %}
<form method="post" action="{{ metadataFormActionUrl }}" class="survey-question-order-save-shell" data-survey-question-order-shell hidden>
<input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}">
<input type="hidden" name="_survey_action" value="reorder-questions">
<input type="hidden" name="item_id" value="{{ survey.id }}">
<input type="hidden" name="_token" value="{{ csrf_token('reorder-questions-' ~ survey.id) }}">
<input type="hidden" name="question_order" value="" data-survey-question-order-input>
<button class="survey-button primary survey-question-order-save">{{ 'survey.edit.save_question_order'|trans }}</button>
</form>
{% endif %}
<div class="survey-accordion-list handorgel" data-survey-handorgel{% if canQuestionReorder %} data-survey-question-sortable="1"{% endif %}>
{% for question in questions %}
{% set questionAnchor = 'survey-question-' ~ question.id %}
{% set isOpen = activeQuestionId == question.id %}
{% set questionItemForm = questionForms[question.id]|default(null) %}
{% set questionFormId = 'survey-question-form-' ~ question.id %}
<article id="{{ questionAnchor }}" class="survey-accordion-item">
<article id="{{ questionAnchor }}" class="survey-accordion-item"{% if canQuestionReorder %} data-survey-question-item data-question-id="{{ question.id }}"{% endif %}>
<h4 class="survey-accordion-header handorgel__header">
<div class="survey-accordion-header-row">
<button
type="button"
class="survey-accordion-toggle handorgel__header__button"
@@ -93,6 +106,18 @@
</span>
</span>
</button>
{% if canQuestionReorder %}
<button type="button" class="survey-accordion-sort-handle" data-survey-question-sort-handle aria-label="{{ 'survey.edit.reorder_question'|trans }}" title="{{ 'survey.edit.reorder_question'|trans }}">
<span class="survey-sort-icon" aria-hidden="true">
<svg viewBox="0 0 18 24" role="presentation" focusable="false">
<path d="M9 3 11.6 5.8H6.4z" fill="currentColor"></path>
<path d="M9 21 6.4 18.2h5.2z" fill="currentColor"></path>
<path d="M4.5 12h9" stroke="currentColor" stroke-linecap="round" stroke-width="1.8"></path>
</svg>
</span>
</button>
{% endif %}
</div>
</h4>
<div class="survey-accordion-panel handorgel__content">
<div class="handorgel__content__inner" data-survey-question-editor>
@@ -1,11 +1,14 @@
{% trans_default_domain 'messages' %}
{% include '@Survey/frontend/_survey_assets.html.twig' %}
{% 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 %}
@@ -63,9 +80,16 @@
{% endif %}
<form method="post" class="survey-question-action-form">
<input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}">
<input type="hidden" name="_token" value="{{ csrf_token('duplicate-survey-' ~ survey.id) }}">
<input type="hidden" name="_survey_action" value="duplicate-survey">
<input type="hidden" name="item_id" value="{{ survey.id }}">
<button class="survey-button secondary" onclick="return confirm('{{ 'survey.list.duplicate_confirm'|trans({'%title%': survey.title|striptags|trim})|e('js') }}');">{{ 'survey.list.duplicate'|trans }}</button>
</form>
<form method="post" class="survey-question-action-form">
<input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}">
<input type="hidden" name="_token" value="{{ csrf_token('delete-survey-' ~ survey.id) }}">
<input type="hidden" name="_survey_action" value="delete-survey">
<input type="hidden" name="item_id" value="{{ survey.id }}">
<input type="hidden" name="_token" value="{{ csrf_token('delete-survey-' ~ survey.id) }}">
<button class="survey-button danger" onclick="return confirm('{{ 'survey.list.delete_confirm'|trans|e('js') }}') && confirm('{{ 'survey.list.delete_confirm_second'|trans|e('js') }}');">{{ 'survey.list.delete'|trans }}</button>
</form>
</div>
@@ -73,9 +97,9 @@
<div class="survey-list-card__head-actions">
<form method="post" class="survey-list-publish-form">
<input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}">
<input type="hidden" name="_token" value="{{ csrf_token('publish-survey-' ~ survey.id) }}">
<input type="hidden" name="_survey_action" value="publish-survey">
<input type="hidden" name="item_id" value="{{ survey.id }}">
<input type="hidden" name="_token" value="{{ csrf_token('publish-survey-' ~ survey.id) }}">
<button
type="submit"
class="survey-button primary survey-list-publish-button"
@@ -91,16 +115,19 @@
<form method="post" class="survey-list-eye-form">
<input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}">
<input type="hidden" name="_token" value="{{ csrf_token('toggle-active-' ~ survey.id) }}">
<input type="hidden" name="_survey_action" value="toggle-active">
<input type="hidden" name="item_id" value="{{ survey.id }}">
<input type="hidden" name="_token" value="{{ csrf_token('toggle-active-' ~ survey.id) }}">
<button type="submit" class="survey-visibility-dot{{ survey.isActive ? ' is-on' : '' }}" title="{{ survey.isActive ? 'survey.edit.deactivate_survey'|trans : 'survey.edit.activate_survey'|trans }}" aria-label="{{ survey.isActive ? 'survey.edit.deactivate_survey'|trans : 'survey.edit.activate_survey'|trans }}"></button>
</form>
</div>
</div>
{% endif %}
</article>
{% else %}
{% if not templatesOnly %}
<div class="survey-card">{{ 'survey.list.empty'|trans }}</div>
{% endif %}
{% endfor %}
</div>
{% endif %}
+27 -21
View File
@@ -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 %}
+1
View File
@@ -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 {
+45
View File
@@ -0,0 +1,45 @@
.tl_checkbox_wizard.survey-assigned-members-checkbox-wizard {
column-count: 4;
column-gap: 1.5rem;
}
.tl_checkbox_wizard.survey-assigned-members-checkbox-wizard > span {
display: block;
break-inside: avoid;
margin-bottom: 0.35rem;
}
.tl_checkbox_wizard.survey-assigned-members-checkbox-wizard .drag-handle {
display: none;
}
.tl_checkbox_wizard.survey-assigned-members-checkbox-wizard label {
display: inline;
white-space: normal;
}
body.survey-question-list-page .parent_view > .tl_header .tl_content_right {
display: none;
}
body.survey-question-list-page.survey-question-list-clipboard-active .parent_view > .tl_header .tl_content_right {
display: block;
}
@media (max-width: 1400px) {
.tl_checkbox_wizard.survey-assigned-members-checkbox-wizard {
column-count: 6;
}
}
@media (max-width: 1100px) {
.tl_checkbox_wizard.survey-assigned-members-checkbox-wizard {
column-count: 4;
}
}
@media (max-width: 767px) {
.tl_checkbox_wizard.survey-assigned-members-checkbox-wizard {
column-count: 2;
}
}
+2
View File
File diff suppressed because one or more lines are too long
+136
View File
@@ -0,0 +1,136 @@
(function () {
function isSurveyListPage() {
var params = new URLSearchParams(window.location.search);
return params.get('do') === 'survey_bundle_surveys';
}
function isSurveyQuestionListPage() {
var params = new URLSearchParams(window.location.search);
return params.get('do') === 'survey_bundle_surveys' && params.get('table') === 'tl_survey_content' && !params.get('act');
}
function toggleQuestionListClass() {
document.body.classList.toggle('survey-question-list-page', isSurveyQuestionListPage());
document.body.classList.toggle('survey-question-list-clipboard-active', isSurveyQuestionListPage() && hasQuestionListClipboardState());
}
function hasQuestionListClipboardState() {
if (!isSurveyQuestionListPage()) {
return false;
}
if (document.querySelector('#paste_hint')) {
return true;
}
return Array.from(document.querySelectorAll('button')).some(function (button) {
return (button.textContent || '').trim() === 'Ablage leeren';
});
}
function getSearchSelect() {
return document.querySelector('select[name="tl_search"]');
}
function getSearchForm(select) {
return select ? select.closest('form') : null;
}
function removeIdOption(select) {
if (!select) {
return;
}
var idOption = select.querySelector('option[value="id"]');
if (!idOption) {
return;
}
var wasSelected = idOption.selected;
idOption.remove();
if (wasSelected) {
var fallback = select.querySelector('option[value="title"]') || select.options[0];
if (fallback) {
fallback.selected = true;
select.value = fallback.value;
select.dispatchEvent(new Event('change', { bubbles: true }));
}
}
}
function removeIdChoicesUi() {
document.querySelectorAll('.choices__item[data-value="id"]').forEach(function (node) {
node.remove();
});
document.querySelectorAll('.choices__item--choice[data-value="id"]').forEach(function (node) {
node.remove();
});
}
function normalizeSearchField() {
toggleQuestionListClass();
if (!isSurveyListPage()) {
return;
}
var select = getSearchSelect();
if (!select) {
return;
}
removeIdOption(select);
removeIdChoicesUi();
var form = getSearchForm(select);
if (form && !form.dataset.surveySearchNormalized) {
form.dataset.surveySearchNormalized = '1';
form.addEventListener('submit', function () {
if (select.value === 'id') {
var fallback = select.querySelector('option[value="title"]') || select.options[0];
if (fallback) {
select.value = fallback.value;
}
}
});
}
}
document.addEventListener('DOMContentLoaded', normalizeSearchField);
document.addEventListener('turbo:load', normalizeSearchField);
// Mutationen werden gebündelt: viele DOM-Änderungen lösen nur einen
// normalizeSearchField-Lauf pro Frame aus statt einen pro Mutation.
var scheduled = false;
var scheduleNormalize = function () {
if (scheduled) {
return;
}
scheduled = true;
var run = function () {
scheduled = false;
normalizeSearchField();
};
if (typeof window.requestAnimationFrame === 'function') {
window.requestAnimationFrame(run);
} else {
window.setTimeout(run, 100);
}
};
var observer = new MutationObserver(scheduleNormalize);
observer.observe(document.documentElement, { childList: true, subtree: true });
})();
+110
View File
@@ -0,0 +1,110 @@
(function () {
function ensureSortIconStyles() {
if (document.getElementById('survey-question-sort-icon-theme')) {
return;
}
var style = document.createElement('style');
style.id = 'survey-question-sort-icon-theme';
style.textContent = [
'.survey-sort-icon{width:1.14rem !important;height:1.52rem !important;color:#58a6da !important;background:none !important;transform:translateY(1px) !important;}',
'.survey-sort-icon::before,.survey-sort-icon::after{content:none !important;}',
'.survey-sort-icon svg{display:block !important;width:100% !important;height:100% !important;transform:translateY(5px);}',
'.survey-sort-icon svg path:first-child{transform:translateY(1.4px);transform-box:fill-box;transform-origin:center;}',
'.survey-sort-icon svg path:nth-child(2){transform:translateY(-1.4px);transform-box:fill-box;transform-origin:center;}',
'.survey-sort-icon svg path[stroke]{stroke-width:2px;}'
].join('');
document.head.appendChild(style);
}
function initSurveyQuestionSorting() {
ensureSortIconStyles();
if (typeof Sortable !== 'function') {
return;
}
var sortableList = document.querySelector('[data-survey-question-sortable]');
var orderForm = document.querySelector('[data-survey-question-order-shell]');
var orderInput = document.querySelector('[data-survey-question-order-input]');
if (!sortableList || !orderForm || !orderInput || sortableList._surveyQuestionSortable) {
return;
}
var getOrderedIds = function () {
return Array.from(sortableList.querySelectorAll('[data-survey-question-item]')).map(function (item) {
return item.getAttribute('data-question-id') || '';
}).filter(function (value) {
return value !== '';
});
};
var initialOrder = getOrderedIds().join(',');
var updateOrderField = function () {
orderInput.value = getOrderedIds().join(',');
};
var updateQuestionIndices = function () {
sortableList.querySelectorAll('[data-survey-question-item]').forEach(function (item, index) {
var indexNode = item.querySelector('.survey-accordion-index');
if (indexNode) {
indexNode.textContent = (index + 1) + '.';
}
});
};
var updateSaveVisibility = function () {
var currentOrder = getOrderedIds().join(',');
var isDirty = currentOrder !== initialOrder;
orderForm.hidden = !isDirty;
orderForm.classList.toggle('is-visible', isDirty);
updateOrderField();
};
sortableList.querySelectorAll('[data-survey-question-sort-handle]').forEach(function (handle) {
handle.addEventListener('click', function (event) {
event.preventDefault();
event.stopPropagation();
});
});
updateQuestionIndices();
updateOrderField();
updateSaveVisibility();
sortableList._surveyQuestionSortable = Sortable.create(sortableList, {
animation: 180,
draggable: '[data-survey-question-item]',
handle: '[data-survey-question-sort-handle]',
ghostClass: 'sortable-ghost',
forceFallback: true,
fallbackTolerance: 3,
onStart: function (event) {
if (event.item) {
event.item.classList.add('is-reordering');
}
},
onEnd: function (event) {
if (event.item) {
event.item.classList.remove('is-reordering');
}
updateQuestionIndices();
updateSaveVisibility();
}
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initSurveyQuestionSorting, { once: true });
} else {
initSurveyQuestionSorting();
}
window.addEventListener('load', initSurveyQuestionSorting, { once: true });
})();
@@ -188,6 +188,7 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController
$template->set('questionForms', $questionFormViews);
$template->set('newQuestionForm', $newQuestionFormView);
$template->set('questions', $questions);
$template->set('canQuestionReorder', !$structureLocked && count($questions) > 1);
$conditionOverview = $this->surveyConditionRepository->findOverviewBySurvey((int) $survey->id);
$template->set('conditions', $this->buildJumpRuleOverview($questions, $conditionOverview));
@@ -279,6 +280,11 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController
$this->addFlash('success', 'Die Frage wurde gelöscht.');
}
if ('reorder-questions' === $action) {
$this->surveyEditorService->reorderQuestions($survey, $this->parseQuestionOrder($request));
$this->addFlash('success', 'Die neue Reihenfolge der Fragen wurde gespeichert.');
}
if ('delete-condition' === $action) {
$this->surveyEditorService->deleteCondition($survey, $itemId);
$this->addFlash('success', 'Die Bedingung wurde gelöscht.');
@@ -290,6 +296,23 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController
return new RedirectResponse($this->buildEditUrl($model, $request, (int) $survey->id));
}
/**
* @return list<int>
*/
private function parseQuestionOrder(Request $request): array
{
$rawOrder = trim((string) $request->request->get('question_order', ''));
if ('' === $rawOrder) {
throw new \RuntimeException('Die neue Reihenfolge der Fragen fehlt.');
}
return array_values(array_filter(
array_map('intval', explode(',', $rawOrder)),
static fn (int $questionId): bool => $questionId > 0,
));
}
private function resolveActiveQuestion(Request $request, int $surveyId): mixed
{
$questionId = (int) $request->query->get('question', 0);
@@ -317,13 +340,15 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController
private function buildQuestionChoices(int $surveyId, int $excludeQuestionId = 0): array
{
$choices = [];
$position = 1;
foreach ($this->surveyQuestionRepository->findAllBySurvey($surveyId) as $question) {
if ($excludeQuestionId > 0 && (int) $question->id === $excludeQuestionId) {
++$position;
continue;
}
$choices[(int) $question->id] = sprintf('#%d %s', (int) $question->id, (string) $question->question);
$choices[(int) $question->id] = sprintf('%d. %s', $position++, (string) $question->question);
}
return $choices;
@@ -20,6 +20,7 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
#[AsFrontendModule(type: 'member_survey_list', category: 'survey', template: 'member_survey_list')]
#[AsFrontendModule(type: 'member_survey_template_list', category: 'survey', template: 'member_survey_list')]
final class MemberSurveyListController extends AbstractFrontendModuleController
{
public function __construct(
@@ -31,12 +32,14 @@ final class MemberSurveyListController extends AbstractFrontendModuleController
protected function getResponse(FragmentTemplate $template, ModuleModel $model, Request $request): Response
{
$templatesOnly = 'member_survey_template_list' === (string) ($model->type ?? '');
$user = $this->getUser();
if (!$user instanceof FrontendUser) {
$template->set('loginRequired', true);
$template->set('surveys', []);
$template->set('createUrl', null);
$template->set('templatesOnly', $templatesOnly);
return $template->getResponse();
}
@@ -45,12 +48,12 @@ final class MemberSurveyListController extends AbstractFrontendModuleController
return $this->handleAction($request, (int) $user->id);
}
$editPage = $this->resolvePage((int) ($model->surveyEditPage ?? 0));
$editPage = $templatesOnly ? null : $this->resolvePage((int) ($model->surveyEditPage ?? 0));
$readerPage = $this->resolvePage((int) $this->getContaoAdapter(Config::class)->get('surveyReaderPage'));
$resultsPage = $this->resolvePage((int) $this->getContaoAdapter(Config::class)->get('surveyResultsPage'));
$items = [];
foreach ($this->surveyRepository->findEditableByMember((int) $user->id) as $survey) {
foreach ($this->surveyRepository->findEditableByMember((int) $user->id, $templatesOnly) as $survey) {
$surveyId = (int) $survey['id'];
$alias = (string) $survey['alias'];
@@ -58,21 +61,27 @@ final class MemberSurveyListController extends AbstractFrontendModuleController
'id' => $surveyId,
'title' => (string) $survey['title'],
'description' => (string) ($survey['description'] ?? ''),
'isTemplate' => !empty($survey['isTemplate']),
'isActive' => !empty($survey['isActive']),
'isLocked' => !empty($survey['isLocked']),
'published' => !empty($survey['published']),
'questionCount' => (int) ($survey['questionCount'] ?? 0),
'participationCount' => (int) ($survey['participationCount'] ?? 0),
'editUrl' => $editPage instanceof PageModel ? $this->generateContentUrl($editPage, ['survey' => $surveyId]) : null,
'publicUrl' => $readerPage instanceof PageModel ? $this->generateContentUrl($readerPage, ['auto_item' => $alias]) : null,
'draftUrl' => $readerPage instanceof PageModel ? $this->generateContentUrl($readerPage, ['auto_item' => $alias, 'preview' => '1']) : null,
'resultsUrl' => $resultsPage instanceof PageModel ? $this->generateContentUrl($resultsPage, ['auto_item' => $alias]) : null,
'publicUrl' => $readerPage instanceof PageModel ? $this->generateContentUrl($readerPage, ['parameters' => '/'.$alias]) : null,
'draftUrl' => $readerPage instanceof PageModel ? $this->generateContentUrl($readerPage, ['parameters' => '/'.$alias, 'preview' => '1']) : null,
'resultsUrl' => $resultsPage instanceof PageModel ? $this->generateContentUrl($resultsPage, ['parameters' => '/'.$alias]) : null,
];
}
if ($templatesOnly && [] === $items) {
return new Response('');
}
$template->set('loginRequired', false);
$template->set('surveys', $items);
$template->set('createUrl', $editPage instanceof PageModel ? $this->generateContentUrl($editPage, ['create' => '1']) : null);
$template->set('createUrl', !$templatesOnly && $editPage instanceof PageModel ? $this->generateContentUrl($editPage, ['create' => '1']) : null);
$template->set('templatesOnly', $templatesOnly);
return $template->getResponse();
}
@@ -81,15 +90,27 @@ final class MemberSurveyListController extends AbstractFrontendModuleController
{
$surveyId = (int) $request->request->get('item_id', 0);
$action = (string) $request->request->get('_survey_action');
$token = (string) $request->request->get('_token');
$token = (string) $request->request->get('_token', '');
if ($surveyId <= 0 || !$this->isCsrfTokenValid($action.'-'.$surveyId, $token)) {
if ($surveyId <= 0 || '' === $action) {
throw new \RuntimeException('Ungültige Umfrage-Aktion.');
}
// Session-basierter Symfony-Token (wie im Editor): zuverlässig für
// eingeloggte Mitglieder. Der Contao-REQUEST_TOKEN ist cookie-gebunden
// und im Frontend-Fragment-Flow nicht stabil verfügbar.
if (!$this->isCsrfTokenValid($action.'-'.$surveyId, $token)) {
throw new \RuntimeException('Ungültiger CSRF-Token.');
}
$survey = $this->resolveEditableSurvey($surveyId, $memberId);
try {
if ('duplicate-survey' === $action) {
$duplicate = $this->surveyEditorService->duplicateSurvey($survey, $memberId);
$this->addFlash('success', sprintf('Die Umfrage "%s" wurde als Kopie angelegt.', (string) $duplicate->title));
}
if ('publish-survey' === $action) {
$this->surveyEditorService->publishSurvey($survey, $memberId);
$this->addFlash('success', 'Die Umfrage ist jetzt veröffentlicht.');
@@ -7,6 +7,7 @@ namespace Mummert\SurveyBundle\Controller\FrontendModule;
use Contao\CoreBundle\Controller\FrontendModule\AbstractFrontendModuleController;
use Contao\CoreBundle\DependencyInjection\Attribute\AsFrontendModule;
use Contao\CoreBundle\Twig\FragmentTemplate;
use Contao\Input;
use Contao\ModuleModel;
use Mummert\SurveyBundle\Model\SurveyContentModel;
use Mummert\SurveyBundle\Model\SurveyModel;
@@ -126,6 +127,7 @@ final class ShowSurveyController extends AbstractFrontendModuleController
$template->set('survey', $survey);
$template->set('progress', $this->surveyFlowService->getProgress($survey, $question));
$template->set('question', $question);
$template->set('jumpHints', $this->buildJumpHints($survey, $question));
$template->set('surveyForm', $form->createView());
$template->set('isComplete', false);
$template->set('answers', []);
@@ -136,6 +138,30 @@ final class ShowSurveyController extends AbstractFrontendModuleController
return $template->getResponse();
}
/**
* Sprung-Hinweise je Ja/Nein-Option für die aktuelle Frage (nur bei konfigurierter Sprunglogik).
*
* @return array<string, array{position:int,label:string}>
*/
private function buildJumpHints(SurveyModel $survey, SurveyContentModel $question): array
{
if ('yes_no_maybe' !== (string) $question->type) {
return [];
}
$hints = [];
if (null !== $yes = $this->surveyFlowService->describeJumpTarget($survey, (int) ($question->jumpOnYes ?? 0))) {
$hints['yes'] = $yes;
}
if (null !== $no = $this->surveyFlowService->describeJumpTarget($survey, (int) ($question->jumpOnNo ?? 0))) {
$hints['no'] = $no;
}
return $hints;
}
private function buildPreviewResponse(FragmentTemplate $template, SurveyModel $survey, Request $request): Response
{
$history = $this->decodePreviewHistory((string) $request->request->get('preview_history', ''));
@@ -198,6 +224,7 @@ final class ShowSurveyController extends AbstractFrontendModuleController
$template->set('survey', $survey);
$template->set('progress', $this->surveyFlowService->getProgress($survey, $question));
$template->set('question', $question);
$template->set('jumpHints', $this->buildJumpHints($survey, $question));
$template->set('surveyForm', $form->createView());
$template->set('isComplete', false);
$template->set('answers', []);
@@ -265,7 +292,10 @@ final class ShowSurveyController extends AbstractFrontendModuleController
private function resolvePublicAlias(Request $request): string
{
$candidate = $request->attributes->get('auto_item');
// Bei sauberen Pfad-URLs (/seite/<alias>) legt Contao den Wert im Input-Adapter
// ab und markiert ihn als Route-Parameter. Input::get liest ihn aus UND markiert
// ihn als benutzt sonst wirft Contao 404 (unbenutzter Route-Parameter).
$candidate = $this->getContaoAdapter(Input::class)->get('auto_item');
if (\is_string($candidate) && '' !== trim($candidate)) {
return trim($candidate);
@@ -8,6 +8,7 @@ use Contao\Config;
use Contao\CoreBundle\Controller\FrontendModule\AbstractFrontendModuleController;
use Contao\CoreBundle\DependencyInjection\Attribute\AsFrontendModule;
use Contao\CoreBundle\Twig\FragmentTemplate;
use Contao\Input;
use Contao\ModuleModel;
use Mummert\SurveyBundle\Repository\SurveyRepository;
use Mummert\SurveyBundle\Service\SurveyResultsViewService;
@@ -82,7 +83,9 @@ final class ShowSurveyResultsController extends AbstractFrontendModuleController
private function resolvePublicAlias(Request $request): string
{
$candidate = $request->attributes->get('auto_item');
// Bei sauberen Pfad-URLs (/seite/<alias>) liegt der Wert im Contao-Input-Adapter;
// Input::get liest ihn aus und markiert ihn als benutzt (sonst Contao-404).
$candidate = $this->getContaoAdapter(Input::class)->get('auto_item');
if (\is_string($candidate) && '' !== trim($candidate)) {
return trim($candidate);
+109 -50
View File
@@ -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;
}
}
-36
View File
@@ -1,36 +0,0 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Form\Model;
use Mummert\SurveyBundle\Model\SurveyConditionModel;
final class SurveyConditionData
{
public int $sourceQuestion = 0;
public string $answerValue = '';
public int $targetQuestion = 0;
public static function fromModel(SurveyConditionModel $condition): self
{
$data = new self();
$data->sourceQuestion = (int) $condition->sourceQuestion;
$data->answerValue = (string) $condition->answerValue;
$data->targetQuestion = (int) $condition->targetQuestion;
return $data;
}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'sourceQuestion' => $this->sourceQuestion,
'answerValue' => $this->answerValue,
'targetQuestion' => $this->targetQuestion,
];
}
}
+3 -1
View File
@@ -15,7 +15,9 @@ final class SurveyEditorData
{
$data = new self();
$data->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;
}
-48
View File
@@ -1,48 +0,0 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Form;
use Mummert\SurveyBundle\Form\Model\SurveyConditionData;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
final class SurveyConditionEditorType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$questionChoices = $options['question_choices'];
$builder
->add('sourceQuestion', ChoiceType::class, [
'label' => 'Ausgangsfrage',
'choices' => array_flip($questionChoices),
'constraints' => [new NotBlank()],
])
->add('answerValue', TextType::class, [
'label' => 'Antwort',
'help' => 'Bei Ja-Nein-Fragen bitte genau ja oder nein eintragen, bei aktivierter Vielleicht-Option auch vielleicht. Bei Choice-Fragen den exakten Antworttext verwenden, bei Multiple-Choice mehrere Werte mit | trennen.',
'constraints' => [new NotBlank(), new Length(['max' => 255])],
])
->add('targetQuestion', ChoiceType::class, [
'label' => 'Nächste Frage',
'choices' => array_flip($questionChoices),
'constraints' => [new NotBlank()],
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => SurveyConditionData::class,
'question_choices' => [],
]);
}
}
+2 -1
View File
@@ -6,6 +6,7 @@ namespace Mummert\SurveyBundle\Repository;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\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],
);
}
+1 -11
View File
@@ -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', [
+2 -1
View File
@@ -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 = [];
+52 -42
View File
@@ -53,10 +53,32 @@ final class SurveyRepository
return $this->findById((int) $id);
}
public function titleExists(string $title, int $excludeId = 0): bool
{
$qb = $this->connection->createQueryBuilder()
->select('id')
->from('tl_survey')
->where('title = :title')
->setParameter('title', trim($title))
->setMaxResults(1)
;
if ($excludeId > 0) {
$qb
->andWhere('id != :excludeId')
->setParameter('excludeId', $excludeId)
;
}
$id = $qb->executeQuery()->fetchOne();
return false !== $id;
}
/**
* @return list<array<string, mixed>>
*/
public function findEditableByMember(int $memberId): array
public function findEditableByMember(int $memberId, bool $templatesOnly = false): array
{
return $this->connection->fetchAllAssociative(
<<<'SQL'
@@ -65,6 +87,7 @@ final class SurveyRepository
s.title,
s.alias,
s.description,
s.isTemplate,
s.published,
s.isActive,
s.isLocked,
@@ -75,10 +98,11 @@ final class SurveyRepository
INNER JOIN tl_survey_editor e ON COALESCE(NULLIF(e.survey, 0), e.pid) = s.id AND e.member = :member
LEFT JOIN tl_survey_content q ON q.pid = s.id
LEFT JOIN tl_survey_submission sub ON sub.survey = s.id
WHERE s.isTemplate = :isTemplate
GROUP BY s.id
ORDER BY s.title ASC, s.id ASC
SQL,
['member' => $memberId],
['member' => $memberId, 'isTemplate' => $templatesOnly ? '1' : ''],
);
}
@@ -93,9 +117,11 @@ final class SurveyRepository
'alias' => $this->ensurePublicAlias(null),
'category' => serialize($this->normalizeCategoryIds($data['category'] ?? [])),
'description' => (string) ($data['description'] ?? ''),
'internalNote' => trim((string) ($data['internalNote'] ?? '')),
'published' => '',
'isActive' => '',
'isActive' => '1',
'isLocked' => '',
'isTemplate' => !empty($data['isTemplate']) ? '1' : '',
'createdBy' => $memberId,
'updatedBy' => $memberId,
'createdAt' => $now,
@@ -118,6 +144,7 @@ final class SurveyRepository
'alias' => $this->ensurePublicAlias((string) $survey->alias, (int) $survey->id),
'category' => serialize($this->normalizeCategoryIds($data['category'] ?? StringUtil::deserialize($survey->category, true))),
'description' => (string) ($data['description'] ?? $survey->description),
'internalNote' => trim((string) ($data['internalNote'] ?? $survey->internalNote)),
'published' => array_key_exists('published', $data) ? (!empty($data['published']) ? '1' : '') : (string) $survey->published,
'isActive' => array_key_exists('isActive', $data) ? (!empty($data['isActive']) ? '1' : '') : (string) ($survey->isActive ?? ''),
'updatedBy' => $memberId,
@@ -172,8 +199,6 @@ final class SurveyRepository
return;
}
$categoryIds = $this->normalizeCategoryIds($survey->category);
$categoryTitles = $this->fetchCategoryTitles($categoryIds);
$assignedMembers = $this->fetchAssignedMembers($surveyId);
$memberIds = [];
$memberNames = [];
@@ -184,12 +209,10 @@ final class SurveyRepository
}
$this->connection->update('tl_survey', [
'categoryFilter' => implode(',', $categoryIds),
'categorySummary' => implode(', ', $categoryTitles),
'templateFilter' => '1' === (string) $survey->isTemplate ? '1' : '0',
'templateSummary' => '1' === (string) $survey->isTemplate ? 'Ja' : 'Nein',
'assignedMembersFilter' => implode(',', $memberIds),
'assignedMembersSummary' => implode(', ', $memberNames),
'assignedMembersSummary' => $this->buildAssignedMembersSummary($memberNames),
], [
'id' => $surveyId,
]);
@@ -306,39 +329,6 @@ final class SurveyRepository
return false !== $qb->executeQuery()->fetchOne();
}
/**
* @param list<int> $categoryIds
*
* @return list<string>
*/
private function fetchCategoryTitles(array $categoryIds): array
{
if ([] === $categoryIds) {
return [];
}
$placeholders = implode(',', array_fill(0, count($categoryIds), '?'));
$rows = $this->connection->fetchAllAssociative(
sprintf('SELECT id, title FROM tl_survey_category WHERE id IN (%s)', $placeholders),
$categoryIds,
);
$titlesById = [];
foreach ($rows as $row) {
$titlesById[(int) $row['id']] = (string) $row['title'];
}
$titles = [];
foreach ($categoryIds as $categoryId) {
if (isset($titlesById[$categoryId])) {
$titles[] = $titlesById[$categoryId];
}
}
return $titles;
}
/**
* @return list<array{id:int,name:string}>
*/
@@ -383,11 +373,31 @@ final class SurveyRepository
);
return $this->hasListMetadataColumns = !array_diff(
['categoryfilter', 'categorysummary', 'templatefilter', 'templatesummary', 'assignedmembersfilter', 'assignedmemberssummary'],
['templatefilter', 'templatesummary', 'assignedmembersfilter', 'assignedmemberssummary'],
$columnNames,
);
}
/**
* @param list<string> $memberNames
*/
private function buildAssignedMembersSummary(array $memberNames): string
{
if ([] === $memberNames) {
return '';
}
$visibleNames = array_slice($memberNames, 0, 3);
$remainingCount = count($memberNames) - count($visibleNames);
$summary = implode(', ', $visibleNames);
if ($remainingCount <= 0) {
return $summary;
}
return sprintf('%s, ... und %d weitere', $summary, $remainingCount);
}
private function isValidPublicAlias(string $alias): bool
{
return 1 === preg_match('/^[a-z0-9]{20}$/', $alias);
@@ -24,16 +24,6 @@ final class SurveySubmissionRepository
return $model instanceof SurveySubmissionModel ? $model : null;
}
public function findOpenBySurveyAndToken(int $surveyId, string $token): ?SurveySubmissionModel
{
$id = $this->connection->fetchOne(
'SELECT id FROM tl_survey_submission WHERE survey = ? AND token = ? AND isFinished != 1 ORDER BY id DESC LIMIT 1',
[$surveyId, $token],
);
return false === $id ? null : $this->findById((int) $id);
}
public function findBySurveyAndToken(int $surveyId, string $token): ?SurveySubmissionModel
{
$id = $this->connection->fetchOne(
@@ -44,14 +34,6 @@ final class SurveySubmissionRepository
return false === $id ? null : $this->findById((int) $id);
}
public function hasFinishedBySurveyAndToken(int $surveyId, string $token): bool
{
return false !== $this->connection->fetchOne(
'SELECT id FROM tl_survey_submission WHERE survey = ? AND token = ? AND isFinished = 1 ORDER BY id DESC LIMIT 1',
[$surveyId, $token],
);
}
public function create(int $surveyId, string $token, ?int $currentQuestionId): SurveySubmissionModel
{
$now = time();
@@ -264,7 +264,6 @@
{% elseif question.type == 'range' %}
<div class="metric-row">
<div class="metric-box">
<span class="metric-label">Minimum</span>
<span class="metric-label">{{ 'survey.pdf.minimum'|trans({}, 'messages') }}</span>
<span class="metric-value">{{ question.minimum is not null ? question.minimum : '-' }}</span>
</div>
-36
View File
@@ -1,36 +0,0 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Security;
use Contao\FrontendUser;
use Mummert\SurveyBundle\Model\SurveyModel;
use Mummert\SurveyBundle\Service\SurveyPermissionService;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
final class SurveyEditorVoter extends Voter
{
public const string EDIT = 'SURVEY_EDIT';
public function __construct(private readonly SurveyPermissionService $surveyPermissionService)
{
}
protected function supports(string $attribute, mixed $subject): bool
{
return self::EDIT === $attribute && $subject instanceof SurveyModel;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
if (!$user instanceof FrontendUser || !$subject instanceof SurveyModel) {
return false;
}
return $this->surveyPermissionService->canEditSurvey($subject, $user);
}
}
+142 -12
View File
@@ -4,9 +4,10 @@ declare(strict_types=1);
namespace Mummert\SurveyBundle\Service;
use Mummert\SurveyBundle\Form\Model\SurveyConditionData;
use Doctrine\DBAL\Connection;
use Mummert\SurveyBundle\Form\Model\SurveyEditorData;
use Mummert\SurveyBundle\Form\Model\SurveyQuestionData;
use Mummert\SurveyBundle\Model\SurveyContentModel;
use Mummert\SurveyBundle\Model\SurveyModel;
use Mummert\SurveyBundle\Repository\SurveyConditionRepository;
use Mummert\SurveyBundle\Repository\SurveyEditorRepository;
@@ -21,6 +22,7 @@ final class SurveyEditorService
private readonly SurveyConditionRepository $surveyConditionRepository,
private readonly SurveyEditorRepository $surveyEditorRepository,
private readonly SurveyCategoryAssignmentService $surveyCategoryAssignmentService,
private readonly Connection $connection,
) {
}
@@ -94,30 +96,127 @@ final class SurveyEditorService
$this->surveyRepository->touch((int) $survey->id);
}
/**
* @param list<int> $orderedQuestionIds
*/
public function reorderQuestions(SurveyModel $survey, array $orderedQuestionIds): void
{
$this->assertUnlocked($survey);
$this->surveyQuestionRepository->reorderQuestions((int) $survey->id, $orderedQuestionIds);
$this->surveyRepository->touch((int) $survey->id);
}
public function deleteSurvey(SurveyModel $survey): void
{
$this->surveyRepository->deleteCascade((int) $survey->id);
}
public function saveCondition(SurveyModel $survey, SurveyConditionData $data, int $conditionId = 0): void
public function duplicateSurvey(SurveyModel $survey, ?int $actorMemberId = null): SurveyModel
{
$this->assertUnlocked($survey);
return $this->connection->transactional(function () use ($survey, $actorMemberId): SurveyModel {
$sourceSurveyId = (int) $survey->id;
$memberIds = $this->surveyEditorRepository->findMemberIdsBySurvey($sourceSurveyId);
$duplicate = $this->surveyRepository->create($actorMemberId ?? (int) ($survey->createdBy ?? 0), [
'title' => $this->buildDuplicateTitle($survey),
'description' => (string) ($survey->description ?? ''),
'internalNote' => (string) ($survey->internalNote ?? ''),
'category' => $survey->category,
'isTemplate' => false,
]);
if ($conditionId > 0) {
$condition = $this->surveyConditionRepository->findById($conditionId);
$duplicateSurveyId = (int) $duplicate->id;
if (null === $condition || (int) $condition->pid !== (int) $survey->id) {
throw new \RuntimeException('Die Bedingung wurde nicht gefunden.');
if ([] !== $memberIds) {
$this->surveyEditorRepository->syncMembersForSurvey($duplicateSurveyId, $memberIds);
}
$this->surveyConditionRepository->update($condition, $data->toArray());
$this->surveyRepository->touch((int) $survey->id);
$duplicateQuestions = [];
$questionIdMap = [];
$sourceQuestions = $this->surveyQuestionRepository->findAllBySurvey($sourceSurveyId);
return;
foreach ($sourceQuestions as $sourceQuestion) {
$newQuestion = $this->surveyQuestionRepository->create($duplicateSurveyId, [
'type' => (string) $sourceQuestion->type,
'question' => (string) $sourceQuestion->question,
'description' => (string) ($sourceQuestion->description ?? ''),
'mandatory' => '1' === (string) $sourceQuestion->mandatory,
'published' => '1' === (string) $sourceQuestion->published,
'allowMaybe' => '1' === (string) ($sourceQuestion->allowMaybe ?? ''),
'allowMultiple' => '1' === (string) ($sourceQuestion->allowMultiple ?? ''),
'answerOption1' => (string) ($sourceQuestion->answerOption1 ?? ''),
'answerOption2' => (string) ($sourceQuestion->answerOption2 ?? ''),
'answerOption3' => (string) ($sourceQuestion->answerOption3 ?? ''),
'answerOption4' => (string) ($sourceQuestion->answerOption4 ?? ''),
'answerOption5' => (string) ($sourceQuestion->answerOption5 ?? ''),
'answerOption6' => (string) ($sourceQuestion->answerOption6 ?? ''),
'answerOption7' => (string) ($sourceQuestion->answerOption7 ?? ''),
'answerOption8' => (string) ($sourceQuestion->answerOption8 ?? ''),
'answerOption9' => (string) ($sourceQuestion->answerOption9 ?? ''),
'answerOption10' => (string) ($sourceQuestion->answerOption10 ?? ''),
'rangeMin' => (int) ($sourceQuestion->rangeMin ?? 0),
'rangeMax' => (int) ($sourceQuestion->rangeMax ?? 10),
'rangeStep' => (int) ($sourceQuestion->rangeStep ?? 1),
'jumpOnYes' => 0,
'jumpOnNo' => 0,
]);
$sourceQuestionId = (int) $sourceQuestion->id;
$questionIdMap[$sourceQuestionId] = (int) $newQuestion->id;
$duplicateQuestions[$sourceQuestionId] = $newQuestion;
}
$this->surveyConditionRepository->create((int) $survey->id, $data->toArray());
$this->surveyRepository->touch((int) $survey->id);
foreach ($sourceQuestions as $sourceQuestion) {
$sourceQuestionId = (int) $sourceQuestion->id;
$duplicateQuestion = $duplicateQuestions[$sourceQuestionId] ?? null;
if (!$duplicateQuestion instanceof SurveyContentModel) {
throw new \RuntimeException('Fragenkopie konnte nicht vervollständigt werden.');
}
$this->surveyQuestionRepository->update($duplicateQuestion, [
'type' => (string) $sourceQuestion->type,
'question' => (string) $sourceQuestion->question,
'description' => (string) ($sourceQuestion->description ?? ''),
'mandatory' => '1' === (string) $sourceQuestion->mandatory,
'published' => '1' === (string) $sourceQuestion->published,
'allowMaybe' => '1' === (string) ($sourceQuestion->allowMaybe ?? ''),
'allowMultiple' => '1' === (string) ($sourceQuestion->allowMultiple ?? ''),
'answerOption1' => (string) ($sourceQuestion->answerOption1 ?? ''),
'answerOption2' => (string) ($sourceQuestion->answerOption2 ?? ''),
'answerOption3' => (string) ($sourceQuestion->answerOption3 ?? ''),
'answerOption4' => (string) ($sourceQuestion->answerOption4 ?? ''),
'answerOption5' => (string) ($sourceQuestion->answerOption5 ?? ''),
'answerOption6' => (string) ($sourceQuestion->answerOption6 ?? ''),
'answerOption7' => (string) ($sourceQuestion->answerOption7 ?? ''),
'answerOption8' => (string) ($sourceQuestion->answerOption8 ?? ''),
'answerOption9' => (string) ($sourceQuestion->answerOption9 ?? ''),
'answerOption10' => (string) ($sourceQuestion->answerOption10 ?? ''),
'rangeMin' => (int) ($sourceQuestion->rangeMin ?? 0),
'rangeMax' => (int) ($sourceQuestion->rangeMax ?? 10),
'rangeStep' => (int) ($sourceQuestion->rangeStep ?? 1),
'jumpOnYes' => $questionIdMap[(int) ($sourceQuestion->jumpOnYes ?? 0)] ?? 0,
'jumpOnNo' => $questionIdMap[(int) ($sourceQuestion->jumpOnNo ?? 0)] ?? 0,
]);
}
foreach ($this->surveyConditionRepository->findOverviewBySurvey($sourceSurveyId) as $condition) {
$sourceQuestionId = $questionIdMap[(int) ($condition['sourceQuestion'] ?? 0)] ?? 0;
if ($sourceQuestionId <= 0) {
continue;
}
$this->surveyConditionRepository->create($duplicateSurveyId, [
'sourceQuestion' => $sourceQuestionId,
'answerValue' => (string) ($condition['answerValue'] ?? ''),
'targetQuestion' => $questionIdMap[(int) ($condition['targetQuestion'] ?? 0)] ?? 0,
]);
}
$this->surveyRepository->refreshListMetadata($duplicateSurveyId);
return $this->surveyRepository->findById($duplicateSurveyId) ?? throw new \RuntimeException('Umfragekopie konnte nicht angelegt werden.');
});
}
public function deleteCondition(SurveyModel $survey, int $conditionId): void
@@ -133,4 +232,35 @@ final class SurveyEditorService
throw new \RuntimeException('Die Fragen dieser Umfrage können nach der Veröffentlichung oder nach ersten Teilnahmen nicht mehr geändert werden.');
}
}
private function buildDuplicateTitle(SurveyModel $survey): string
{
$title = trim((string) $survey->title);
if ('' === $title) {
$title = 'Neue Umfrage';
}
$baseTitle = trim((string) preg_replace('/ - Kopie(?: \d+)?$/u', '', $title));
if ('' === $baseTitle) {
$baseTitle = 'Neue Umfrage';
}
for ($copyNumber = 1; ; ++$copyNumber) {
$suffix = 1 === $copyNumber ? ' - Kopie' : sprintf(' - Kopie %d', $copyNumber);
$candidate = $this->buildDuplicateTitleCandidate($baseTitle, $suffix);
if (!$this->surveyRepository->titleExists($candidate, (int) $survey->id)) {
return $candidate;
}
}
}
private function buildDuplicateTitleCandidate(string $baseTitle, string $suffix): string
{
$maxBaseLength = max(0, 255 - mb_strlen($suffix));
return mb_substr($baseTitle, 0, $maxBaseLength).$suffix;
}
}
+23
View File
@@ -112,6 +112,29 @@ final class SurveyFlowService
];
}
/**
* Beschreibt das Sprungziel einer Ja/Nein-Antwort über Positionsnummer und Label.
*
* @return array{position:int,label:string}|null
*/
public function describeJumpTarget(SurveyModel $survey, int $targetQuestionId): ?array
{
if ($targetQuestionId <= 0) {
return null;
}
foreach ($this->getOrderedQuestions($survey) as $index => $question) {
if ((int) $question->id === $targetQuestionId) {
return [
'position' => $index + 1,
'label' => trim((string) $question->question),
];
}
}
return null;
}
/**
* @return list<SurveyContentModel>
*/
-29
View File
@@ -1,29 +0,0 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Service;
use Contao\FrontendUser;
use Mummert\SurveyBundle\Model\SurveyModel;
use Mummert\SurveyBundle\Repository\SurveyEditorRepository;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
final class SurveyPermissionService
{
public function __construct(private readonly SurveyEditorRepository $surveyEditorRepository)
{
}
public function canEditSurvey(SurveyModel $survey, FrontendUser $member): bool
{
return $this->surveyEditorRepository->isEditor((int) $survey->id, (int) $member->id);
}
public function assertEditorAccess(SurveyModel $survey, FrontendUser $member): void
{
if (!$this->canEditSurvey($survey, $member)) {
throw new AccessDeniedHttpException('Sie dürfen diese Umfrage nicht bearbeiten.');
}
}
}
+4 -8
View File
@@ -136,23 +136,19 @@ final class SurveyResultsPdfService
private function buildChartLegend(array $questionResult): array
{
$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'] : [],
+15 -10
View File
@@ -50,12 +50,7 @@ final class SurveyResultsViewService
$results = [];
foreach ($questions as $question) {
$key = $this->buildQuestionResultKey(
(int) $question->id,
(string) $question->type,
(string) $question->question,
(string) ($question->description ?? ''),
);
$key = $this->buildQuestionResultKey((int) $question->id, (string) $question->type);
$results[$key] = $this->buildResultSkeleton(
(int) $question->id,
@@ -65,6 +60,7 @@ final class SurveyResultsViewService
(int) ($question->sorting ?? 0),
);
$results[$key]['_choiceOptions'] = $this->getChoiceOptions($question);
$results[$key]['_allowMaybe'] = '1' === (string) $question->allowMaybe;
}
foreach ($answers as $answer) {
@@ -72,7 +68,7 @@ final class SurveyResultsViewService
$questionType = (string) ($answer['questionType'] ?? 'text');
$questionLabel = trim((string) ($answer['questionLabel'] ?? '')) ?: 'Frage #'.$questionId;
$questionDescription = (string) ($answer['questionDescription'] ?? '');
$key = $this->buildQuestionResultKey($questionId, $questionType, $questionLabel, $questionDescription);
$key = $this->buildQuestionResultKey($questionId, $questionType);
if (!isset($results[$key])) {
$results[$key] = $this->buildResultSkeleton(
@@ -104,9 +100,11 @@ final class SurveyResultsViewService
return array_values($results);
}
private function buildQuestionResultKey(int $questionId, string $questionType, string $questionLabel, string $questionDescription): string
private function buildQuestionResultKey(int $questionId, string $questionType): string
{
return implode('|', [$questionId, $questionType, md5($questionLabel), md5($questionDescription)]);
// Bewusst nur Frage-ID + Typ: So werden Antwort-Snapshots und die aktuelle
// Frage auch dann zusammengeführt, wenn das Label nachträglich geändert wurde.
return implode('|', [$questionId, $questionType]);
}
/**
@@ -125,6 +123,7 @@ final class SurveyResultsViewService
'_sorting' => $sorting,
'_values' => [],
'_choiceOptions' => [],
'_allowMaybe' => false,
];
}
@@ -146,6 +145,12 @@ final class SurveyResultsViewService
case 'yes_no_maybe':
$optionCounts = ['Ja' => 0, 'Nein' => 0];
// Wie beim Excel-Export: Bei erlaubtem "Vielleicht" die Zeile auch dann
// zeigen, wenn (noch) niemand "Vielleicht" gewählt hat.
if (!empty($result['_allowMaybe'])) {
$optionCounts['Vielleicht'] = 0;
}
foreach ($values as $value) {
$normalizedValue = mb_strtolower(trim($value));
@@ -205,7 +210,7 @@ final class SurveyResultsViewService
break;
}
unset($result['_sorting'], $result['_values'], $result['_choiceOptions']);
unset($result['_sorting'], $result['_values'], $result['_choiceOptions'], $result['_allowMaybe']);
return $result;
}
+24 -1
View File
@@ -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);
+5 -1
View File
@@ -2,6 +2,8 @@ survey:
list:
login_required: "Dieser Bereich verwendet den normalen Contao-Mitgliederlogin. Bitte binden Sie das Modul auf einer geschützten Seite oder mit einer separaten Login-Seite ein."
create: "Neue Umfrage anlegen"
duplicate: "Duplizieren"
duplicate_confirm: "Sind Sie sicher, dass Sie die Umfrage \"%title%\" mit allen Fragen duplizieren wollen?"
delete: "Löschen"
delete_confirm: "Umfrage wirklich löschen? Damit werden auch alle Fragen, Antworten und Ergebnisse endgültig gelöscht. Alternativ können Sie die Umfrage auch einfach vorübergehend deaktivieren."
delete_confirm_second: "Wirklich sicher?"
@@ -78,6 +80,8 @@ survey:
delete: "Löschen"
no_questions: "Noch keine Fragen angelegt."
new_question: "Neue Frage anlegen"
reorder_question: "Frage verschieben"
save_question_order: "Neue Reihenfolge speichern"
open_question: "Akkordeon öffnen"
close_question: "Akkordeon schließen"
conditions_title: "Sprungregeln"
@@ -104,7 +108,7 @@ survey:
answer_no: "Nein"
answer_maybe: "Vielleicht"
question_progress: "Frage %current%"
answer_flow_hint: "Diese Antwort wird direkt für den weiteren Ablauf verwendet."
jump_to: "weiter zu Frage %position%: %label%"
current_value: "Aktueller Wert"
back: "Zurück"
next: "Weiter"
+5 -1
View File
@@ -2,6 +2,8 @@ survey:
list:
login_required: "This area uses the regular Contao member login. Please place the module on a protected page or provide a separate login page."
create: "Create new survey"
duplicate: "Duplicate"
duplicate_confirm: "Are you sure you want to duplicate the survey \"%title%\" with all questions?"
delete: "Delete"
delete_confirm: "Delete this survey? This will permanently remove all questions, answers, and results as well. Alternatively, you can simply deactivate the survey temporarily."
delete_confirm_second: "Are you really sure?"
@@ -78,6 +80,8 @@ survey:
delete: "Delete"
no_questions: "No questions created yet."
new_question: "Create new question"
reorder_question: "Reorder question"
save_question_order: "Save new order"
open_question: "Open accordion"
close_question: "Close accordion"
conditions_title: "Jump rules"
@@ -104,7 +108,7 @@ survey:
answer_no: "No"
answer_maybe: "Maybe"
question_progress: "Question %current%"
answer_flow_hint: "This answer is used directly for the further flow."
jump_to: "continue to question %position%: %label%"
current_value: "Current value"
back: "Back"
next: "Next"