From a8930789457971ae0bb4d9f2c7607f870d050850 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BCrgen=20Mummert?= Date: Mon, 25 May 2026 16:02:47 +0200 Subject: [PATCH] Refine survey editor flow and auth styling --- contao/config/config.php | 1 + .../frontend/_survey_assets.html.twig | 554 +++++++++++++++++- .../frontend/member_survey_edit.html.twig | 367 +++++++----- .../frontend/member_survey_list.html.twig | 60 +- .../frontend/show_survey_results.html.twig | 4 +- public/css/auth-module.css | 227 +++++++ .../MemberSurveyEditController.php | 317 ++++++++-- .../MemberSurveyListController.php | 59 +- src/Form/Model/SurveyQuestionData.php | 34 +- src/Form/SurveyEditorType.php | 1 + src/Form/SurveyQuestionEditorType.php | 72 ++- src/Repository/SurveyQuestionRepository.php | 91 +-- src/Repository/SurveyRepository.php | 31 +- .../views/pdf/survey_results.html.twig | 4 +- src/Service/SurveyEditorService.php | 23 +- src/Service/SurveySubmissionService.php | 61 +- translations/messages.de.yaml | 28 +- translations/messages.en.yaml | 28 +- 18 files changed, 1649 insertions(+), 313 deletions(-) create mode 100644 public/css/auth-module.css diff --git a/contao/config/config.php b/contao/config/config.php index 71e7883..f80b051 100644 --- a/contao/config/config.php +++ b/contao/config/config.php @@ -3,6 +3,7 @@ declare(strict_types=1); $GLOBALS['TL_CSS'][] = 'assets/survey-backend.css|static'; +$GLOBALS['TL_CSS'][] = 'bundles/survey/css/auth-module.css|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/templates/frontend/_survey_assets.html.twig b/contao/templates/frontend/_survey_assets.html.twig index 950dea3..6061e35 100644 --- a/contao/templates/frontend/_survey_assets.html.twig +++ b/contao/templates/frontend/_survey_assets.html.twig @@ -40,30 +40,22 @@ .survey-brand-strip { display: flex; align-items: center; - gap: 1.5rem; justify-content: center; + gap: 1.5rem; max-width: 1400px; - box-sizing: border-box; - margin-top: 0; - margin-right: max(var(--survey-page-gutter), calc((100% - 1400px) / 2)); - margin-bottom: clamp(2rem, 4vw, 3.5rem); margin-left: max(var(--survey-page-gutter), calc((100% - 1400px) / 2)); - padding: 1rem 1.25rem 0.5rem; - } - - .survey-brand-logo { - display: block; - min-width: 0; - max-width: 100%; - flex-shrink: 1; - object-fit: contain; - object-position: center; + margin-right: max(var(--survey-page-gutter), calc((100% - 1400px) / 2)); + margin-top: 0; + margin-bottom: clamp(1.25rem, 3vw, 2.5rem); + padding: 0.5rem 0 0; + box-sizing: border-box; } .survey-brand-logo.primary { width: auto; max-width: min(100%, 320px); - height: 75px; + height: 82px; + display: block; } .survey-brand-logo.secondary { @@ -240,6 +232,72 @@ box-shadow: 0 12px 32px rgba(34, 34, 34, 0.06); } + .survey-list-grid { + grid-template-columns: 1fr; + align-items: stretch; + } + + .survey-list-card { + display: grid; + gap: 0.9rem; + padding: 1.1rem 1.15rem; + } + + .survey-list-card__head { + display: flex; + gap: 1rem; + justify-content: space-between; + align-items: flex-start; + } + + .survey-list-card__title-wrap { + display: grid; + gap: 0.45rem; + min-width: 0; + } + + .survey-list-card__title-wrap h3 { + margin: 0; + } + + .survey-list-card__stats { + gap: 0.6rem; + } + + .survey-list-card__actions { + margin-top: 0; + align-items: center; + } + + .survey-list-card__actions .survey-question-action-form { + margin: 0; + } + + .survey-list-card__toggle-form { + flex-shrink: 0; + align-self: flex-start; + } + + .survey-publish-toggle--compact { + min-height: 2.8rem; + padding: 0.45rem 0.65rem 0.45rem 0.85rem; + gap: 0.7rem; + } + + .survey-publish-toggle--compact .survey-publish-toggle__track { + width: 3rem; + height: 1.7rem; + } + + .survey-publish-toggle--compact .survey-publish-toggle__thumb { + width: 1.4rem; + height: 1.4rem; + } + + .survey-publish-toggle--compact.is-on .survey-publish-toggle__thumb { + transform: translateX(1.25rem); + } + .survey-meta { display: flex; flex-wrap: wrap; @@ -270,6 +328,89 @@ margin-top: 1rem; } + .survey-button-row--form-actions { + align-items: stretch; + } + + .survey-button-row--editor-actions { + margin-top: 0; + margin-bottom: 1rem; + align-items: center; + } + + .survey-publish-toggle-form { + margin: 0; + } + + .survey-publish-toggle { + display: inline-flex; + align-items: center; + gap: 0.9rem; + padding: 0.55rem 0.7rem 0.55rem 1rem; + border-radius: 999px; + border: 1px solid rgba(0, 100, 173, 0.18); + background: rgba(255, 255, 255, 0.9); + color: var(--survey-blue); + cursor: pointer; + min-height: 3.2rem; + } + + .survey-publish-toggle.is-on { + border-color: rgba(0, 174, 151, 0.3); + background: rgba(0, 174, 151, 0.08); + } + + .survey-publish-toggle__copy { + display: grid; + gap: 0.05rem; + text-align: left; + line-height: 1.1; + } + + .survey-publish-toggle__label { + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--survey-gray-dark); + } + + .survey-publish-toggle__value { + font-weight: 700; + } + + .survey-publish-toggle__track { + position: relative; + display: inline-flex; + align-items: center; + width: 3.35rem; + height: 1.9rem; + border-radius: 999px; + background: rgba(162, 168, 180, 0.45); + transition: background 0.2s ease; + flex-shrink: 0; + } + + .survey-publish-toggle.is-on .survey-publish-toggle__track { + background: rgba(0, 174, 151, 0.72); + } + + .survey-publish-toggle__thumb { + position: absolute; + top: 0.15rem; + left: 0.15rem; + width: 1.6rem; + height: 1.6rem; + border-radius: 50%; + background: #fff; + box-shadow: 0 4px 12px rgba(34, 34, 34, 0.18); + transition: transform 0.2s ease; + } + + .survey-publish-toggle.is-on .survey-publish-toggle__thumb { + transform: translateX(1.45rem); + } + .survey-button { display: inline-flex; justify-content: center; @@ -287,6 +428,11 @@ .survey-button.secondary { background: rgba(255,255,255,0.86); color: var(--survey-blue); border-color: rgba(0,100,173,0.18); } .survey-button.danger { background: rgba(157, 82, 118, 0.12); color: var(--survey-purple); border-color: rgba(157, 82, 118, 0.22); } + .survey-button-compact { + padding: 0.65rem 0.9rem; + font-size: 0.95rem; + } + .survey-form-grid { display: grid; gap: 1rem; @@ -302,6 +448,7 @@ .survey-label textarea, .survey-label select { width: 100%; + box-sizing: border-box; border-radius: 1rem; border: 1px solid rgba(162,168,180,0.5); padding: 0.8rem 0.9rem; @@ -319,6 +466,16 @@ background: rgba(252,228,214,0.42); } + .survey-question-toggles { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + } + + .survey-question-toggles .survey-inline-check { + margin: 0; + } + .survey-alert { border-radius: 1.2rem; padding: 1rem 1.1rem; @@ -327,6 +484,15 @@ border: 1px solid rgba(162,168,180,0.35); } + .survey-alert.success { + background: linear-gradient(135deg, rgba(0, 174, 151, 0.2), rgba(255,255,255,0.96)); + border-color: rgba(0, 174, 151, 0.62); + color: #085e57; + box-shadow: 0 18px 40px rgba(0, 174, 151, 0.16); + font-weight: 700; + border-left: 0.45rem solid rgba(0, 174, 151, 0.92); + } + .survey-alert.error { border-color: rgba(157, 82, 118, 0.28); color: var(--survey-purple); } .survey-alert.warning { border-color: rgba(236, 124, 50, 0.28); color: var(--survey-orange); } @@ -336,6 +502,178 @@ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); } + .survey-kpis--compact { + grid-template-columns: minmax(0, 360px); + } + + .survey-editor-layout { + align-items: start; + } + + .survey-editor-meta { + align-self: start; + } + + .survey-question-editor-shell { + background: transparent; + border: 0; + padding: 0; + box-shadow: none; + } + + .survey-section-head { + justify-content: space-between; + align-items: center; + } + + .survey-accordion-list { + display: grid; + gap: 0.85rem; + } + + .survey-accordion-item { + border: 1px solid rgba(162, 168, 180, 0.34); + border-radius: 1.3rem; + background: rgba(255, 255, 255, 0.78); + overflow: hidden; + } + + .survey-accordion-header { + margin: 0; + } + + .survey-accordion-toggle { + display: flex; + width: 100%; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + padding: 1rem 1.1rem; + background: transparent; + border: 0; + text-align: left; + } + + .survey-accordion-item .handorgel__header__button, + .survey-accordion-item .handorgel__header__button:hover, + .survey-accordion-item .handorgel__header__button:focus { + background: transparent; + border-top: 0; + color: inherit; + transition: background 0.2s ease, color 0.2s ease; + } + + .survey-accordion-item .handorgel__header__button:hover, + .survey-accordion-item .handorgel__header__button:focus, + .survey-accordion-item .handorgel__header--opened .handorgel__header__button { + background: var(--survey-blue); + color: #fff; + } + + .survey-accordion-item .handorgel__header__button:hover .survey-accordion-index, + .survey-accordion-item .handorgel__header__button:focus .survey-accordion-index, + .survey-accordion-item .handorgel__header--opened .survey-accordion-index, + .survey-accordion-item .handorgel__header__button:hover .survey-accordion-copy strong, + .survey-accordion-item .handorgel__header__button:focus .survey-accordion-copy strong, + .survey-accordion-item .handorgel__header--opened .survey-accordion-copy strong { + color: #fff; + } + + .survey-accordion-item .handorgel__header__button:hover .survey-badge, + .survey-accordion-item .handorgel__header__button:focus .survey-badge, + .survey-accordion-item .handorgel__header--opened .survey-badge { + background: rgba(255, 255, 255, 0.18); + color: #fff; + } + + .survey-accordion-heading { + display: flex; + align-items: flex-start; + gap: 0.8rem; + min-width: 0; + flex: 1 1 280px; + } + + .survey-accordion-index { + display: inline-flex; + min-width: 2ch; + justify-content: flex-end; + font-family: "Blogger Sans", sans-serif; + font-weight: 700; + color: var(--survey-orange); + } + + .survey-accordion-copy { + display: grid; + gap: 0.2rem; + min-width: 0; + } + + .survey-accordion-copy strong { + color: var(--survey-blue); + font-family: "Blogger Sans", sans-serif; + font-weight: 500; + } + + .survey-accordion-copy span { + color: var(--survey-gray-dark); + font-size: 0.95rem; + } + + .survey-accordion-actions { + justify-content: flex-end; + align-items: center; + flex: 1 0 auto; + margin-left: auto; + } + + .survey-accordion-panel { + border-top: 1px solid rgba(162, 168, 180, 0.24); + background: rgba(255, 255, 255, 0.72); + } + + .survey-accordion-panel .handorgel__content__inner { + display: grid; + gap: 1rem; + padding: 1.4rem; + } + + .survey-condition-card { + padding: 1rem 1.1rem; + } + + .survey-button-row--question-actions { + margin-top: 0; + align-items: center; + } + + .survey-question-action-form { + margin: 0; + } + + .survey-dirty-hint { + display: inline-flex; + align-items: center; + gap: 0.45rem; + justify-content: center; + margin-left: auto; + min-height: 3rem; + max-width: 100%; + padding: 0.8rem 1rem; + border-radius: 999px; + background: rgba(189, 29, 29, 0.14); + border: 1px solid rgba(189, 29, 29, 0.35); + color: #a11f1f; + font-size: 0.9rem; + font-weight: 700; + letter-spacing: 0.02em; + text-align: center; + } + + .survey-dirty-hint[hidden] { + display: none !important; + } + .survey-progress { width: 100%; height: 0.75rem; @@ -367,24 +705,96 @@ grid-template-columns: 0.95fr 1.05fr; } + .survey-editor-layout > .survey-editor-meta { + grid-column: 1 / -1; + } + + .survey-editor-layout > [data-survey-question-editor] { + grid-column: 1 / -1; + } + + .survey-editor-layout > .survey-editor-side { + grid-column: 1 / -1; + } + .survey-results-layout { grid-template-columns: minmax(320px, 0.95fr) minmax(0, 1.05fr); } } @media (max-width: 767px) { + .survey-list-card__head { + flex-direction: column; + } + + .survey-list-card__toggle-form { + width: auto; + max-width: 100%; + } + + .survey-list-card__toggle-form .survey-publish-toggle { + width: auto; + max-width: 100%; + } + .survey-brand-strip { padding: 0.75rem 0.75rem 0.25rem; } + .survey-brand-logo.primary { + height: 64px; + max-width: min(100%, 260px); + } + .survey-brand-logo.secondary { height: 75px; max-width: 100%; } + + .survey-kpis--compact { + grid-template-columns: 1fr; + } + + .survey-accordion-header { + margin: 0; + } + + .survey-accordion-actions { + width: auto; + justify-content: flex-end; + } + + .survey-accordion-toggle { + padding: 0.95rem 1rem; + } + + .survey-publish-toggle:not(.survey-publish-toggle--compact) { + width: 100%; + justify-content: space-between; + } } \ 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 467a9ef..ff63886 100644 --- a/contao/templates/frontend/member_survey_edit.html.twig +++ b/contao/templates/frontend/member_survey_edit.html.twig @@ -1,11 +1,20 @@ {% trans_default_domain 'messages' %} {% include '@Survey/frontend/_survey_assets.html.twig' %} + {% include '@Survey/frontend/_survey_branding.html.twig' %} {% set moduleHeadline = (headline is iterable ? headline.text|default('') : headline|default(''))|striptags|trim %}
+ {% for message in app.flashes('success') %} +
{{ message }}
+ {% endfor %} + + {% for message in app.flashes('error') %} +
{{ message }}
+ {% endfor %} + {% if moduleHeadline %}

