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
This commit is contained in:
Jürgen Mummert
2026-06-14 09:13:07 +02:00
parent 79c4402aee
commit 1b16675ae9
23 changed files with 928 additions and 40 deletions
+9
View File
@@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(git add *)",
"Bash(git commit -q -m ' *)",
"Bash(git push *)"
]
}
}
+79
View File
@@ -63,6 +63,7 @@ Diese Entscheidung ist absichtlich so getroffen und kein Bug.
- `tl_survey` - `tl_survey`
- Umfrage-Stammdaten und Statusfelder - Umfrage-Stammdaten und Statusfelder
- Enthält zusätzlich die backend-only gepflegte `internalNote`
- `tl_survey_content` - `tl_survey_content`
- Fragen der Umfrage - Fragen der Umfrage
- `tl_survey_condition` - `tl_survey_condition`
@@ -115,10 +116,19 @@ Aktuelle Button- und Sichtbarkeitslogik pro Umfrage:
- `Umfrage veröffentlichen` - `Umfrage veröffentlichen`
- Eigene Aktion in der Liste - Eigene Aktion in der Liste
- Nach Veröffentlichung disabled und textlich als veröffentlicht markiert - Nach Veröffentlichung disabled und textlich als veröffentlicht markiert
- `Duplizieren`
- Legt eine Kopie der Umfrage für den Redakteur an
- Titelvergabe ist fortlaufend: `Titel - Kopie`, danach `Titel - Kopie 2`, `Titel - Kopie 3` usw.
- Bestehende `- Kopie`-Suffixe werden fachlich normalisiert, damit keine Titel wie `... - Kopie - Kopie` mehr neu entstehen
- Kreis grau/grün - Kreis grau/grün
- Schaltet `isActive` - Schaltet `isActive`
- Ist bewusst kein Auge mehr - Ist bewusst kein Auge mehr
Wichtiger technischer Punkt:
- Die Frontend-Listenaktionen arbeiten nur noch mit dem Contao-`REQUEST_TOKEN` plus Berechtigungsprüfung.
- Die zusätzliche Symfony-Aktions-CSRF-Prüfung wurde bewusst entfernt, weil sie in diesem Frontend-Flow zu ungültigen Tokens führte.
### 2. Frontend-Editor ### 2. Frontend-Editor
Controller: Controller:
@@ -154,6 +164,9 @@ Wichtige Entscheidung:
- Typ, Pflicht usw. sollen nicht schon im Header gezeigt werden. - Typ, Pflicht usw. sollen nicht schon im Header gezeigt werden.
- Die Details sieht man erst nach dem Aufklappen. - Die Details sieht man erst nach dem Aufklappen.
- Die Sprungziel-Dropdowns im Frontend zeigen Positionsnummern der Umfrage (`1. ...`, `2. ...`) statt globaler Datenbank-IDs.
- Die aktuell bearbeitete Frage wird dort nicht als eigenes Sprungziel angeboten.
### 3. Öffentliche Umfrageansicht ### 3. Öffentliche Umfrageansicht
Controller: Controller:
@@ -232,6 +245,13 @@ Reihenfolge der Navigation:
Wichtiger Detailpunkt: Wichtiger Detailpunkt:
- In der Sprungregelübersicht im Frontend-Editor werden bewusst die Positionsnummern innerhalb der Umfrage angezeigt, nicht die globalen Datenbank-IDs der Fragen. - In der Sprungregelübersicht im Frontend-Editor werden bewusst die Positionsnummern innerhalb der Umfrage angezeigt, nicht die globalen Datenbank-IDs der Fragen.
- Gleiches gilt für die Sprungziel-Dropdowns in Backend und Frontend.
Wichtige technische Eigenschaft:
- `jumpOnYes`, `jumpOnNo` und generische Bedingungen referenzieren Ziel-Fragen intern immer über die Frage-ID, nicht über die Position.
- Dadurch bleiben Sprünge bei einer reinen Umordnung der Fragen fachlich stabil an dieselbe Frage gebunden.
- Wenn eine Ziel-Frage an eine andere Position verschoben wird, zeigt die Oberfläche danach automatisch die neue Positionsnummer an.
## Excel-Export ## Excel-Export
@@ -315,6 +335,26 @@ Wichtige Listenoperationen:
- `Link kopieren` - `Link kopieren`
- `Ergebnisse anzeigen` - `Ergebnisse anzeigen`
Aktuelle Listen- und Filterlogik:
- Die mehrspaltige Umfragenliste zeigt aktuell:
- `Titel`
- `Zugewiesene Benutzer`
- `Vorlage`
- `Interne Notiz`
- `Kategorien` wurden bewusst aus der mehrspaltigen Liste und aus der rechten Filterspalte entfernt.
- Die Spalte `Zugewiesene Benutzer` wird in der Listenmetadatenlogik gekürzt:
- maximal 3 Namen direkt sichtbar
- danach Zusatz wie `... und X weitere`
- `internalNote`
- backend-only Feld in `tl_survey`
- nur im Backend bearbeitbar
- in der mehrspaltigen Liste sichtbar
- bei Duplikaten wird der Wert mitkopiert
- In der rechten Suchspalte ist `Interne Notiz` suchbar.
- `ID` wurde für die Umfragenliste bewusst aus dem Suchfeld entfernt.
- Das geschieht über einen kleinen backend-spezifischen JS-Workaround, weil Contao `id` im Core für `DC_Table` hart ergänzt.
Aktuelle Backend-Entscheidung: Aktuelle Backend-Entscheidung:
- Das Warnsymbol genügt als Hinweis bei veröffentlichten Umfragen - Das Warnsymbol genügt als Hinweis bei veröffentlichten Umfragen
@@ -329,6 +369,21 @@ Aktuelle Backend-Entscheidung:
Hinweis: Hinweis:
- `isLocked` wird in den Child-Headerfeldern nicht mehr angezeigt, weil der Backend-Lock bewusst keine führende Benutzerfunktion mehr ist. - `isLocked` wird in den Child-Headerfeldern nicht mehr angezeigt, weil der Backend-Lock bewusst keine führende Benutzerfunktion mehr ist.
- In `tl_survey_content` ist der Fragenlisten-Header zusätzlich reduziert:
- nur noch `Titel` und `Veröffentlicht`
- kein öffentlicher Link-Schlüssel mehr
- die rechte Header-Leiste mit Aktionen ist im Normalzustand ausgeblendet
- im aktiven Verschiebe-/Clipboard-Modus wird sie wieder eingeblendet, damit `Neu oben` bzw. die Einfügepositionen erreichbar bleiben
Fragenliste `tl_survey_content`:
- Direkt sichtbare Primäraktion pro Frage:
- `Bearbeiten`-Stift
- Verschieben:
- erfolgt im Backend über den normalen Contao-Paste-/Move-Flow
- die Aktion `Verschieben` liegt bewusst nur unter den drei Punkten
- dort wird der Contao-Pfeil `mover.svg` verwendet, nicht die blaue Schere
- nach Aktivierung des Clipboard-Zustands erscheinen die üblichen Einfügeziele wie `Nach dem Element ... einfügen`
### Systemeinstellungen ### Systemeinstellungen
@@ -356,6 +411,7 @@ Wichtige Settings:
- `published` reversibel erlaubt - `published` reversibel erlaubt
- `isActive` per Standard-Contao-Toggle - `isActive` per Standard-Contao-Toggle
- nur Warnsymbol statt Backend-Sperre - nur Warnsymbol statt Backend-Sperre
- Admin darf Fragen auch nach Veröffentlichung oder nach vorhandenen Antworten umsortieren
## Wichtige Dateien nach Themengebiet ## Wichtige Dateien nach Themengebiet
@@ -398,12 +454,35 @@ Backend:
- `ddev exec php bin/console lint:yaml ...` - `ddev exec php bin/console lint:yaml ...`
- `ddev exec php bin/console cache:clear` - `ddev exec php bin/console cache:clear`
## Offene technische Punkte / Deployment-Hinweise
- Neue DB-Spalte in `tl_survey`:
- `internalNote`
- Typ aktuell: `varchar(255) NOT NULL default ''`
- Für andere Umgebungen muss diese Spalte per Migration/SQL vorhanden sein, sonst fehlen Backend-Feld, Listenanzeige und Suchfunktion fachlich bzw. führen zu Inkonsistenzen.
- Nach Änderungen an DCA, Backend-CSS oder Backend-JS ist in dieser Installation typischerweise nötig:
- `php vendor/bin/contao-console assets:install public --symlink --relative`
- `php vendor/bin/contao-console cache:clear --env=prod`
- Es gibt aktuell zwei bewusst eingebaute backend-spezifische UI-Workarounds über Bundle-Assets:
- In der Umfragenliste wird `ID` aus dem Suchfeld per JS entfernt, weil Contao das Feld im Core von `DC_Table` hart ergänzt.
- In der Fragenliste (`table=tl_survey_content`) wird die rechte Parent-Header-Leiste per CSS/JS im Normalzustand ausgeblendet und nur im aktiven Clipboard-/Verschiebe-Modus wieder eingeblendet.
- Lokale DDEV-Besonderheit, die bei Fehleranalyse relevant sein kann:
- Das Bundle läuft hier als Path-Repository/Symlink.
- Wenn Backend-Übersetzungen oder Assets trotz korrektem Code unerwartet fehlen, zuerst Bundle-Pfad, veröffentlichte Assets und Prod-Cache prüfen.
## Bekannte bewusste Trade-offs ## Bekannte bewusste Trade-offs
- Backend kann veröffentlichte Umfragen technisch weiterhin verändern. - Backend kann veröffentlichte Umfragen technisch weiterhin verändern.
- Das ist bewusst akzeptiert, weil nur ein Admin arbeitet. - Das ist bewusst akzeptiert, weil nur ein Admin arbeitet.
- `isLocked` bleibt als internes Logikfeld bestehen, obwohl der Backend-Lock nicht mehr durchgesetzt wird. - `isLocked` bleibt als internes Logikfeld bestehen, obwohl der Backend-Lock nicht mehr durchgesetzt wird.
- PDF-Export hängt von Gotenberg ab und ist absichtlich optional. - PDF-Export hängt von Gotenberg ab und ist absichtlich optional.
- Eine reine Umordnung von Fragen ist für Sprungziele logisch unkritisch, weil Sprünge an Frage-IDs hängen.
- Ergebnisse vorhandener, weiterhin existierender Fragen folgen bei der Anzeige jedoch der aktuellen Sortierung der Frage.
- Historische Antwort-Snapshots speichern zwar `questionSorting`, die Ergebnisansicht sortiert vorhandene Fragen aber primär nach der aktuellen Frage-Sortierung.
- Das ist im toleranten Backend-Modell ein bewusst akzeptierter Side-Effect.
## Empfohlene Einstiegspunkte für künftige Änderungen ## Empfohlene Einstiegspunkte für künftige Änderungen
+2 -1
View File
@@ -2,8 +2,9 @@
declare(strict_types=1); declare(strict_types=1);
$GLOBALS['TL_CSS'][] = 'assets/survey-backend.css|static'; $GLOBALS['TL_CSS'][] = 'bundles/survey/css/survey-backend.css|static';
$GLOBALS['TL_CSS'][] = 'bundles/survey/css/auth-module.css|static'; $GLOBALS['TL_CSS'][] = 'bundles/survey/css/auth-module.css|static';
$GLOBALS['TL_JAVASCRIPT'][] = 'bundles/survey/js/survey-backend.js|static';
$GLOBALS['TL_MODELS']['tl_survey'] = Mummert\SurveyBundle\Model\SurveyModel::class; $GLOBALS['TL_MODELS']['tl_survey'] = Mummert\SurveyBundle\Model\SurveyModel::class;
$GLOBALS['TL_MODELS']['tl_survey_category'] = Mummert\SurveyBundle\Model\SurveyCategoryModel::class; $GLOBALS['TL_MODELS']['tl_survey_category'] = Mummert\SurveyBundle\Model\SurveyCategoryModel::class;
+21 -11
View File
@@ -36,7 +36,7 @@ $GLOBALS['TL_DCA']['tl_survey'] = [
'panelLayout' => 'filter;sort,search,limit', 'panelLayout' => 'filter;sort,search,limit',
], ],
'label' => [ 'label' => [
'fields' => ['title', 'categorySummary', 'assignedMembersSummary', 'templateSummary'], 'fields' => ['title', 'assignedMembersSummary', 'templateSummary', 'internalNote'],
'showColumns' => true, 'showColumns' => true,
], ],
'global_operations' => [ 'global_operations' => [
@@ -47,22 +47,26 @@ $GLOBALS['TL_DCA']['tl_survey'] = [
], ],
], ],
'operations' => [ 'operations' => [
'editheader' => [
'label' => ['Umfrage bearbeiten', 'Umfrage bearbeiten'],
'href' => 'act=edit',
'icon' => 'edit.svg',
'primary' => true,
'showInHeader' => false,
],
'edit' => [ 'edit' => [
'label' => ['Fragen bearbeiten', 'Fragen der Umfrage bearbeiten'], 'label' => ['Fragen bearbeiten', 'Fragen der Umfrage bearbeiten'],
'href' => 'table=tl_survey_content', 'href' => 'table=tl_survey_content',
'icon' => 'children.svg', 'icon' => 'children.svg',
], 'primary' => true,
'editheader' => [ 'showInHeader' => false,
'label' => ['Umfrage bearbeiten', 'Umfrage bearbeiten'],
'href' => 'act=edit',
'icon' => 'header.svg',
], ],
'toggle' => [ 'toggle' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey']['toggleActive'], 'label' => &$GLOBALS['TL_LANG']['tl_survey']['toggleActive'],
'href' => 'act=toggle&field=isActive', 'href' => 'act=toggle&field=isActive',
'icon' => 'visible.svg', 'icon' => 'visible.svg',
'primary' => true, 'primary' => true,
'showInHeader' => true, 'showInHeader' => false,
], ],
'publishedWarning' => [ 'publishedWarning' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey']['publishedWarning'], 'label' => &$GLOBALS['TL_LANG']['tl_survey']['publishedWarning'],
@@ -100,7 +104,7 @@ $GLOBALS['TL_DCA']['tl_survey'] = [
], ],
], ],
'palettes' => [ 'palettes' => [
'default' => '{title_legend},title,category,description;{assignment_legend},assignedMembers;{publishing_legend},published,isActive,isTemplate;{meta_legend},createdAt,updatedAt', 'default' => '{title_legend},title,category,description,internalNote;{assignment_legend},assignedMembers;{publishing_legend},published,isActive,isTemplate;{meta_legend},createdAt,updatedAt',
], ],
'fields' => [ 'fields' => [
'id' => [ 'id' => [
@@ -129,9 +133,15 @@ $GLOBALS['TL_DCA']['tl_survey'] = [
'eval' => ['rte' => 'tinyMCE', 'tl_class' => 'clr'], 'eval' => ['rte' => 'tinyMCE', 'tl_class' => 'clr'],
'sql' => 'text NULL', 'sql' => 'text NULL',
], ],
'internalNote' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey']['internalNote'],
'inputType' => 'text',
'search' => true,
'eval' => ['maxlength' => 255, 'tl_class' => 'clr w50'],
'sql' => "varchar(255) NOT NULL default ''",
],
'category' => [ 'category' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey']['category'], 'label' => &$GLOBALS['TL_LANG']['tl_survey']['category'],
'filter' => true,
'inputType' => 'select', 'inputType' => 'select',
'foreignKey' => 'tl_survey_category.title', 'foreignKey' => 'tl_survey_category.title',
'eval' => ['multiple' => true, 'chosen' => true, 'tl_class' => 'clr'], 'eval' => ['multiple' => true, 'chosen' => true, 'tl_class' => 'clr'],
@@ -188,9 +198,9 @@ $GLOBALS['TL_DCA']['tl_survey'] = [
], ],
'assignedMembers' => [ 'assignedMembers' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey']['assignedMembers'], 'label' => &$GLOBALS['TL_LANG']['tl_survey']['assignedMembers'],
'inputType' => 'select', 'inputType' => 'checkboxWizard',
'options_callback' => [Mummert\SurveyBundle\EventListener\SurveyDcaListener::class, 'getMemberOptions'], 'options_callback' => [Mummert\SurveyBundle\EventListener\SurveyDcaListener::class, 'getMemberOptions'],
'eval' => ['multiple' => true, 'chosen' => true, 'tl_class' => 'clr'], 'eval' => ['multiple' => true, 'class' => 'survey-assigned-members-checkbox-wizard', 'tl_class' => 'clr'],
'load_callback' => [[Mummert\SurveyBundle\EventListener\SurveyDcaListener::class, 'loadSurveyAssignedMembers']], 'load_callback' => [[Mummert\SurveyBundle\EventListener\SurveyDcaListener::class, 'loadSurveyAssignedMembers']],
'save_callback' => [[Mummert\SurveyBundle\EventListener\SurveyDcaListener::class, 'saveSurveyAssignedMembers']], 'save_callback' => [[Mummert\SurveyBundle\EventListener\SurveyDcaListener::class, 'saveSurveyAssignedMembers']],
'sql' => 'blob NULL', 'sql' => 'blob NULL',
+8 -1
View File
@@ -32,7 +32,7 @@ $GLOBALS['TL_DCA']['tl_survey_content'] = [
'sorting' => [ 'sorting' => [
'mode' => 4, 'mode' => 4,
'fields' => ['sorting'], 'fields' => ['sorting'],
'headerFields' => ['title', 'alias', 'published'], 'headerFields' => ['title', 'published'],
'child_record_class' => 'survey-question-record', 'child_record_class' => 'survey-question-record',
'panelLayout' => 'sort,search,limit', 'panelLayout' => 'sort,search,limit',
], ],
@@ -44,6 +44,13 @@ $GLOBALS['TL_DCA']['tl_survey_content'] = [
'edit' => [ 'edit' => [
'href' => 'act=edit', 'href' => 'act=edit',
'icon' => 'edit.svg', 'icon' => 'edit.svg',
'primary' => true,
],
'cut' => [
'href' => 'act=paste&mode=cut',
'method' => 'POST',
'icon' => 'mover.svg',
'attributes' => 'data-action="contao--scroll-offset#store"',
], ],
'copy' => [ 'copy' => [
'href' => 'act=copy', 'href' => 'act=copy',
+1
View File
@@ -11,6 +11,7 @@ $GLOBALS['TL_LANG']['tl_survey']['templateSummary'] = ['Vorlage', 'Zeigt an, ob
$GLOBALS['TL_LANG']['tl_survey']['template_yes'] = 'Ja'; $GLOBALS['TL_LANG']['tl_survey']['template_yes'] = 'Ja';
$GLOBALS['TL_LANG']['tl_survey']['template_no'] = 'Nein'; $GLOBALS['TL_LANG']['tl_survey']['template_no'] = 'Nein';
$GLOBALS['TL_LANG']['tl_survey']['description'] = ['Beschreibung', 'Einleitung für den Editor und das Frontend.']; $GLOBALS['TL_LANG']['tl_survey']['description'] = ['Beschreibung', 'Einleitung für den Editor und das Frontend.'];
$GLOBALS['TL_LANG']['tl_survey']['internalNote'] = ['Interne Notiz', 'Nur im Backend sichtbare interne Notiz zur Umfrage.'];
$GLOBALS['TL_LANG']['tl_survey']['title_legend'] = 'Grunddaten'; $GLOBALS['TL_LANG']['tl_survey']['title_legend'] = 'Grunddaten';
$GLOBALS['TL_LANG']['tl_survey']['assignment_legend'] = 'Bearbeitende Mitglieder'; $GLOBALS['TL_LANG']['tl_survey']['assignment_legend'] = 'Bearbeitende Mitglieder';
$GLOBALS['TL_LANG']['tl_survey']['publishing_legend'] = 'Sichtbarkeit'; $GLOBALS['TL_LANG']['tl_survey']['publishing_legend'] = 'Sichtbarkeit';
+1
View File
@@ -11,6 +11,7 @@ $GLOBALS['TL_LANG']['tl_survey']['templateSummary'] = ['Template', 'Shows whethe
$GLOBALS['TL_LANG']['tl_survey']['template_yes'] = 'Yes'; $GLOBALS['TL_LANG']['tl_survey']['template_yes'] = 'Yes';
$GLOBALS['TL_LANG']['tl_survey']['template_no'] = 'No'; $GLOBALS['TL_LANG']['tl_survey']['template_no'] = 'No';
$GLOBALS['TL_LANG']['tl_survey']['description'] = ['Description', 'Introduction for the editor and the frontend.']; $GLOBALS['TL_LANG']['tl_survey']['description'] = ['Description', 'Introduction for the editor and the frontend.'];
$GLOBALS['TL_LANG']['tl_survey']['internalNote'] = ['Internal note', 'Internal note for the survey, visible in the backend only.'];
$GLOBALS['TL_LANG']['tl_survey']['title_legend'] = 'Basic data'; $GLOBALS['TL_LANG']['tl_survey']['title_legend'] = 'Basic data';
$GLOBALS['TL_LANG']['tl_survey']['assignment_legend'] = 'Editing members'; $GLOBALS['TL_LANG']['tl_survey']['assignment_legend'] = 'Editing members';
$GLOBALS['TL_LANG']['tl_survey']['publishing_legend'] = 'Visibility'; $GLOBALS['TL_LANG']['tl_survey']['publishing_legend'] = 'Visibility';
@@ -708,6 +708,11 @@
margin: 0; margin: 0;
} }
.survey-accordion-header-row {
display: flex;
align-items: stretch;
}
.survey-accordion-toggle { .survey-accordion-toggle {
display: flex; display: flex;
width: 100%; width: 100%;
@@ -720,6 +725,82 @@
text-align: left; text-align: left;
} }
.survey-accordion-sort-handle {
position: relative;
display: inline-flex;
flex: 0 0 auto;
min-width: 3.5rem;
padding: 0 1rem;
border: 0;
border-left: 1px solid rgba(162, 168, 180, 0.18);
background: transparent;
cursor: grab;
align-items: center;
justify-content: center;
align-self: stretch;
transition: background 0.2s ease, color 0.2s ease;
}
.survey-accordion-sort-handle:active {
cursor: grabbing;
}
.survey-accordion-item .handorgel__header__button:hover + .survey-accordion-sort-handle,
.survey-accordion-item .handorgel__header__button:focus + .survey-accordion-sort-handle,
.survey-accordion-item .handorgel__header--opened .survey-accordion-sort-handle,
.survey-accordion-sort-handle:hover,
.survey-accordion-sort-handle:focus {
background: var(--survey-blue);
color: #fff;
}
.survey-sort-icon {
display: inline-flex;
width: 0.95rem;
height: 1.28rem;
align-items: center;
justify-content: center;
color: #58a6da;
line-height: 0;
transform: translateY(1px);
}
.survey-sort-icon svg {
display: block;
width: 100%;
height: 100%;
transform: translateY(4px);
}
.survey-question-order-save-shell {
position: fixed;
top: 50%;
right: 1rem;
z-index: 40;
transform: translate(120%, -50%);
opacity: 0;
pointer-events: none;
transition: transform 0.26s ease, opacity 0.26s ease;
}
.survey-question-order-save-shell.is-visible {
transform: translate(0, -50%);
opacity: 1;
pointer-events: auto;
}
.survey-question-order-save {
box-shadow: 0 18px 40px rgba(0, 59, 102, 0.24);
}
.survey-accordion-item.is-reordering {
box-shadow: 0 20px 44px rgba(0, 59, 102, 0.18);
}
.survey-accordion-item.sortable-ghost {
opacity: 0.55;
}
.survey-accordion-item .handorgel__header__button, .survey-accordion-item .handorgel__header__button,
.survey-accordion-item .handorgel__header__button:hover, .survey-accordion-item .handorgel__header__button:hover,
.survey-accordion-item .handorgel__header__button:focus { .survey-accordion-item .handorgel__header__button:focus {
@@ -994,6 +1075,22 @@
padding: 0.95rem 1rem; padding: 0.95rem 1rem;
} }
.survey-accordion-sort-handle {
min-width: 3.1rem;
padding: 0 0.8rem;
}
.survey-question-order-save-shell {
top: auto;
right: 0.85rem;
bottom: 0.85rem;
transform: translate(0, 130%);
}
.survey-question-order-save-shell.is-visible {
transform: translate(0, 0);
}
} }
</style> </style>
<script> <script>
@@ -1152,6 +1249,133 @@
}); });
}); });
var initializeQuestionSorting = function () {
var styleId = 'survey-question-sort-icon-runtime';
if (!document.getElementById(styleId)) {
var runtimeStyle = document.createElement('style');
runtimeStyle.id = styleId;
runtimeStyle.textContent = '' +
'.survey-sort-icon{' +
'width:1.14rem !important;' +
'height:1.52rem !important;' +
'color:#58a6da !important;' +
'background:none !important;' +
'transform:translateY(1px) !important;' +
'}' +
'.survey-sort-icon::before,.survey-sort-icon::after{' +
'content:none !important;' +
'}' +
'.survey-sort-icon svg{' +
'display:block !important;' +
'width:100% !important;' +
'height:100% !important;' +
'transform:translateY(4px);' +
'}' +
'.survey-sort-icon svg path:first-child{' +
'transform:translateY(1.4px);' +
'transform-box:fill-box;' +
'transform-origin:center;' +
'}' +
'.survey-sort-icon svg path:nth-child(2){' +
'transform:translateY(-1.4px);' +
'transform-box:fill-box;' +
'transform-origin:center;' +
'}' +
'.survey-sort-icon svg path[stroke]{' +
'stroke-width:2px;' +
'}';
document.head.appendChild(runtimeStyle);
}
if (typeof Sortable !== 'function') {
return;
}
var sortableList = document.querySelector('[data-survey-question-sortable]');
var orderForm = document.querySelector('[data-survey-question-order-shell]');
var orderInput = document.querySelector('[data-survey-question-order-input]');
if (!sortableList || !orderForm || !orderInput) {
return;
}
if (sortableList._surveyQuestionSortable) {
return;
}
var getOrderedIds = function () {
return Array.from(sortableList.querySelectorAll('[data-survey-question-item]')).map(function (item) {
return item.getAttribute('data-question-id') || '';
}).filter(function (value) {
return value !== '';
});
};
var initialOrder = getOrderedIds().join(',');
var updateOrderField = function () {
orderInput.value = getOrderedIds().join(',');
};
var updateQuestionIndices = function () {
sortableList.querySelectorAll('[data-survey-question-item]').forEach(function (item, index) {
var indexNode = item.querySelector('.survey-accordion-index');
if (indexNode) {
indexNode.textContent = (index + 1) + '.';
}
});
};
var updateSaveVisibility = function () {
var currentOrder = getOrderedIds().join(',');
var isDirty = currentOrder !== initialOrder;
orderForm.hidden = !isDirty;
orderForm.classList.toggle('is-visible', isDirty);
updateOrderField();
};
sortableList.querySelectorAll('[data-survey-question-sort-handle]').forEach(function (handle) {
handle.addEventListener('click', function (event) {
event.preventDefault();
event.stopPropagation();
});
});
updateQuestionIndices();
updateOrderField();
updateSaveVisibility();
var sortableInstance = Sortable.create(sortableList, {
animation: 180,
draggable: '[data-survey-question-item]',
handle: '[data-survey-question-sort-handle]',
ghostClass: 'sortable-ghost',
forceFallback: true,
fallbackTolerance: 3,
onStart: function (event) {
if (event.item) {
event.item.classList.add('is-reordering');
}
},
onEnd: function (event) {
if (event.item) {
event.item.classList.remove('is-reordering');
}
updateQuestionIndices();
updateSaveVisibility();
}
});
sortableList._surveyQuestionSortable = sortableInstance;
};
window.initializeSurveyQuestionSorting = initializeQuestionSorting;
if (typeof handorgel === 'function') { if (typeof handorgel === 'function') {
document.querySelectorAll('[data-survey-handorgel]').forEach(function (element) { document.querySelectorAll('[data-survey-handorgel]').forEach(function (element) {
var accordion = new handorgel(element, { var accordion = new handorgel(element, {
@@ -1171,6 +1395,8 @@
}); });
} }
initializeQuestionSorting();
resetEditorViewport(); resetEditorViewport();
}); });
</script> </script>
@@ -0,0 +1,119 @@
<style>
.survey-accordion-header-row {
display: flex;
align-items: stretch;
}
.survey-accordion-sort-handle {
position: relative;
flex: 0 0 auto;
min-width: 3.5rem;
padding: 0 1rem;
border: 0;
border-left: 1px solid rgba(162, 168, 180, 0.18);
background: transparent;
cursor: grab;
transition: background 0.2s ease, color 0.2s ease;
}
.survey-accordion-sort-handle:active {
cursor: grabbing;
}
.survey-accordion-item .handorgel__header__button:hover + .survey-accordion-sort-handle,
.survey-accordion-item .handorgel__header__button:focus + .survey-accordion-sort-handle,
.survey-accordion-item .handorgel__header--opened .survey-accordion-sort-handle,
.survey-accordion-sort-handle:hover,
.survey-accordion-sort-handle:focus {
background: var(--survey-blue);
color: #fff;
}
.survey-sort-icon {
position: relative;
display: inline-flex;
width: 0.9rem;
height: 1.2rem;
align-items: center;
justify-content: center;
color: #58a6da;
background-image:
linear-gradient(currentColor, currentColor),
linear-gradient(currentColor, currentColor),
linear-gradient(currentColor, currentColor);
background-repeat: no-repeat;
background-position: center 0.36rem, center center, center calc(100% - 0.36rem);
background-size: 0.78rem 2px, 0.78rem 2px, 0.78rem 2px;
}
.survey-sort-icon::before,
.survey-sort-icon::after {
content: '';
position: absolute;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 0.24rem solid transparent;
border-right: 0.24rem solid transparent;
}
.survey-sort-icon::before {
top: 0;
border-bottom: 0.24rem solid currentColor;
}
.survey-sort-icon::after {
bottom: 0;
border-top: 0.24rem solid currentColor;
}
.survey-question-order-save-shell {
position: fixed;
top: 50%;
right: 1rem;
z-index: 40;
transform: translate(120%, -50%);
opacity: 0;
pointer-events: none;
transition: transform 0.26s ease, opacity 0.26s ease;
}
.survey-question-order-save-shell.is-visible {
transform: translate(0, -50%);
opacity: 1;
pointer-events: auto;
}
.survey-question-order-save {
box-shadow: 0 18px 40px rgba(0, 59, 102, 0.24);
}
.survey-accordion-item.is-reordering {
box-shadow: 0 20px 44px rgba(0, 59, 102, 0.18);
}
.survey-accordion-item.sortable-ghost {
opacity: 0.55;
}
@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="https://cdn.jsdelivr.net/npm/sortablejs@1.15.6/Sortable.min.js"></script>
<script src="{{ asset('bundles/survey/js/survey-frontend-editor-sort.js') }}"></script>
@@ -1,6 +1,7 @@
{% trans_default_domain 'messages' %} {% trans_default_domain 'messages' %}
{% include '@Survey/frontend/_survey_assets.html.twig' %} {% include '@Survey/frontend/_survey_assets.html.twig' %}
{% include '@Survey/frontend/_survey_question_sort_assets.html.twig' %}
<link rel="stylesheet" href="{{ asset('assets/handorgel/css/handorgel.min.css') }}"> <link rel="stylesheet" href="{{ asset('assets/handorgel/css/handorgel.min.css') }}">
{% include '@Survey/frontend/_survey_branding.html.twig' %} {% include '@Survey/frontend/_survey_branding.html.twig' %}
@@ -47,6 +48,7 @@
{% else %} {% else %}
{% set surveyMetaFormId = 'survey-meta-form' %} {% set surveyMetaFormId = 'survey-meta-form' %}
{% set newQuestionFormId = 'survey-question-form-new' %} {% set newQuestionFormId = 'survey-question-form-new' %}
{% set canQuestionReorder = canQuestionReorder|default(false) %}
<div class="survey-button-row survey-button-row--editor-actions"> <div class="survey-button-row survey-button-row--editor-actions">
{% if backUrl %}<a class="survey-button secondary" href="{{ backUrl }}">{{ 'survey.edit.back_to_list'|trans }}</a>{% endif %} {% if backUrl %}<a class="survey-button secondary" href="{{ backUrl }}">{{ 'survey.edit.back_to_list'|trans }}</a>{% endif %}
@@ -74,25 +76,48 @@
<div class="survey-grid-columns survey-editor-layout"> <div class="survey-grid-columns survey-editor-layout">
<div class="survey-question-editor-shell" data-survey-question-editor> <div class="survey-question-editor-shell" data-survey-question-editor>
<div class="survey-accordion-list handorgel" data-survey-handorgel> {% if canQuestionReorder %}
<form method="post" action="{{ metadataFormActionUrl }}" class="survey-question-order-save-shell" data-survey-question-order-shell hidden>
<input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}">
<input type="hidden" name="_survey_action" value="reorder-questions">
<input type="hidden" name="item_id" value="{{ survey.id }}">
<input type="hidden" name="_token" value="{{ csrf_token('reorder-questions-' ~ survey.id) }}">
<input type="hidden" name="question_order" value="" data-survey-question-order-input>
<button class="survey-button primary survey-question-order-save">{{ 'survey.edit.save_question_order'|trans }}</button>
</form>
{% endif %}
<div class="survey-accordion-list handorgel" data-survey-handorgel{% if canQuestionReorder %} data-survey-question-sortable="1"{% endif %}>
{% for question in questions %} {% for question in questions %}
{% set questionAnchor = 'survey-question-' ~ question.id %} {% set questionAnchor = 'survey-question-' ~ question.id %}
{% set isOpen = activeQuestionId == question.id %} {% set isOpen = activeQuestionId == question.id %}
{% set questionItemForm = questionForms[question.id]|default(null) %} {% set questionItemForm = questionForms[question.id]|default(null) %}
{% set questionFormId = 'survey-question-form-' ~ question.id %} {% set questionFormId = 'survey-question-form-' ~ question.id %}
<article id="{{ questionAnchor }}" class="survey-accordion-item"> <article id="{{ questionAnchor }}" class="survey-accordion-item"{% if canQuestionReorder %} data-survey-question-item data-question-id="{{ question.id }}"{% endif %}>
<h4 class="survey-accordion-header handorgel__header"> <h4 class="survey-accordion-header handorgel__header">
<button <div class="survey-accordion-header-row">
type="button" <button
class="survey-accordion-toggle handorgel__header__button" type="button"
> class="survey-accordion-toggle handorgel__header__button"
<span class="survey-accordion-heading"> >
<span class="survey-accordion-index">{{ loop.index }}.</span> <span class="survey-accordion-heading">
<span class="survey-accordion-copy"> <span class="survey-accordion-index">{{ loop.index }}.</span>
<strong>{{ question.question|striptags|trim ?: ('survey.edit.question_fallback'|trans({'%id%': question.id})) }}</strong> <span class="survey-accordion-copy">
<strong>{{ question.question|striptags|trim ?: ('survey.edit.question_fallback'|trans({'%id%': question.id})) }}</strong>
</span>
</span> </span>
</span> </button>
</button> {% if canQuestionReorder %}
<button type="button" class="survey-accordion-sort-handle" data-survey-question-sort-handle aria-label="{{ 'survey.edit.reorder_question'|trans }}" title="{{ 'survey.edit.reorder_question'|trans }}">
<span class="survey-sort-icon" aria-hidden="true">
<svg viewBox="0 0 18 24" role="presentation" focusable="false">
<path d="M9 3 11.6 5.8H6.4z" fill="currentColor"></path>
<path d="M9 21 6.4 18.2h5.2z" fill="currentColor"></path>
<path d="M4.5 12h9" stroke="currentColor" stroke-linecap="round" stroke-width="1.8"></path>
</svg>
</span>
</button>
{% endif %}
</div>
</h4> </h4>
<div class="survey-accordion-panel handorgel__content"> <div class="survey-accordion-panel handorgel__content">
<div class="handorgel__content__inner" data-survey-question-editor> <div class="handorgel__content__inner" data-survey-question-editor>
@@ -53,7 +53,6 @@
<input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}"> <input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}">
<input type="hidden" name="_survey_action" value="duplicate-survey"> <input type="hidden" name="_survey_action" value="duplicate-survey">
<input type="hidden" name="item_id" value="{{ survey.id }}"> <input type="hidden" name="item_id" value="{{ survey.id }}">
<input type="hidden" name="_token" value="{{ csrf_token('duplicate-survey-' ~ survey.id) }}">
<button class="survey-button secondary" onclick="return confirm('{{ 'survey.list.duplicate_confirm'|trans({'%title%': survey.title|striptags|trim})|e('js') }}');">{{ 'survey.list.duplicate'|trans }}</button> <button class="survey-button secondary" onclick="return confirm('{{ 'survey.list.duplicate_confirm'|trans({'%title%': survey.title|striptags|trim})|e('js') }}');">{{ 'survey.list.duplicate'|trans }}</button>
</form> </form>
</div> </div>
@@ -82,14 +81,12 @@
<input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}"> <input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}">
<input type="hidden" name="_survey_action" value="duplicate-survey"> <input type="hidden" name="_survey_action" value="duplicate-survey">
<input type="hidden" name="item_id" value="{{ survey.id }}"> <input type="hidden" name="item_id" value="{{ survey.id }}">
<input type="hidden" name="_token" value="{{ csrf_token('duplicate-survey-' ~ survey.id) }}">
<button class="survey-button secondary" onclick="return confirm('{{ 'survey.list.duplicate_confirm'|trans({'%title%': survey.title|striptags|trim})|e('js') }}');">{{ 'survey.list.duplicate'|trans }}</button> <button class="survey-button secondary" onclick="return confirm('{{ 'survey.list.duplicate_confirm'|trans({'%title%': survey.title|striptags|trim})|e('js') }}');">{{ 'survey.list.duplicate'|trans }}</button>
</form> </form>
<form method="post" class="survey-question-action-form"> <form method="post" class="survey-question-action-form">
<input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}"> <input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}">
<input type="hidden" name="_survey_action" value="delete-survey"> <input type="hidden" name="_survey_action" value="delete-survey">
<input type="hidden" name="item_id" value="{{ survey.id }}"> <input type="hidden" name="item_id" value="{{ survey.id }}">
<input type="hidden" name="_token" value="{{ csrf_token('delete-survey-' ~ survey.id) }}">
<button class="survey-button danger" onclick="return confirm('{{ 'survey.list.delete_confirm'|trans|e('js') }}') && confirm('{{ 'survey.list.delete_confirm_second'|trans|e('js') }}');">{{ 'survey.list.delete'|trans }}</button> <button class="survey-button danger" onclick="return confirm('{{ 'survey.list.delete_confirm'|trans|e('js') }}') && confirm('{{ 'survey.list.delete_confirm_second'|trans|e('js') }}');">{{ 'survey.list.delete'|trans }}</button>
</form> </form>
</div> </div>
@@ -99,7 +96,6 @@
<input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}"> <input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}">
<input type="hidden" name="_survey_action" value="publish-survey"> <input type="hidden" name="_survey_action" value="publish-survey">
<input type="hidden" name="item_id" value="{{ survey.id }}"> <input type="hidden" name="item_id" value="{{ survey.id }}">
<input type="hidden" name="_token" value="{{ csrf_token('publish-survey-' ~ survey.id) }}">
<button <button
type="submit" type="submit"
class="survey-button primary survey-list-publish-button" class="survey-button primary survey-list-publish-button"
@@ -117,7 +113,6 @@
<input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}"> <input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}">
<input type="hidden" name="_survey_action" value="toggle-active"> <input type="hidden" name="_survey_action" value="toggle-active">
<input type="hidden" name="item_id" value="{{ survey.id }}"> <input type="hidden" name="item_id" value="{{ survey.id }}">
<input type="hidden" name="_token" value="{{ csrf_token('toggle-active-' ~ survey.id) }}">
<button type="submit" class="survey-visibility-dot{{ survey.isActive ? ' is-on' : '' }}" title="{{ survey.isActive ? 'survey.edit.deactivate_survey'|trans : 'survey.edit.activate_survey'|trans }}" aria-label="{{ survey.isActive ? 'survey.edit.deactivate_survey'|trans : 'survey.edit.activate_survey'|trans }}"></button> <button type="submit" class="survey-visibility-dot{{ survey.isActive ? ' is-on' : '' }}" title="{{ survey.isActive ? 'survey.edit.deactivate_survey'|trans : 'survey.edit.activate_survey'|trans }}" aria-label="{{ survey.isActive ? 'survey.edit.deactivate_survey'|trans : 'survey.edit.activate_survey'|trans }}"></button>
</form> </form>
</div> </div>
+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: 3;
}
}
@media (max-width: 1100px) {
.tl_checkbox_wizard.survey-assigned-members-checkbox-wizard {
column-count: 2;
}
}
@media (max-width: 767px) {
.tl_checkbox_wizard.survey-assigned-members-checkbox-wizard {
column-count: 1;
}
}
+116
View File
@@ -0,0 +1,116 @@
(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);
var observer = new MutationObserver(function () {
normalizeSearchField();
});
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(4px);}',
'.survey-sort-icon svg path:first-child{transform:translateY(1.4px);transform-box:fill-box;transform-origin:center;}',
'.survey-sort-icon svg path:nth-child(2){transform:translateY(-1.4px);transform-box:fill-box;transform-origin:center;}',
'.survey-sort-icon svg path[stroke]{stroke-width:2px;}'
].join('');
document.head.appendChild(style);
}
function initSurveyQuestionSorting() {
ensureSortIconStyles();
if (typeof Sortable !== 'function') {
return;
}
var sortableList = document.querySelector('[data-survey-question-sortable]');
var orderForm = document.querySelector('[data-survey-question-order-shell]');
var orderInput = document.querySelector('[data-survey-question-order-input]');
if (!sortableList || !orderForm || !orderInput || sortableList._surveyQuestionSortable) {
return;
}
var getOrderedIds = function () {
return Array.from(sortableList.querySelectorAll('[data-survey-question-item]')).map(function (item) {
return item.getAttribute('data-question-id') || '';
}).filter(function (value) {
return value !== '';
});
};
var initialOrder = getOrderedIds().join(',');
var updateOrderField = function () {
orderInput.value = getOrderedIds().join(',');
};
var updateQuestionIndices = function () {
sortableList.querySelectorAll('[data-survey-question-item]').forEach(function (item, index) {
var indexNode = item.querySelector('.survey-accordion-index');
if (indexNode) {
indexNode.textContent = (index + 1) + '.';
}
});
};
var updateSaveVisibility = function () {
var currentOrder = getOrderedIds().join(',');
var isDirty = currentOrder !== initialOrder;
orderForm.hidden = !isDirty;
orderForm.classList.toggle('is-visible', isDirty);
updateOrderField();
};
sortableList.querySelectorAll('[data-survey-question-sort-handle]').forEach(function (handle) {
handle.addEventListener('click', function (event) {
event.preventDefault();
event.stopPropagation();
});
});
updateQuestionIndices();
updateOrderField();
updateSaveVisibility();
sortableList._surveyQuestionSortable = Sortable.create(sortableList, {
animation: 180,
draggable: '[data-survey-question-item]',
handle: '[data-survey-question-sort-handle]',
ghostClass: 'sortable-ghost',
forceFallback: true,
fallbackTolerance: 3,
onStart: function (event) {
if (event.item) {
event.item.classList.add('is-reordering');
}
},
onEnd: function (event) {
if (event.item) {
event.item.classList.remove('is-reordering');
}
updateQuestionIndices();
updateSaveVisibility();
}
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initSurveyQuestionSorting, { once: true });
} else {
initSurveyQuestionSorting();
}
window.addEventListener('load', initSurveyQuestionSorting, { once: true });
})();
@@ -188,6 +188,7 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController
$template->set('questionForms', $questionFormViews); $template->set('questionForms', $questionFormViews);
$template->set('newQuestionForm', $newQuestionFormView); $template->set('newQuestionForm', $newQuestionFormView);
$template->set('questions', $questions); $template->set('questions', $questions);
$template->set('canQuestionReorder', !$structureLocked && count($questions) > 1);
$conditionOverview = $this->surveyConditionRepository->findOverviewBySurvey((int) $survey->id); $conditionOverview = $this->surveyConditionRepository->findOverviewBySurvey((int) $survey->id);
$template->set('conditions', $this->buildJumpRuleOverview($questions, $conditionOverview)); $template->set('conditions', $this->buildJumpRuleOverview($questions, $conditionOverview));
@@ -279,6 +280,11 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController
$this->addFlash('success', 'Die Frage wurde gelöscht.'); $this->addFlash('success', 'Die Frage wurde gelöscht.');
} }
if ('reorder-questions' === $action) {
$this->surveyEditorService->reorderQuestions($survey, $this->parseQuestionOrder($request));
$this->addFlash('success', 'Die neue Reihenfolge der Fragen wurde gespeichert.');
}
if ('delete-condition' === $action) { if ('delete-condition' === $action) {
$this->surveyEditorService->deleteCondition($survey, $itemId); $this->surveyEditorService->deleteCondition($survey, $itemId);
$this->addFlash('success', 'Die Bedingung wurde gelöscht.'); $this->addFlash('success', 'Die Bedingung wurde gelöscht.');
@@ -290,6 +296,23 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController
return new RedirectResponse($this->buildEditUrl($model, $request, (int) $survey->id)); return new RedirectResponse($this->buildEditUrl($model, $request, (int) $survey->id));
} }
/**
* @return list<int>
*/
private function parseQuestionOrder(Request $request): array
{
$rawOrder = trim((string) $request->request->get('question_order', ''));
if ('' === $rawOrder) {
throw new \RuntimeException('Die neue Reihenfolge der Fragen fehlt.');
}
return array_values(array_filter(
array_map('intval', explode(',', $rawOrder)),
static fn (int $questionId): bool => $questionId > 0,
));
}
private function resolveActiveQuestion(Request $request, int $surveyId): mixed private function resolveActiveQuestion(Request $request, int $surveyId): mixed
{ {
$questionId = (int) $request->query->get('question', 0); $questionId = (int) $request->query->get('question', 0);
@@ -317,13 +340,15 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController
private function buildQuestionChoices(int $surveyId, int $excludeQuestionId = 0): array private function buildQuestionChoices(int $surveyId, int $excludeQuestionId = 0): array
{ {
$choices = []; $choices = [];
$position = 1;
foreach ($this->surveyQuestionRepository->findAllBySurvey($surveyId) as $question) { foreach ($this->surveyQuestionRepository->findAllBySurvey($surveyId) as $question) {
if ($excludeQuestionId > 0 && (int) $question->id === $excludeQuestionId) { if ($excludeQuestionId > 0 && (int) $question->id === $excludeQuestionId) {
++$position;
continue; continue;
} }
$choices[(int) $question->id] = sprintf('#%d %s', (int) $question->id, (string) $question->question); $choices[(int) $question->id] = sprintf('%d. %s', $position++, (string) $question->question);
} }
return $choices; return $choices;
@@ -90,9 +90,8 @@ final class MemberSurveyListController extends AbstractFrontendModuleController
{ {
$surveyId = (int) $request->request->get('item_id', 0); $surveyId = (int) $request->request->get('item_id', 0);
$action = (string) $request->request->get('_survey_action'); $action = (string) $request->request->get('_survey_action');
$token = (string) $request->request->get('_token');
if ($surveyId <= 0 || !$this->isCsrfTokenValid($action.'-'.$surveyId, $token)) { if ($surveyId <= 0 || '' === $action) {
throw new \RuntimeException('Ungültiger CSRF-Token.'); throw new \RuntimeException('Ungültiger CSRF-Token.');
} }
+2 -1
View File
@@ -196,9 +196,10 @@ final class SurveyDcaListener
private function buildQuestionOptions(int $surveyId): array private function buildQuestionOptions(int $surveyId): array
{ {
$choices = []; $choices = [];
$position = 1;
foreach ($this->surveyQuestionRepository->findAllBySurvey($surveyId) as $question) { foreach ($this->surveyQuestionRepository->findAllBySurvey($surveyId) as $question) {
$choices[(int) $question->id] = sprintf('#%d %s', (int) $question->id, (string) $question->question); $choices[(int) $question->id] = sprintf('%d. %s', $position++, (string) $question->question);
} }
return $choices; return $choices;
+1 -1
View File
@@ -156,7 +156,7 @@ final class SurveyEditorRepository
foreach ($rows as $row) { foreach ($rows as $row) {
$name = trim(sprintf('%s %s', (string) $row['firstname'], (string) $row['lastname'])); $name = trim(sprintf('%s %s', (string) $row['firstname'], (string) $row['lastname']));
$choices[(int) $row['id']] = '' !== $name ? sprintf('%s <%s>', $name, (string) $row['email']) : (string) $row['email']; $choices[(int) $row['id']] = '' !== $name ? $name : (string) $row['email'];
} }
return $choices; return $choices;
@@ -165,6 +165,46 @@ final class SurveyQuestionRepository
); );
} }
/**
* @param list<int> $orderedQuestionIds
*/
public function reorderQuestions(int $surveyId, array $orderedQuestionIds): void
{
$orderedQuestionIds = array_values(array_unique(array_filter(
array_map('intval', $orderedQuestionIds),
static fn (int $questionId): bool => $questionId > 0,
)));
$currentQuestionIds = array_map(static fn (SurveyContentModel $question): int => (int) $question->id, $this->findAllBySurvey($surveyId));
if ($orderedQuestionIds === $currentQuestionIds || count($orderedQuestionIds) < 2) {
return;
}
$sortedCurrentIds = $currentQuestionIds;
$sortedOrderedIds = $orderedQuestionIds;
sort($sortedCurrentIds);
sort($sortedOrderedIds);
if ($sortedCurrentIds !== $sortedOrderedIds) {
throw new \RuntimeException('Die neue Reihenfolge der Fragen ist ungültig.');
}
$this->connection->transactional(function () use ($surveyId, $orderedQuestionIds): void {
$sorting = 128;
foreach ($orderedQuestionIds as $questionId) {
$this->connection->update('tl_survey_content', [
'sorting' => $sorting,
], [
'id' => $questionId,
'pid' => $surveyId,
]);
$sorting += 128;
}
});
}
private function hydrate(array $ids): array private function hydrate(array $ids): array
{ {
$models = []; $models = [];
+45 -1
View File
@@ -53,6 +53,28 @@ final class SurveyRepository
return $this->findById((int) $id); return $this->findById((int) $id);
} }
public function titleExists(string $title, int $excludeId = 0): bool
{
$qb = $this->connection->createQueryBuilder()
->select('id')
->from('tl_survey')
->where('title = :title')
->setParameter('title', trim($title))
->setMaxResults(1)
;
if ($excludeId > 0) {
$qb
->andWhere('id != :excludeId')
->setParameter('excludeId', $excludeId)
;
}
$id = $qb->executeQuery()->fetchOne();
return false !== $id;
}
/** /**
* @return list<array<string, mixed>> * @return list<array<string, mixed>>
*/ */
@@ -95,6 +117,7 @@ final class SurveyRepository
'alias' => $this->ensurePublicAlias(null), 'alias' => $this->ensurePublicAlias(null),
'category' => serialize($this->normalizeCategoryIds($data['category'] ?? [])), 'category' => serialize($this->normalizeCategoryIds($data['category'] ?? [])),
'description' => (string) ($data['description'] ?? ''), 'description' => (string) ($data['description'] ?? ''),
'internalNote' => trim((string) ($data['internalNote'] ?? '')),
'published' => '', 'published' => '',
'isActive' => '1', 'isActive' => '1',
'isLocked' => '', 'isLocked' => '',
@@ -121,6 +144,7 @@ final class SurveyRepository
'alias' => $this->ensurePublicAlias((string) $survey->alias, (int) $survey->id), 'alias' => $this->ensurePublicAlias((string) $survey->alias, (int) $survey->id),
'category' => serialize($this->normalizeCategoryIds($data['category'] ?? StringUtil::deserialize($survey->category, true))), 'category' => serialize($this->normalizeCategoryIds($data['category'] ?? StringUtil::deserialize($survey->category, true))),
'description' => (string) ($data['description'] ?? $survey->description), 'description' => (string) ($data['description'] ?? $survey->description),
'internalNote' => trim((string) ($data['internalNote'] ?? $survey->internalNote)),
'published' => array_key_exists('published', $data) ? (!empty($data['published']) ? '1' : '') : (string) $survey->published, 'published' => array_key_exists('published', $data) ? (!empty($data['published']) ? '1' : '') : (string) $survey->published,
'isActive' => array_key_exists('isActive', $data) ? (!empty($data['isActive']) ? '1' : '') : (string) ($survey->isActive ?? ''), 'isActive' => array_key_exists('isActive', $data) ? (!empty($data['isActive']) ? '1' : '') : (string) ($survey->isActive ?? ''),
'updatedBy' => $memberId, 'updatedBy' => $memberId,
@@ -192,7 +216,7 @@ final class SurveyRepository
'templateFilter' => '1' === (string) $survey->isTemplate ? '1' : '0', 'templateFilter' => '1' === (string) $survey->isTemplate ? '1' : '0',
'templateSummary' => '1' === (string) $survey->isTemplate ? 'Ja' : 'Nein', 'templateSummary' => '1' === (string) $survey->isTemplate ? 'Ja' : 'Nein',
'assignedMembersFilter' => implode(',', $memberIds), 'assignedMembersFilter' => implode(',', $memberIds),
'assignedMembersSummary' => implode(', ', $memberNames), 'assignedMembersSummary' => $this->buildAssignedMembersSummary($memberNames),
], [ ], [
'id' => $surveyId, 'id' => $surveyId,
]); ]);
@@ -391,6 +415,26 @@ final class SurveyRepository
); );
} }
/**
* @param list<string> $memberNames
*/
private function buildAssignedMembersSummary(array $memberNames): string
{
if ([] === $memberNames) {
return '';
}
$visibleNames = array_slice($memberNames, 0, 3);
$remainingCount = count($memberNames) - count($visibleNames);
$summary = implode(', ', $visibleNames);
if ($remainingCount <= 0) {
return $summary;
}
return sprintf('%s, ... und %d weitere', $summary, $remainingCount);
}
private function isValidPublicAlias(string $alias): bool private function isValidPublicAlias(string $alias): bool
{ {
return 1 === preg_match('/^[a-z0-9]{20}$/', $alias); return 1 === preg_match('/^[a-z0-9]{20}$/', $alias);
+35 -4
View File
@@ -97,6 +97,16 @@ final class SurveyEditorService
$this->surveyRepository->touch((int) $survey->id); $this->surveyRepository->touch((int) $survey->id);
} }
/**
* @param list<int> $orderedQuestionIds
*/
public function reorderQuestions(SurveyModel $survey, array $orderedQuestionIds): void
{
$this->assertUnlocked($survey);
$this->surveyQuestionRepository->reorderQuestions((int) $survey->id, $orderedQuestionIds);
$this->surveyRepository->touch((int) $survey->id);
}
public function deleteSurvey(SurveyModel $survey): void public function deleteSurvey(SurveyModel $survey): void
{ {
$this->surveyRepository->deleteCascade((int) $survey->id); $this->surveyRepository->deleteCascade((int) $survey->id);
@@ -108,8 +118,9 @@ final class SurveyEditorService
$sourceSurveyId = (int) $survey->id; $sourceSurveyId = (int) $survey->id;
$memberIds = $this->surveyEditorRepository->findMemberIdsBySurvey($sourceSurveyId); $memberIds = $this->surveyEditorRepository->findMemberIdsBySurvey($sourceSurveyId);
$duplicate = $this->surveyRepository->create($actorMemberId ?? (int) ($survey->createdBy ?? 0), [ $duplicate = $this->surveyRepository->create($actorMemberId ?? (int) ($survey->createdBy ?? 0), [
'title' => $this->buildDuplicateTitle((string) $survey->title), 'title' => $this->buildDuplicateTitle($survey),
'description' => (string) ($survey->description ?? ''), 'description' => (string) ($survey->description ?? ''),
'internalNote' => (string) ($survey->internalNote ?? ''),
'category' => $survey->category, 'category' => $survey->category,
'isTemplate' => false, 'isTemplate' => false,
]); ]);
@@ -244,14 +255,34 @@ final class SurveyEditorService
} }
} }
private function buildDuplicateTitle(string $title): string private function buildDuplicateTitle(SurveyModel $survey): string
{ {
$title = trim($title); $title = trim((string) $survey->title);
if ('' === $title) { if ('' === $title) {
$title = 'Neue Umfrage'; $title = 'Neue Umfrage';
} }
return mb_substr($title.' - Kopie', 0, 255); $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;
} }
} }
+2
View File
@@ -80,6 +80,8 @@ survey:
delete: "Löschen" delete: "Löschen"
no_questions: "Noch keine Fragen angelegt." no_questions: "Noch keine Fragen angelegt."
new_question: "Neue Frage anlegen" new_question: "Neue Frage anlegen"
reorder_question: "Frage verschieben"
save_question_order: "Neue Reihenfolge speichern"
open_question: "Akkordeon öffnen" open_question: "Akkordeon öffnen"
close_question: "Akkordeon schließen" close_question: "Akkordeon schließen"
conditions_title: "Sprungregeln" conditions_title: "Sprungregeln"
+2
View File
@@ -80,6 +80,8 @@ survey:
delete: "Delete" delete: "Delete"
no_questions: "No questions created yet." no_questions: "No questions created yet."
new_question: "Create new question" new_question: "Create new question"
reorder_question: "Reorder question"
save_question_order: "Save new order"
open_question: "Open accordion" open_question: "Open accordion"
close_question: "Close accordion" close_question: "Close accordion"
conditions_title: "Jump rules" conditions_title: "Jump rules"