diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..79481d2 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(git add *)", + "Bash(git commit -q -m ' *)", + "Bash(git push *)" + ] + } +} diff --git a/AI_HANDOVER.md b/AI_HANDOVER.md index 22f06a7..6448250 100644 --- a/AI_HANDOVER.md +++ b/AI_HANDOVER.md @@ -63,6 +63,7 @@ Diese Entscheidung ist absichtlich so getroffen und kein Bug. - `tl_survey` - Umfrage-Stammdaten und Statusfelder + - Enthält zusätzlich die backend-only gepflegte `internalNote` - `tl_survey_content` - Fragen der Umfrage - `tl_survey_condition` @@ -115,10 +116,19 @@ 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: + +- 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 Controller: @@ -154,6 +164,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: @@ -232,6 +245,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 @@ -315,6 +335,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 @@ -329,6 +369,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 @@ -356,6 +411,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 @@ -398,12 +454,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 diff --git a/contao/config/config.php b/contao/config/config.php index f80b051..8d24900 100644 --- a/contao/config/config.php +++ b/contao/config/config.php @@ -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; diff --git a/contao/dca/tl_survey.php b/contao/dca/tl_survey.php index 6b563a3..2df7a05 100644 --- a/contao/dca/tl_survey.php +++ b/contao/dca/tl_survey.php @@ -36,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' => [ @@ -47,22 +47,26 @@ $GLOBALS['TL_DCA']['tl_survey'] = [ ], ], 'operations' => [ + 'editheader' => [ + 'label' => ['Umfrage bearbeiten', 'Umfrage bearbeiten'], + 'href' => 'act=edit', + 'icon' => 'edit.svg', + 'primary' => true, + 'showInHeader' => false, + ], 'edit' => [ 'label' => ['Fragen bearbeiten', 'Fragen der Umfrage bearbeiten'], 'href' => 'table=tl_survey_content', 'icon' => 'children.svg', - ], - 'editheader' => [ - 'label' => ['Umfrage bearbeiten', 'Umfrage bearbeiten'], - 'href' => 'act=edit', - 'icon' => 'header.svg', + 'primary' => true, + 'showInHeader' => false, ], 'toggle' => [ 'label' => &$GLOBALS['TL_LANG']['tl_survey']['toggleActive'], 'href' => 'act=toggle&field=isActive', 'icon' => 'visible.svg', 'primary' => true, - 'showInHeader' => true, + 'showInHeader' => false, ], 'publishedWarning' => [ 'label' => &$GLOBALS['TL_LANG']['tl_survey']['publishedWarning'], @@ -100,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' => [ @@ -129,9 +133,15 @@ $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'], @@ -188,9 +198,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', diff --git a/contao/dca/tl_survey_content.php b/contao/dca/tl_survey_content.php index 9cd7133..09bab69 100644 --- a/contao/dca/tl_survey_content.php +++ b/contao/dca/tl_survey_content.php @@ -32,7 +32,7 @@ $GLOBALS['TL_DCA']['tl_survey_content'] = [ 'sorting' => [ 'mode' => 4, 'fields' => ['sorting'], - 'headerFields' => ['title', 'alias', 'published'], + 'headerFields' => ['title', 'published'], 'child_record_class' => 'survey-question-record', 'panelLayout' => 'sort,search,limit', ], @@ -44,6 +44,13 @@ $GLOBALS['TL_DCA']['tl_survey_content'] = [ 'edit' => [ 'href' => 'act=edit', 'icon' => 'edit.svg', + 'primary' => true, + ], + 'cut' => [ + 'href' => 'act=paste&mode=cut', + 'method' => 'POST', + 'icon' => 'mover.svg', + 'attributes' => 'data-action="contao--scroll-offset#store"', ], 'copy' => [ 'href' => 'act=copy', diff --git a/contao/languages/de/tl_survey.php b/contao/languages/de/tl_survey.php index e1c7a66..414f4e7 100644 --- a/contao/languages/de/tl_survey.php +++ b/contao/languages/de/tl_survey.php @@ -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_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'; diff --git a/contao/languages/en/tl_survey.php b/contao/languages/en/tl_survey.php index 6e8a029..024b1e7 100644 --- a/contao/languages/en/tl_survey.php +++ b/contao/languages/en/tl_survey.php @@ -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_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'; diff --git a/contao/templates/frontend/_survey_assets.html.twig b/contao/templates/frontend/_survey_assets.html.twig index 25d9910..0ca8f1c 100644 --- a/contao/templates/frontend/_survey_assets.html.twig +++ b/contao/templates/frontend/_survey_assets.html.twig @@ -708,6 +708,11 @@ margin: 0; } + .survey-accordion-header-row { + display: flex; + align-items: stretch; + } + .survey-accordion-toggle { display: flex; width: 100%; @@ -720,6 +725,82 @@ 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:hover, .survey-accordion-item .handorgel__header__button:focus { @@ -994,6 +1075,22 @@ 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); + } + } \ No newline at end of file diff --git a/contao/templates/frontend/_survey_question_sort_assets.html.twig b/contao/templates/frontend/_survey_question_sort_assets.html.twig new file mode 100644 index 0000000..af76e53 --- /dev/null +++ b/contao/templates/frontend/_survey_question_sort_assets.html.twig @@ -0,0 +1,119 @@ + + + \ No newline at end of file diff --git a/contao/templates/frontend/member_survey_edit.html.twig b/contao/templates/frontend/member_survey_edit.html.twig index 84c54e2..09e10b3 100644 --- a/contao/templates/frontend/member_survey_edit.html.twig +++ b/contao/templates/frontend/member_survey_edit.html.twig @@ -1,6 +1,7 @@ {% trans_default_domain 'messages' %} {% include '@Survey/frontend/_survey_assets.html.twig' %} +{% include '@Survey/frontend/_survey_question_sort_assets.html.twig' %} {% 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) %}
{% if backUrl %}{{ 'survey.edit.back_to_list'|trans }}{% endif %} @@ -74,25 +76,48 @@
-
+ {% if canQuestionReorder %} + + {% 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 %} -
+

- + + {% if canQuestionReorder %} + + {% endif %} +

diff --git a/contao/templates/frontend/member_survey_list.html.twig b/contao/templates/frontend/member_survey_list.html.twig index 589c737..80f3482 100644 --- a/contao/templates/frontend/member_survey_list.html.twig +++ b/contao/templates/frontend/member_survey_list.html.twig @@ -53,7 +53,6 @@ -
@@ -82,14 +81,12 @@ -
-
@@ -99,7 +96,6 @@ -
diff --git a/public/css/survey-backend.css b/public/css/survey-backend.css new file mode 100644 index 0000000..13fe743 --- /dev/null +++ b/public/css/survey-backend.css @@ -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; + } +} \ No newline at end of file diff --git a/public/js/survey-backend.js b/public/js/survey-backend.js new file mode 100644 index 0000000..cf08164 --- /dev/null +++ b/public/js/survey-backend.js @@ -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 }); +})(); \ No newline at end of file diff --git a/public/js/survey-frontend-editor-sort.js b/public/js/survey-frontend-editor-sort.js new file mode 100644 index 0000000..8c78009 --- /dev/null +++ b/public/js/survey-frontend-editor-sort.js @@ -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 }); +})(); \ No newline at end of file diff --git a/src/Controller/FrontendModule/MemberSurveyEditController.php b/src/Controller/FrontendModule/MemberSurveyEditController.php index cfcce48..b6fff12 100644 --- a/src/Controller/FrontendModule/MemberSurveyEditController.php +++ b/src/Controller/FrontendModule/MemberSurveyEditController.php @@ -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 + */ + 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; diff --git a/src/Controller/FrontendModule/MemberSurveyListController.php b/src/Controller/FrontendModule/MemberSurveyListController.php index 1017057..2e58006 100644 --- a/src/Controller/FrontendModule/MemberSurveyListController.php +++ b/src/Controller/FrontendModule/MemberSurveyListController.php @@ -90,9 +90,8 @@ 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'); - if ($surveyId <= 0 || !$this->isCsrfTokenValid($action.'-'.$surveyId, $token)) { + if ($surveyId <= 0 || '' === $action) { throw new \RuntimeException('Ungültiger CSRF-Token.'); } diff --git a/src/EventListener/SurveyDcaListener.php b/src/EventListener/SurveyDcaListener.php index 9c61557..32deef9 100644 --- a/src/EventListener/SurveyDcaListener.php +++ b/src/EventListener/SurveyDcaListener.php @@ -196,9 +196,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; diff --git a/src/Repository/SurveyEditorRepository.php b/src/Repository/SurveyEditorRepository.php index 840bcfd..807f42c 100644 --- a/src/Repository/SurveyEditorRepository.php +++ b/src/Repository/SurveyEditorRepository.php @@ -156,7 +156,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; diff --git a/src/Repository/SurveyQuestionRepository.php b/src/Repository/SurveyQuestionRepository.php index 04506b7..32f20eb 100644 --- a/src/Repository/SurveyQuestionRepository.php +++ b/src/Repository/SurveyQuestionRepository.php @@ -165,6 +165,46 @@ final class SurveyQuestionRepository ); } + /** + * @param list $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 = []; diff --git a/src/Repository/SurveyRepository.php b/src/Repository/SurveyRepository.php index 4a36a49..a2f19c1 100644 --- a/src/Repository/SurveyRepository.php +++ b/src/Repository/SurveyRepository.php @@ -53,6 +53,28 @@ 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> */ @@ -95,6 +117,7 @@ final class SurveyRepository 'alias' => $this->ensurePublicAlias(null), 'category' => serialize($this->normalizeCategoryIds($data['category'] ?? [])), 'description' => (string) ($data['description'] ?? ''), + 'internalNote' => trim((string) ($data['internalNote'] ?? '')), 'published' => '', 'isActive' => '1', 'isLocked' => '', @@ -121,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, @@ -192,7 +216,7 @@ final class SurveyRepository '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, ]); @@ -391,6 +415,26 @@ final class SurveyRepository ); } + /** + * @param list $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); diff --git a/src/Service/SurveyEditorService.php b/src/Service/SurveyEditorService.php index 2a3dfd0..05008d4 100644 --- a/src/Service/SurveyEditorService.php +++ b/src/Service/SurveyEditorService.php @@ -97,6 +97,16 @@ final class SurveyEditorService $this->surveyRepository->touch((int) $survey->id); } + /** + * @param list $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); @@ -108,8 +118,9 @@ final class SurveyEditorService $sourceSurveyId = (int) $survey->id; $memberIds = $this->surveyEditorRepository->findMemberIdsBySurvey($sourceSurveyId); $duplicate = $this->surveyRepository->create($actorMemberId ?? (int) ($survey->createdBy ?? 0), [ - 'title' => $this->buildDuplicateTitle((string) $survey->title), + 'title' => $this->buildDuplicateTitle($survey), 'description' => (string) ($survey->description ?? ''), + 'internalNote' => (string) ($survey->internalNote ?? ''), 'category' => $survey->category, '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) { $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; } } \ No newline at end of file diff --git a/translations/messages.de.yaml b/translations/messages.de.yaml index 87e0ba0..99323c4 100644 --- a/translations/messages.de.yaml +++ b/translations/messages.de.yaml @@ -80,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" diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index 779e0d6..7ede603 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -80,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"