{{ moduleHeadline }}

@@ -14,177 +23,264 @@ {% if loginRequired %}
{{ 'survey.edit.login_required'|trans }}
+ {% elseif entryNotAllowed|default(false) %} +
+
{{ 'survey.edit.entry_not_allowed'|trans }}
+
+ {% if backUrl %}{{ 'survey.edit.back_to_list'|trans }}{% endif %} +
+
{% elseif createMode %}

{{ 'survey.edit.create_title'|trans }}

{{ 'survey.edit.create_text'|trans }}

{{ form_start(surveyForm, {attr: {class: 'survey-form-grid'}}) }} - + + {{ form_widget(surveyForm._token) }} + -
{% if backUrl %}{{ 'survey.edit.back_to_list'|trans }}{% endif %}
- {{ form_end(surveyForm) }} + {{ form_end(surveyForm, {render_rest: false}) }}
{% else %} {% set locked = survey.isLocked == '1' %} -
+
{% if backUrl %}{{ 'survey.edit.back_to_list'|trans }}{% endif %} {% if publicUrl %}{{ 'survey.edit.public_view_open'|trans }}{% endif %} -
- -
-
{{ 'survey.edit.public_link'|trans }}
{% if publicUrl %}{{ publicUrl }}{% else %}{{ 'survey.edit.no_public_page'|trans }}{% endif %}
-
{{ 'survey.edit.status'|trans }}
{{ survey.published ? 'survey.edit.published'|trans : 'survey.edit.draft'|trans }}{% if locked %}{{ 'survey.edit.locked'|trans }}{% endif %}
-
{{ 'survey.edit.editors'|trans }}
{{ 'survey.edit.editors_count'|trans({'%count%': editors|length}) }}
+ {% if publicUrl %} + + {% endif %} +
+ + + + + +
{% if locked %}
{{ 'survey.edit.structure_locked'|trans }}
{% endif %} -
-
-
-

{{ 'survey.edit.metadata'|trans }}

- {{ form_start(surveyForm, {attr: {class: 'survey-form-grid'}}) }} - - - -
- {{ form_end(surveyForm) }} -
+ {% set surveyMetaFormId = 'survey-meta-form' %} + {% set newQuestionFormId = 'survey-question-form-new' %} -
-

{{ 'survey.edit.editors_title'|trans }}

-
- {% for editor in editors %} -
- {{ ([editor.firstname, editor.lastname]|join(' ')|trim|striptags) ?: (editor.email|striptags|trim) }}
- {{ editor.email|striptags|trim }} -
- {% else %} -
{{ 'survey.edit.no_editors'|trans }}
- {% endfor %} -
-
- -
-

{{ 'survey.edit.responses_title'|trans }}

-
- {% for submission in submissions %} -
-
- {{ 'survey.list.answers'|trans({'%count%': submission.answerCount}) }} - {{ submission.isFinished ? 'survey.edit.completed'|trans : 'survey.edit.open'|trans }} +
+
+
+
+

+ +

+
+
+ {{ form_start(surveyForm, {action: metadataFormActionUrl, attr: {class: 'survey-form-grid', id: surveyMetaFormId, 'data-survey-dirty-form': '1'}}) }} + + + {{ form_widget(surveyForm._token) }} + {{ form_errors(surveyForm) }} + + +
+ + +
+ {{ form_end(surveyForm, {render_rest: false}) }}
-
{{ 'survey.edit.started_at'|trans }}: {{ submission.startedAt ? submission.startedAt|date('d.m.Y H:i') : 'survey.edit.unknown'|trans }}
-
{{ 'survey.edit.completed_at'|trans }}: {{ submission.completedAt ? submission.completedAt|date('d.m.Y H:i') : 'survey.edit.unknown'|trans }}
-
{{ 'survey.edit.participation_code'|trans }}: {{ submission.token }}
-
- {% else %} -
{{ 'survey.edit.no_submissions'|trans }}
- {% endfor %} -
-
-
+
+
-
-
-

{{ 'survey.edit.questions_title'|trans }}

{% if activeQuestionId %}{{ 'survey.edit.edit_mode'|trans }}{% endif %}
- {% if not locked %} - {% set questionType = questionForm.type.vars.value ?: 'yes_no_maybe' %} - {{ form_start(questionForm, {attr: {class: 'survey-form-grid'}}) }} - - - - - - - - - - -
- {{ form_end(questionForm) }} - {% endif %} - -
{% for question in questions %} -
-
- {{ question.type }} + {% 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 %} +
+

+

-

{{ question.question|striptags|trim }}

-

{{ (question.description|striptags|trim) ?: 'survey.edit.no_extra_description'|trans }}

