From 1174410c1bddcccb3e6491714f544e6c97e5666f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BCrgen=20Mummert?= Date: Fri, 29 May 2026 10:19:19 +0200 Subject: [PATCH] Refine survey editor workflow and publication handling --- composer.json | 2 +- contao/dca/tl_survey.php | 53 +++- contao/dca/tl_survey_answer.php | 9 + contao/dca/tl_survey_category.php | 2 +- contao/dca/tl_survey_condition.php | 5 +- contao/dca/tl_survey_content.php | 5 +- contao/dca/tl_survey_editor.php | 2 +- contao/languages/de/modules.php | 8 +- contao/languages/de/tl_member.php | 2 +- contao/languages/de/tl_module.php | 6 +- contao/languages/de/tl_settings.php | 6 +- contao/languages/de/tl_survey.php | 18 +- contao/languages/de/tl_survey_category.php | 2 +- contao/languages/de/tl_survey_condition.php | 2 +- contao/languages/de/tl_survey_content.php | 26 +- contao/languages/de/tl_survey_submission.php | 2 +- contao/languages/en/tl_survey.php | 8 + .../frontend/_survey_assets.html.twig | 56 ++++ .../frontend/member_survey_edit.html.twig | 83 +++--- .../frontend/member_survey_list.html.twig | 37 +-- .../templates/frontend/show_survey.html.twig | 24 +- .../frontend/show_survey_results.html.twig | 18 +- .../MemberSurveyEditController.php | 102 ++++--- .../MemberSurveyListController.php | 18 +- .../FrontendModule/ShowSurveyController.php | 188 ++++++++++-- src/EventListener/SurveyDcaListener.php | 190 +++++++++++- src/Form/SurveyConditionEditorType.php | 2 +- src/Form/SurveyEditorType.php | 2 +- src/Form/SurveyQuestionAnswerType.php | 2 +- src/Form/SurveyQuestionEditorType.php | 2 +- src/Repository/SurveyAnswerRepository.php | 64 +++- src/Repository/SurveyRepository.php | 40 ++- src/Repository/SurveySubmissionRepository.php | 11 + src/Service/SurveyEditorService.php | 30 +- src/Service/SurveyFlowService.php | 22 ++ src/Service/SurveyPermissionService.php | 2 +- src/Service/SurveyResultsViewService.php | 275 +++++++++++------- src/Service/SurveySubmissionService.php | 94 +++++- translations/messages.de.yaml | 18 +- translations/messages.en.yaml | 18 +- 40 files changed, 1121 insertions(+), 335 deletions(-) diff --git a/composer.json b/composer.json index 699c356..71a40bd 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "mummert/survey-bundle", - "description": "Modernes Survey-Bundle fuer Contao 5.7 mit Symfony-Frontend.", + "description": "Modernes Survey-Bundle für Contao 5.7 mit Symfony-Frontend.", "type": "contao-bundle", "license": "proprietary", "require": { diff --git a/contao/dca/tl_survey.php b/contao/dca/tl_survey.php index ed40cf3..d99f4d7 100644 --- a/contao/dca/tl_survey.php +++ b/contao/dca/tl_survey.php @@ -22,6 +22,8 @@ $GLOBALS['TL_DCA']['tl_survey'] = [ 'id' => 'primary', 'alias' => 'unique', 'published' => 'index', + 'isActive' => 'index', + 'isTemplate' => 'index', ], ], ], @@ -33,7 +35,7 @@ $GLOBALS['TL_DCA']['tl_survey'] = [ 'panelLayout' => 'filter;sort,search,limit', ], 'label' => [ - 'fields' => ['title', 'categorySummary', 'assignedMembersSummary'], + 'fields' => ['title', 'categorySummary', 'assignedMembersSummary', 'templateSummary'], 'showColumns' => true, ], 'global_operations' => [ @@ -52,10 +54,22 @@ $GLOBALS['TL_DCA']['tl_survey'] = [ 'href' => 'act=edit', 'icon' => 'header.svg', ], + 'publishSurvey' => [ + 'label' => &$GLOBALS['TL_LANG']['tl_survey']['publishSurvey'], + 'href' => 'act=toggle&field=published', + 'icon' => 'visible.svg', + 'button_callback' => [SurveyDcaListener::class, 'generatePublishSurveyButton'], + ], + 'toggleActive' => [ + 'label' => &$GLOBALS['TL_LANG']['tl_survey']['toggleActive'], + 'href' => 'act=toggle&field=isActive', + 'icon' => 'visible.svg', + 'button_callback' => [SurveyDcaListener::class, 'generateToggleActiveButton'], + ], 'delete' => [ 'href' => 'act=delete', 'icon' => 'delete.svg', - 'attributes' => 'onclick="if(!confirm(\'Eintrag wirklich loeschen?\'))return false;Backend.getScrollOffset()"', + 'attributes' => 'onclick="if(!confirm(\'Eintrag wirklich löschen?\'))return false;Backend.getScrollOffset()"', ], 'show' => [ 'href' => 'act=show', @@ -74,7 +88,7 @@ $GLOBALS['TL_DCA']['tl_survey'] = [ ], ], 'palettes' => [ - 'default' => '{title_legend},title,category,description;{assignment_legend},assignedMembers;{publishing_legend},published,isLocked;{meta_legend},createdAt,updatedAt', + 'default' => '{title_legend},title,category,description;{assignment_legend},assignedMembers;{publishing_legend},isTemplate;{meta_legend},createdAt,updatedAt', ], 'fields' => [ 'id' => [ @@ -105,6 +119,7 @@ $GLOBALS['TL_DCA']['tl_survey'] = [ ], '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'], @@ -113,9 +128,8 @@ $GLOBALS['TL_DCA']['tl_survey'] = [ ], 'categoryFilter' => [ 'label' => &$GLOBALS['TL_LANG']['tl_survey']['categoryFilter'], - 'filter' => true, 'inputType' => 'select', - 'options_callback' => [SurveyDcaListener::class, 'getCategoryOptions'], + 'options_callback' => [SurveyDcaListener::class, 'getUsedCategoryFilterOptions'], 'eval' => ['includeBlankOption' => true, 'chosen' => true, 'findInSet' => true], 'sql' => "varchar(255) NOT NULL default ''", ], @@ -127,8 +141,33 @@ $GLOBALS['TL_DCA']['tl_survey'] = [ 'label' => &$GLOBALS['TL_LANG']['tl_survey']['published'], 'inputType' => 'checkbox', 'eval' => ['tl_class' => 'w50 m12'], + 'save_callback' => [[SurveyDcaListener::class, 'savePublishedState']], 'sql' => "char(1) NOT NULL default ''", ], + 'isActive' => [ + 'label' => &$GLOBALS['TL_LANG']['tl_survey']['isActive'], + 'inputType' => 'checkbox', + 'save_callback' => [[SurveyDcaListener::class, 'saveActiveState']], + 'sql' => "char(1) NOT NULL default ''", + ], + 'isTemplate' => [ + 'label' => &$GLOBALS['TL_LANG']['tl_survey']['isTemplate'], + 'inputType' => 'checkbox', + 'eval' => ['tl_class' => 'w50 m12'], + 'sql' => "char(1) NOT NULL default ''", + ], + 'templateFilter' => [ + 'label' => &$GLOBALS['TL_LANG']['tl_survey']['templateFilter'], + 'filter' => true, + 'inputType' => 'select', + 'options_callback' => [SurveyDcaListener::class, 'getTemplateFilterOptions'], + 'eval' => ['includeBlankOption' => true, 'chosen' => true], + 'sql' => "char(1) NOT NULL default ''", + ], + 'templateSummary' => [ + 'label' => &$GLOBALS['TL_LANG']['tl_survey']['templateSummary'], + 'sql' => "varchar(16) NOT NULL default ''", + ], 'isLocked' => [ 'label' => &$GLOBALS['TL_LANG']['tl_survey']['isLocked'], 'inputType' => 'checkbox', @@ -192,7 +231,7 @@ if ('cli' !== PHP_SAPI) { System::getContainer()->get('database_connection')->fetchFirstColumn('SHOW COLUMNS FROM tl_survey'), ); $hasListMetadataColumns = !array_diff( - ['categoryfilter', 'categorysummary', 'assignedmembersfilter', 'assignedmemberssummary'], + ['categoryfilter', 'categorysummary', 'templatefilter', 'templatesummary', 'assignedmembersfilter', 'assignedmemberssummary'], $columnNames, ); } catch (\Throwable) { @@ -205,6 +244,8 @@ if ('cli' !== PHP_SAPI) { unset( $GLOBALS['TL_DCA']['tl_survey']['fields']['categoryFilter'], $GLOBALS['TL_DCA']['tl_survey']['fields']['categorySummary'], + $GLOBALS['TL_DCA']['tl_survey']['fields']['templateFilter'], + $GLOBALS['TL_DCA']['tl_survey']['fields']['templateSummary'], $GLOBALS['TL_DCA']['tl_survey']['fields']['assignedMembersFilter'], $GLOBALS['TL_DCA']['tl_survey']['fields']['assignedMembersSummary'], ); diff --git a/contao/dca/tl_survey_answer.php b/contao/dca/tl_survey_answer.php index 01bd1b9..265f79a 100644 --- a/contao/dca/tl_survey_answer.php +++ b/contao/dca/tl_survey_answer.php @@ -52,6 +52,15 @@ $GLOBALS['TL_DCA']['tl_survey_answer'] = [ 'label' => &$GLOBALS['TL_LANG']['tl_survey_answer']['questionType'], 'sql' => "varchar(32) NOT NULL default ''", ], + 'questionLabel' => [ + 'sql' => "varchar(255) NOT NULL default ''", + ], + 'questionDescription' => [ + 'sql' => 'text NULL', + ], + 'questionSorting' => [ + 'sql' => 'int(10) unsigned NOT NULL default 0', + ], 'value' => [ 'label' => &$GLOBALS['TL_LANG']['tl_survey_answer']['value'], 'sql' => 'text NULL', diff --git a/contao/dca/tl_survey_category.php b/contao/dca/tl_survey_category.php index 0445ab3..7d4f87a 100644 --- a/contao/dca/tl_survey_category.php +++ b/contao/dca/tl_survey_category.php @@ -34,7 +34,7 @@ $GLOBALS['TL_DCA']['tl_survey_category'] = [ 'delete' => [ 'href' => 'act=delete', 'icon' => 'delete.svg', - 'attributes' => 'onclick="if(!confirm(\'Eintrag wirklich loeschen?\'))return false;Backend.getScrollOffset()"', + 'attributes' => 'onclick="if(!confirm(\'Eintrag wirklich löschen?\'))return false;Backend.getScrollOffset()"', ], 'show' => [ 'href' => 'act=show', diff --git a/contao/dca/tl_survey_condition.php b/contao/dca/tl_survey_condition.php index cfc7fd0..549e539 100644 --- a/contao/dca/tl_survey_condition.php +++ b/contao/dca/tl_survey_condition.php @@ -12,6 +12,9 @@ $GLOBALS['TL_DCA']['tl_survey_condition'] = [ 'dataContainer' => DC_Table::class, 'ptable' => 'tl_survey', 'enableVersioning' => true, + 'onload_callback' => [ + [SurveyDcaListener::class, 'restrictSurveyConditionEditing'], + ], 'onsubmit_callback' => [ [SurveyDcaListener::class, 'touchSurveyConditionParent'], ], @@ -51,7 +54,7 @@ $GLOBALS['TL_DCA']['tl_survey_condition'] = [ 'delete' => [ 'href' => 'act=delete', 'icon' => 'delete.svg', - 'attributes' => 'onclick="if(!confirm(\'Eintrag wirklich loeschen?\'))return false;Backend.getScrollOffset()"', + 'attributes' => 'onclick="if(!confirm(\'Eintrag wirklich löschen?\'))return false;Backend.getScrollOffset()"', ], 'show' => [ 'href' => 'act=show', diff --git a/contao/dca/tl_survey_content.php b/contao/dca/tl_survey_content.php index 7f07827..59e737e 100644 --- a/contao/dca/tl_survey_content.php +++ b/contao/dca/tl_survey_content.php @@ -12,6 +12,9 @@ $GLOBALS['TL_DCA']['tl_survey_content'] = [ 'dataContainer' => DC_Table::class, 'ptable' => 'tl_survey', 'enableVersioning' => true, + 'onload_callback' => [ + [SurveyDcaListener::class, 'restrictSurveyContentEditing'], + ], 'onsubmit_callback' => [ [SurveyDcaListener::class, 'touchSurveyContentParent'], ], @@ -55,7 +58,7 @@ $GLOBALS['TL_DCA']['tl_survey_content'] = [ 'delete' => [ 'href' => 'act=delete', 'icon' => 'delete.svg', - 'attributes' => 'onclick="if(!confirm(\'Eintrag wirklich loeschen?\'))return false;Backend.getScrollOffset()"', + 'attributes' => 'onclick="if(!confirm(\'Eintrag wirklich löschen?\'))return false;Backend.getScrollOffset()"', ], 'show' => [ 'href' => 'act=show', diff --git a/contao/dca/tl_survey_editor.php b/contao/dca/tl_survey_editor.php index 65d4847..974b01c 100644 --- a/contao/dca/tl_survey_editor.php +++ b/contao/dca/tl_survey_editor.php @@ -47,7 +47,7 @@ $GLOBALS['TL_DCA']['tl_survey_editor'] = [ 'delete' => [ 'href' => 'act=delete', 'icon' => 'delete.svg', - 'attributes' => 'onclick="if(!confirm(\'Eintrag wirklich loeschen?\'))return false;Backend.getScrollOffset()"', + 'attributes' => 'onclick="if(!confirm(\'Eintrag wirklich löschen?\'))return false;Backend.getScrollOffset()"', ], 'show' => [ 'href' => 'act=show', diff --git a/contao/languages/de/modules.php b/contao/languages/de/modules.php index 5a4f9ca..c42f870 100644 --- a/contao/languages/de/modules.php +++ b/contao/languages/de/modules.php @@ -1,10 +1,10 @@ {% else %} - {% set locked = survey.isLocked == '1' %}
{% if backUrl %}{{ 'survey.edit.back_to_list'|trans }}{% endif %} - {% if publicUrl %}{{ 'survey.edit.public_view_open'|trans }}{% endif %} - {% if publicUrl %} - - {% endif %} -
- - - - - -
+
+ {% if not survey.published %} +
+ + + + + +
+
{{ 'survey.edit.publish_help'|trans }}
+ {% else %} +
+ + + + + +
+
{{ survey.isActive ? 'survey.edit.deactivate_help'|trans : 'survey.edit.activate_help'|trans }}
+ {% endif %} +
- {% if locked %} + {% if structureLocked|default(false) %}
{{ 'survey.edit.structure_locked'|trans }}
{% endif %} @@ -147,7 +138,15 @@
- {% if questionItemForm %} + {% if structureLocked|default(false) %} +
+
+ {{ ('survey.survey.question_type_' ~ question.type)|trans }} + {% if question.mandatory %}{{ 'survey.edit.mandatory_badge'|trans }}{% endif %} +
+ {% if question.description|striptags|trim %}

{{ question.description|striptags|trim }}

{% endif %} +
+ {% elseif questionItemForm %} {% set questionType = questionItemForm.type.vars.value ?: question.type ?: 'yes_no_maybe' %} {{ form_start(questionItemForm, {attr: {class: 'survey-form-grid', id: questionFormId, 'data-survey-dirty-form': '1'}}) }} @@ -186,15 +185,13 @@
- {% if not locked %} -
- - - - - -
- {% endif %} +
+ + + + + +
{% endif %} @@ -205,7 +202,7 @@
{{ 'survey.edit.no_questions'|trans }}
{% endfor %} - {% if not locked %} + {% if not (structureLocked|default(false)) %}

- {% endif %} + {% endif %}
diff --git a/contao/templates/frontend/member_survey_list.html.twig b/contao/templates/frontend/member_survey_list.html.twig index ab95d15..6ff9b84 100644 --- a/contao/templates/frontend/member_survey_list.html.twig +++ b/contao/templates/frontend/member_survey_list.html.twig @@ -35,31 +35,15 @@

{{ survey.title|striptags|trim }}

- {% if survey.isLocked %} -
+
+ {{ survey.published ? 'survey.list.published'|trans : 'survey.list.draft'|trans }} + {% if survey.published %} + {{ survey.isActive ? 'survey.list.active'|trans : 'survey.list.inactive'|trans }} + {% elseif survey.isLocked %} {{ 'survey.list.locked'|trans }} -
- {% endif %} + {% endif %} +
-
- - - - - -
{{ 'survey.list.questions'|trans({'%count%': survey.questionCount}) }} @@ -69,8 +53,13 @@ {% if survey.editUrl %} {{ 'survey.list.edit'|trans }} {% endif %} - {% if survey.publicUrl %} + {% if survey.publicUrl and survey.published and survey.isActive %} {{ 'survey.list.public_view'|trans }} + {% elseif survey.publicUrl %} + {{ 'survey.list.public_view'|trans }} + {% endif %} + {% if survey.draftUrl and not survey.published %} + {{ 'survey.list.draft_view'|trans }} {% endif %} {% if survey.resultsUrl %} {{ 'survey.list.show_results'|trans }} diff --git a/contao/templates/frontend/show_survey.html.twig b/contao/templates/frontend/show_survey.html.twig index 263fc16..43e053a 100644 --- a/contao/templates/frontend/show_survey.html.twig +++ b/contao/templates/frontend/show_survey.html.twig @@ -16,10 +16,15 @@

{{ survey.title|striptags|trim }}

+ {% if isPreview %}{{ 'survey.survey.preview_badge'|trans }}{% endif %} {% if progress.total > 0 %}{{ progress.current }}/{{ progress.total }}{% endif %}

{{ (survey.description|striptags|trim) ?: 'survey.survey.default_description'|trans }}

+ {% if isPreview %} +
{{ 'survey.survey.preview_notice'|trans }}
+ {% endif %} + {% if progress.total > 0 %}
{% endif %} @@ -28,7 +33,7 @@ {% if isComplete %}

{{ 'survey.survey.thanks_title'|trans }}

-

{{ 'survey.survey.thanks_text'|trans }}

+

{{ isPreview ? 'survey.survey.preview_thanks_text'|trans : 'survey.survey.thanks_text'|trans }}

{% if answers %}
{% for answer in answers %} @@ -47,8 +52,25 @@

{{ question.question|striptags|trim }}

{% if question.description|striptags|trim %}

{{ question.description|striptags|trim }}

{% endif %} + {% if canGoBack %} +
+ + + + {% if isPreview %} + + + {% endif %} + +
+ {% endif %} + {{ form_start(surveyForm, {attr: {class: 'survey-form-grid'}}) }} + {% if isPreview %} + + + {% endif %} {% if question.type == 'yes_no_maybe' %}
{% for child in surveyForm.answer %} diff --git a/contao/templates/frontend/show_survey_results.html.twig b/contao/templates/frontend/show_survey_results.html.twig index 4544350..dbca0b6 100644 --- a/contao/templates/frontend/show_survey_results.html.twig +++ b/contao/templates/frontend/show_survey_results.html.twig @@ -108,25 +108,25 @@
{% else %} {% if question.responseSummary is defined and question.responseSummary %} -
+
{% for option in question.responseSummary %} -
+
+ {{ option.label|striptags|trim }}
- {{ option.label|striptags|trim }} {{ option.count }} {{ option.percentage }}%
-
- -
-
+
{% endfor %}
{% endif %} -
+
{% for response in question.responses %} -
{{ response|striptags|trim|nl2br }}
+
+ {{ loop.index }} +
{{ response|striptags|trim|nl2br }}
+
{% else %}

{{ 'survey.results.no_text_responses'|trans }}

{% endfor %} diff --git a/src/Controller/FrontendModule/MemberSurveyEditController.php b/src/Controller/FrontendModule/MemberSurveyEditController.php index 634eb31..81c76af 100644 --- a/src/Controller/FrontendModule/MemberSurveyEditController.php +++ b/src/Controller/FrontendModule/MemberSurveyEditController.php @@ -98,6 +98,7 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController $template->set('loginRequired', false); $template->set('entryNotAllowed', false); $template->set('createMode', true); + $template->set('structureLocked', false); $template->set('surveyForm', $surveyForm->createView()); $template->set('backUrl', $this->resolveListUrl($model)); $template->set('publicUrl', null); @@ -112,70 +113,78 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController return $template->getResponse(); } + $structureLocked = $this->surveyEditorService->isStructureLocked($survey); + $questions = $this->surveyQuestionRepository->findAllBySurvey((int) $survey->id); $activeQuestion = $this->resolveActiveQuestion($request, (int) $survey->id); $activeQuestionId = $activeQuestion ? (int) $activeQuestion->id : 0; $questionFormViews = []; + $newQuestionFormView = null; - foreach ($questions as $question) { - $questionId = (int) $question->id; - $questionForm = $this->formFactory->createNamed('survey_question_'.$questionId, SurveyQuestionEditorType::class, SurveyQuestionData::fromModel($question), [ - 'question_choices' => $this->buildQuestionChoices((int) $survey->id, $questionId), + if (!$structureLocked) { + foreach ($questions as $question) { + $questionId = (int) $question->id; + $questionForm = $this->formFactory->createNamed('survey_question_'.$questionId, SurveyQuestionEditorType::class, SurveyQuestionData::fromModel($question), [ + 'question_choices' => $this->buildQuestionChoices((int) $survey->id, $questionId), + ]); + $questionForm->handleRequest($request); + + if ($questionForm->isSubmitted()) { + $activeQuestionId = $questionId; + $createQuestionMode = false; + + if ($questionForm->isValid()) { + try { + $this->surveyEditorService->saveQuestion($survey, $questionForm->getData(), $questionId); + $this->addFlash('success', 'Die Frage wurde aktualisiert.'); + + return new RedirectResponse($this->buildEditUrl($model, $request, (int) $survey->id)); + } catch (\Throwable $exception) { + $this->addFlash('error', $exception->getMessage()); + } + } else { + $this->addFlash('error', 'Frage konnte nicht gespeichert werden: '.$this->describeFormErrors($questionForm)); + } + } + + $questionFormViews[$questionId] = $questionForm->createView(); + } + + $newQuestionForm = $this->formFactory->createNamed('survey_question_new', SurveyQuestionEditorType::class, new SurveyQuestionData(), [ + 'question_choices' => $this->buildQuestionChoices((int) $survey->id), ]); - $questionForm->handleRequest($request); + $newQuestionForm->handleRequest($request); - if ($questionForm->isSubmitted()) { - $activeQuestionId = $questionId; - $createQuestionMode = false; + if ($newQuestionForm->isSubmitted()) { + $activeQuestionId = 0; + $createQuestionMode = true; - if ($questionForm->isValid()) { + if ($newQuestionForm->isValid()) { try { - $savedQuestionId = $this->surveyEditorService->saveQuestion($survey, $questionForm->getData(), $questionId); - $this->addFlash('success', 'Die Frage wurde aktualisiert.'); + $this->surveyEditorService->saveQuestion($survey, $newQuestionForm->getData(), 0); + $this->addFlash('success', 'Die Frage wurde angelegt.'); return new RedirectResponse($this->buildEditUrl($model, $request, (int) $survey->id)); } catch (\Throwable $exception) { $this->addFlash('error', $exception->getMessage()); } } else { - $this->addFlash('error', 'Frage konnte nicht gespeichert werden: '.$this->describeFormErrors($questionForm)); + $this->addFlash('error', 'Neue Frage konnte nicht gespeichert werden: '.$this->describeFormErrors($newQuestionForm)); } } - $questionFormViews[$questionId] = $questionForm->createView(); - } - - $newQuestionForm = $this->formFactory->createNamed('survey_question_new', SurveyQuestionEditorType::class, new SurveyQuestionData(), [ - 'question_choices' => $this->buildQuestionChoices((int) $survey->id), - ]); - $newQuestionForm->handleRequest($request); - - if ($newQuestionForm->isSubmitted()) { - $activeQuestionId = 0; - $createQuestionMode = true; - - if ($newQuestionForm->isValid()) { - try { - $savedQuestionId = $this->surveyEditorService->saveQuestion($survey, $newQuestionForm->getData(), 0); - $this->addFlash('success', 'Die Frage wurde angelegt.'); - - return new RedirectResponse($this->buildEditUrl($model, $request, (int) $survey->id)); - } catch (\Throwable $exception) { - $this->addFlash('error', $exception->getMessage()); - } - } else { - $this->addFlash('error', 'Neue Frage konnte nicht gespeichert werden: '.$this->describeFormErrors($newQuestionForm)); - } + $newQuestionFormView = $newQuestionForm->createView(); } $template->set('loginRequired', false); $template->set('entryNotAllowed', false); $template->set('createMode', false); + $template->set('structureLocked', $structureLocked); $template->set('createQuestionMode', $createQuestionMode); $template->set('survey', $survey); $template->set('surveyForm', $surveyForm->createView()); $template->set('questionForms', $questionFormViews); - $template->set('newQuestionForm', $newQuestionForm->createView()); + $template->set('newQuestionForm', $newQuestionFormView); $template->set('questions', $questions); $conditionOverview = $this->surveyConditionRepository->findOverviewBySurvey((int) $survey->id); @@ -205,7 +214,7 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController } if (!$this->surveyEditorRepository->isEditor((int) $survey->id, $memberId)) { - throw new \RuntimeException('Sie duerfen diese Umfrage nicht bearbeiten.'); + throw new \RuntimeException('Sie dürfen diese Umfrage nicht bearbeiten.'); } return $survey; @@ -259,23 +268,28 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController $token = (string) $request->request->get('_token'); if (!$this->isCsrfTokenValid($action.'-'.$itemId, $token)) { - throw new \RuntimeException('Ungueltiger CSRF-Token.'); + throw new \RuntimeException('Ungültiger CSRF-Token.'); } try { - if ('toggle-published' === $action) { - $isPublished = $this->surveyEditorService->toggleSurveyPublished($survey, $memberId); - $this->addFlash('success', $isPublished ? 'Die Umfrage ist jetzt veroeffentlicht.' : 'Die Umfrage ist jetzt nicht mehr veroeffentlicht.'); + if ('publish-survey' === $action) { + $this->surveyEditorService->publishSurvey($survey, $memberId); + $this->addFlash('success', 'Die Umfrage ist jetzt veröffentlicht.'); + } + + if ('toggle-active' === $action) { + $isActive = $this->surveyEditorService->toggleSurveyActive($survey, $memberId); + $this->addFlash('success', $isActive ? 'Die Umfrage ist jetzt wieder aktiv.' : 'Die Umfrage wurde vorübergehend deaktiviert.'); } if ('delete-question' === $action) { $this->surveyEditorService->deleteQuestion($survey, $itemId); - $this->addFlash('success', 'Die Frage wurde geloescht.'); + $this->addFlash('success', 'Die Frage wurde gelöscht.'); } if ('delete-condition' === $action) { $this->surveyEditorService->deleteCondition($survey, $itemId); - $this->addFlash('success', 'Die Bedingung wurde geloescht.'); + $this->addFlash('success', 'Die Bedingung wurde gelöscht.'); } } catch (\Throwable $exception) { $this->addFlash('error', $exception->getMessage()); diff --git a/src/Controller/FrontendModule/MemberSurveyListController.php b/src/Controller/FrontendModule/MemberSurveyListController.php index b04706a..69bdcb1 100644 --- a/src/Controller/FrontendModule/MemberSurveyListController.php +++ b/src/Controller/FrontendModule/MemberSurveyListController.php @@ -52,18 +52,21 @@ final class MemberSurveyListController extends AbstractFrontendModuleController foreach ($this->surveyRepository->findEditableByMember((int) $user->id) as $survey) { $surveyId = (int) $survey['id']; + $alias = (string) $survey['alias']; $items[] = [ 'id' => $surveyId, 'title' => (string) $survey['title'], 'description' => (string) ($survey['description'] ?? ''), + 'isActive' => !empty($survey['isActive']), 'isLocked' => !empty($survey['isLocked']), 'published' => !empty($survey['published']), 'questionCount' => (int) ($survey['questionCount'] ?? 0), 'participationCount' => (int) ($survey['participationCount'] ?? 0), 'editUrl' => $editPage instanceof PageModel ? $this->generateContentUrl($editPage, ['survey' => $surveyId]) : null, - 'publicUrl' => $readerPage instanceof PageModel ? $this->generateContentUrl($readerPage, ['auto_item' => (string) $survey['alias']]) : null, - 'resultsUrl' => $resultsPage instanceof PageModel ? $this->generateContentUrl($resultsPage, ['auto_item' => (string) $survey['alias']]) : null, + 'publicUrl' => $readerPage instanceof PageModel ? $this->generateContentUrl($readerPage, ['auto_item' => $alias]) : null, + 'draftUrl' => $readerPage instanceof PageModel ? $this->generateContentUrl($readerPage, ['auto_item' => $alias, 'preview' => '1']) : null, + 'resultsUrl' => $resultsPage instanceof PageModel ? $this->generateContentUrl($resultsPage, ['auto_item' => $alias]) : null, ]; } @@ -81,20 +84,15 @@ final class MemberSurveyListController extends AbstractFrontendModuleController $token = (string) $request->request->get('_token'); if ($surveyId <= 0 || !$this->isCsrfTokenValid($action.'-'.$surveyId, $token)) { - throw new \RuntimeException('Ungueltiger CSRF-Token.'); + throw new \RuntimeException('Ungültiger CSRF-Token.'); } $survey = $this->resolveEditableSurvey($surveyId, $memberId); try { - if ('toggle-published' === $action) { - $isPublished = $this->surveyEditorService->toggleSurveyPublished($survey, $memberId); - $this->addFlash('success', $isPublished ? 'Die Umfrage ist jetzt veroeffentlicht.' : 'Die Umfrage ist jetzt nicht mehr veroeffentlicht.'); - } - if ('delete-survey' === $action) { $this->surveyEditorService->deleteSurvey($survey); - $this->addFlash('success', 'Die Umfrage mit allen Fragen, Antworten und Ergebnissen wurde geloescht.'); + $this->addFlash('success', 'Die Umfrage mit allen Fragen, Antworten und Ergebnissen wurde gelöscht.'); } } catch (\Throwable $exception) { $this->addFlash('error', $exception->getMessage()); @@ -108,7 +106,7 @@ final class MemberSurveyListController extends AbstractFrontendModuleController $survey = $this->surveyRepository->findById($surveyId); if (!$survey instanceof SurveyModel || !$this->surveyEditorRepository->isEditor($surveyId, $memberId)) { - throw new \RuntimeException('Sie duerfen diese Umfrage nicht bearbeiten.'); + throw new \RuntimeException('Sie dürfen diese Umfrage nicht bearbeiten.'); } return $survey; diff --git a/src/Controller/FrontendModule/ShowSurveyController.php b/src/Controller/FrontendModule/ShowSurveyController.php index 35db9c5..6b4e1e4 100644 --- a/src/Controller/FrontendModule/ShowSurveyController.php +++ b/src/Controller/FrontendModule/ShowSurveyController.php @@ -8,6 +8,8 @@ use Contao\CoreBundle\Controller\FrontendModule\AbstractFrontendModuleController use Contao\CoreBundle\DependencyInjection\Attribute\AsFrontendModule; use Contao\CoreBundle\Twig\FragmentTemplate; use Contao\ModuleModel; +use Mummert\SurveyBundle\Model\SurveyContentModel; +use Mummert\SurveyBundle\Model\SurveyModel; use Mummert\SurveyBundle\Form\Model\SurveyAnswerData; use Mummert\SurveyBundle\Form\SurveyQuestionAnswerType; use Mummert\SurveyBundle\Repository\SurveyRepository; @@ -30,48 +32,57 @@ final class ShowSurveyController extends AbstractFrontendModuleController protected function getResponse(FragmentTemplate $template, ModuleModel $model, Request $request): Response { $publicAlias = $this->resolvePublicAlias($request); + $previewMode = $request->query->getBoolean('preview'); if ('' === $publicAlias) { - $template->set('errorMessage', 'Es wurde kein oeffentlicher Link auf der Anzeigeseite gefunden.'); - $template->set('question', null); - $template->set('surveyForm', null); - $template->set('isComplete', false); - $template->set('answers', []); - $template->set('survey', null); - $template->set('progress', ['current' => 0, 'total' => 0, 'percentage' => 0]); + $this->applyEmptyState($template, 'Es wurde kein öffentlicher Link auf der Anzeigeseite gefunden.', $previewMode); return $template->getResponse(); } - $survey = $this->surveyRepository->findByPublicAlias($publicAlias, true); + $survey = $this->surveyRepository->findByPublicAlias($publicAlias, !$previewMode); if (null === $survey) { - $template->set('errorMessage', 'Die Umfrage wurde nicht gefunden oder ist nicht veroeffentlicht.'); - $template->set('question', null); - $template->set('surveyForm', null); - $template->set('isComplete', false); - $template->set('answers', []); - $template->set('survey', null); - $template->set('progress', ['current' => 0, 'total' => 0, 'percentage' => 0]); + $this->applyEmptyState($template, 'Die Umfrage wurde nicht gefunden, ist nicht veröffentlicht oder aktuell deaktiviert.', $previewMode); + + return $template->getResponse(); + } + + if ($previewMode && '1' === (string) $survey->published) { + $this->applyEmptyState($template, 'Die Entwurfsansicht ist für bereits veröffentlichte Umfragen nicht verfügbar.', true, $survey); return $template->getResponse(); } if ($htmlHeadBag = $this->getHtmlHeadBag()) { - $htmlHeadBag->setTitle((string) $survey->title); + $htmlHeadBag->setTitle((string) $survey->title.($previewMode ? ' - Entwurfsansicht' : '')); + } + + if ($previewMode) { + return $this->buildPreviewResponse($template, $survey, $request); } $submission = $this->surveySubmissionService->resolveActiveSubmission($survey, $request->getSession(), null); + + if ($request->isMethod('POST') && 'back' === (string) $request->request->get('_survey_navigation')) { + $this->assertNavigationTokenValid($request, 'survey-back-'.(int) $survey->id); + + if ($this->surveySubmissionService->goBack($survey, $submission, $request->getSession())) { + return new RedirectResponse($request->getUri()); + } + } + $question = $this->surveyFlowService->resolveCurrentQuestion($survey, $submission); if (null === $question && '1' !== (string) $submission->isFinished) { - $template->set('errorMessage', 'Diese Umfrage ist noch nicht vollstaendig eingerichtet. Es fehlt eine gueltige erste Frage oder eine verknuepfte Folgefrage ist nicht mehr verfuegbar.'); + $template->set('errorMessage', 'Diese Umfrage ist noch nicht vollständig eingerichtet. Es fehlt eine gültige erste Frage oder eine verknüpfte Folgefrage ist nicht mehr verfügbar.'); $template->set('question', null); $template->set('surveyForm', null); $template->set('isComplete', false); $template->set('answers', []); $template->set('survey', $survey); $template->set('progress', ['current' => 0, 'total' => 0, 'percentage' => 0]); + $template->set('isPreview', false); return $template->getResponse(); } @@ -84,17 +95,28 @@ final class ShowSurveyController extends AbstractFrontendModuleController $template->set('surveyForm', null); $template->set('isComplete', true); $template->set('answers', $this->surveySubmissionService->getAnswersForSubmission($submission)); + $template->set('isPreview', false); + $template->set('canGoBack', false); + $template->set('historyState', ''); return $template->getResponse(); } - $answerData = new SurveyAnswerData(); + $answerData = $this->surveySubmissionService->createAnswerData( + $question, + $this->surveySubmissionService->getStoredAnswerValue($submission, $question), + ); $form = $this->createForm(SurveyQuestionAnswerType::class, $answerData, ['question' => $question]); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $normalizedAnswer = $this->surveySubmissionService->storeAnswer($survey, $submission, $question, $answerData->answer); $nextQuestion = $this->surveyFlowService->determineNextQuestion($survey, $question, $normalizedAnswer); + + if ($nextQuestion instanceof SurveyContentModel) { + $this->surveySubmissionService->rememberQuestion($survey, $request->getSession(), $question); + } + $this->surveySubmissionService->advance($submission, $nextQuestion); return new RedirectResponse($request->getUri()); @@ -107,10 +129,140 @@ final class ShowSurveyController extends AbstractFrontendModuleController $template->set('surveyForm', $form->createView()); $template->set('isComplete', false); $template->set('answers', []); + $template->set('isPreview', false); + $template->set('canGoBack', $this->surveySubmissionService->canGoBack($survey, $request->getSession())); + $template->set('historyState', ''); return $template->getResponse(); } + private function buildPreviewResponse(FragmentTemplate $template, SurveyModel $survey, Request $request): Response + { + $history = $this->decodePreviewHistory((string) $request->request->get('preview_history', '')); + $question = $this->resolvePreviewQuestion($survey, (int) $request->request->get('preview_question', 0)); + + if ($request->isMethod('POST') && 'back' === (string) $request->request->get('_survey_navigation')) { + $this->assertNavigationTokenValid($request, 'survey-preview-back-'.(int) $survey->id); + + if ([] !== $history) { + $question = $this->resolvePreviewQuestion($survey, (int) array_pop($history)); + } + } + + $question ??= $this->surveyFlowService->getFirstQuestion($survey); + + if (!$question instanceof SurveyContentModel) { + $template->set('errorMessage', 'Diese Umfrage ist noch nicht vollständig eingerichtet. Es fehlt eine gültige erste Frage oder eine verknüpfte Folgefrage ist nicht mehr verfügbar.'); + $template->set('question', null); + $template->set('surveyForm', null); + $template->set('isComplete', false); + $template->set('answers', []); + $template->set('survey', $survey); + $template->set('progress', ['current' => 0, 'total' => 0, 'percentage' => 0]); + $template->set('isPreview', true); + $template->set('canGoBack', false); + $template->set('historyState', ''); + + return $template->getResponse(); + } + + $answerData = new SurveyAnswerData(); + $form = $this->createForm(SurveyQuestionAnswerType::class, $answerData, ['question' => $question]); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $normalizedAnswer = $this->surveySubmissionService->normalizeAnswerForQuestion($question, $answerData->answer); + $nextQuestion = $this->surveyFlowService->determineNextQuestion($survey, $question, $normalizedAnswer); + + if ($nextQuestion instanceof SurveyContentModel) { + $history[] = (int) $question->id; + $question = $nextQuestion; + $form = $this->createForm(SurveyQuestionAnswerType::class, new SurveyAnswerData(), ['question' => $question]); + } else { + $template->set('errorMessage', null); + $template->set('survey', $survey); + $template->set('progress', $this->surveyFlowService->getProgress($survey, null)); + $template->set('question', null); + $template->set('surveyForm', null); + $template->set('isComplete', true); + $template->set('answers', []); + $template->set('isPreview', true); + $template->set('canGoBack', false); + $template->set('historyState', ''); + + return $template->getResponse(); + } + } + + $template->set('errorMessage', null); + $template->set('survey', $survey); + $template->set('progress', $this->surveyFlowService->getProgress($survey, $question)); + $template->set('question', $question); + $template->set('surveyForm', $form->createView()); + $template->set('isComplete', false); + $template->set('answers', []); + $template->set('isPreview', true); + $template->set('canGoBack', !empty($history)); + $template->set('historyState', $this->encodePreviewHistory($history)); + + return $template->getResponse(); + } + + private function applyEmptyState(FragmentTemplate $template, string $message, bool $previewMode, ?SurveyModel $survey = null): void + { + $template->set('errorMessage', $message); + $template->set('question', null); + $template->set('surveyForm', null); + $template->set('isComplete', false); + $template->set('answers', []); + $template->set('survey', $survey); + $template->set('progress', ['current' => 0, 'total' => 0, 'percentage' => 0]); + $template->set('isPreview', $previewMode); + $template->set('canGoBack', false); + $template->set('historyState', ''); + } + + private function resolvePreviewQuestion(SurveyModel $survey, int $questionId): ?SurveyContentModel + { + if ($questionId <= 0) { + return null; + } + + return $this->surveyFlowService->resolveQuestionById($survey, $questionId); + } + + /** + * @return list + */ + private function decodePreviewHistory(string $historyState): array + { + if ('' === trim($historyState)) { + return []; + } + + return array_values(array_filter( + array_map(static fn (string $entry): int => (int) trim($entry), explode(',', $historyState)), + static fn (int $entry): bool => $entry > 0, + )); + } + + /** + * @param list $history + */ + private function encodePreviewHistory(array $history): string + { + return implode(',', $history); + } + + private function assertNavigationTokenValid(Request $request, string $tokenId): void + { + $token = (string) $request->request->get('_navigation_token', ''); + + if (!$this->isCsrfTokenValid($tokenId, $token)) { + throw new \RuntimeException('Ungültiger CSRF-Token.'); + } + } + private function resolvePublicAlias(Request $request): string { $candidate = $request->attributes->get('auto_item'); diff --git a/src/EventListener/SurveyDcaListener.php b/src/EventListener/SurveyDcaListener.php index 7aafa9e..5da79ae 100644 --- a/src/EventListener/SurveyDcaListener.php +++ b/src/EventListener/SurveyDcaListener.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Mummert\SurveyBundle\EventListener; +use Contao\Backend; use Contao\Config; use Contao\CoreBundle\Routing\ContentUrlGenerator; use Contao\CoreBundle\Framework\ContaoFramework; @@ -11,6 +12,7 @@ use Contao\DataContainer; use Contao\Image; use Contao\Input; use Contao\PageModel; +use Contao\StringUtil; use Doctrine\DBAL\Connection; use Mummert\SurveyBundle\Repository\SurveyEditorRepository; use Mummert\SurveyBundle\Repository\SurveyQuestionRepository; @@ -40,12 +42,12 @@ final class SurveyDcaListener { $publicUrl = $this->buildSurveyPageUrl((string) ($row['alias'] ?? ''), 'surveyReaderPage'); $label = 'Link kopieren'; - $title = 'Den oeffentlichen Link in die Zwischenablage kopieren'; + $title = 'Den öffentlichen Link in die Zwischenablage kopieren'; if (null === $publicUrl) { return sprintf( '%s', - htmlspecialchars('In den Contao-Einstellungen ist noch keine Reader-Seite hinterlegt oder der Link-Schluessel ist ungueltig.', ENT_QUOTES), + htmlspecialchars('In den Contao-Einstellungen ist noch keine Reader-Seite hinterlegt oder der Link-Schlüssel ist ungültig.', ENT_QUOTES), Image::getHtml($icon ?? 'copy.svg', $label) ); } @@ -53,8 +55,8 @@ final class SurveyDcaListener $copyScript = sprintf( 'navigator.clipboard.writeText(%s).then(function(){Contao.flashMessage(%s, %s, "confirmation");});return false;', json_encode($publicUrl, JSON_THROW_ON_ERROR), - json_encode('Oeffentlicher Link wurde in die Zwischenablage kopiert.', JSON_THROW_ON_ERROR), - json_encode('Bestaetigung', JSON_THROW_ON_ERROR), + json_encode('Öffentlicher Link wurde in die Zwischenablage kopiert.', JSON_THROW_ON_ERROR), + json_encode('Bestätigung', JSON_THROW_ON_ERROR), ); return sprintf( @@ -75,7 +77,7 @@ final class SurveyDcaListener if (null === $resultsUrl) { return sprintf( '%s', - htmlspecialchars('In den Contao-Einstellungen ist noch keine Ergebnisseite hinterlegt oder der Link-Schluessel ist ungueltig.', ENT_QUOTES), + htmlspecialchars('In den Contao-Einstellungen ist noch keine Ergebnisseite hinterlegt oder der Link-Schlüssel ist ungültig.', ENT_QUOTES), Image::getHtml($icon ?? 'show.svg', $label) ); } @@ -89,6 +91,88 @@ final class SurveyDcaListener ); } + public function generatePublishSurveyButton(array $row, ?string $href, string $label, string $title, ?string $icon, string $attributes): string + { + if ('1' === (string) ($row['published'] ?? '')) { + return sprintf( + '%s', + htmlspecialchars('Die Umfrage ist bereits veröffentlicht.', ENT_QUOTES), + Image::getHtml(str_replace('.svg', '--disabled.svg', $icon ?? 'visible.svg'), $label) + ); + } + + return sprintf( + '%s', + htmlspecialchars(Backend::addToUrl(($href ?? '').'&id='.(int) ($row['id'] ?? 0)), ENT_QUOTES), + htmlspecialchars($title, ENT_QUOTES), + StringUtil::specialchars('Wirklich sicher? Nachdem die Umfrage veröffentlicht wurde sind keine Änderungen mehr möglich!'), + $attributes, + Image::getHtml($icon ?? 'visible.svg', $label) + ); + } + + public function generateToggleActiveButton(array $row, ?string $href, string $label, string $title, ?string $icon, string $attributes): string + { + if ('1' !== (string) ($row['published'] ?? '')) { + return sprintf( + '%s', + htmlspecialchars('Die Umfrage muss zuerst veröffentlicht werden.', ENT_QUOTES), + Image::getHtml('visible--disabled.svg', $label) + ); + } + + $isActive = '1' === (string) ($row['isActive'] ?? ''); + $icon = $isActive ? 'visible.svg' : 'invisible.svg'; + $title = $isActive ? 'Umfrage vorübergehend deaktivieren' : 'Umfrage wieder aktivieren'; + + return sprintf( + '%s', + htmlspecialchars(Backend::addToUrl(($href ?? '').'&id='.(int) ($row['id'] ?? 0)), ENT_QUOTES), + htmlspecialchars($title, ENT_QUOTES), + $attributes, + Image::getHtml($icon, $label) + ); + } + + public function savePublishedState(mixed $value, DataContainer $dataContainer): string + { + $surveyId = (int) ($dataContainer->id ?? 0); + $survey = $this->surveyRepository->findById($surveyId); + + if (null === $survey) { + return !empty($value) ? '1' : ''; + } + + if ('1' === (string) $survey->published) { + return '1'; + } + + if (!empty($value)) { + $now = time(); + $this->connection->update('tl_survey', [ + 'isActive' => '1', + 'isLocked' => '1', + 'tstamp' => $now, + 'updatedAt' => $now, + ], ['id' => $surveyId]); + + return '1'; + } + + return ''; + } + + public function saveActiveState(mixed $value, DataContainer $dataContainer): string + { + $survey = $this->surveyRepository->findById((int) ($dataContainer->id ?? 0)); + + if (null === $survey || '1' !== (string) $survey->published) { + return ''; + } + + return !empty($value) ? '1' : ''; + } + /** * @return array */ @@ -145,6 +229,50 @@ final class SurveyDcaListener return $options; } + /** + * @return array + */ + public function getUsedCategoryFilterOptions(): array + { + $usedCategoryIds = []; + + foreach ($this->connection->fetchFirstColumn('SELECT category FROM tl_survey') as $rawCategoryValue) { + foreach (StringUtil::deserialize($rawCategoryValue, true) as $categoryId) { + $categoryId = (int) $categoryId; + + if ($categoryId > 0) { + $usedCategoryIds[$categoryId] = $categoryId; + } + } + } + + if ([] === $usedCategoryIds) { + return []; + } + + $allCategoryOptions = $this->getCategoryOptions(); + $options = []; + + foreach (array_values($usedCategoryIds) as $categoryId) { + if (isset($allCategoryOptions[$categoryId])) { + $options[$categoryId] = $allCategoryOptions[$categoryId]; + } + } + + return $options; + } + + /** + * @return array + */ + public function getTemplateFilterOptions(): array + { + return [ + '1' => $GLOBALS['TL_LANG']['tl_survey']['template_yes'] ?? 'Ja', + '0' => $GLOBALS['TL_LANG']['tl_survey']['template_no'] ?? 'Nein', + ]; + } + /** * @return array */ @@ -190,7 +318,6 @@ final class SurveyDcaListener public function guardEditorSave(mixed $value, DataContainer $dataContainer): mixed { $surveyId = $this->resolveSurveyId('tl_survey_editor', $dataContainer); - $this->assertSurveyUnlocked($surveyId); $this->surveyCategoryAssignmentService->applyMemberCategoriesToSurvey((int) $value, $surveyId); return $value; @@ -239,10 +366,6 @@ final class SurveyDcaListener $previousSurveyIds = $this->surveyEditorRepository->findSurveyIdsByMember($memberId); if ($previousSurveyIds !== $surveyIds) { - foreach (array_unique(array_merge($previousSurveyIds, $surveyIds)) as $surveyId) { - $this->assertSurveyUnlocked((int) $surveyId); - } - $this->surveyEditorRepository->syncSurveysForMember($memberId, $surveyIds); } @@ -273,7 +396,6 @@ final class SurveyDcaListener $previousMemberIds = $this->surveyEditorRepository->findMemberIdsBySurvey($surveyId); if ($previousMemberIds !== $memberIds) { - $this->assertSurveyUnlocked($surveyId); $this->surveyEditorRepository->syncMembersForSurvey($surveyId, $memberIds); $this->surveyCategoryAssignmentService->syncSurvey($surveyId); } @@ -361,13 +483,36 @@ final class SurveyDcaListener public function guardEditorDelete(DataContainer $dataContainer): void { $surveyId = $this->resolveSurveyId('tl_survey_editor', $dataContainer); - $this->assertSurveyUnlocked($surveyId); if ($dataContainer->activeRecord?->member) { $this->surveyCategoryAssignmentService->syncSurveyWithoutMember($surveyId, (int) $dataContainer->activeRecord->member); } } + public function restrictSurveyContentEditing(): void + { + if (!$this->isSurveyStructureLocked($this->resolveSurveyId('tl_survey_content'))) { + return; + } + + $GLOBALS['TL_DCA']['tl_survey_content']['config']['notCreatable'] = true; + $GLOBALS['TL_DCA']['tl_survey_content']['config']['notCopyable'] = true; + $GLOBALS['TL_DCA']['tl_survey_content']['config']['notEditable'] = true; + $GLOBALS['TL_DCA']['tl_survey_content']['config']['notDeletable'] = true; + } + + public function restrictSurveyConditionEditing(): void + { + if (!$this->isSurveyStructureLocked($this->resolveSurveyId('tl_survey_condition'))) { + return; + } + + $GLOBALS['TL_DCA']['tl_survey_condition']['config']['notCreatable'] = true; + $GLOBALS['TL_DCA']['tl_survey_condition']['config']['notCopyable'] = true; + $GLOBALS['TL_DCA']['tl_survey_condition']['config']['notEditable'] = true; + $GLOBALS['TL_DCA']['tl_survey_condition']['config']['notDeletable'] = true; + } + public function guardSurveyStructureSave(mixed $value, DataContainer $dataContainer): mixed { $surveyId = (int) ($dataContainer->id ?? 0); @@ -395,15 +540,24 @@ final class SurveyDcaListener private function assertSurveyUnlocked(int $surveyId): void { + if ($this->isSurveyStructureLocked($surveyId)) { + throw new \RuntimeException('Die Fragen dieser Umfrage können nach der Veröffentlichung oder nach ersten Teilnahmen nicht mehr geändert werden.'); + } + } + + private function isSurveyStructureLocked(int $surveyId): bool + { + if ($surveyId <= 0) { + return false; + } + $survey = $this->surveyRepository->findById($surveyId); if (null === $survey) { - return; + return false; } - if ('1' === (string) $survey->isLocked) { - throw new \RuntimeException('Die Umfrage ist gesperrt, weil bereits Antworten existieren.'); - } + return '1' === (string) $survey->published || '1' === (string) $survey->isLocked; } private function resolveSurveyId(string $table, ?DataContainer $dataContainer = null): int @@ -430,6 +584,10 @@ final class SurveyDcaListener return 0; } + if ($table === (string) Input::get('table') && '' === (string) Input::get('act')) { + return $recordId; + } + if ('survey' === $table) { $value = $this->connection->fetchOne(sprintf('SELECT %s FROM %s WHERE id = ?', 'id', $table), [$recordId]); diff --git a/src/Form/SurveyConditionEditorType.php b/src/Form/SurveyConditionEditorType.php index 54c5153..2d88149 100644 --- a/src/Form/SurveyConditionEditorType.php +++ b/src/Form/SurveyConditionEditorType.php @@ -31,7 +31,7 @@ final class SurveyConditionEditorType extends AbstractType 'constraints' => [new NotBlank(), new Length(['max' => 255])], ]) ->add('targetQuestion', ChoiceType::class, [ - 'label' => 'Naechste Frage', + 'label' => 'Nächste Frage', 'choices' => array_flip($questionChoices), 'constraints' => [new NotBlank()], ]) diff --git a/src/Form/SurveyEditorType.php b/src/Form/SurveyEditorType.php index 35f108e..6eb8160 100644 --- a/src/Form/SurveyEditorType.php +++ b/src/Form/SurveyEditorType.php @@ -30,7 +30,7 @@ final class SurveyEditorType extends AbstractType 'attr' => ['rows' => 5], ]) ->add('published', CheckboxType::class, [ - 'label' => 'Veroeffentlicht', + 'label' => 'Veröffentlicht', 'required' => false, ]) ; diff --git a/src/Form/SurveyQuestionAnswerType.php b/src/Form/SurveyQuestionAnswerType.php index 690b918..68fc62a 100644 --- a/src/Form/SurveyQuestionAnswerType.php +++ b/src/Form/SurveyQuestionAnswerType.php @@ -22,7 +22,7 @@ final class SurveyQuestionAnswerType extends AbstractType $question = $options['question']; if (!$question instanceof SurveyContentModel) { - throw new \InvalidArgumentException('Die Frage fehlt fuer das Antwortformular.'); + throw new \InvalidArgumentException('Die Frage fehlt für das Antwortformular.'); } $this->questionTypeRegistry->get((string) $question->type)->buildField($builder, $question); diff --git a/src/Form/SurveyQuestionEditorType.php b/src/Form/SurveyQuestionEditorType.php index 0dc11ca..164942f 100644 --- a/src/Form/SurveyQuestionEditorType.php +++ b/src/Form/SurveyQuestionEditorType.php @@ -153,7 +153,7 @@ final class SurveyQuestionEditorType extends AbstractType 'attr' => ['data-question-editor-target' => 'rangeField'], ]) ->add('rangeMax', IntegerType::class, [ - 'label' => 'Groesster Wert', + 'label' => 'Größter Wert', 'empty_data' => '10', 'constraints' => [new GreaterThanOrEqual(['value' => -100000])], 'attr' => ['data-question-editor-target' => 'rangeField'], diff --git a/src/Repository/SurveyAnswerRepository.php b/src/Repository/SurveyAnswerRepository.php index 62825ff..9b8aa23 100644 --- a/src/Repository/SurveyAnswerRepository.php +++ b/src/Repository/SurveyAnswerRepository.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Mummert\SurveyBundle\Repository; +use Doctrine\DBAL\ArrayParameterType; use Doctrine\DBAL\Connection; final class SurveyAnswerRepository @@ -12,7 +13,15 @@ final class SurveyAnswerRepository { } - public function saveAnswer(int $submissionId, int $questionId, string $questionType, string $value): void + public function saveAnswer( + int $submissionId, + int $questionId, + string $questionType, + string $questionLabel, + string $questionDescription, + int $questionSorting, + string $value, + ): void { $existingId = $this->connection->fetchOne( 'SELECT id FROM tl_survey_answer WHERE submission = ? AND question = ? LIMIT 1', @@ -22,6 +31,9 @@ final class SurveyAnswerRepository if (false !== $existingId) { $this->connection->update('tl_survey_answer', [ 'questionType' => $questionType, + 'questionLabel' => $questionLabel, + 'questionDescription' => $questionDescription, + 'questionSorting' => $questionSorting, 'value' => $value, ], [ 'id' => (int) $existingId, @@ -35,6 +47,9 @@ final class SurveyAnswerRepository 'submission' => $submissionId, 'question' => $questionId, 'questionType' => $questionType, + 'questionLabel' => $questionLabel, + 'questionDescription' => $questionDescription, + 'questionSorting' => $questionSorting, 'value' => $value, ]); } @@ -53,8 +68,36 @@ final class SurveyAnswerRepository ); } + public function findStoredAnswerValue(int $submissionId, int $questionId): ?string + { + $value = $this->connection->fetchOne( + 'SELECT value FROM tl_survey_answer WHERE submission = ? AND question = ? LIMIT 1', + [$submissionId, $questionId], + ); + + return false === $value ? null : (string) $value; + } + /** - * @return list + * @param list $questionIds + */ + public function deleteAnswersForSubmissionExceptQuestions(int $submissionId, array $questionIds): void + { + if ([] === $questionIds) { + $this->connection->delete('tl_survey_answer', ['submission' => $submissionId]); + + return; + } + + $this->connection->executeStatement( + 'DELETE FROM tl_survey_answer WHERE submission = ? AND question NOT IN (?)', + [$submissionId, array_values(array_unique(array_map('intval', $questionIds)))], + [\PDO::PARAM_INT, ArrayParameterType::INTEGER], + ); + } + + /** + * @return list */ public function findFinishedAnswersBySurvey(int $surveyId): array { @@ -62,11 +105,15 @@ final class SurveyAnswerRepository <<<'SQL' SELECT a.question AS questionId, + a.questionType, + a.questionLabel, + a.questionDescription, + a.questionSorting, a.value FROM tl_survey_answer a INNER JOIN tl_survey_submission sub ON sub.id = a.submission WHERE sub.survey = :survey AND sub.isFinished = 1 - ORDER BY a.question ASC, a.id ASC + ORDER BY a.questionSorting ASC, a.question ASC, a.id ASC SQL, ['survey' => $surveyId], ); @@ -74,6 +121,10 @@ final class SurveyAnswerRepository return array_map( static fn (array $row): array => [ 'questionId' => (int) $row['questionId'], + 'questionType' => (string) ($row['questionType'] ?? ''), + 'questionLabel' => (string) ($row['questionLabel'] ?? ''), + 'questionDescription' => (string) ($row['questionDescription'] ?? ''), + 'questionSorting' => (int) ($row['questionSorting'] ?? 0), 'value' => (string) $row['value'], ], $rows, @@ -92,11 +143,12 @@ final class SurveyAnswerRepository a.question AS questionId, a.value, a.questionType, - q.question + COALESCE(NULLIF(a.questionLabel, ''), q.question, CONCAT('Frage #', a.question)) AS question, + COALESCE(a.questionDescription, q.description, '') AS questionDescription FROM tl_survey_answer a - INNER JOIN tl_survey_content q ON q.id = a.question + LEFT JOIN tl_survey_content q ON q.id = a.question WHERE a.submission = :submission - ORDER BY q.sorting ASC + ORDER BY COALESCE(NULLIF(a.questionSorting, 0), q.sorting, 0) ASC, a.id ASC SQL, ['submission' => $submissionId], ); diff --git a/src/Repository/SurveyRepository.php b/src/Repository/SurveyRepository.php index 68239b7..4887b7f 100644 --- a/src/Repository/SurveyRepository.php +++ b/src/Repository/SurveyRepository.php @@ -41,6 +41,7 @@ final class SurveyRepository if ($publishedOnly) { $qb->andWhere('published = 1'); + $qb->andWhere('isActive = 1'); } $id = $qb->executeQuery()->fetchOne(); @@ -65,6 +66,7 @@ final class SurveyRepository s.alias, s.description, s.published, + s.isActive, s.isLocked, s.updatedAt, COUNT(DISTINCT q.id) AS questionCount, @@ -92,6 +94,7 @@ final class SurveyRepository 'category' => serialize($this->normalizeCategoryIds($data['category'] ?? [])), 'description' => (string) ($data['description'] ?? ''), 'published' => '', + 'isActive' => '', 'isLocked' => '', 'createdBy' => $memberId, 'updatedBy' => $memberId, @@ -115,7 +118,8 @@ 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), - 'published' => !empty($data['published']) ? '1' : '', + '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, 'updatedAt' => time(), ], [ @@ -182,6 +186,8 @@ final class SurveyRepository $this->connection->update('tl_survey', [ 'categoryFilter' => implode(',', $categoryIds), 'categorySummary' => implode(', ', $categoryTitles), + 'templateFilter' => '1' === (string) $survey->isTemplate ? '1' : '0', + 'templateSummary' => '1' === (string) $survey->isTemplate ? 'Ja' : 'Nein', 'assignedMembersFilter' => implode(',', $memberIds), 'assignedMembersSummary' => implode(', ', $memberNames), ], [ @@ -212,6 +218,36 @@ final class SurveyRepository ]); } + public function publish(int $surveyId, int $memberId): void + { + $now = time(); + + $this->connection->update('tl_survey', [ + 'published' => '1', + 'isActive' => '1', + 'isLocked' => '1', + 'tstamp' => $now, + 'updatedBy' => $memberId, + 'updatedAt' => $now, + ], [ + 'id' => $surveyId, + ]); + } + + public function setActive(int $surveyId, int $memberId, bool $isActive): void + { + $now = time(); + + $this->connection->update('tl_survey', [ + 'isActive' => $isActive ? '1' : '', + 'tstamp' => $now, + 'updatedBy' => $memberId, + 'updatedAt' => $now, + ], [ + 'id' => $surveyId, + ]); + } + public function deleteCascade(int $surveyId): void { $this->connection->transactional(function () use ($surveyId): void { @@ -347,7 +383,7 @@ final class SurveyRepository ); return $this->hasListMetadataColumns = !array_diff( - ['categoryfilter', 'categorysummary', 'assignedmembersfilter', 'assignedmemberssummary'], + ['categoryfilter', 'categorysummary', 'templatefilter', 'templatesummary', 'assignedmembersfilter', 'assignedmemberssummary'], $columnNames, ); } diff --git a/src/Repository/SurveySubmissionRepository.php b/src/Repository/SurveySubmissionRepository.php index 6bac2f4..7c4a847 100644 --- a/src/Repository/SurveySubmissionRepository.php +++ b/src/Repository/SurveySubmissionRepository.php @@ -91,6 +91,17 @@ final class SurveySubmissionRepository ]); } + public function reopen(int $submissionId, int $questionId): void + { + $this->connection->update('tl_survey_submission', [ + 'isFinished' => '', + 'completedAt' => 0, + 'currentQuestion' => $questionId, + ], [ + 'id' => $submissionId, + ]); + } + /** * @return list> */ diff --git a/src/Service/SurveyEditorService.php b/src/Service/SurveyEditorService.php index e177fd9..d44ed20 100644 --- a/src/Service/SurveyEditorService.php +++ b/src/Service/SurveyEditorService.php @@ -41,14 +41,30 @@ final class SurveyEditorService $this->surveyRepository->update($survey, $memberId, $data->toArray()); } - public function toggleSurveyPublished(SurveyModel $survey, int $memberId): bool + public function publishSurvey(SurveyModel $survey, int $memberId): void { - $data = SurveyEditorData::fromModel($survey); - $data->published = !$data->published; + if ('1' === (string) $survey->published) { + throw new \RuntimeException('Die Umfrage ist bereits veröffentlicht.'); + } - $this->surveyRepository->update($survey, $memberId, $data->toArray()); + $this->surveyRepository->publish((int) $survey->id, $memberId); + } - return $data->published; + public function toggleSurveyActive(SurveyModel $survey, int $memberId): bool + { + if ('1' !== (string) $survey->published) { + throw new \RuntimeException('Die Umfrage muss zuerst veröffentlicht werden.'); + } + + $isActive = '1' !== (string) ($survey->isActive ?? ''); + $this->surveyRepository->setActive((int) $survey->id, $memberId, $isActive); + + return $isActive; + } + + public function isStructureLocked(SurveyModel $survey): bool + { + return '1' === (string) $survey->published || '1' === (string) $survey->isLocked; } public function saveQuestion(SurveyModel $survey, SurveyQuestionData $data, int $questionId = 0): int @@ -117,8 +133,8 @@ final class SurveyEditorService private function assertUnlocked(SurveyModel $survey): void { - if ('1' === (string) $survey->isLocked) { - throw new \RuntimeException('Die Umfrage ist gesperrt, weil bereits Antworten existieren.'); + if ($this->isStructureLocked($survey)) { + throw new \RuntimeException('Die Fragen dieser Umfrage können nach der Veröffentlichung oder nach ersten Teilnahmen nicht mehr geändert werden.'); } } } \ No newline at end of file diff --git a/src/Service/SurveyFlowService.php b/src/Service/SurveyFlowService.php index 4ca62da..315115f 100644 --- a/src/Service/SurveyFlowService.php +++ b/src/Service/SurveyFlowService.php @@ -63,6 +63,28 @@ final class SurveyFlowService return null; } + public function getFirstQuestion(SurveyModel $survey): ?SurveyContentModel + { + $questions = $this->getOrderedQuestions($survey); + + return $questions[0] ?? null; + } + + public function resolveQuestionById(SurveyModel $survey, int $questionId): ?SurveyContentModel + { + if ($questionId <= 0) { + return null; + } + + foreach ($this->getOrderedQuestions($survey) as $question) { + if ((int) $question->id === $questionId) { + return $question; + } + } + + return null; + } + /** * @return array{current:int,total:int,percentage:int} */ diff --git a/src/Service/SurveyPermissionService.php b/src/Service/SurveyPermissionService.php index 15f1714..85ddc8f 100644 --- a/src/Service/SurveyPermissionService.php +++ b/src/Service/SurveyPermissionService.php @@ -23,7 +23,7 @@ final class SurveyPermissionService public function assertEditorAccess(SurveyModel $survey, FrontendUser $member): void { if (!$this->canEditSurvey($survey, $member)) { - throw new AccessDeniedHttpException('Sie duerfen diese Umfrage nicht bearbeiten.'); + throw new AccessDeniedHttpException('Sie dürfen diese Umfrage nicht bearbeiten.'); } } } \ No newline at end of file diff --git a/src/Service/SurveyResultsViewService.php b/src/Service/SurveyResultsViewService.php index bdd26d7..f083a77 100644 --- a/src/Service/SurveyResultsViewService.php +++ b/src/Service/SurveyResultsViewService.php @@ -41,118 +41,191 @@ final class SurveyResultsViewService /** * @param list $questions - * @param list $answers + * @param list $answers * * @return list> */ private function buildQuestionResults(array $questions, array $answers, int $completedSubmissionCount): array { - $answersByQuestion = []; - - foreach ($answers as $answer) { - $answersByQuestion[$answer['questionId']][] = $answer['value']; - } - $results = []; foreach ($questions as $question) { - $questionId = (int) $question->id; - $values = $answersByQuestion[$questionId] ?? []; - $totalAnswers = count($values); - $responseRate = $completedSubmissionCount > 0 ? (int) round(($totalAnswers / $completedSubmissionCount) * 100) : 0; + $key = $this->buildQuestionResultKey( + (int) $question->id, + (string) $question->type, + (string) $question->question, + (string) ($question->description ?? ''), + ); - $result = [ - 'id' => $questionId, - 'question' => (string) $question->question, - 'description' => (string) ($question->description ?? ''), - 'type' => (string) $question->type, - 'totalAnswers' => $totalAnswers, - 'responseRate' => $responseRate, - 'chart' => null, - ]; - - switch ((string) $question->type) { - case 'yes_no_maybe': - $optionCounts = [ - 'Ja' => 0, - 'Nein' => 0, - ]; - - if ('1' === (string) $question->allowMaybe) { - $optionCounts['Vielleicht'] = 0; - } - - foreach ($values as $value) { - $normalizedValue = mb_strtolower(trim($value)); - - if ('yes' === $normalizedValue) { - ++$optionCounts['Ja']; - } elseif ('no' === $normalizedValue) { - ++$optionCounts['Nein']; - } elseif ('maybe' === $normalizedValue && isset($optionCounts['Vielleicht'])) { - ++$optionCounts['Vielleicht']; - } - } - - $result['options'] = $this->buildOptionStats($optionCounts); - $result['chart'] = $this->buildPieChartConfig($result['options']); - break; - - case 'choice': - $optionCounts = []; - - for ($index = 1; $index <= 10; ++$index) { - $option = trim((string) $question->{'answerOption'.$index}); - - if ('' !== $option) { - $optionCounts[$option] = 0; - } - } - - foreach ($values as $value) { - foreach (array_filter(array_map('trim', explode(' | ', $value)), static fn (string $entry): bool => '' !== $entry) as $selectedOption) { - if (!array_key_exists($selectedOption, $optionCounts)) { - $optionCounts[$selectedOption] = 0; - } - - ++$optionCounts[$selectedOption]; - } - } - - $result['options'] = $this->buildOptionStats($optionCounts); - $result['chart'] = $this->buildPieChartConfig($result['options']); - break; - - case 'range': - $numericValues = array_values(array_map(static fn (string $value): int => (int) $value, $values)); - $distribution = []; - - foreach ($numericValues as $numericValue) { - $label = (string) $numericValue; - $distribution[$label] = ($distribution[$label] ?? 0) + 1; - } - - ksort($distribution, SORT_NATURAL); - - $result['minimum'] = [] !== $numericValues ? min($numericValues) : null; - $result['maximum'] = [] !== $numericValues ? max($numericValues) : null; - $result['average'] = [] !== $numericValues ? round(array_sum($numericValues) / count($numericValues), 2) : null; - $result['distribution'] = $this->buildOptionStats($distribution); - $result['chart'] = $this->buildRangeChartConfig($result['distribution']); - break; - - case 'text': - default: - $result['responses'] = array_values(array_filter($values, static fn (string $value): bool => '' !== trim($value))); - $result['responseSummary'] = $this->buildTextResponseSummary($result['responses']); - $result['chart'] = $this->buildPieChartConfig($result['responseSummary']); - break; - } - - $results[] = $result; + $results[$key] = $this->buildResultSkeleton( + (int) $question->id, + (string) $question->type, + (string) $question->question, + (string) ($question->description ?? ''), + (int) ($question->sorting ?? 0), + ); + $results[$key]['_choiceOptions'] = $this->getChoiceOptions($question); } - return $results; + foreach ($answers as $answer) { + $questionId = (int) ($answer['questionId'] ?? 0); + $questionType = (string) ($answer['questionType'] ?? 'text'); + $questionLabel = trim((string) ($answer['questionLabel'] ?? '')) ?: 'Frage #'.$questionId; + $questionDescription = (string) ($answer['questionDescription'] ?? ''); + $key = $this->buildQuestionResultKey($questionId, $questionType, $questionLabel, $questionDescription); + + if (!isset($results[$key])) { + $results[$key] = $this->buildResultSkeleton( + $questionId, + $questionType, + $questionLabel, + $questionDescription, + (int) ($answer['questionSorting'] ?? 0), + ); + } + + $results[$key]['_values'][] = (string) ($answer['value'] ?? ''); + } + + foreach ($results as $key => $result) { + $results[$key] = $this->finalizeQuestionResult($result, $completedSubmissionCount); + } + + uasort($results, static function (array $left, array $right): int { + $sortingComparison = ((int) ($left['_sorting'] ?? 0)) <=> ((int) ($right['_sorting'] ?? 0)); + + if (0 !== $sortingComparison) { + return $sortingComparison; + } + + return strcasecmp((string) ($left['question'] ?? ''), (string) ($right['question'] ?? '')); + }); + + return array_values($results); + } + + private function buildQuestionResultKey(int $questionId, string $questionType, string $questionLabel, string $questionDescription): string + { + return implode('|', [$questionId, $questionType, md5($questionLabel), md5($questionDescription)]); + } + + /** + * @return array + */ + private function buildResultSkeleton(int $questionId, string $questionType, string $questionLabel, string $questionDescription, int $sorting): array + { + return [ + 'id' => $questionId, + 'question' => $questionLabel, + 'description' => $questionDescription, + 'type' => $questionType, + 'totalAnswers' => 0, + 'responseRate' => 0, + 'chart' => null, + '_sorting' => $sorting, + '_values' => [], + '_choiceOptions' => [], + ]; + } + + /** + * @param array $result + * + * @return array + */ + private function finalizeQuestionResult(array $result, int $completedSubmissionCount): array + { + $values = $result['_values'] ?? []; + $values = \is_array($values) ? array_values(array_map('strval', $values)) : []; + $totalAnswers = count($values); + + $result['totalAnswers'] = $totalAnswers; + $result['responseRate'] = $completedSubmissionCount > 0 ? (int) round(($totalAnswers / $completedSubmissionCount) * 100) : 0; + + switch ((string) ($result['type'] ?? 'text')) { + case 'yes_no_maybe': + $optionCounts = ['Ja' => 0, 'Nein' => 0]; + + foreach ($values as $value) { + $normalizedValue = mb_strtolower(trim($value)); + + if ('yes' === $normalizedValue) { + ++$optionCounts['Ja']; + } elseif ('no' === $normalizedValue) { + ++$optionCounts['Nein']; + } elseif ('maybe' === $normalizedValue) { + $optionCounts['Vielleicht'] = ($optionCounts['Vielleicht'] ?? 0) + 1; + } + } + + $result['options'] = $this->buildOptionStats($optionCounts); + $result['chart'] = $this->buildPieChartConfig($result['options']); + break; + + case 'choice': + $optionCounts = []; + + foreach (($result['_choiceOptions'] ?? []) as $choiceOption) { + $optionCounts[(string) $choiceOption] = 0; + } + + foreach ($values as $value) { + foreach (array_filter(array_map('trim', explode(' | ', $value)), static fn (string $entry): bool => '' !== $entry) as $selectedOption) { + $optionCounts[$selectedOption] = ($optionCounts[$selectedOption] ?? 0) + 1; + } + } + + $result['options'] = $this->buildOptionStats($optionCounts); + $result['chart'] = $this->buildPieChartConfig($result['options']); + break; + + case 'range': + $numericValues = array_values(array_map(static fn (string $value): int => (int) $value, $values)); + $distribution = []; + + foreach ($numericValues as $numericValue) { + $label = (string) $numericValue; + $distribution[$label] = ($distribution[$label] ?? 0) + 1; + } + + ksort($distribution, SORT_NATURAL); + + $result['minimum'] = [] !== $numericValues ? min($numericValues) : null; + $result['maximum'] = [] !== $numericValues ? max($numericValues) : null; + $result['average'] = [] !== $numericValues ? round(array_sum($numericValues) / count($numericValues), 2) : null; + $result['distribution'] = $this->buildOptionStats($distribution); + $result['chart'] = $this->buildRangeChartConfig($result['distribution']); + break; + + case 'text': + default: + $result['responses'] = array_values(array_filter($values, static fn (string $value): bool => '' !== trim($value))); + $result['responseSummary'] = $this->buildTextResponseSummary($result['responses']); + $result['chart'] = null; + break; + } + + unset($result['_sorting'], $result['_values'], $result['_choiceOptions']); + + return $result; + } + + /** + * @return list + */ + private function getChoiceOptions(SurveyContentModel $question): array + { + $options = []; + + for ($index = 1; $index <= 10; ++$index) { + $option = trim((string) $question->{'answerOption'.$index}); + + if ('' !== $option) { + $options[] = $option; + } + } + + return $options; } /** diff --git a/src/Service/SurveySubmissionService.php b/src/Service/SurveySubmissionService.php index 66e4840..af44edb 100644 --- a/src/Service/SurveySubmissionService.php +++ b/src/Service/SurveySubmissionService.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Mummert\SurveyBundle\Service; use Mummert\SurveyBundle\Model\SurveyContentModel; +use Mummert\SurveyBundle\Form\Model\SurveyAnswerData; use Mummert\SurveyBundle\Model\SurveyModel; use Mummert\SurveyBundle\Model\SurveySubmissionModel; use Mummert\SurveyBundle\QuestionType\QuestionTypeRegistry; @@ -42,20 +43,23 @@ final class SurveySubmissionService $token = (string) new Ulid(); $submission = $this->surveySubmissionRepository->create((int) $survey->id, $token, $firstQuestion?->id ? (int) $firstQuestion->id : null); $session->set($sessionKey, $token); + $session->set($this->buildHistoryKey((int) $survey->id), []); return $submission; } public function storeAnswer(SurveyModel $survey, SurveySubmissionModel $submission, SurveyContentModel $question, mixed $rawAnswer): string { - $questionType = $this->questionTypeRegistry->get((string) $question->type); - $normalizedAnswer = $questionType->normalizeAnswer($rawAnswer, $question); + $normalizedAnswer = $this->normalizeAnswerForQuestion($question, $rawAnswer); $surveyHadAnswers = $this->surveyAnswerRepository->hasAnswersForSurvey((int) $survey->id); $this->surveyAnswerRepository->saveAnswer( (int) $submission->id, (int) $question->id, (string) $question->type, + trim((string) $question->question), + trim((string) ($question->description ?? '')), + (int) ($question->sorting ?? 0), $normalizedAnswer, ); @@ -66,6 +70,73 @@ final class SurveySubmissionService return $normalizedAnswer; } + public function normalizeAnswerForQuestion(SurveyContentModel $question, mixed $rawAnswer): string + { + return $this->questionTypeRegistry->get((string) $question->type)->normalizeAnswer($rawAnswer, $question); + } + + public function createAnswerData(SurveyContentModel $question, mixed $storedValue = null): SurveyAnswerData + { + $answerData = new SurveyAnswerData(); + + if (null === $storedValue || '' === trim((string) $storedValue)) { + return $answerData; + } + + $normalizedValue = (string) $storedValue; + + $answerData->answer = match ((string) $question->type) { + 'choice' => '1' === (string) $question->allowMultiple + ? array_values(array_filter(array_map('trim', explode(' | ', $normalizedValue)), static fn (string $entry): bool => '' !== $entry)) + : $normalizedValue, + 'range' => (int) $normalizedValue, + default => $normalizedValue, + }; + + return $answerData; + } + + public function getStoredAnswerValue(SurveySubmissionModel $submission, SurveyContentModel $question): ?string + { + return $this->surveyAnswerRepository->findStoredAnswerValue((int) $submission->id, (int) $question->id); + } + + public function rememberQuestion(SurveyModel $survey, SessionInterface $session, SurveyContentModel $question): void + { + $history = $this->getHistory($survey, $session); + $questionId = (int) $question->id; + + if (($history[array_key_last($history)] ?? null) !== $questionId) { + $history[] = $questionId; + } + + $session->set($this->buildHistoryKey((int) $survey->id), $history); + } + + public function canGoBack(SurveyModel $survey, SessionInterface $session): bool + { + return [] !== $this->getHistory($survey, $session); + } + + public function goBack(SurveyModel $survey, SurveySubmissionModel $submission, SessionInterface $session): bool + { + $history = $this->getHistory($survey, $session); + + if ([] === $history) { + return false; + } + + $previousQuestionId = (int) array_pop($history); + $this->surveySubmissionRepository->reopen((int) $submission->id, $previousQuestionId); + $this->surveyAnswerRepository->deleteAnswersForSubmissionExceptQuestions( + (int) $submission->id, + array_merge($history, [$previousQuestionId]), + ); + $session->set($this->buildHistoryKey((int) $survey->id), $history); + + return true; + } + public function advance(SurveySubmissionModel $submission, ?SurveyContentModel $nextQuestion): void { if ($nextQuestion instanceof SurveyContentModel) { @@ -100,6 +171,25 @@ final class SurveySubmissionService return 'mummert_survey_submission_'.$surveyId; } + private function buildHistoryKey(int $surveyId): string + { + return 'mummert_survey_history_'.$surveyId; + } + + /** + * @return list + */ + private function getHistory(SurveyModel $survey, SessionInterface $session): array + { + $history = $session->get($this->buildHistoryKey((int) $survey->id), []); + + if (!\is_array($history)) { + return []; + } + + return array_values(array_filter(array_map('intval', $history), static fn (int $questionId): bool => $questionId > 0)); + } + /** * @param array $answer * diff --git a/translations/messages.de.yaml b/translations/messages.de.yaml index 6c38dab..592acba 100644 --- a/translations/messages.de.yaml +++ b/translations/messages.de.yaml @@ -3,15 +3,18 @@ survey: login_required: "Dieser Bereich verwendet den normalen Contao-Mitgliederlogin. Bitte binden Sie das Modul auf einer geschützten Seite oder mit einer separaten Login-Seite ein." create: "Neue Umfrage anlegen" delete: "Löschen" - delete_confirm: "Umfrage wirklich löschen? Damit werden auch alle Fragen, Antworten und Ergebnisse endgültig gelöscht. Alternativ können Sie die Umfrage auch einfach auf nicht veröffentlicht stellen." + delete_confirm: "Umfrage wirklich löschen? Damit werden auch alle Fragen, Antworten und Ergebnisse endgültig gelöscht. Alternativ können Sie die Umfrage auch einfach vorübergehend deaktivieren." delete_confirm_second: "Wirklich sicher?" published: "veröffentlicht" draft: "Entwurf" locked: "gesperrt" + active: "aktiv" + inactive: "deaktiviert" questions: "%count% Fragen" participations: "%count% Teilnahmen" edit: "Bearbeiten" public_view: "Öffentliche Ansicht" + draft_view: "Entwurfs-Ansicht" show_results: "Ergebnisse anzeigen" empty: "Dem eingeloggten Mitglied ist aktuell keine Umfrage zugewiesen." edit: @@ -36,9 +39,16 @@ survey: published: "veröffentlicht" draft: "Entwurf" locked: "gesperrt" - structure_locked: "Die Umfrage ist strukturell gesperrt, weil bereits Antworten vorliegen. Stammdaten können weiter gepflegt werden, der Ablauf und die Fragen jedoch nicht." + structure_locked: "Die Fragen dieser Umfrage sind gesperrt, weil die Umfrage veröffentlicht wurde oder bereits erste Teilnahmen vorliegen. Die Metadaten können weiter gepflegt werden." + published_readonly: "Diese Umfrage ist veröffentlicht. Die Bearbeitung im Frontend ist deshalb deaktiviert. Änderungen sind weiterhin im Backend möglich." metadata: "Umfrage-Metadaten" publish_survey: "Umfrage veröffentlichen" + publish_help: "Umfrage veröffentlichen. Danach sind keine Änderungen mehr möglich." + publish_confirm: "Sind Sie wirklich sicher? Nachdem die Umfrage veröffentlicht wurde sind keine Änderungen mehr möglich!" + deactivate_survey: "Umfrage vorübergehend deaktivieren" + activate_survey: "Umfrage wieder aktivieren" + deactivate_help: "Die veröffentlichte Umfrage wird vorübergehend offline genommen. Fragen bleiben weiter gesperrt." + activate_help: "Die veröffentlichte Umfrage wird wieder öffentlich erreichbar gemacht. Fragen bleiben weiter gesperrt." save: "Speichern" save_metadata: "Metadaten speichern" save_this_question: "Diese Frage speichern" @@ -107,7 +117,11 @@ survey: question_progress: "Frage %current%" answer_flow_hint: "Diese Antwort wird direkt für den weiteren Ablauf verwendet." current_value: "Aktueller Wert" + back: "Zurück" next: "Weiter" + preview_badge: "Entwurf" + preview_notice: "Dies ist eine Entwurfsansicht. Antworten können getestet werden, werden aber nicht gespeichert." + preview_thanks_text: "Der Testlauf ist abgeschlossen. Antworten wurden nicht gespeichert." results: no_description: "Keine Beschreibung hinterlegt." completed_submissions: "%count% abgeschlossene Teilnahmen" diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index 8412d80..1b5fa34 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -3,15 +3,18 @@ survey: login_required: "This area uses the regular Contao member login. Please place the module on a protected page or provide a separate login page." create: "Create new survey" delete: "Delete" - delete_confirm: "Delete this survey? This will permanently remove all questions, answers, and results as well. Alternatively, you can simply switch the survey to unpublished." + delete_confirm: "Delete this survey? This will permanently remove all questions, answers, and results as well. Alternatively, you can simply deactivate the survey temporarily." delete_confirm_second: "Are you really sure?" published: "published" draft: "Draft" locked: "locked" + active: "active" + inactive: "inactive" questions: "%count% questions" participations: "%count% participations" edit: "Edit" public_view: "Public view" + draft_view: "Draft view" show_results: "Show results" empty: "There is currently no survey assigned to the logged-in member." edit: @@ -36,9 +39,16 @@ survey: published: "published" draft: "draft" locked: "locked" - structure_locked: "The survey structure is locked because answers already exist. Master data can still be maintained, but the flow and questions can no longer be changed." + structure_locked: "The questions of this survey are locked because the survey was published or first participations already exist. The metadata can still be maintained." + published_readonly: "This survey is published. Frontend editing is therefore disabled. Changes are still possible in the backend." metadata: "Survey metadata" publish_survey: "Publish survey" + publish_help: "Publish survey. No further changes will be possible afterwards." + publish_confirm: "Are you really sure? After the survey has been published, no further changes will be possible!" + deactivate_survey: "Temporarily deactivate survey" + activate_survey: "Reactivate survey" + deactivate_help: "The published survey is temporarily taken offline. Questions remain locked." + activate_help: "The published survey is made publicly reachable again. Questions remain locked." save: "Save" save_metadata: "Save metadata" save_this_question: "Save this question" @@ -107,7 +117,11 @@ survey: question_progress: "Question %current%" answer_flow_hint: "This answer is used directly for the further flow." current_value: "Current value" + back: "Back" next: "Next" + preview_badge: "Draft" + preview_notice: "This is a draft view. Answers can be tested, but they will not be stored." + preview_thanks_text: "The test run is complete. Answers were not stored." results: no_description: "No description available." completed_submissions: "%count% completed participations"