- {% if not locked %} -
- {{ 'survey.list.edit'|trans }} -
- - - - -
+ + + +
+
+ {% if 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'}}) }} + + {{ form_errors(questionItemForm) }} + + + +
+ + + + +
+ + + + {{ form_end(questionItemForm) }} + +
+ + {% if not locked %} +
+ + + + + +
+ {% endif %} + +
+ {% endif %}
- {% endif %} +
{% else %}
{{ 'survey.edit.no_questions'|trans }}
{% endfor %} + + {% if not locked %} +
+

+ +

+
+
+ {% if newQuestionForm %} + {% set questionType = newQuestionForm.type.vars.value ?: 'yes_no_maybe' %} + {{ form_start(newQuestionForm, {attr: {class: 'survey-form-grid', id: newQuestionFormId, 'data-survey-dirty-form': '1'}}) }} + + {{ form_errors(newQuestionForm) }} + + + +
+ + + + +
+ + + +
+ + +
+ {{ form_end(newQuestionForm) }} + {% endif %} +
+
+
+ {% endif %}
-
+
+
-

{{ 'survey.edit.conditions_title'|trans }}

{% if activeConditionId %}{{ 'survey.edit.edit_mode'|trans }}{% endif %}
- {% if not locked %} - {{ form_start(conditionForm, {attr: {class: 'survey-form-grid'}}) }} - - - -
- {{ form_end(conditionForm) }} - {% endif %} - +

{{ 'survey.edit.conditions_title'|trans }}

{% for condition in conditions %} -
-
{{ 'survey.edit.condition_prefix'|trans({'%value%': condition.answerValue|striptags|trim}) }}
-
{{ (condition.sourceQuestionLabel|striptags|trim) ?: 'survey.edit.question_fallback'|trans({'%id%': condition.sourceQuestion}) }}
-
{{ 'survey.edit.continue_to'|trans({'%target%': ((condition.targetQuestionLabel|striptags|trim) ?: ('survey.edit.question_fallback'|trans({'%id%': condition.targetQuestion})))}) }}
- {% if not locked %} -
- {{ 'survey.edit.edit_condition'|trans }} -
- - - - -
-
- {% endif %} +
+ {% set sourceLabel = (condition.sourceQuestionLabel|default('')|striptags|trim) ?: ('survey.edit.question_fallback'|trans({'%id%': condition.sourceQuestion})) %} + {% set targetLabel = (condition.targetQuestionLabel|default('')|striptags|trim) ?: ('survey.edit.question_fallback'|trans({'%id%': condition.targetQuestion})) %} +
Frage {{ condition.sourceQuestion }}: {{ sourceLabel }}
+
{{ 'survey.edit.condition_prefix'|trans({'%value%': condition.answerValue|default('')|striptags|trim|capitalize}) }}
+
weiter zu Frage {{ condition.targetQuestion }}: {{ targetLabel }}
{% else %}
{{ 'survey.edit.no_conditions'|trans }}
@@ -194,4 +290,5 @@
{% endif %} -
\ No newline at end of file + + \ No newline at end of file diff --git a/contao/templates/frontend/member_survey_list.html.twig b/contao/templates/frontend/member_survey_list.html.twig index 9f4cd44..ab95d15 100644 --- a/contao/templates/frontend/member_survey_list.html.twig +++ b/contao/templates/frontend/member_survey_list.html.twig @@ -6,6 +6,14 @@ {% set moduleHeadline = (headline is iterable ? headline.text|default('') : headline|default(''))|striptags|trim %}
+ {% for message in app.flashes('success') %} +
{{ message }}
+ {% endfor %} + + {% for message in app.flashes('error') %} +
{{ message }}
+ {% endfor %} + {% if moduleHeadline %}

{{ moduleHeadline }}

@@ -21,22 +29,43 @@ {% endif %} -
+
{% for survey in surveys %} -
-
-

{{ survey.title|striptags|trim }}

- {{ survey.published ? 'survey.list.published'|trans : 'survey.list.draft'|trans }} - {% if survey.isLocked %} - {{ 'survey.list.locked'|trans }} - {% endif %} +
+
+
+

{{ survey.title|striptags|trim }}

+ {% if survey.isLocked %} +
+ {{ 'survey.list.locked'|trans }} +
+ {% endif %} +
+
+ + + + + +
-

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

-
+
{{ 'survey.list.questions'|trans({'%count%': survey.questionCount}) }} - {{ 'survey.list.answers'|trans({'%count%': survey.answerCount}) }} + {{ 'survey.list.participations'|trans({'%count%': survey.participationCount}) }}
-
+
{% if survey.editUrl %} {{ 'survey.list.edit'|trans }} {% endif %} @@ -46,6 +75,13 @@ {% if survey.resultsUrl %} {{ 'survey.list.show_results'|trans }} {% endif %} +
+ + + + + +
{% else %} diff --git a/contao/templates/frontend/show_survey_results.html.twig b/contao/templates/frontend/show_survey_results.html.twig index 67b39ec..4544350 100644 --- a/contao/templates/frontend/show_survey_results.html.twig +++ b/contao/templates/frontend/show_survey_results.html.twig @@ -18,7 +18,7 @@

{{ survey.title|striptags|trim }}

- {{ 'survey.results.completed_submissions'|trans({'%count%': completedSubmissionCount}) }} + {{ 'survey.results.participations'|trans({'%count%': completedSubmissionCount}) }} {{ 'survey.results.questions'|trans({'%count%': questionResults|length}) }}
@@ -41,7 +41,7 @@

{{ question.question|striptags|trim }}

- {{ 'survey.results.answers'|trans({'%count%': question.totalAnswers}) }} + {{ 'survey.results.participations'|trans({'%count%': completedSubmissionCount}) }} {{ 'survey.results.response_rate'|trans({'%rate%': question.responseRate}) }}
diff --git a/public/css/auth-module.css b/public/css/auth-module.css new file mode 100644 index 0000000..8f570fc --- /dev/null +++ b/public/css/auth-module.css @@ -0,0 +1,227 @@ +@font-face { + font-family: "PT Sans Narrow"; + src: url("../fonts/PTSansNarrow-Regular.woff2") format("woff2"); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "PT Sans Narrow"; + src: url("../fonts/PTSansNarrow-Bold.woff2") format("woff2"); + font-weight: 700; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "Blogger Sans"; + src: url("../fonts/Blogger%20Sans-Medium.woff2") format("woff2"); + font-weight: 500; + font-style: normal; + font-display: swap; +} + +:root { + --survey-auth-blue: #0064ad; + --survey-auth-orange: #ec7c32; + --survey-auth-green: #00ae97; + --survey-auth-ink: #22313f; + --survey-auth-muted: #5f6975; + --survey-auth-border: rgba(162, 168, 180, 0.34); + --survey-auth-surface: rgba(255, 255, 255, 0.94); + --survey-auth-shadow: 0 28px 70px rgba(34, 34, 34, 0.12); +} + +.mod_login.block.login { + max-width: min(100%, 36rem); + margin: clamp(1.5rem, 4vw, 3rem) auto; + padding: clamp(1.5rem, 2.6vw, 2.4rem); + border: 1px solid var(--survey-auth-border); + border-radius: 1.8rem; + background: + radial-gradient(circle at top left, rgba(0, 100, 173, 0.2), transparent 36%), + radial-gradient(circle at bottom right, rgba(0, 174, 151, 0.16), transparent 38%), + linear-gradient(180deg, rgba(255, 255, 255, 0.97) 0%, rgba(248, 251, 255, 0.96) 100%); + box-shadow: var(--survey-auth-shadow); + box-sizing: border-box; + font-family: "PT Sans Narrow", sans-serif; + color: var(--survey-auth-ink); +} + +.mod_login.block.login form, +.mod_login.block.login .formbody { + margin: 0; +} + +.mod_login.block.login .formbody { + display: grid; + gap: 1rem; +} + +.mod_login.block.login .widget { + margin: 0; +} + +.mod_login.block.login .widget-text, +.mod_login.block.login .widget-password { + display: grid; + gap: 0.45rem; +} + +.mod_login.block.login label, +.mod_login.block.login legend { + font-family: "Blogger Sans", sans-serif; + font-size: 1rem; + font-weight: 500; + color: var(--survey-auth-blue); + letter-spacing: 0.01em; +} + +.mod_login.block.login input.text, +.mod_login.block.login input.password { + width: 100%; + min-height: 3.2rem; + padding: 0.8rem 0.95rem; + border: 1px solid rgba(162, 168, 180, 0.48); + border-radius: 1rem; + background: rgba(255, 255, 255, 0.94); + box-shadow: inset 0 1px 2px rgba(34, 34, 34, 0.04); + box-sizing: border-box; + font: inherit; + color: var(--survey-auth-ink); + transition: border-color 0.18s ease, box-shadow 0.18s ease, background-color 0.18s ease; +} + +.mod_login.block.login input.text:focus, +.mod_login.block.login input.password:focus { + outline: none; + border-color: rgba(0, 100, 173, 0.72); + box-shadow: 0 0 0 4px rgba(0, 100, 173, 0.12); + background: #fff; +} + +.mod_login.block.login .widget-checkbox { + margin-top: 0.15rem; +} + +.mod_login.block.login .checkbox_container { + margin: 0; + padding: 0; + border: 0; +} + +.mod_login.block.login .checkbox_container > span { + display: inline-flex; + align-items: center; + gap: 0.65rem; + color: var(--survey-auth-muted); +} + +.mod_login.block.login input.checkbox { + width: 1.05rem; + height: 1.05rem; + accent-color: var(--survey-auth-green); +} + +.mod_login.block.login .widget-submit { + margin-top: 0.3rem; +} + +.mod_login.block.login button.submit { + display: inline-flex; + justify-content: center; + align-items: center; + min-height: 3.2rem; + padding: 0.8rem 1.35rem; + border: 0; + border-radius: 999px; + background: linear-gradient(135deg, var(--survey-auth-blue), #0d7acb); + color: #fff; + font-family: "Blogger Sans", sans-serif; + font-size: 1rem; + font-weight: 500; + letter-spacing: 0.01em; + cursor: pointer; + box-shadow: 0 16px 28px rgba(0, 100, 173, 0.24); + transition: transform 0.18s ease, box-shadow 0.18s ease, filter 0.18s ease; +} + +.mod_login.block.login button.submit:hover, +.mod_login.block.login button.submit:focus { + transform: translateY(-1px); + box-shadow: 0 18px 34px rgba(0, 100, 173, 0.28); + filter: saturate(1.05); +} + +.mod_login.block.login button.submit:focus { + outline: none; +} + +nav.mod_customnav.block:has(a[href*="/abmelden"]) { + max-width: min(100%, 24rem); + margin: clamp(1.25rem, 3vw, 2.5rem) auto; + padding: 0.85rem; + border: 1px solid rgba(0, 100, 173, 0.14); + border-radius: 1.4rem; + background: linear-gradient(135deg, rgba(0, 100, 173, 0.08), rgba(255, 255, 255, 0.96)); + box-shadow: 0 18px 40px rgba(34, 34, 34, 0.08); + box-sizing: border-box; +} + +nav.mod_customnav.block:has(a[href*="/abmelden"]) .level_1 { + display: flex; + justify-content: center; + margin: 0; + padding: 0; + list-style: none; +} + +nav.mod_customnav.block:has(a[href*="/abmelden"]) .level_1 > li { + margin: 0; + padding: 0; +} + +nav.mod_customnav.block:has(a[href*="/abmelden"]) .level_1 > li > a { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 2.9rem; + padding: 0.7rem 1.15rem; + border-radius: 999px; + background: rgba(255, 255, 255, 0.98); + color: var(--survey-auth-blue); + font-family: "Blogger Sans", sans-serif; + font-size: 0.98rem; + font-weight: 500; + text-decoration: none; + box-shadow: 0 12px 26px rgba(0, 100, 173, 0.12); + transition: transform 0.18s ease, box-shadow 0.18s ease, color 0.18s ease; +} + +nav.mod_customnav.block:has(a[href*="/abmelden"]) .level_1 > li > a:hover, +nav.mod_customnav.block:has(a[href*="/abmelden"]) .level_1 > li > a:focus { + transform: translateY(-1px); + box-shadow: 0 16px 30px rgba(0, 100, 173, 0.18); + color: #004f88; +} + +@media (max-width: 767px) { + .mod_login.block.login { + padding: 1.2rem; + border-radius: 1.35rem; + } + + .mod_login.block.login button.submit, + nav.mod_customnav.block:has(a[href*="/abmelden"]) .level_1 > li > a { + width: 100%; + } + + nav.mod_customnav.block:has(a[href*="/abmelden"]) { + padding: 0.75rem; + } + + nav.mod_customnav.block:has(a[href*="/abmelden"]) .level_1 { + width: 100%; + } +} \ No newline at end of file diff --git a/src/Controller/FrontendModule/MemberSurveyEditController.php b/src/Controller/FrontendModule/MemberSurveyEditController.php index afcb315..634eb31 100644 --- a/src/Controller/FrontendModule/MemberSurveyEditController.php +++ b/src/Controller/FrontendModule/MemberSurveyEditController.php @@ -11,10 +11,8 @@ use Contao\CoreBundle\Twig\FragmentTemplate; use Contao\FrontendUser; use Contao\ModuleModel; use Contao\PageModel; -use Mummert\SurveyBundle\Form\Model\SurveyConditionData; use Mummert\SurveyBundle\Form\Model\SurveyEditorData; use Mummert\SurveyBundle\Form\Model\SurveyQuestionData; -use Mummert\SurveyBundle\Form\SurveyConditionEditorType; use Mummert\SurveyBundle\Form\SurveyEditorType; use Mummert\SurveyBundle\Form\SurveyQuestionEditorType; use Mummert\SurveyBundle\Model\SurveyModel; @@ -22,9 +20,10 @@ use Mummert\SurveyBundle\Repository\SurveyConditionRepository; use Mummert\SurveyBundle\Repository\SurveyEditorRepository; use Mummert\SurveyBundle\Repository\SurveyQuestionRepository; use Mummert\SurveyBundle\Repository\SurveyRepository; -use Mummert\SurveyBundle\Security\SurveyEditorVoter; use Mummert\SurveyBundle\Service\SurveyEditorService; use Mummert\SurveyBundle\Service\SurveySubmissionService; +use Symfony\Component\Form\FormFactoryInterface; +use Symfony\Component\Form\FormInterface; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -39,43 +38,57 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController private readonly SurveyEditorRepository $surveyEditorRepository, private readonly SurveyEditorService $surveyEditorService, private readonly SurveySubmissionService $surveySubmissionService, + private readonly FormFactoryInterface $formFactory, ) { } protected function getResponse(FragmentTemplate $template, ModuleModel $model, Request $request): Response { - $user = $this->getUser(); + $memberId = $this->resolveEditorMemberId($request); - if (!$user instanceof FrontendUser) { + if ($memberId <= 0) { $template->set('loginRequired', true); + $template->set('entryNotAllowed', false); $template->set('backUrl', $this->resolveListUrl($model)); + $template->set('createQuestionMode', false); return $template->getResponse(); } - $survey = $this->resolveSurvey($request, $user); + if (!$this->isEditorEntryAllowed($request)) { + $template->set('loginRequired', false); + $template->set('entryNotAllowed', true); + $template->set('createMode', false); + $template->set('survey', null); + $template->set('backUrl', $this->resolveListUrl($model)); + $template->set('createQuestionMode', false); - if ($survey instanceof SurveyModel && $request->isMethod('POST') && $request->request->has('_survey_action')) { - return $this->handleAction($survey, $request, $model); + return $template->getResponse(); } - $questionChoices = $survey instanceof SurveyModel ? $this->buildQuestionChoices((int) $survey->id) : []; - $surveyForm = $this->createNamed('survey_meta', SurveyEditorType::class, $survey instanceof SurveyModel ? SurveyEditorData::fromModel($survey) : new SurveyEditorData()); + $survey = $this->resolveSurvey($request, $memberId); + $createQuestionMode = $this->isCreateQuestionRequested($request); + + if ($survey instanceof SurveyModel && $request->isMethod('POST') && $request->request->has('_survey_action')) { + return $this->handleAction($survey, $request, $model, $memberId); + } + + $surveyForm = $this->formFactory->createNamed('survey_meta', SurveyEditorType::class, $survey instanceof SurveyModel ? SurveyEditorData::fromModel($survey) : new SurveyEditorData()); $surveyForm->handleRequest($request); if ($surveyForm->isSubmitted() && $surveyForm->isValid()) { try { if ($survey instanceof SurveyModel) { - $this->surveyEditorService->updateSurvey($survey, (int) $user->id, $surveyForm->getData()); + $this->surveyEditorService->updateSurvey($survey, $memberId, $surveyForm->getData()); $this->addFlash('success', 'Die Umfrage wurde gespeichert.'); - return new RedirectResponse($this->buildEditUrl($model, (int) $survey->id)); + return new RedirectResponse($this->buildEditUrl($model, $request, (int) $survey->id)); } - $createdSurvey = $this->surveyEditorService->createSurvey((int) $user->id, $surveyForm->getData()); + $createdSurvey = $this->surveyEditorService->createSurvey($memberId, $surveyForm->getData()); $this->addFlash('success', 'Die Umfrage wurde angelegt.'); - return new RedirectResponse($this->buildEditUrl($model, (int) $createdSurvey->id)); + return new RedirectResponse($this->buildEditUrl($model, $request, (int) $createdSurvey->id)); } catch (\Throwable $exception) { $this->addFlash('error', $exception->getMessage()); } @@ -83,76 +96,101 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController if (!$survey instanceof SurveyModel) { $template->set('loginRequired', false); + $template->set('entryNotAllowed', false); $template->set('createMode', true); $template->set('surveyForm', $surveyForm->createView()); $template->set('backUrl', $this->resolveListUrl($model)); $template->set('publicUrl', null); $template->set('survey', null); - $template->set('questionForm', null); - $template->set('conditionForm', null); $template->set('questions', []); $template->set('conditions', []); $template->set('editors', []); $template->set('submissions', []); $template->set('activeQuestionId', 0); - $template->set('activeConditionId', 0); + $template->set('createQuestionMode', false); return $template->getResponse(); } + $questions = $this->surveyQuestionRepository->findAllBySurvey((int) $survey->id); $activeQuestion = $this->resolveActiveQuestion($request, (int) $survey->id); - $activeCondition = $this->resolveActiveCondition($request, (int) $survey->id); - $questionForm = $this->createNamed('survey_question', SurveyQuestionEditorType::class, $activeQuestion ? SurveyQuestionData::fromModel($activeQuestion) : new SurveyQuestionData(), [ - 'question_choices' => $this->buildQuestionChoices((int) $survey->id), - ]); - $conditionForm = $this->createNamed('survey_condition', SurveyConditionEditorType::class, $activeCondition ? SurveyConditionData::fromModel($activeCondition) : new SurveyConditionData(), [ - 'question_choices' => $questionChoices, - ]); + $activeQuestionId = $activeQuestion ? (int) $activeQuestion->id : 0; + $questionFormViews = []; - $questionForm->handleRequest($request); - $conditionForm->handleRequest($request); + 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() && $questionForm->isValid()) { - try { - $this->surveyEditorService->saveQuestion($survey, $questionForm->getData(), $activeQuestion ? (int) $activeQuestion->id : 0); - $this->addFlash('success', $activeQuestion ? 'Die Frage wurde aktualisiert.' : 'Die Frage wurde angelegt.'); + if ($questionForm->isSubmitted()) { + $activeQuestionId = $questionId; + $createQuestionMode = false; - return new RedirectResponse($this->buildEditUrl($model, (int) $survey->id)); - } catch (\Throwable $exception) { - $this->addFlash('error', $exception->getMessage()); + if ($questionForm->isValid()) { + try { + $savedQuestionId = $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(); } - if ($conditionForm->isSubmitted() && $conditionForm->isValid()) { - try { - $this->surveyEditorService->saveCondition($survey, $conditionForm->getData(), $activeCondition ? (int) $activeCondition->id : 0); - $this->addFlash('success', $activeCondition ? 'Die Bedingung wurde aktualisiert.' : 'Die Bedingung wurde angelegt.'); + $newQuestionForm = $this->formFactory->createNamed('survey_question_new', SurveyQuestionEditorType::class, new SurveyQuestionData(), [ + 'question_choices' => $this->buildQuestionChoices((int) $survey->id), + ]); + $newQuestionForm->handleRequest($request); - return new RedirectResponse($this->buildEditUrl($model, (int) $survey->id)); - } catch (\Throwable $exception) { - $this->addFlash('error', $exception->getMessage()); + 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)); } } $template->set('loginRequired', false); + $template->set('entryNotAllowed', false); $template->set('createMode', false); + $template->set('createQuestionMode', $createQuestionMode); $template->set('survey', $survey); $template->set('surveyForm', $surveyForm->createView()); - $template->set('questionForm', $questionForm->createView()); - $template->set('conditionForm', $conditionForm->createView()); - $template->set('questions', $this->surveyQuestionRepository->findAllBySurvey((int) $survey->id)); - $template->set('conditions', $this->surveyConditionRepository->findOverviewBySurvey((int) $survey->id)); + $template->set('questionForms', $questionFormViews); + $template->set('newQuestionForm', $newQuestionForm->createView()); + $template->set('questions', $questions); + $conditionOverview = $this->surveyConditionRepository->findOverviewBySurvey((int) $survey->id); + + $template->set('conditions', $this->buildJumpRuleOverview($questions, $conditionOverview)); $template->set('editors', $this->surveyEditorRepository->findEditorsBySurvey((int) $survey->id)); $template->set('submissions', $this->surveySubmissionService->getSubmissionOverview($survey)); - $template->set('activeQuestionId', $activeQuestion ? (int) $activeQuestion->id : 0); - $template->set('activeConditionId', $activeCondition ? (int) $activeCondition->id : 0); + $template->set('activeQuestionId', $activeQuestionId); $template->set('backUrl', $this->resolveListUrl($model)); $template->set('publicUrl', $this->resolveReaderUrl($model, (string) $survey->alias)); + $template->set('metadataFormActionUrl', $this->buildEditUrl($model, $request, (int) $survey->id)); return $template->getResponse(); } - private function resolveSurvey(Request $request, FrontendUser $user): ?SurveyModel + private function resolveSurvey(Request $request, int $memberId): ?SurveyModel { $surveyId = (int) $request->query->get('survey', 0); @@ -166,12 +204,55 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController throw new \RuntimeException('Die Umfrage wurde nicht gefunden.'); } - $this->denyAccessUnlessGranted(SurveyEditorVoter::EDIT, $survey); + if (!$this->surveyEditorRepository->isEditor((int) $survey->id, $memberId)) { + throw new \RuntimeException('Sie duerfen diese Umfrage nicht bearbeiten.'); + } return $survey; } - private function handleAction(SurveyModel $survey, Request $request, ModuleModel $model): Response + private function resolveEditorMemberId(Request $request): int + { + $user = $this->getUser(); + + if ($user instanceof FrontendUser) { + return (int) $user->id; + } + + return $this->isDebugAccessAllowed($request) ? $this->resolveDebugMemberId($request) : 0; + } + + private function resolveDebugMemberId(Request $request): int + { + $memberId = (int) $request->query->get('debugMember', 0); + + if ($memberId > 0) { + return $memberId; + } + + $surveyId = (int) $request->query->get('survey', 0); + + if ($surveyId <= 0) { + return 0; + } + + $memberIds = $this->surveyEditorRepository->findMemberIdsBySurvey($surveyId); + + return $memberIds[0] ?? 0; + } + + private function isDebugAccessAllowed(Request $request): bool + { + $host = strtolower((string) ($request->server->get('HTTP_HOST') ?: $request->getHost())); + + return ( + 'localhost' === $host + || '127.0.0.1' === $host + || str_ends_with($host, '.ddev.site') + ) && $request->query->getBoolean('debugAccess'); + } + + private function handleAction(SurveyModel $survey, Request $request, ModuleModel $model, int $memberId): Response { $action = (string) $request->request->get('_survey_action'); $itemId = (int) $request->request->get('item_id', 0); @@ -182,6 +263,11 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController } 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-question' === $action) { $this->surveyEditorService->deleteQuestion($survey, $itemId); $this->addFlash('success', 'Die Frage wurde geloescht.'); @@ -195,7 +281,7 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController $this->addFlash('error', $exception->getMessage()); } - return new RedirectResponse($this->buildEditUrl($model, (int) $survey->id)); + return new RedirectResponse($this->buildEditUrl($model, $request, (int) $survey->id)); } private function resolveActiveQuestion(Request $request, int $surveyId): mixed @@ -205,16 +291,18 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController return $questionId > 0 ? $this->surveyQuestionRepository->findByIdForSurvey($surveyId, $questionId) : null; } - private function resolveActiveCondition(Request $request, int $surveyId): mixed + private function isCreateQuestionRequested(Request $request): bool { - $conditionId = (int) $request->query->get('condition', 0); - $condition = $conditionId > 0 ? $this->surveyConditionRepository->findById($conditionId) : null; + return 'new' === trim((string) $request->query->get('question', '')); + } - if (null === $condition || (int) $condition->pid !== $surveyId) { - return null; + private function isEditorEntryAllowed(Request $request): bool + { + if ((int) $request->query->get('survey', 0) > 0) { + return true; } - return $condition; + return $request->query->getBoolean('create'); } /** @@ -235,6 +323,84 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController return $choices; } + /** + * @param array $questions + * @param list> $conditionOverview + * + * @return list> + */ + private function buildJumpRuleOverview(array $questions, array $conditionOverview): array + { + $questionLabels = []; + $questionSortings = []; + $rules = []; + + foreach ($questions as $question) { + $questionId = (int) ($question->id ?? 0); + + if ($questionId <= 0) { + continue; + } + + $questionLabels[$questionId] = (string) ($question->question ?? ''); + $questionSortings[$questionId] = (int) ($question->sorting ?? 0); + } + + foreach ($questions as $question) { + $questionId = (int) ($question->id ?? 0); + + if ($questionId <= 0) { + continue; + } + + if ('yes_no_maybe' !== (string) ($question->type ?? '')) { + continue; + } + + foreach (['Ja' => (int) ($question->jumpOnYes ?? 0), 'Nein' => (int) ($question->jumpOnNo ?? 0)] as $answerValue => $targetQuestionId) { + if ($targetQuestionId <= 0 || !isset($questionLabels[$targetQuestionId])) { + continue; + } + + $rules[] = [ + 'id' => 'question-'.$questionId.'-'.$targetQuestionId.'-'.$answerValue, + 'answerValue' => $answerValue, + 'sourceQuestion' => $questionId, + 'targetQuestion' => $targetQuestionId, + 'sourceQuestionLabel' => (string) ($question->question ?? ''), + 'targetQuestionLabel' => $questionLabels[$targetQuestionId] ?? '', + 'sourceSorting' => $questionSortings[$questionId] ?? 0, + ]; + } + } + + foreach ($conditionOverview as $condition) { + $sourceQuestionId = (int) ($condition['sourceQuestion'] ?? 0); + + $rules[] = [ + 'id' => $condition['id'] ?? 0, + 'answerValue' => (string) ($condition['answerValue'] ?? ''), + 'sourceQuestion' => $sourceQuestionId, + 'targetQuestion' => (int) ($condition['targetQuestion'] ?? 0), + 'sourceQuestionLabel' => (string) ($condition['sourceQuestionLabel'] ?? ''), + 'targetQuestionLabel' => (string) ($condition['targetQuestionLabel'] ?? ''), + 'sourceSorting' => $questionSortings[$sourceQuestionId] ?? 0, + ]; + } + + usort($rules, static function (array $left, array $right): int { + $sortingComparison = ((int) ($left['sourceSorting'] ?? 0)) <=> ((int) ($right['sourceSorting'] ?? 0)); + + if (0 !== $sortingComparison) { + return $sortingComparison; + } + + return ((int) ($left['targetQuestion'] ?? 0)) <=> ((int) ($right['targetQuestion'] ?? 0)); + }); + + return $rules; + } + private function resolveListUrl(ModuleModel $model): string { $page = $this->resolvePage((int) ($model->surveyListPage ?? 0)) ?? $this->getPageModel(); @@ -253,11 +419,44 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController return $this->generateContentUrl($page, ['auto_item' => $publicAlias]); } - private function buildEditUrl(ModuleModel $model, int $surveyId): string + private function buildEditUrl(ModuleModel $model, Request $request, int $surveyId): string { - $page = $this->getPageModel(); + $parameters = ['survey' => $surveyId]; - return $page instanceof PageModel ? $this->generateContentUrl($page, ['survey' => $surveyId]) : $this->resolveListUrl($model); + if ($request->query->getBoolean('debugAccess') && $this->isDebugAccessAllowed($request)) { + $parameters['debugAccess'] = '1'; + } + + $debugMemberId = (int) $request->query->get('debugMember', 0); + + if ($debugMemberId > 0) { + $parameters['debugMember'] = (string) $debugMemberId; + } + + $basePath = $request->getBaseUrl().$request->getPathInfo(); + + if ('' !== $basePath) { + $queryString = http_build_query($parameters); + $url = $basePath.('' !== $queryString ? '?'.$queryString : ''); + } else { + $page = $this->getPageModel(); + $url = $page instanceof PageModel ? $this->generateContentUrl($page, $parameters) : $this->resolveListUrl($model); + } + + return $url; + } + + private function describeFormErrors(FormInterface $form): string + { + $messages = []; + + foreach ($form->getErrors(true, true) as $error) { + $origin = $error->getOrigin(); + $name = $origin instanceof FormInterface ? $origin->getName() : 'form'; + $messages[] = sprintf('%s: %s', $name, $error->getMessage()); + } + + return [] !== $messages ? implode(' | ', array_unique($messages)) : 'Unbekannter Formularfehler.'; } private function resolvePage(int $pageId): ?PageModel diff --git a/src/Controller/FrontendModule/MemberSurveyListController.php b/src/Controller/FrontendModule/MemberSurveyListController.php index de09d74..b04706a 100644 --- a/src/Controller/FrontendModule/MemberSurveyListController.php +++ b/src/Controller/FrontendModule/MemberSurveyListController.php @@ -11,8 +11,11 @@ use Contao\CoreBundle\Twig\FragmentTemplate; use Contao\FrontendUser; use Contao\ModuleModel; use Contao\PageModel; +use Mummert\SurveyBundle\Model\SurveyModel; +use Mummert\SurveyBundle\Repository\SurveyEditorRepository; use Mummert\SurveyBundle\Repository\SurveyRepository; -use Mummert\SurveyBundle\Service\SurveySubmissionService; +use Mummert\SurveyBundle\Service\SurveyEditorService; +use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -21,7 +24,8 @@ final class MemberSurveyListController extends AbstractFrontendModuleController { public function __construct( private readonly SurveyRepository $surveyRepository, - private readonly SurveySubmissionService $surveySubmissionService, + private readonly SurveyEditorRepository $surveyEditorRepository, + private readonly SurveyEditorService $surveyEditorService, ) { } @@ -37,6 +41,10 @@ final class MemberSurveyListController extends AbstractFrontendModuleController return $template->getResponse(); } + if ($request->isMethod('POST') && $request->request->has('_survey_action')) { + return $this->handleAction($request, (int) $user->id); + } + $editPage = $this->resolvePage((int) ($model->surveyEditPage ?? 0)); $readerPage = $this->resolvePage((int) $this->getContaoAdapter(Config::class)->get('surveyReaderPage')); $resultsPage = $this->resolvePage((int) $this->getContaoAdapter(Config::class)->get('surveyResultsPage')); @@ -44,7 +52,6 @@ final class MemberSurveyListController extends AbstractFrontendModuleController foreach ($this->surveyRepository->findEditableByMember((int) $user->id) as $survey) { $surveyId = (int) $survey['id']; - $isCompletedInCurrentSession = $this->surveySubmissionService->hasFinishedSubmissionForSurvey($surveyId, $request->getSession()); $items[] = [ 'id' => $surveyId, @@ -53,20 +60,60 @@ final class MemberSurveyListController extends AbstractFrontendModuleController 'isLocked' => !empty($survey['isLocked']), 'published' => !empty($survey['published']), 'questionCount' => (int) ($survey['questionCount'] ?? 0), - 'answerCount' => (int) ($survey['answerCount'] ?? 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' => !$isCompletedInCurrentSession && $resultsPage instanceof PageModel ? $this->generateContentUrl($resultsPage, ['auto_item' => (string) $survey['alias']]) : null, + 'resultsUrl' => $resultsPage instanceof PageModel ? $this->generateContentUrl($resultsPage, ['auto_item' => (string) $survey['alias']]) : null, ]; } $template->set('loginRequired', false); $template->set('surveys', $items); - $template->set('createUrl', $editPage instanceof PageModel ? $this->generateContentUrl($editPage) : null); + $template->set('createUrl', $editPage instanceof PageModel ? $this->generateContentUrl($editPage, ['create' => '1']) : null); return $template->getResponse(); } + private function handleAction(Request $request, int $memberId): Response + { + $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)) { + throw new \RuntimeException('Ungueltiger 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.'); + } + } catch (\Throwable $exception) { + $this->addFlash('error', $exception->getMessage()); + } + + return new RedirectResponse($request->getBaseUrl().$request->getPathInfo()); + } + + private function resolveEditableSurvey(int $surveyId, int $memberId): SurveyModel + { + $survey = $this->surveyRepository->findById($surveyId); + + if (!$survey instanceof SurveyModel || !$this->surveyEditorRepository->isEditor($surveyId, $memberId)) { + throw new \RuntimeException('Sie duerfen diese Umfrage nicht bearbeiten.'); + } + + return $survey; + } + private function resolvePage(int $pageId): ?PageModel { if ($pageId <= 0) { diff --git a/src/Form/Model/SurveyQuestionData.php b/src/Form/Model/SurveyQuestionData.php index b671b34..c9929f7 100644 --- a/src/Form/Model/SurveyQuestionData.php +++ b/src/Form/Model/SurveyQuestionData.php @@ -10,23 +10,23 @@ final class SurveyQuestionData { public string $type = 'yes_no_maybe'; public string $question = ''; - public string $description = ''; + public ?string $description = ''; public bool $mandatory = true; public bool $published = true; public bool $allowMaybe = false; public bool $allowMultiple = false; - public int $jumpOnYes = 0; - public int $jumpOnNo = 0; - public string $answerOption1 = ''; - public string $answerOption2 = ''; - public string $answerOption3 = ''; - public string $answerOption4 = ''; - public string $answerOption5 = ''; - public string $answerOption6 = ''; - public string $answerOption7 = ''; - public string $answerOption8 = ''; - public string $answerOption9 = ''; - public string $answerOption10 = ''; + public ?int $jumpOnYes = 0; + public ?int $jumpOnNo = 0; + public ?string $answerOption1 = ''; + public ?string $answerOption2 = ''; + public ?string $answerOption3 = ''; + public ?string $answerOption4 = ''; + public ?string $answerOption5 = ''; + public ?string $answerOption6 = ''; + public ?string $answerOption7 = ''; + public ?string $answerOption8 = ''; + public ?string $answerOption9 = ''; + public ?string $answerOption10 = ''; public int $rangeMin = 0; public int $rangeMax = 10; public int $rangeStep = 1; @@ -64,13 +64,13 @@ final class SurveyQuestionData $payload = [ 'type' => $this->type, 'question' => $this->question, - 'description' => $this->description, + 'description' => (string) ($this->description ?? ''), 'mandatory' => $this->mandatory, 'published' => $this->published, 'allowMaybe' => $this->allowMaybe, 'allowMultiple' => $this->allowMultiple, - 'jumpOnYes' => $this->jumpOnYes, - 'jumpOnNo' => $this->jumpOnNo, + 'jumpOnYes' => max(0, (int) ($this->jumpOnYes ?? 0)), + 'jumpOnNo' => max(0, (int) ($this->jumpOnNo ?? 0)), 'rangeMin' => $this->rangeMin, 'rangeMax' => $this->rangeMax, 'rangeStep' => $this->rangeStep, @@ -78,7 +78,7 @@ final class SurveyQuestionData for ($index = 1; $index <= 10; ++$index) { $property = 'answerOption'.$index; - $payload[$property] = $this->{$property}; + $payload[$property] = (string) ($this->{$property} ?? ''); } return $payload; diff --git a/src/Form/SurveyEditorType.php b/src/Form/SurveyEditorType.php index 1e11795..35f108e 100644 --- a/src/Form/SurveyEditorType.php +++ b/src/Form/SurveyEditorType.php @@ -26,6 +26,7 @@ final class SurveyEditorType extends AbstractType ->add('description', TextareaType::class, [ 'label' => 'Beschreibung', 'required' => false, + 'empty_data' => '', 'attr' => ['rows' => 5], ]) ->add('published', CheckboxType::class, [ diff --git a/src/Form/SurveyQuestionEditorType.php b/src/Form/SurveyQuestionEditorType.php index 19f25c8..0dc11ca 100644 --- a/src/Form/SurveyQuestionEditorType.php +++ b/src/Form/SurveyQuestionEditorType.php @@ -12,7 +12,10 @@ use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\IntegerType; use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\Form\Extension\Core\Type\TextType; +use Symfony\Component\Form\FormEvent; use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormError; +use Symfony\Component\Form\FormEvents; use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\Validator\Constraints\GreaterThanOrEqual; use Symfony\Component\Validator\Constraints\Length; @@ -26,6 +29,9 @@ final class SurveyQuestionEditorType extends AbstractType public function buildForm(FormBuilderInterface $builder, array $options): void { + $builder->addEventListener(FormEvents::PRE_SUBMIT, $this->normalizeOptionalFields(...)); + $builder->addEventListener(FormEvents::POST_SUBMIT, $this->validateChoiceOptions(...)); + $builder ->add('type', ChoiceType::class, [ 'label' => 'Fragetyp', @@ -43,6 +49,7 @@ final class SurveyQuestionEditorType extends AbstractType ->add('description', TextareaType::class, [ 'label' => 'Beschreibung', 'required' => false, + 'empty_data' => '', 'attr' => ['rows' => 4], ]) ->add('mandatory', CheckboxType::class, [ @@ -61,15 +68,17 @@ final class SurveyQuestionEditorType extends AbstractType ->add('jumpOnYes', ChoiceType::class, [ 'label' => 'Zur Frage springen bei Auswahl ja', 'required' => false, + 'empty_data' => '', 'choices' => array_flip($options['question_choices']), - 'placeholder' => 'keine', + 'placeholder' => 'normal weitermachen', 'attr' => ['data-question-editor-target' => 'yesNoMaybeField'], ]) ->add('jumpOnNo', ChoiceType::class, [ 'label' => 'Zur Frage springen bei Auswahl nein', 'required' => false, + 'empty_data' => '', 'choices' => array_flip($options['question_choices']), - 'placeholder' => 'keine', + 'placeholder' => 'normal weitermachen', 'attr' => ['data-question-editor-target' => 'yesNoMaybeField'], ]) ->add('allowMultiple', CheckboxType::class, [ @@ -80,71 +89,130 @@ final class SurveyQuestionEditorType extends AbstractType ->add('answerOption1', TextType::class, [ 'label' => 'Antwort 1', 'required' => false, + 'empty_data' => '', 'attr' => ['data-question-editor-target' => 'choiceField'], ]) ->add('answerOption2', TextType::class, [ 'label' => 'Antwort 2', 'required' => false, + 'empty_data' => '', 'attr' => ['data-question-editor-target' => 'choiceField'], ]) ->add('answerOption3', TextType::class, [ 'label' => 'Antwort 3', 'required' => false, + 'empty_data' => '', 'attr' => ['data-question-editor-target' => 'choiceField'], ]) ->add('answerOption4', TextType::class, [ 'label' => 'Antwort 4', 'required' => false, + 'empty_data' => '', 'attr' => ['data-question-editor-target' => 'choiceField'], ]) ->add('answerOption5', TextType::class, [ 'label' => 'Antwort 5', 'required' => false, + 'empty_data' => '', 'attr' => ['data-question-editor-target' => 'choiceField'], ]) ->add('answerOption6', TextType::class, [ 'label' => 'Antwort 6', 'required' => false, + 'empty_data' => '', 'attr' => ['data-question-editor-target' => 'choiceField'], ]) ->add('answerOption7', TextType::class, [ 'label' => 'Antwort 7', 'required' => false, + 'empty_data' => '', 'attr' => ['data-question-editor-target' => 'choiceField'], ]) ->add('answerOption8', TextType::class, [ 'label' => 'Antwort 8', 'required' => false, + 'empty_data' => '', 'attr' => ['data-question-editor-target' => 'choiceField'], ]) ->add('answerOption9', TextType::class, [ 'label' => 'Antwort 9', 'required' => false, + 'empty_data' => '', 'attr' => ['data-question-editor-target' => 'choiceField'], ]) ->add('answerOption10', TextType::class, [ 'label' => 'Antwort 10', 'required' => false, + 'empty_data' => '', 'attr' => ['data-question-editor-target' => 'choiceField'], ]) ->add('rangeMin', IntegerType::class, [ 'label' => 'Kleinster Wert', + 'empty_data' => '0', 'constraints' => [new GreaterThanOrEqual(['value' => -100000])], 'attr' => ['data-question-editor-target' => 'rangeField'], ]) ->add('rangeMax', IntegerType::class, [ 'label' => 'Groesster Wert', + 'empty_data' => '10', 'constraints' => [new GreaterThanOrEqual(['value' => -100000])], 'attr' => ['data-question-editor-target' => 'rangeField'], ]) ->add('rangeStep', IntegerType::class, [ 'label' => 'Schrittweite', + 'empty_data' => '1', 'constraints' => [new GreaterThanOrEqual(['value' => 1])], 'attr' => ['data-question-editor-target' => 'rangeField'], ]) ; } + private function normalizeOptionalFields(FormEvent $event): void + { + $data = $event->getData(); + + if (!\is_array($data)) { + return; + } + + $stringFields = ['description']; + + for ($index = 1; $index <= 10; ++$index) { + $stringFields[] = 'answerOption'.$index; + } + + foreach ($stringFields as $field) { + if (null === ($data[$field] ?? null)) { + $data[$field] = ''; + } + } + + foreach (['jumpOnYes' => '', 'jumpOnNo' => '', 'rangeMin' => '0', 'rangeMax' => '10', 'rangeStep' => '1'] as $field => $defaultValue) { + if (null === ($data[$field] ?? null) || '' === $data[$field]) { + $data[$field] = $defaultValue; + } + } + + $event->setData($data); + } + + private function validateChoiceOptions(FormEvent $event): void + { + $data = $event->getData(); + + if (!$data instanceof SurveyQuestionData || 'choice' !== $data->type) { + return; + } + + for ($index = 1; $index <= 10; ++$index) { + if ('' !== trim((string) ($data->{'answerOption'.$index} ?? ''))) { + return; + } + } + + $event->getForm()->get('answerOption1')->addError(new FormError('Mindestens eine Antwortoption ist erforderlich.')); + } + public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ diff --git a/src/Repository/SurveyQuestionRepository.php b/src/Repository/SurveyQuestionRepository.php index dbc6fbf..04506b7 100644 --- a/src/Repository/SurveyQuestionRepository.php +++ b/src/Repository/SurveyQuestionRepository.php @@ -59,32 +59,34 @@ final class SurveyQuestionRepository public function create(int $surveyId, array $data): SurveyContentModel { $sorting = (int) $this->connection->fetchOne('SELECT COALESCE(MAX(sorting), 0) FROM tl_survey_content WHERE pid = ?', [$surveyId]) + 128; + $type = (string) ($data['type'] ?? 'yes_no_maybe'); + $isRangeType = 'range' === $type; $this->connection->insert('tl_survey_content', [ 'pid' => $surveyId, 'sorting' => $sorting, - 'type' => (string) ($data['type'] ?? 'yes_no_maybe'), + 'type' => $type, 'question' => (string) ($data['question'] ?? ''), 'description' => (string) ($data['description'] ?? ''), 'mandatory' => !empty($data['mandatory']) ? '1' : '', 'published' => !empty($data['published']) ? '1' : '', - 'allowMaybe' => 'yes_no_maybe' === (string) ($data['type'] ?? 'yes_no_maybe') && !empty($data['allowMaybe']) ? '1' : '', - 'jumpOnYes' => 'yes_no_maybe' === (string) ($data['type'] ?? 'yes_no_maybe') ? max(0, (int) ($data['jumpOnYes'] ?? 0)) : 0, - 'jumpOnNo' => 'yes_no_maybe' === (string) ($data['type'] ?? 'yes_no_maybe') ? max(0, (int) ($data['jumpOnNo'] ?? 0)) : 0, - 'allowMultiple' => 'choice' === (string) ($data['type'] ?? 'yes_no_maybe') && !empty($data['allowMultiple']) ? '1' : '', - 'answerOption1' => 'choice' === (string) ($data['type'] ?? 'yes_no_maybe') ? trim((string) ($data['answerOption1'] ?? '')) : '', - 'answerOption2' => 'choice' === (string) ($data['type'] ?? 'yes_no_maybe') ? trim((string) ($data['answerOption2'] ?? '')) : '', - 'answerOption3' => 'choice' === (string) ($data['type'] ?? 'yes_no_maybe') ? trim((string) ($data['answerOption3'] ?? '')) : '', - 'answerOption4' => 'choice' === (string) ($data['type'] ?? 'yes_no_maybe') ? trim((string) ($data['answerOption4'] ?? '')) : '', - 'answerOption5' => 'choice' === (string) ($data['type'] ?? 'yes_no_maybe') ? trim((string) ($data['answerOption5'] ?? '')) : '', - 'answerOption6' => 'choice' === (string) ($data['type'] ?? 'yes_no_maybe') ? trim((string) ($data['answerOption6'] ?? '')) : '', - 'answerOption7' => 'choice' === (string) ($data['type'] ?? 'yes_no_maybe') ? trim((string) ($data['answerOption7'] ?? '')) : '', - 'answerOption8' => 'choice' === (string) ($data['type'] ?? 'yes_no_maybe') ? trim((string) ($data['answerOption8'] ?? '')) : '', - 'answerOption9' => 'choice' === (string) ($data['type'] ?? 'yes_no_maybe') ? trim((string) ($data['answerOption9'] ?? '')) : '', - 'answerOption10' => 'choice' === (string) ($data['type'] ?? 'yes_no_maybe') ? trim((string) ($data['answerOption10'] ?? '')) : '', - 'rangeMin' => (int) ($data['rangeMin'] ?? 0), - 'rangeMax' => (int) ($data['rangeMax'] ?? 10), - 'rangeStep' => max(1, (int) ($data['rangeStep'] ?? 1)), + 'allowMaybe' => 'yes_no_maybe' === $type && !empty($data['allowMaybe']) ? '1' : '', + 'jumpOnYes' => 'yes_no_maybe' === $type ? max(0, (int) ($data['jumpOnYes'] ?? 0)) : 0, + 'jumpOnNo' => 'yes_no_maybe' === $type ? max(0, (int) ($data['jumpOnNo'] ?? 0)) : 0, + 'allowMultiple' => 'choice' === $type && !empty($data['allowMultiple']) ? '1' : '', + 'answerOption1' => 'choice' === $type ? trim((string) ($data['answerOption1'] ?? '')) : '', + 'answerOption2' => 'choice' === $type ? trim((string) ($data['answerOption2'] ?? '')) : '', + 'answerOption3' => 'choice' === $type ? trim((string) ($data['answerOption3'] ?? '')) : '', + 'answerOption4' => 'choice' === $type ? trim((string) ($data['answerOption4'] ?? '')) : '', + 'answerOption5' => 'choice' === $type ? trim((string) ($data['answerOption5'] ?? '')) : '', + 'answerOption6' => 'choice' === $type ? trim((string) ($data['answerOption6'] ?? '')) : '', + 'answerOption7' => 'choice' === $type ? trim((string) ($data['answerOption7'] ?? '')) : '', + 'answerOption8' => 'choice' === $type ? trim((string) ($data['answerOption8'] ?? '')) : '', + 'answerOption9' => 'choice' === $type ? trim((string) ($data['answerOption9'] ?? '')) : '', + 'answerOption10' => 'choice' === $type ? trim((string) ($data['answerOption10'] ?? '')) : '', + 'rangeMin' => $isRangeType ? (int) ($data['rangeMin'] ?? 0) : 0, + 'rangeMax' => $isRangeType ? (int) ($data['rangeMax'] ?? 10) : 10, + 'rangeStep' => $isRangeType ? max(1, (int) ($data['rangeStep'] ?? 1)) : 1, ]); $id = (int) $this->connection->lastInsertId(); @@ -94,29 +96,32 @@ final class SurveyQuestionRepository public function update(SurveyContentModel $question, array $data): void { + $type = (string) ($data['type'] ?? $question->type); + $isRangeType = 'range' === $type; + $this->connection->update('tl_survey_content', [ - 'type' => (string) ($data['type'] ?? $question->type), + 'type' => $type, 'question' => (string) ($data['question'] ?? $question->question), 'description' => (string) ($data['description'] ?? $question->description), 'mandatory' => !empty($data['mandatory']) ? '1' : '', 'published' => !empty($data['published']) ? '1' : '', - 'allowMaybe' => 'yes_no_maybe' === (string) ($data['type'] ?? $question->type) && !empty($data['allowMaybe']) ? '1' : '', - 'jumpOnYes' => 'yes_no_maybe' === (string) ($data['type'] ?? $question->type) ? max(0, (int) ($data['jumpOnYes'] ?? $question->jumpOnYes)) : 0, - 'jumpOnNo' => 'yes_no_maybe' === (string) ($data['type'] ?? $question->type) ? max(0, (int) ($data['jumpOnNo'] ?? $question->jumpOnNo)) : 0, - 'allowMultiple' => 'choice' === (string) ($data['type'] ?? $question->type) && !empty($data['allowMultiple']) ? '1' : '', - 'answerOption1' => 'choice' === (string) ($data['type'] ?? $question->type) ? trim((string) ($data['answerOption1'] ?? $question->answerOption1)) : '', - 'answerOption2' => 'choice' === (string) ($data['type'] ?? $question->type) ? trim((string) ($data['answerOption2'] ?? $question->answerOption2)) : '', - 'answerOption3' => 'choice' === (string) ($data['type'] ?? $question->type) ? trim((string) ($data['answerOption3'] ?? $question->answerOption3)) : '', - 'answerOption4' => 'choice' === (string) ($data['type'] ?? $question->type) ? trim((string) ($data['answerOption4'] ?? $question->answerOption4)) : '', - 'answerOption5' => 'choice' === (string) ($data['type'] ?? $question->type) ? trim((string) ($data['answerOption5'] ?? $question->answerOption5)) : '', - 'answerOption6' => 'choice' === (string) ($data['type'] ?? $question->type) ? trim((string) ($data['answerOption6'] ?? $question->answerOption6)) : '', - 'answerOption7' => 'choice' === (string) ($data['type'] ?? $question->type) ? trim((string) ($data['answerOption7'] ?? $question->answerOption7)) : '', - 'answerOption8' => 'choice' === (string) ($data['type'] ?? $question->type) ? trim((string) ($data['answerOption8'] ?? $question->answerOption8)) : '', - 'answerOption9' => 'choice' === (string) ($data['type'] ?? $question->type) ? trim((string) ($data['answerOption9'] ?? $question->answerOption9)) : '', - 'answerOption10' => 'choice' === (string) ($data['type'] ?? $question->type) ? trim((string) ($data['answerOption10'] ?? $question->answerOption10)) : '', - 'rangeMin' => (int) ($data['rangeMin'] ?? $question->rangeMin), - 'rangeMax' => (int) ($data['rangeMax'] ?? $question->rangeMax), - 'rangeStep' => max(1, (int) ($data['rangeStep'] ?? $question->rangeStep)), + 'allowMaybe' => 'yes_no_maybe' === $type && !empty($data['allowMaybe']) ? '1' : '', + 'jumpOnYes' => 'yes_no_maybe' === $type ? max(0, (int) ($data['jumpOnYes'] ?? $question->jumpOnYes)) : 0, + 'jumpOnNo' => 'yes_no_maybe' === $type ? max(0, (int) ($data['jumpOnNo'] ?? $question->jumpOnNo)) : 0, + 'allowMultiple' => 'choice' === $type && !empty($data['allowMultiple']) ? '1' : '', + 'answerOption1' => 'choice' === $type ? trim((string) ($data['answerOption1'] ?? $question->answerOption1)) : '', + 'answerOption2' => 'choice' === $type ? trim((string) ($data['answerOption2'] ?? $question->answerOption2)) : '', + 'answerOption3' => 'choice' === $type ? trim((string) ($data['answerOption3'] ?? $question->answerOption3)) : '', + 'answerOption4' => 'choice' === $type ? trim((string) ($data['answerOption4'] ?? $question->answerOption4)) : '', + 'answerOption5' => 'choice' === $type ? trim((string) ($data['answerOption5'] ?? $question->answerOption5)) : '', + 'answerOption6' => 'choice' === $type ? trim((string) ($data['answerOption6'] ?? $question->answerOption6)) : '', + 'answerOption7' => 'choice' === $type ? trim((string) ($data['answerOption7'] ?? $question->answerOption7)) : '', + 'answerOption8' => 'choice' === $type ? trim((string) ($data['answerOption8'] ?? $question->answerOption8)) : '', + 'answerOption9' => 'choice' === $type ? trim((string) ($data['answerOption9'] ?? $question->answerOption9)) : '', + 'answerOption10' => 'choice' === $type ? trim((string) ($data['answerOption10'] ?? $question->answerOption10)) : '', + 'rangeMin' => $isRangeType ? (int) ($data['rangeMin'] ?? $question->rangeMin) : 0, + 'rangeMax' => $isRangeType ? (int) ($data['rangeMax'] ?? $question->rangeMax) : 10, + 'rangeStep' => $isRangeType ? max(1, (int) ($data['rangeStep'] ?? $question->rangeStep)) : 1, ], [ 'id' => (int) $question->id, ]); @@ -129,6 +134,20 @@ final class SurveyQuestionRepository 'pid' => $surveyId, ]); + $this->connection->update('tl_survey_content', [ + 'jumpOnYes' => 0, + ], [ + 'pid' => $surveyId, + 'jumpOnYes' => $questionId, + ]); + + $this->connection->update('tl_survey_content', [ + 'jumpOnNo' => 0, + ], [ + 'pid' => $surveyId, + 'jumpOnNo' => $questionId, + ]); + $this->connection->delete('tl_survey_condition', [ 'sourceQuestion' => $questionId, ]); diff --git a/src/Repository/SurveyRepository.php b/src/Repository/SurveyRepository.php index 559e7c5..68239b7 100644 --- a/src/Repository/SurveyRepository.php +++ b/src/Repository/SurveyRepository.php @@ -6,6 +6,7 @@ namespace Mummert\SurveyBundle\Repository; use Contao\CoreBundle\Framework\ContaoFramework; use Contao\StringUtil; +use Doctrine\DBAL\ArrayParameterType; use Doctrine\DBAL\Connection; use Mummert\SurveyBundle\Model\SurveyModel; @@ -67,12 +68,11 @@ final class SurveyRepository s.isLocked, s.updatedAt, COUNT(DISTINCT q.id) AS questionCount, - COUNT(DISTINCT a.id) AS answerCount + COUNT(DISTINCT CASE WHEN sub.isFinished = 1 THEN sub.id END) AS participationCount FROM tl_survey s INNER JOIN tl_survey_editor e ON COALESCE(NULLIF(e.survey, 0), e.pid) = s.id AND e.member = :member LEFT JOIN tl_survey_content q ON q.pid = s.id LEFT JOIN tl_survey_submission sub ON sub.survey = s.id - LEFT JOIN tl_survey_answer a ON a.submission = sub.id GROUP BY s.id ORDER BY s.updatedAt DESC, s.title ASC SQL, @@ -91,7 +91,7 @@ final class SurveyRepository 'alias' => $this->ensurePublicAlias(null), 'category' => serialize($this->normalizeCategoryIds($data['category'] ?? [])), 'description' => (string) ($data['description'] ?? ''), - 'published' => !empty($data['published']) ? '1' : '', + 'published' => '', 'isLocked' => '', 'createdBy' => $memberId, 'updatedBy' => $memberId, @@ -212,6 +212,31 @@ final class SurveyRepository ]); } + public function deleteCascade(int $surveyId): void + { + $this->connection->transactional(function () use ($surveyId): void { + $submissionIds = array_values(array_map('intval', $this->connection->fetchFirstColumn( + 'SELECT id FROM tl_survey_submission WHERE survey = ?', + [$surveyId], + ))); + + if ([] !== $submissionIds) { + $this->connection->executeStatement( + 'DELETE FROM tl_survey_answer WHERE submission IN (?)', + [$submissionIds], + [ArrayParameterType::INTEGER], + ); + } + + $this->connection->delete('tl_survey_submission', ['survey' => $surveyId]); + $this->connection->delete('tl_survey_condition', ['pid' => $surveyId]); + $this->connection->delete('tl_survey_content', ['pid' => $surveyId]); + $this->connection->delete('tl_survey_editor', ['survey' => $surveyId]); + $this->connection->delete('tl_survey_editor', ['pid' => $surveyId]); + $this->connection->delete('tl_survey', ['id' => $surveyId]); + }); + } + private function generateRandomPublicAlias(): string { $characters = 'abcdefghijklmnopqrstuvwxyz0123456789'; diff --git a/src/Resources/views/pdf/survey_results.html.twig b/src/Resources/views/pdf/survey_results.html.twig index e53a7fd..ddc7ac2 100644 --- a/src/Resources/views/pdf/survey_results.html.twig +++ b/src/Resources/views/pdf/survey_results.html.twig @@ -208,7 +208,7 @@

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

- {{ 'survey.pdf.completed_submissions'|trans({'%count%': completedSubmissionCount}) }} + {{ 'survey.pdf.participations'|trans({'%count%': completedSubmissionCount}) }} {{ 'survey.pdf.questions'|trans({'%count%': questionResults|length}) }} {{ 'survey.pdf.export_prefix'|trans({'%date%': exportedAt}) }}
@@ -223,7 +223,7 @@ {% endif %}
- {{ 'survey.pdf.answers'|trans({'%count%': question.totalAnswers}) }} + {{ 'survey.pdf.participations'|trans({'%count%': completedSubmissionCount}) }} {{ 'survey.pdf.response_rate'|trans({'%rate%': question.responseRate}) }}
diff --git a/src/Service/SurveyEditorService.php b/src/Service/SurveyEditorService.php index bad0032..e177fd9 100644 --- a/src/Service/SurveyEditorService.php +++ b/src/Service/SurveyEditorService.php @@ -41,7 +41,17 @@ final class SurveyEditorService $this->surveyRepository->update($survey, $memberId, $data->toArray()); } - public function saveQuestion(SurveyModel $survey, SurveyQuestionData $data, int $questionId = 0): void + public function toggleSurveyPublished(SurveyModel $survey, int $memberId): bool + { + $data = SurveyEditorData::fromModel($survey); + $data->published = !$data->published; + + $this->surveyRepository->update($survey, $memberId, $data->toArray()); + + return $data->published; + } + + public function saveQuestion(SurveyModel $survey, SurveyQuestionData $data, int $questionId = 0): int { $this->assertUnlocked($survey); @@ -55,11 +65,13 @@ final class SurveyEditorService $this->surveyQuestionRepository->update($question, $data->toArray()); $this->surveyRepository->touch((int) $survey->id); - return; + return (int) $question->id; } - $this->surveyQuestionRepository->create((int) $survey->id, $data->toArray()); + $question = $this->surveyQuestionRepository->create((int) $survey->id, $data->toArray()); $this->surveyRepository->touch((int) $survey->id); + + return (int) $question->id; } public function deleteQuestion(SurveyModel $survey, int $questionId): void @@ -70,6 +82,11 @@ final class SurveyEditorService $this->surveyRepository->touch((int) $survey->id); } + public function deleteSurvey(SurveyModel $survey): void + { + $this->surveyRepository->deleteCascade((int) $survey->id); + } + public function saveCondition(SurveyModel $survey, SurveyConditionData $data, int $conditionId = 0): void { $this->assertUnlocked($survey); diff --git a/src/Service/SurveySubmissionService.php b/src/Service/SurveySubmissionService.php index 3eacc4d..66e4840 100644 --- a/src/Service/SurveySubmissionService.php +++ b/src/Service/SurveySubmissionService.php @@ -12,6 +12,7 @@ use Mummert\SurveyBundle\Repository\SurveyAnswerRepository; use Mummert\SurveyBundle\Repository\SurveyRepository; use Mummert\SurveyBundle\Repository\SurveySubmissionRepository; use Symfony\Component\HttpFoundation\Session\SessionInterface; +use Symfony\Contracts\Translation\TranslatorInterface; use Symfony\Component\Uid\Ulid; final class SurveySubmissionService @@ -21,6 +22,7 @@ final class SurveySubmissionService private readonly SurveyAnswerRepository $surveyAnswerRepository, private readonly SurveyRepository $surveyRepository, private readonly QuestionTypeRegistry $questionTypeRegistry, + private readonly TranslatorInterface $translator, ) { } @@ -80,7 +82,9 @@ final class SurveySubmissionService */ public function getAnswersForSubmission(SurveySubmissionModel $submission): array { - return $this->surveyAnswerRepository->findAnswersBySubmission((int) $submission->id); + $answers = $this->surveyAnswerRepository->findAnswersBySubmission((int) $submission->id); + + return array_map(fn (array $answer): array => $this->formatSubmissionAnswer($answer), $answers); } /** @@ -91,23 +95,48 @@ final class SurveySubmissionService return $this->surveySubmissionRepository->findOverviewBySurvey((int) $survey->id); } - public function hasFinishedSubmissionForSurvey(int $surveyId, SessionInterface $session): bool - { - if ($surveyId <= 0) { - return false; - } - - $token = (string) $session->get($this->buildSessionKey($surveyId), ''); - - if ('' === $token) { - return false; - } - - return $this->surveySubmissionRepository->hasFinishedBySurveyAndToken($surveyId, $token); - } - private function buildSessionKey(int $surveyId): string { return 'mummert_survey_submission_'.$surveyId; } + + /** + * @param array $answer + * + * @return array + */ + private function formatSubmissionAnswer(array $answer): array + { + $questionType = (string) ($answer['questionType'] ?? 'text'); + $value = trim((string) ($answer['value'] ?? '')); + + $answer['questionType'] = $this->translateQuestionType($questionType); + $answer['value'] = $this->translateAnswerValue($questionType, $value); + + return $answer; + } + + private function translateQuestionType(string $questionType): string + { + return $this->translator->trans('survey.survey.question_type_'.$questionType, [], 'messages'); + } + + private function translateAnswerValue(string $questionType, string $value): string + { + if ('' === $value) { + return $value; + } + + if ('yes_no_maybe' === $questionType) { + return $this->translator->trans('survey.survey.answer_'.$value, [], 'messages'); + } + + if ('choice' === $questionType) { + $parts = array_values(array_filter(array_map('trim', explode(' | ', $value)), static fn (string $entry): bool => '' !== $entry)); + + return implode(' | ', $parts); + } + + return $value; + } } \ No newline at end of file diff --git a/translations/messages.de.yaml b/translations/messages.de.yaml index b2e1cda..6c38dab 100644 --- a/translations/messages.de.yaml +++ b/translations/messages.de.yaml @@ -2,26 +2,32 @@ survey: list: login_required: "Dieser Bereich verwendet den normalen Contao-Mitgliederlogin. Bitte binden Sie das Modul auf einer geschützten Seite oder mit einer separaten Login-Seite ein." create: "Neue Umfrage anlegen" + 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_second: "Wirklich sicher?" published: "veröffentlicht" draft: "Entwurf" locked: "gesperrt" - no_description: "Keine Beschreibung hinterlegt." questions: "%count% Fragen" - answers: "%count% Antworten" + participations: "%count% Teilnahmen" edit: "Bearbeiten" public_view: "Öffentliche Ansicht" show_results: "Ergebnisse anzeigen" empty: "Dem eingeloggten Mitglied ist aktuell keine Umfrage zugewiesen." edit: login_required: "Dieses Frontendmodul ist für eingeloggte Mitglieder gedacht. Nutzen Sie den normalen Contao-Mitgliederlogin auf einer geschützten Seite." + entry_not_allowed: "Diese Seite kann nicht direkt geöffnet werden. Bitte wechseln Sie über die Umfragen-Liste in den Bearbeitungsmodus." create_title: "Neue Umfrage erstellen" create_text: "Die Umfrage wird nach dem Anlegen automatisch dem eingeloggten Mitglied zugewiesen." label_title: "Titel" + label_survey_title: "Titel der Umfrage" label_description: "Beschreibung" publish_directly: "Direkt veröffentlichen" create_submit: "Umfrage erstellen" back_to_list: "Zur Liste" public_view_open: "Öffentliche Ansicht öffnen" + copy_public_link: "Öffentlichen Link kopieren" + copy_public_link_success: "Link kopiert" public_link: "Öffentlicher Link" no_public_page: "Noch keine öffentliche Seite hinterlegt." status: "Status" @@ -34,6 +40,12 @@ survey: metadata: "Umfrage-Metadaten" publish_survey: "Umfrage veröffentlichen" save: "Speichern" + save_metadata: "Metadaten speichern" + save_this_question: "Diese Frage speichern" + save_new_question: "Neue Frage speichern" + dirty_metadata: "Ungespeicherte Änderungen in den Metadaten" + dirty_question: "Ungespeicherte Änderungen in dieser Frage" + dirty_new_question: "Ungespeicherte Änderungen an der neuen Frage" editors_title: "Bearbeitende Mitglieder" no_editors: "Keine Editoren hinterlegt." responses_title: "Antworten und Ergebnisse" @@ -66,6 +78,9 @@ survey: delete_question_confirm: "Frage wirklich löschen?" delete: "Löschen" no_questions: "Noch keine Fragen angelegt." + new_question: "Neue Frage anlegen" + open_question: "Akkordeon öffnen" + close_question: "Akkordeon schließen" conditions_title: "Sprungregeln" source_question: "Ausgangsfrage" answer: "Antwort" @@ -82,6 +97,13 @@ survey: default_description: "Bitte beantworten Sie die folgenden Fragen. Jede Frage wird einzeln angezeigt." thanks_title: "Vielen Dank fürs Ausfüllen." thanks_text: "Ihre Antworten wurden anonym gespeichert. Es wurde keine Teilnehmeridentität mit diesem Umfrage-Durchlauf verknüpft." + question_type_yes_no_maybe: "Ja/Nein/Vielleicht" + question_type_choice: "Auswahl" + question_type_range: "Bewertung" + question_type_text: "Freitext" + answer_yes: "Ja" + answer_no: "Nein" + answer_maybe: "Vielleicht" question_progress: "Frage %current%" answer_flow_hint: "Diese Antwort wird direkt für den weiteren Ablauf verwendet." current_value: "Aktueller Wert" @@ -89,6 +111,7 @@ survey: results: no_description: "Keine Beschreibung hinterlegt." completed_submissions: "%count% abgeschlossene Teilnahmen" + participations: "%count% Teilnahmen" questions: "%count% Fragen" download_excel: "Ergebnisse als Excel herunterladen" download_pdf: "Ergebnisse als PDF herunterladen" @@ -103,6 +126,7 @@ survey: pdf: no_description: "Keine Beschreibung hinterlegt." completed_submissions: "%count% abgeschlossene Teilnahmen" + participations: "%count% Teilnahmen" questions: "%count% Fragen" export_prefix: "Export %date%" answers: "%count% Antworten" diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index 0b016e4..8412d80 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -2,26 +2,32 @@ survey: list: login_required: "This area uses the regular Contao member login. Please place the module on a protected page or provide a separate login page." create: "Create new survey" + 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_second: "Are you really sure?" published: "published" draft: "Draft" locked: "locked" - no_description: "No description available." questions: "%count% questions" - answers: "%count% answers" + participations: "%count% participations" edit: "Edit" public_view: "Public view" show_results: "Show results" empty: "There is currently no survey assigned to the logged-in member." edit: login_required: "This frontend module is intended for logged-in members. Please use the regular Contao member login on a protected page." + entry_not_allowed: "This page cannot be opened directly. Please switch to edit mode via the survey list." create_title: "Create new survey" create_text: "The survey will automatically be assigned to the logged-in member after creation." label_title: "Title" + label_survey_title: "Survey title" label_description: "Description" publish_directly: "Publish immediately" create_submit: "Create survey" back_to_list: "Back to list" public_view_open: "Open public view" + copy_public_link: "Copy public link" + copy_public_link_success: "Link copied" public_link: "Public link" no_public_page: "No public page configured yet." status: "Status" @@ -34,6 +40,12 @@ survey: metadata: "Survey metadata" publish_survey: "Publish survey" save: "Save" + save_metadata: "Save metadata" + save_this_question: "Save this question" + save_new_question: "Save new question" + dirty_metadata: "Unsaved changes in metadata" + dirty_question: "Unsaved changes in this question" + dirty_new_question: "Unsaved changes in the new question" editors_title: "Editing members" no_editors: "No editors configured." responses_title: "Answers and results" @@ -66,6 +78,9 @@ survey: delete_question_confirm: "Delete this question?" delete: "Delete" no_questions: "No questions created yet." + new_question: "Create new question" + open_question: "Open accordion" + close_question: "Close accordion" conditions_title: "Jump rules" source_question: "Source question" answer: "Answer" @@ -82,6 +97,13 @@ survey: default_description: "Please answer the following questions. Each question is shown individually." thanks_title: "Thank you for completing the survey." thanks_text: "Your answers have been stored anonymously. No participant identity was linked to this survey run." + question_type_yes_no_maybe: "Yes/No/Maybe" + question_type_choice: "Choice" + question_type_range: "Rating" + question_type_text: "Free text" + answer_yes: "Yes" + answer_no: "No" + answer_maybe: "Maybe" question_progress: "Question %current%" answer_flow_hint: "This answer is used directly for the further flow." current_value: "Current value" @@ -89,6 +111,7 @@ survey: results: no_description: "No description available." completed_submissions: "%count% completed participations" + participations: "%count% participations" questions: "%count% questions" download_excel: "Download results as Excel" download_pdf: "Download results as PDF" @@ -103,6 +126,7 @@ survey: pdf: no_description: "No description available." completed_submissions: "%count% completed participations" + participations: "%count% participations" questions: "%count% questions" export_prefix: "Export %date%" answers: "%count% answers"