Add topic sections, ordered inserts, rich-text descriptions and grouped results (Ausbaustufe 3)

- section content type with structure service, reader context and grouped results/PDF/Excel
- new questions append at the end (backend oncreate) and per-section add buttons
- rich-text descriptions: Quill 2 (frontend), reduced TinyMCE (backend), server-side whitelist sanitizer
- externalize inline assets, bundle Chart.js locally, harden debug access, validate range bounds

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Claude Fable 5
2026-08-22 07:57:40 +02:00
parent a9ffffbc78
commit 833a8964cf
47 changed files with 3234 additions and 1769 deletions
+15 -1
View File
@@ -657,4 +657,18 @@ Backend:
- Fragerouting: `SurveyFlowService`, `SurveyConditionRepository`
- Ergebnisdarstellung: `SurveyResultsViewService`
- Exportlogik: `SurveyResultsExportService`, `SurveyResultsPdfService`
- Backend-Operationen und DCA-Verhalten: `tl_survey.php`, `SurveyDcaListener`
- Backend-Operationen und DCA-Verhalten: `tl_survey.php`, `SurveyDcaListener`
## Ausbaustufe 3 (22.08.2026): Themenbereiche, Fragenreihenfolge, Rich-Text
Führende Doku: Trilium → Kunden → Kinderschutzbund → Survey-Bundle → „12 Ausbaustufe 3“.
- **Themenbereiche = fünfter Typ `section` in `tl_survey_content`** (keine eigene Tabelle). Ein Abschnitt ist eine Gliederungszeile ohne Antwortfeld; eine Frage gehört zum zuletzt vorangehenden Abschnitt in der Sortierung. Fragen vor dem ersten Abschnitt = „Allgemeine Fragen“. Abschnitte haben kein `published`/`mandatory`.
- `SurveyQuestionRepository`: `findAllBySurvey()` = alle Inhalte inkl. Abschnitte (Editor/Backend/Gliederung), `findQuestionsBySurvey()` = nur Fragen (Ablauf, Sprungziele, Zählung, Export). Zeilen mit leerem `type` (abgebrochene Backend-Entwürfe) werden überall ignoriert. `create(..., $insertAfterId)` fügt hinter einem Element ein und nummeriert neu (`renumber()`).
- `SurveyStructureService`: Outline (Positionsnummern nur für Fragen), Bereichszuordnung, Reader-Kontext („Frage n von m aus Bereich“, „Frage x von y insgesamt“), Gruppierung der Ergebnisse (`groupBySection`, sortierungsbasiert → funktioniert auch für Snapshots gelöschter Fragen).
- Frontend-Editor: gemeinsames Teil-Template `_survey_question_form.html.twig`; Buttons „Themenbereich hinzufügen“ und „Frage in X hinzufügen“ (Query `question=new&newType=section` bzw. `after=<id>`, Hidden-Feld `insertAfter`). Drag&Drop sortiert Abschnitte wie Fragen; Add-Buttons werden bei ungespeicherter Reihenfolge ausgeblendet.
- Backend: `label_callback` rendert Abschnitte als Balken, Fragen eingerückt mit Positionsnummer; `oncreate_callback` setzt bei „Neu“ im Listenkopf (`mode=2`) die Sortierung ans Ende. Palette `section` = Typ + Titel.
- Reader: Einleitungstext nur bei der ersten Frage (`isFirstQuestion`); Kopfzeile mit Themenbereich; Ergebnisse/PDF/Excel nach Bereichen gegliedert (Excel: Bereichszeile 2, Fragetexte 3, Köpfe 4, Freeze `A5` ohne Abschnitte bleibt das alte Layout).
- Rich-Text: `SurveyHtmlSanitizer` (symfony/html-sanitizer, Whitelist `p, br, ul, ol, li, strong, em, b, i`, keine Links) in Repositories + Backend-`save_callback`; Ausgabe über Twig-Filter `survey_rich` / `survey_has_text` / `survey_plain`. Frontend: Quill 2 lokal (`public/js/quill.min.js`, `survey-rich-text.js`, Toolbar fett/kursiv/Listen). Backend: `rte => 'tinyMCE_survey'` (`contao/templates/backend/be_tinyMCE_survey.html.twig`).
- Assets: Inline-CSS/JS aus `_survey_assets.html.twig` nach `public/css/survey-frontend.css` + `public/js/survey-frontend.js` ausgelagert; Chart.js lokal (`public/js/chart.umd.min.js`).
- Sicherheit: Editor-Debug-Zugang nur noch bei `kernel.debug=true` + lokalem Host (`$request->getHost()`); Range: `rangeMin/Max >= 0`, `rangeMax >= rangeMin`, `rangeStep >= 1` (FE + BE).
- Neue DB-Spalte: `tl_survey_answer.questionSection` (Snapshot des Bereichstitels).
+2 -1
View File
@@ -130,7 +130,8 @@ $GLOBALS['TL_DCA']['tl_survey'] = [
'description' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey']['description'],
'inputType' => 'textarea',
'eval' => ['rte' => 'tinyMCE', 'tl_class' => 'clr'],
'eval' => ['rte' => 'tinyMCE_survey', 'tl_class' => 'clr'],
'save_callback' => [[SurveyDcaListener::class, 'sanitizeRichText']],
'sql' => 'text NULL',
],
'internalNote' => [
+3
View File
@@ -61,6 +61,9 @@ $GLOBALS['TL_DCA']['tl_survey_answer'] = [
'questionSorting' => [
'sql' => 'int(10) unsigned NOT NULL default 0',
],
'questionSection' => [
'sql' => "varchar(255) NOT NULL default ''",
],
'value' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey_answer']['value'],
'sql' => 'text NULL',
+12 -3
View File
@@ -13,6 +13,10 @@ $GLOBALS['TL_DCA']['tl_survey_content'] = [
'onsubmit_callback' => [
[SurveyDcaListener::class, 'touchSurveyContentParent'],
],
'oncreate_callback' => [
// "Neu" im Listenkopf fügt in Contao oben ein Fragen sollen immer ans Ende.
[SurveyDcaListener::class, 'placeNewContentAtEnd'],
],
'onpalette_callback' => [
[SurveyDcaListener::class, 'resolveSurveyContentPalette'],
],
@@ -38,7 +42,7 @@ $GLOBALS['TL_DCA']['tl_survey_content'] = [
],
'label' => [
'fields' => ['question', 'type'],
'showColumns' => true,
'label_callback' => [SurveyDcaListener::class, 'renderSurveyContentLabel'],
],
'operations' => [
'edit' => [
@@ -73,6 +77,8 @@ $GLOBALS['TL_DCA']['tl_survey_content'] = [
'text' => '{question_legend},type,question,description;{settings_legend},mandatory,published',
'range' => '{question_legend},type,question,description;{settings_legend},mandatory,published,rangeMin,rangeMax,rangeStep',
'choice' => '{question_legend},type,question,description;{settings_legend},mandatory,published,allowMultiple,answerOption1,answerOption2,answerOption3,answerOption4,answerOption5,answerOption6,answerOption7,answerOption8,answerOption9,answerOption10',
// Themenbereich: reine Gliederungszeile, kein Antwortfeld, kein Aktiv-/Pflicht-Flag.
'section' => '{question_legend},type,question',
],
'fields' => [
'id' => ['sql' => 'int(10) unsigned NOT NULL auto_increment'],
@@ -84,7 +90,7 @@ $GLOBALS['TL_DCA']['tl_survey_content'] = [
'type' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey_content']['type'],
'inputType' => 'select',
'options' => ['yes_no_maybe', 'text', 'range', 'choice'],
'options' => ['yes_no_maybe', 'text', 'range', 'choice', 'section'],
'reference' => &$GLOBALS['TL_LANG']['tl_survey_content']['types'],
'eval' => ['mandatory' => true, 'includeBlankOption' => true, 'submitOnChange' => true, 'chosen' => true, 'tl_class' => 'w50'],
'sql' => "varchar(32) NOT NULL default ''",
@@ -98,7 +104,8 @@ $GLOBALS['TL_DCA']['tl_survey_content'] = [
'description' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey_content']['description'],
'inputType' => 'textarea',
'eval' => ['tl_class' => 'clr'],
'eval' => ['rte' => 'tinyMCE_survey', 'tl_class' => 'clr'],
'save_callback' => [[SurveyDcaListener::class, 'sanitizeRichText']],
'sql' => 'text NULL',
],
'mandatory' => [
@@ -211,12 +218,14 @@ $GLOBALS['TL_DCA']['tl_survey_content'] = [
'label' => &$GLOBALS['TL_LANG']['tl_survey_content']['rangeMax'],
'inputType' => 'text',
'eval' => ['rgxp' => 'digit', 'tl_class' => 'w50'],
'save_callback' => [[SurveyDcaListener::class, 'validateRangeMax']],
'sql' => 'int(10) NOT NULL default 10',
],
'rangeStep' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey_content']['rangeStep'],
'inputType' => 'text',
'eval' => ['rgxp' => 'digit', 'tl_class' => 'w50'],
'save_callback' => [[SurveyDcaListener::class, 'validateRangeStep']],
'sql' => 'int(10) unsigned NOT NULL default 1',
],
],
+7 -2
View File
@@ -1,11 +1,12 @@
<?php
$GLOBALS['TL_LANG']['tl_survey_content']['type'] = ['Fragetyp', 'Typ der Frage.'];
$GLOBALS['TL_LANG']['tl_survey_content']['type'] = ['Fragetyp', 'Typ der Frage. „Themenbereich“ ist keine Frage, sondern eine Überschrift: Alle folgenden Fragen bis zum nächsten Themenbereich gehören dazu.'];
$GLOBALS['TL_LANG']['tl_survey_content']['types'] = [
'yes_no_maybe' => 'Ja-Nein-Frage',
'text' => 'Offene Frage',
'range' => 'Bewertungsfrage',
'choice' => 'Single/Multiple-Choice',
'section' => 'Themenbereich (Abschnitt)',
];
$GLOBALS['TL_LANG']['tl_survey_content']['question'] = ['Frage', 'Titel bzw. Wortlaut der Frage.'];
$GLOBALS['TL_LANG']['tl_survey_content']['description'] = ['Beschreibung', 'Optionale Zusatzbeschreibung zur Frage.'];
@@ -29,4 +30,8 @@ $GLOBALS['TL_LANG']['tl_survey_content']['answerOption9'] = ['Antwort 9', 'Neunt
$GLOBALS['TL_LANG']['tl_survey_content']['answerOption10'] = ['Antwort 10', 'Zehnte mögliche Antwort.'];
$GLOBALS['TL_LANG']['tl_survey_content']['rangeMin'] = ['Kleinster Wert', 'Kleinster Wert des Schiebereglers.'];
$GLOBALS['TL_LANG']['tl_survey_content']['rangeMax'] = ['Größter Wert', 'Größter Wert des Schiebereglers.'];
$GLOBALS['TL_LANG']['tl_survey_content']['rangeStep'] = ['Schrittweite', 'Abstand zwischen den möglichen Werten des Schiebereglers.'];
$GLOBALS['TL_LANG']['tl_survey_content']['rangeStep'] = ['Schrittweite', 'Abstand zwischen den möglichen Werten des Schiebereglers.'];
$GLOBALS['TL_LANG']['tl_survey_content']['mandatoryBadge'] = 'Pflicht';
$GLOBALS['TL_LANG']['tl_survey_content']['inactiveBadge'] = 'inaktiv';
$GLOBALS['TL_LANG']['tl_survey_content']['rangeMaxBelowMin'] = 'Der größte Wert muss mindestens so groß sein wie der kleinste Wert.';
$GLOBALS['TL_LANG']['tl_survey_content']['rangeStepInvalid'] = 'Die Schrittweite muss mindestens 1 sein.';
+7 -2
View File
@@ -1,11 +1,12 @@
<?php
$GLOBALS['TL_LANG']['tl_survey_content']['type'] = ['Question type', 'Type of the question.'];
$GLOBALS['TL_LANG']['tl_survey_content']['type'] = ['Question type', 'Type of the question. "Topic section" is not a question but a heading: all following questions up to the next section belong to it.'];
$GLOBALS['TL_LANG']['tl_survey_content']['types'] = [
'yes_no_maybe' => 'Yes-no question',
'text' => 'Open question',
'range' => 'Rating question',
'choice' => 'Single/multiple choice',
'section' => 'Topic section',
];
$GLOBALS['TL_LANG']['tl_survey_content']['question'] = ['Question', 'Title or wording of the question.'];
$GLOBALS['TL_LANG']['tl_survey_content']['description'] = ['Description', 'Optional additional description for the question.'];
@@ -29,4 +30,8 @@ $GLOBALS['TL_LANG']['tl_survey_content']['answerOption9'] = ['Answer 9', 'Ninth
$GLOBALS['TL_LANG']['tl_survey_content']['answerOption10'] = ['Answer 10', 'Tenth possible answer.'];
$GLOBALS['TL_LANG']['tl_survey_content']['rangeMin'] = ['Minimum value', 'Smallest value of the slider.'];
$GLOBALS['TL_LANG']['tl_survey_content']['rangeMax'] = ['Maximum value', 'Largest value of the slider.'];
$GLOBALS['TL_LANG']['tl_survey_content']['rangeStep'] = ['Step size', 'Distance between the possible slider values.'];
$GLOBALS['TL_LANG']['tl_survey_content']['rangeStep'] = ['Step size', 'Distance between the possible slider values.'];
$GLOBALS['TL_LANG']['tl_survey_content']['mandatoryBadge'] = 'mandatory';
$GLOBALS['TL_LANG']['tl_survey_content']['inactiveBadge'] = 'inactive';
$GLOBALS['TL_LANG']['tl_survey_content']['rangeMaxBelowMin'] = 'The largest value must not be smaller than the smallest value.';
$GLOBALS['TL_LANG']['tl_survey_content']['rangeStepInvalid'] = 'The step must be at least 1.';
@@ -0,0 +1,28 @@
{# Reduzierter TinyMCE für Umfrage-Beschreibungen: nur Absätze, Listen, fett, kursiv.
Dieselbe Whitelist wie der Frontend-Editor (Quill) und der serverseitige Sanitizer. #}
{% extends '@Contao/be_tinyMCE.html.twig' %}
{% block picker %}{% endblock %}
{% block plugins %}
plugins: 'autosave lists',
{% endblock %}
{% block valid_elements %}
valid_elements: 'p,br,ul,ol,li,strong/b,em/i',
forced_root_block: 'p',
{% endblock %}
{% block menubar %}
menubar: false,
{% endblock %}
{% block toolbar %}
toolbar: 'bold italic | bullist numlist | undo redo | removeformat',
{% endblock %}
{% block custom %}
statusbar: false,
paste_block_drop: true,
paste_as_text: false,
{% endblock %}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,58 @@
{# Gemeinsames Formular für Fragen und Themenbereiche (bestehend und neu).
Erwartet: form, formId, questionType, isNew (bool), dirtyHint (string), submitLabel (string), newQuestionContext (optional) #}
{% trans_default_domain 'messages' %}
{% set isSectionType = questionType == 'section' %}
{{ form_start(form, {attr: {class: 'survey-form-grid', id: formId, 'data-survey-dirty-form': '1', 'data-survey-question-form': '1'}}) }}
<input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}">
{{ form_errors(form) }}
{% if isNew and newQuestionContext|default(null) and newQuestionContext.insertAfter %}
<div class="survey-alert info survey-insert-hint">
{% if newQuestionContext.sectionTitle %}
{{ 'survey.edit.insert_into_section'|trans({'%section%': newQuestionContext.sectionTitle}) }}
{% else %}
{{ 'survey.edit.insert_before_first_section'|trans }}
{% endif %}
</div>
{% endif %}
<label class="survey-label">{{ 'survey.edit.label_type'|trans }}{{ form_widget(form.type, {attr: {'data-survey-question-type': '1'}}) }}</label>
<label class="survey-label">
<span class="question-only-field"{% if isSectionType %} style="display:none"{% endif %}>{{ 'survey.edit.label_question'|trans }}</span>
<span class="section-only-field"{% if not isSectionType %} style="display:none"{% endif %}>{{ 'survey.edit.label_section_title'|trans }}</span>
{{ form_widget(form.question) }}{{ form_errors(form.question) }}
</label>
<div class="section-only-field survey-field-hint"{% if not isSectionType %} style="display:none"{% endif %}>{{ 'survey.edit.section_hint'|trans }}</div>
<div class="question-only-field survey-form-grid"{% if isSectionType %} style="display:none"{% endif %}>
<label class="survey-label">{{ 'survey.edit.label_description'|trans }}{{ form_widget(form.description) }}</label>
<div class="survey-question-toggles">
<label class="survey-inline-check">{{ form_widget(form.mandatory) }}<span>{{ 'survey.edit.mandatory'|trans }}</span></label>
<label class="survey-inline-check">{{ form_widget(form.published) }}<span>{{ 'survey.edit.active'|trans }}</span></label>
<label class="survey-inline-check yes-no-config-field"{% if questionType != 'yes_no_maybe' %} style="display:none"{% endif %}>{{ form_widget(form.allowMaybe) }}<span>{{ 'survey.edit.allow_maybe'|trans }}</span></label>
<label class="survey-inline-check choice-config-field"{% if questionType != 'choice' %} style="display:none"{% endif %}>{{ form_widget(form.allowMultiple) }}<span>{{ 'survey.edit.allow_multiple'|trans }}</span></label>
</div>
<div class="yes-no-config-field survey-grid-columns" style="grid-template-columns: repeat(2, minmax(0, 1fr));{% if questionType != 'yes_no_maybe' %}display:none;{% endif %}">
<label class="survey-label">{{ 'survey.edit.jump_yes'|trans }}{{ form_widget(form.jumpOnYes) }}</label>
<label class="survey-label">{{ 'survey.edit.jump_no'|trans }}{{ form_widget(form.jumpOnNo) }}</label>
</div>
<div class="choice-config-field survey-grid-columns" style="grid-template-columns: repeat(2, minmax(0, 1fr));{% if questionType != 'choice' %}display:none;{% endif %}">
{% for number in 1..10 %}
<label class="survey-label">{{ 'survey.edit.answer_option'|trans({'%number%': number}) }}{{ form_widget(form['answerOption' ~ number]) }}{{ form_errors(form['answerOption' ~ number]) }}</label>
{% endfor %}
</div>
<div class="range-config-field survey-grid-columns" style="grid-template-columns: repeat(3, minmax(0, 1fr));{% if questionType != 'range' %}display:none;{% endif %}">
<label class="survey-label">{{ 'survey.edit.range_min'|trans }}{{ form_widget(form.rangeMin) }}{{ form_errors(form.rangeMin) }}</label>
<label class="survey-label">{{ 'survey.edit.range_max'|trans }}{{ form_widget(form.rangeMax) }}{{ form_errors(form.rangeMax) }}</label>
<label class="survey-label">{{ 'survey.edit.range_step'|trans }}{{ form_widget(form.rangeStep) }}{{ form_errors(form.rangeStep) }}</label>
</div>
</div>
{% if isNew %}
<div class="survey-button-row survey-button-row--form-actions">
<button class="survey-button primary">{{ submitLabel }}</button>
<div class="survey-dirty-hint" data-survey-dirty-hint-for="{{ formId }}" hidden>{{ dirtyHint }}</div>
</div>
{% endif %}
{{ form_end(form) }}
@@ -1,119 +1,2 @@
<style>
.survey-accordion-header-row {
display: flex;
align-items: stretch;
}
.survey-accordion-sort-handle {
position: relative;
flex: 0 0 auto;
min-width: 3.5rem;
padding: 0 1rem;
border: 0;
border-left: 1px solid rgba(162, 168, 180, 0.18);
background: transparent;
cursor: grab;
transition: background 0.2s ease, color 0.2s ease;
}
.survey-accordion-sort-handle:active {
cursor: grabbing;
}
.survey-accordion-item .handorgel__header__button:hover + .survey-accordion-sort-handle,
.survey-accordion-item .handorgel__header__button:focus + .survey-accordion-sort-handle,
.survey-accordion-item .handorgel__header--opened .survey-accordion-sort-handle,
.survey-accordion-sort-handle:hover,
.survey-accordion-sort-handle:focus {
background: var(--survey-blue);
color: #fff;
}
.survey-sort-icon {
position: relative;
display: inline-flex;
width: 0.9rem;
height: 1.2rem;
align-items: center;
justify-content: center;
color: #58a6da;
background-image:
linear-gradient(currentColor, currentColor),
linear-gradient(currentColor, currentColor),
linear-gradient(currentColor, currentColor);
background-repeat: no-repeat;
background-position: center 0.36rem, center center, center calc(100% - 0.36rem);
background-size: 0.78rem 2px, 0.78rem 2px, 0.78rem 2px;
}
.survey-sort-icon::before,
.survey-sort-icon::after {
content: '';
position: absolute;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 0.24rem solid transparent;
border-right: 0.24rem solid transparent;
}
.survey-sort-icon::before {
top: 0;
border-bottom: 0.24rem solid currentColor;
}
.survey-sort-icon::after {
bottom: 0;
border-top: 0.24rem solid currentColor;
}
.survey-question-order-save-shell {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
z-index: 40;
transform: translate(120%, 0);
opacity: 0;
pointer-events: none;
transition: transform 0.26s ease, opacity 0.26s ease;
}
.survey-question-order-save-shell.is-visible {
transform: translate(0, 0);
opacity: 1;
pointer-events: auto;
}
.survey-question-order-save {
box-shadow: 0 18px 40px rgba(0, 59, 102, 0.24);
}
.survey-accordion-item.is-reordering {
box-shadow: 0 20px 44px rgba(0, 59, 102, 0.18);
}
.survey-accordion-item.sortable-ghost {
opacity: 0.55;
}
@media (max-width: 767px) {
.survey-accordion-sort-handle {
min-width: 3.1rem;
padding: 0 0.8rem;
}
.survey-question-order-save-shell {
top: auto;
right: 0.85rem;
bottom: 0.85rem;
transform: translate(0, 130%);
}
.survey-question-order-save-shell.is-visible {
transform: translate(0, 0);
}
}
</style>
<script src="{{ asset('bundles/survey/js/sortable.min.js') }}"></script>
<script src="{{ asset('bundles/survey/js/survey-frontend-editor-sort.js') }}"></script>
<script src="{{ asset('bundles/survey/js/survey-frontend-editor-sort.js') }}"></script>
@@ -0,0 +1,3 @@
<link rel="stylesheet" href="{{ asset('bundles/survey/css/quill.snow.css') }}">
<script src="{{ asset('bundles/survey/js/quill.min.js') }}"></script>
<script src="{{ asset('bundles/survey/js/survey-rich-text.js') }}"></script>
@@ -2,6 +2,7 @@
{% include '@Survey/frontend/_survey_assets.html.twig' %}
{% include '@Survey/frontend/_survey_question_sort_assets.html.twig' %}
{% include '@Survey/frontend/_survey_rich_text_assets.html.twig' %}
<link rel="stylesheet" href="{{ asset('assets/handorgel/css/handorgel.min.css') }}">
{% include '@Survey/frontend/_survey_branding.html.twig' %}
@@ -49,6 +50,7 @@
{% set surveyMetaFormId = 'survey-meta-form' %}
{% set newQuestionFormId = 'survey-question-form-new' %}
{% set canQuestionReorder = canQuestionReorder|default(false) %}
{% set isLocked = structureLocked|default(false) %}
<div class="survey-button-row survey-button-row--editor-actions">
{% if backUrl %}<a class="survey-button secondary" href="{{ backUrl }}">{{ 'survey.edit.back_to_list'|trans }}</a>{% endif %}
@@ -61,7 +63,8 @@
{{ form_widget(surveyForm._token) }}
{{ form_errors(surveyForm) }}
<label class="survey-label">{{ 'survey.edit.label_survey_title'|trans }}{{ form_widget(surveyForm.title) }}{{ form_errors(surveyForm.title) }}</label>
<label class="survey-label">{{ 'survey.edit.label_description'|trans }}{{ form_widget(surveyForm.description) }}</label>
<label class="survey-label">{{ 'survey.edit.label_intro'|trans }}{{ form_widget(surveyForm.description) }}</label>
<div class="survey-field-hint">{{ 'survey.edit.intro_hint'|trans }}</div>
<div class="survey-button-row survey-button-row--form-actions">
<button class="survey-button primary">{{ 'survey.edit.save_metadata'|trans }}</button>
<div class="survey-dirty-hint" data-survey-dirty-hint-for="{{ surveyMetaFormId }}" hidden>{{ 'survey.edit.dirty_metadata'|trans }}</div>
@@ -70,7 +73,7 @@
</div>
{% endif %}
{% if structureLocked|default(false) and not survey.published %}
{% if isLocked and not survey.published %}
<div class="survey-alert warning">{{ 'survey.edit.structure_locked'|trans }}</div>
{% endif %}
@@ -86,13 +89,25 @@
<button class="survey-button primary survey-question-order-save">{{ 'survey.edit.save_question_order'|trans }}</button>
</form>
{% endif %}
{% if not isLocked %}
<div class="survey-button-row survey-structure-actions">
<a class="survey-button secondary" href="{{ editBaseUrl }}&amp;question=new&amp;newType=section#survey-question-new">{{ 'survey.edit.add_section'|trans }}</a>
{% if not hasSections %}
<span class="survey-field-hint">{{ 'survey.edit.sections_intro'|trans }}</span>
{% endif %}
</div>
{% endif %}
<div class="survey-accordion-list handorgel" data-survey-handorgel{% if canQuestionReorder %} data-survey-question-sortable="1"{% endif %}>
{% for question in questions %}
{% for entry in outline %}
{% set question = entry.model %}
{% set questionAnchor = 'survey-question-' ~ question.id %}
{% set isOpen = activeQuestionId == question.id %}
{% set questionItemForm = questionForms[question.id]|default(null) %}
{% set questionFormId = 'survey-question-form-' ~ question.id %}
<article id="{{ questionAnchor }}" class="survey-accordion-item"{% if canQuestionReorder %} data-survey-question-item data-question-id="{{ question.id }}"{% endif %}>
{% set isSection = entry.isSection %}
<article id="{{ questionAnchor }}" class="survey-accordion-item{% if isSection %} survey-accordion-item--section{% endif %}"{% if canQuestionReorder %} data-survey-question-item data-question-id="{{ question.id }}"{% endif %}{% if isSection %} data-survey-section-item{% endif %}{% if isOpen %} data-survey-open="1"{% endif %}>
<h4 class="survey-accordion-header handorgel__header">
<div class="survey-accordion-header-row">
<button
@@ -100,14 +115,22 @@
class="survey-accordion-toggle handorgel__header__button"
>
<span class="survey-accordion-heading">
<span class="survey-accordion-index">{{ loop.index }}.</span>
<span class="survey-accordion-copy">
<strong>{{ question.question|striptags|trim ?: ('survey.edit.question_fallback'|trans({'%id%': question.id})) }}</strong>
</span>
{% if isSection %}
<span class="survey-accordion-index survey-accordion-index--section" aria-hidden="true">§</span>
<span class="survey-accordion-copy">
<span class="survey-section-kicker">{{ 'survey.edit.section_badge'|trans }}</span>
<strong>{{ question.question|striptags|trim ?: ('survey.edit.section_fallback'|trans) }}</strong>
</span>
{% else %}
<span class="survey-accordion-index">{{ entry.position }}.</span>
<span class="survey-accordion-copy">
<strong>{{ question.question|striptags|trim ?: ('survey.edit.question_fallback'|trans({'%id%': question.id})) }}</strong>
</span>
{% endif %}
</span>
</button>
{% if canQuestionReorder %}
<button type="button" class="survey-accordion-sort-handle" data-survey-question-sort-handle aria-label="{{ 'survey.edit.reorder_question'|trans }}" title="{{ 'survey.edit.reorder_question'|trans }}">
<button type="button" class="survey-accordion-sort-handle" data-survey-question-sort-handle aria-label="{{ (isSection ? 'survey.edit.reorder_section' : 'survey.edit.reorder_question')|trans }}" title="{{ (isSection ? 'survey.edit.reorder_section' : 'survey.edit.reorder_question')|trans }}">
<span class="survey-sort-icon" aria-hidden="true">
<svg viewBox="0 0 18 24" role="presentation" focusable="false">
<path d="M9 3 11.6 5.8H6.4z" fill="currentColor"></path>
@@ -121,59 +144,38 @@
</h4>
<div class="survey-accordion-panel handorgel__content">
<div class="handorgel__content__inner" data-survey-question-editor>
{% if structureLocked|default(false) %}
{% if isLocked %}
<div class="survey-grid">
<div class="survey-meta">
<span class="survey-badge neutral">{{ ('survey.survey.question_type_' ~ question.type)|trans }}</span>
{% if question.mandatory %}<span class="survey-badge warning">{{ 'survey.edit.mandatory_badge'|trans }}</span>{% endif %}
</div>
{% if question.description|striptags|trim %}<p>{{ question.description|striptags|trim }}</p>{% endif %}
{% if isSection %}
<p class="survey-field-hint">{{ 'survey.edit.section_locked_hint'|trans }}</p>
{% else %}
<div class="survey-meta">
<span class="survey-badge neutral">{{ ('survey.survey.question_type_' ~ question.type)|trans }}</span>
{% if question.mandatory %}<span class="survey-badge warning">{{ 'survey.edit.mandatory_badge'|trans }}</span>{% endif %}
{% if entry.sectionTitle %}<span class="survey-badge neutral">{{ entry.sectionTitle }}</span>{% endif %}
</div>
{% if question.description|survey_has_text %}<div class="survey-rich-text">{{ question.description|survey_rich }}</div>{% endif %}
{% endif %}
</div>
{% 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'}}) }}
<input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}">
{{ form_errors(questionItemForm) }}
<label class="survey-label">{{ 'survey.edit.label_type'|trans }}{{ form_widget(questionItemForm.type, {attr: {'data-survey-question-type': '1'}}) }}</label>
<label class="survey-label">{{ 'survey.edit.label_question'|trans }}{{ form_widget(questionItemForm.question) }}{{ form_errors(questionItemForm.question) }}</label>
<label class="survey-label">{{ 'survey.edit.label_description'|trans }}{{ form_widget(questionItemForm.description) }}</label>
<div class="survey-question-toggles">
<label class="survey-inline-check">{{ form_widget(questionItemForm.mandatory) }}<span>{{ 'survey.edit.mandatory'|trans }}</span></label>
<label class="survey-inline-check">{{ form_widget(questionItemForm.published) }}<span>{{ 'survey.edit.active'|trans }}</span></label>
<label class="survey-inline-check yes-no-config-field"{% if questionType != 'yes_no_maybe' %} style="display:none"{% endif %}>{{ form_widget(questionItemForm.allowMaybe) }}<span>{{ 'survey.edit.allow_maybe'|trans }}</span></label>
<label class="survey-inline-check choice-config-field"{% if questionType != 'choice' %} style="display:none"{% endif %}>{{ form_widget(questionItemForm.allowMultiple) }}<span>{{ 'survey.edit.allow_multiple'|trans }}</span></label>
</div>
<div class="yes-no-config-field survey-grid-columns"{% if questionType != 'yes_no_maybe' %} style="display:none;grid-template-columns: repeat(2, minmax(0, 1fr));"{% else %} style="grid-template-columns: repeat(2, minmax(0, 1fr));"{% endif %}>
<label class="survey-label">{{ 'survey.edit.jump_yes'|trans }}{{ form_widget(questionItemForm.jumpOnYes) }}</label>
<label class="survey-label">{{ 'survey.edit.jump_no'|trans }}{{ form_widget(questionItemForm.jumpOnNo) }}</label>
</div>
<div class="choice-config-field survey-grid-columns"{% if questionType != 'choice' %} style="display:none;grid-template-columns: repeat(2, minmax(0, 1fr));"{% else %} style="grid-template-columns: repeat(2, minmax(0, 1fr));"{% endif %}>
<label class="survey-label">{{ 'survey.edit.answer_option'|trans({'%number%': 1}) }}{{ form_widget(questionItemForm.answerOption1) }}</label>
<label class="survey-label">{{ 'survey.edit.answer_option'|trans({'%number%': 2}) }}{{ form_widget(questionItemForm.answerOption2) }}</label>
<label class="survey-label">{{ 'survey.edit.answer_option'|trans({'%number%': 3}) }}{{ form_widget(questionItemForm.answerOption3) }}</label>
<label class="survey-label">{{ 'survey.edit.answer_option'|trans({'%number%': 4}) }}{{ form_widget(questionItemForm.answerOption4) }}</label>
<label class="survey-label">{{ 'survey.edit.answer_option'|trans({'%number%': 5}) }}{{ form_widget(questionItemForm.answerOption5) }}</label>
<label class="survey-label">{{ 'survey.edit.answer_option'|trans({'%number%': 6}) }}{{ form_widget(questionItemForm.answerOption6) }}</label>
<label class="survey-label">{{ 'survey.edit.answer_option'|trans({'%number%': 7}) }}{{ form_widget(questionItemForm.answerOption7) }}</label>
<label class="survey-label">{{ 'survey.edit.answer_option'|trans({'%number%': 8}) }}{{ form_widget(questionItemForm.answerOption8) }}</label>
<label class="survey-label">{{ 'survey.edit.answer_option'|trans({'%number%': 9}) }}{{ form_widget(questionItemForm.answerOption9) }}</label>
<label class="survey-label">{{ 'survey.edit.answer_option'|trans({'%number%': 10}) }}{{ form_widget(questionItemForm.answerOption10) }}</label>
</div>
<div class="range-config-field survey-grid-columns"{% if questionType != 'range' %} style="display:none;grid-template-columns: repeat(3, minmax(0, 1fr));"{% else %} style="grid-template-columns: repeat(3, minmax(0, 1fr));"{% endif %}>
<label class="survey-label">{{ 'survey.edit.range_min'|trans }}{{ form_widget(questionItemForm.rangeMin) }}</label>
<label class="survey-label">{{ 'survey.edit.range_max'|trans }}{{ form_widget(questionItemForm.rangeMax) }}</label>
<label class="survey-label">{{ 'survey.edit.range_step'|trans }}{{ form_widget(questionItemForm.rangeStep) }}</label>
</div>
{{ form_end(questionItemForm) }}
{% include '@Survey/frontend/_survey_question_form.html.twig' with {
form: questionItemForm,
formId: questionFormId,
questionType: questionType,
isNew: false,
dirtyHint: 'survey.edit.dirty_question'|trans,
submitLabel: 'survey.edit.save_this_question'|trans
} only %}
<div class="survey-button-row survey-button-row--form-actions survey-button-row--question-actions">
<button class="survey-button primary" type="submit" form="{{ questionFormId }}">{{ 'survey.edit.save_this_question'|trans }}</button>
<button class="survey-button primary" type="submit" form="{{ questionFormId }}">{{ (isSection ? 'survey.edit.save_this_section' : 'survey.edit.save_this_question')|trans }}</button>
<form method="post" class="survey-question-action-form">
<input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}">
<input type="hidden" name="_survey_action" value="delete-question">
<input type="hidden" name="item_id" value="{{ question.id }}">
<input type="hidden" name="_token" value="{{ csrf_token('delete-question-' ~ question.id) }}">
<button class="survey-button danger" onclick="return confirm('{{ 'survey.edit.delete_question_confirm'|trans|e('js') }}');">{{ 'survey.edit.delete'|trans }}</button>
<button class="survey-button danger" onclick="return confirm('{{ (isSection ? 'survey.edit.delete_section_confirm' : 'survey.edit.delete_question_confirm')|trans|e('js') }}');">{{ 'survey.edit.delete'|trans }}</button>
</form>
<div class="survey-dirty-hint" data-survey-dirty-hint-for="{{ questionFormId }}" hidden>{{ 'survey.edit.dirty_question'|trans }}</div>
</div>
@@ -181,12 +183,25 @@
</div>
</div>
</article>
{% if entry.isLastInSection and not isLocked and (entry.sectionId > 0 or hasSections) %}
<div class="survey-section-add-row" data-survey-section-add>
<a class="survey-button ghost" href="{{ editBaseUrl }}&amp;question=new&amp;after={{ entry.id }}#survey-question-new">
{% if entry.sectionTitle %}
{{ 'survey.edit.add_question_to_section'|trans({'%section%': entry.sectionTitle}) }}
{% else %}
{{ 'survey.edit.add_question_before_sections'|trans }}
{% endif %}
</a>
</div>
{% endif %}
{% else %}
<div>{{ 'survey.edit.no_questions'|trans }}</div>
{% endfor %}
{% if not (structureLocked|default(false)) %}
<article id="survey-question-new" class="survey-accordion-item">
{% if not isLocked %}
{% set newType = newQuestionContext.type|default('yes_no_maybe') %}
<article id="survey-question-new" class="survey-accordion-item survey-accordion-item--new"{% if createQuestionMode %} data-survey-open="1"{% endif %}>
<h4 class="survey-accordion-header handorgel__header">
<button
type="button"
@@ -195,7 +210,7 @@
<span class="survey-accordion-heading">
<span class="survey-accordion-index">+</span>
<span class="survey-accordion-copy">
<strong>{{ 'survey.edit.new_question'|trans }}</strong>
<strong>{{ (newType == 'section' ? 'survey.edit.new_section' : 'survey.edit.new_question')|trans }}</strong>
</span>
</span>
</button>
@@ -203,50 +218,21 @@
<div class="survey-accordion-panel handorgel__content">
<div class="handorgel__content__inner" data-survey-question-editor>
{% 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'}}) }}
<input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}">
{{ form_errors(newQuestionForm) }}
<label class="survey-label">{{ 'survey.edit.label_type'|trans }}{{ form_widget(newQuestionForm.type, {attr: {'data-survey-question-type': '1'}}) }}</label>
<label class="survey-label">{{ 'survey.edit.label_question'|trans }}{{ form_widget(newQuestionForm.question) }}{{ form_errors(newQuestionForm.question) }}</label>
<label class="survey-label">{{ 'survey.edit.label_description'|trans }}{{ form_widget(newQuestionForm.description) }}</label>
<div class="survey-question-toggles">
<label class="survey-inline-check">{{ form_widget(newQuestionForm.mandatory) }}<span>{{ 'survey.edit.mandatory'|trans }}</span></label>
<label class="survey-inline-check">{{ form_widget(newQuestionForm.published) }}<span>{{ 'survey.edit.active'|trans }}</span></label>
<label class="survey-inline-check yes-no-config-field"{% if questionType != 'yes_no_maybe' %} style="display:none"{% endif %}>{{ form_widget(newQuestionForm.allowMaybe) }}<span>{{ 'survey.edit.allow_maybe'|trans }}</span></label>
<label class="survey-inline-check choice-config-field"{% if questionType != 'choice' %} style="display:none"{% endif %}>{{ form_widget(newQuestionForm.allowMultiple) }}<span>{{ 'survey.edit.allow_multiple'|trans }}</span></label>
</div>
<div class="yes-no-config-field survey-grid-columns"{% if questionType != 'yes_no_maybe' %} style="display:none;grid-template-columns: repeat(2, minmax(0, 1fr));"{% else %} style="grid-template-columns: repeat(2, minmax(0, 1fr));"{% endif %}>
<label class="survey-label">{{ 'survey.edit.jump_yes'|trans }}{{ form_widget(newQuestionForm.jumpOnYes) }}</label>
<label class="survey-label">{{ 'survey.edit.jump_no'|trans }}{{ form_widget(newQuestionForm.jumpOnNo) }}</label>
</div>
<div class="choice-config-field survey-grid-columns"{% if questionType != 'choice' %} style="display:none;grid-template-columns: repeat(2, minmax(0, 1fr));"{% else %} style="grid-template-columns: repeat(2, minmax(0, 1fr));"{% endif %}>
<label class="survey-label">{{ 'survey.edit.answer_option'|trans({'%number%': 1}) }}{{ form_widget(newQuestionForm.answerOption1) }}</label>
<label class="survey-label">{{ 'survey.edit.answer_option'|trans({'%number%': 2}) }}{{ form_widget(newQuestionForm.answerOption2) }}</label>
<label class="survey-label">{{ 'survey.edit.answer_option'|trans({'%number%': 3}) }}{{ form_widget(newQuestionForm.answerOption3) }}</label>
<label class="survey-label">{{ 'survey.edit.answer_option'|trans({'%number%': 4}) }}{{ form_widget(newQuestionForm.answerOption4) }}</label>
<label class="survey-label">{{ 'survey.edit.answer_option'|trans({'%number%': 5}) }}{{ form_widget(newQuestionForm.answerOption5) }}</label>
<label class="survey-label">{{ 'survey.edit.answer_option'|trans({'%number%': 6}) }}{{ form_widget(newQuestionForm.answerOption6) }}</label>
<label class="survey-label">{{ 'survey.edit.answer_option'|trans({'%number%': 7}) }}{{ form_widget(newQuestionForm.answerOption7) }}</label>
<label class="survey-label">{{ 'survey.edit.answer_option'|trans({'%number%': 8}) }}{{ form_widget(newQuestionForm.answerOption8) }}</label>
<label class="survey-label">{{ 'survey.edit.answer_option'|trans({'%number%': 9}) }}{{ form_widget(newQuestionForm.answerOption9) }}</label>
<label class="survey-label">{{ 'survey.edit.answer_option'|trans({'%number%': 10}) }}{{ form_widget(newQuestionForm.answerOption10) }}</label>
</div>
<div class="range-config-field survey-grid-columns"{% if questionType != 'range' %} style="display:none;grid-template-columns: repeat(3, minmax(0, 1fr));"{% else %} style="grid-template-columns: repeat(3, minmax(0, 1fr));"{% endif %}>
<label class="survey-label">{{ 'survey.edit.range_min'|trans }}{{ form_widget(newQuestionForm.rangeMin) }}</label>
<label class="survey-label">{{ 'survey.edit.range_max'|trans }}{{ form_widget(newQuestionForm.rangeMax) }}</label>
<label class="survey-label">{{ 'survey.edit.range_step'|trans }}{{ form_widget(newQuestionForm.rangeStep) }}</label>
</div>
<div class="survey-button-row survey-button-row--form-actions">
<button class="survey-button primary">{{ 'survey.edit.save_new_question'|trans }}</button>
<div class="survey-dirty-hint" data-survey-dirty-hint-for="{{ newQuestionFormId }}" hidden>{{ 'survey.edit.dirty_new_question'|trans }}</div>
</div>
{{ form_end(newQuestionForm) }}
{% set questionType = newQuestionForm.type.vars.value ?: newType %}
{% include '@Survey/frontend/_survey_question_form.html.twig' with {
form: newQuestionForm,
formId: newQuestionFormId,
questionType: questionType,
isNew: true,
newQuestionContext: newQuestionContext,
dirtyHint: 'survey.edit.dirty_new_question'|trans,
submitLabel: (questionType == 'section' ? 'survey.edit.save_new_section' : 'survey.edit.save_new_question')|trans
} only %}
{% endif %}
</div>
</div>
</article>
{% endif %}
{% endif %}
</div>
</div>
@@ -271,4 +257,4 @@
</div>
{% endif %}
</section>
<script src="{{ asset('assets/handorgel/js/handorgel.min.js') }}"></script>
<script src="{{ asset('assets/handorgel/js/handorgel.min.js') }}"></script>
@@ -13,13 +13,22 @@
{% if errorMessage %}
<div class="survey-alert error">{{ errorMessage }}</div>
{% elseif survey %}
{% set showIntro = isFirstQuestion|default(false) and not isComplete %}
<article class="survey-card">
<div class="survey-meta">
<h3>{{ survey.title|striptags|trim }}</h3>
{% if isPreview %}<span class="survey-badge warning">{{ 'survey.survey.preview_badge'|trans }}</span>{% endif %}
{% if progress.total > 0 %}<span class="survey-badge neutral">{{ progress.current }}/{{ progress.total }}</span>{% endif %}
</div>
<p>{{ (survey.description|striptags|trim) ?: 'survey.survey.default_description'|trans }}</p>
{# Der Einleitungstext erscheint nur auf der ersten Seite. #}
{% if showIntro %}
{% if survey.description|survey_has_text %}
<div class="survey-rich-text survey-intro">{{ survey.description|survey_rich }}</div>
{% else %}
<p>{{ 'survey.survey.default_description'|trans }}</p>
{% endif %}
{% endif %}
{% if isPreview %}
<div class="survey-alert warning">{{ 'survey.survey.preview_notice'|trans }}</div>
@@ -48,9 +57,16 @@
</article>
{% elseif question %}
<article class="survey-card">
<div class="survey-meta"><span class="survey-badge neutral">{{ 'survey.survey.question_progress'|trans({'%current%': progress.current}) }}</span></div>
{% if progress.sectionTitle %}
<div class="survey-section-context">
<span class="survey-section-kicker">{{ 'survey.survey.section_kicker'|trans({'%number%': progress.sectionNumber, '%count%': progress.sectionCount}) }}</span>
<strong>{{ progress.sectionTitle }}</strong>
<span class="survey-section-counter">{{ 'survey.survey.section_progress'|trans({'%current%': progress.sectionCurrent, '%total%': progress.sectionTotal, '%section%': progress.sectionTitle}) }}</span>
</div>
{% endif %}
<div class="survey-meta"><span class="survey-badge neutral">{{ 'survey.survey.overall_progress'|trans({'%current%': progress.current, '%total%': progress.total}) }}</span></div>
<h3>{{ question.question|striptags|trim }}</h3>
{% if question.description|striptags|trim %}<p>{{ question.description|striptags|trim }}</p>{% endif %}
{% if question.description|survey_has_text %}<div class="survey-rich-text">{{ question.description|survey_rich }}</div>{% endif %}
{{ form_start(surveyForm, {attr: {class: 'survey-form-grid', id: 'survey-answer-form-' ~ question.id}}) }}
<input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}">
@@ -113,4 +129,4 @@
</article>
{% endif %}
{% endif %}
</section>
</section>
@@ -22,7 +22,11 @@
<span class="survey-badge neutral">{{ 'survey.results.questions'|trans({'%count%': questionResults|length}) }}</span>
</div>
<p>{{ (survey.description|striptags|trim) ?: 'survey.results.no_description'|trans }}</p>
{% if survey.description|survey_has_text %}
<div class="survey-rich-text">{{ survey.description|survey_rich }}</div>
{% else %}
<p>{{ 'survey.results.no_description'|trans }}</p>
{% endif %}
{% if downloadUrl or pdfDownloadUrl %}
<div class="survey-button-row">
@@ -37,7 +41,16 @@
</article>
<div class="survey-grid">
{% set previousSectionId = null %}
{% for question in questionResults %}
{% set sectionId = question.section.id|default(0) %}
{% if hasSections|default(false) and sectionId != previousSectionId %}
<header class="survey-results-section-head">
<span class="survey-section-kicker">{{ 'survey.results.section_kicker'|trans }}</span>
<h3>{{ question.section.title|default('') ?: 'survey.results.section_default'|trans }}</h3>
</header>
{% set previousSectionId = sectionId %}
{% endif %}
<article class="survey-card survey-results-card">
<div class="survey-meta">
<h3>{{ question.question|striptags|trim }}</h3>
@@ -47,8 +60,8 @@
{% endif %}
</div>
{% if question.description|striptags|trim %}
<p>{{ question.description|striptags|trim }}</p>
{% if question.description|survey_has_text %}
<div class="survey-rich-text">{{ question.description|survey_rich }}</div>
{% endif %}
<div class="survey-results-layout{% if not question.chart %} no-chart{% endif %}">
@@ -132,7 +145,7 @@
{% endif %}
</section>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.5.1/dist/chart.umd.min.js"></script>
<script src="{{ asset('bundles/survey/js/chart.umd.min.js') }}"></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
if (typeof Chart === 'undefined') {
File diff suppressed because one or more lines are too long
+69 -1
View File
@@ -42,4 +42,72 @@ body.survey-question-list-page.survey-question-list-clipboard-active .parent_vie
.tl_checkbox_wizard.survey-assigned-members-checkbox-wizard {
column-count: 2;
}
}
}
/* --- Fragenliste: Themenbereiche und eingerückte Fragen --- */
.survey-content-record {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.25rem 0.6rem;
min-height: 1.6em;
}
.survey-content-record--indented {
padding-left: 1.75rem;
}
.survey-content-record__position {
min-width: 2em;
color: #7b8794;
font-variant-numeric: tabular-nums;
}
.survey-content-record__badge {
display: inline-block;
padding: 0.05em 0.5em;
border-radius: 999px;
background: #eaf2fa;
color: #0f4f88;
font-size: 0.82em;
line-height: 1.5;
}
.survey-content-record__badge--mandatory {
background: #fdeedd;
color: #9a4b0f;
}
.survey-content-record__badge--inactive {
background: #eeeeee;
color: #777777;
}
.survey-content-record--section {
gap: 0.2rem 0.75rem;
}
.survey-content-record--section strong {
font-size: 1.05em;
}
.survey-content-record__kicker {
font-size: 0.72em;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #ec7c32;
}
.parent_view .tl_content:has(.survey-content-record--section) {
background: #fff6ee;
border-left: 4px solid #ec7c32;
}
[data-color-scheme="dark"] .parent_view .tl_content:has(.survey-content-record--section) {
background: rgba(236, 124, 50, 0.12);
}
[data-color-scheme="dark"] .survey-content-record__badge {
background: rgba(0, 100, 173, 0.25);
color: #bcd9f2;
}
File diff suppressed because it is too large Load Diff
+14
View File
File diff suppressed because one or more lines are too long
+3
View File
File diff suppressed because one or more lines are too long
+13 -23
View File
@@ -1,26 +1,5 @@
(function () {
function ensureSortIconStyles() {
if (document.getElementById('survey-question-sort-icon-theme')) {
return;
}
var style = document.createElement('style');
style.id = 'survey-question-sort-icon-theme';
style.textContent = [
'.survey-sort-icon{width:1.14rem !important;height:1.52rem !important;color:#58a6da !important;background:none !important;transform:translateY(1px) !important;}',
'.survey-sort-icon::before,.survey-sort-icon::after{content:none !important;}',
'.survey-sort-icon svg{display:block !important;width:100% !important;height:100% !important;transform:translateY(5px);}',
'.survey-sort-icon svg path:first-child{transform:translateY(1.4px);transform-box:fill-box;transform-origin:center;}',
'.survey-sort-icon svg path:nth-child(2){transform:translateY(-1.4px);transform-box:fill-box;transform-origin:center;}',
'.survey-sort-icon svg path[stroke]{stroke-width:2px;}'
].join('');
document.head.appendChild(style);
}
function initSurveyQuestionSorting() {
ensureSortIconStyles();
if (typeof Sortable !== 'function') {
return;
}
@@ -48,11 +27,19 @@
};
var updateQuestionIndices = function () {
sortableList.querySelectorAll('[data-survey-question-item]').forEach(function (item, index) {
var position = 0;
sortableList.querySelectorAll('[data-survey-question-item]').forEach(function (item) {
// Themenbereiche verbrauchen keine Positionsnummer.
if (item.hasAttribute('data-survey-section-item')) {
return;
}
var indexNode = item.querySelector('.survey-accordion-index');
position += 1;
if (indexNode) {
indexNode.textContent = (index + 1) + '.';
indexNode.textContent = position + '.';
}
});
};
@@ -63,6 +50,9 @@
orderForm.hidden = !isDirty;
orderForm.classList.toggle('is-visible', isDirty);
// Die "Frage hinzufügen"-Zeilen beziehen sich auf die gespeicherte
// Reihenfolge; nach dem Verschieben bis zum Speichern ausblenden.
sortableList.classList.toggle('is-order-dirty', isDirty);
updateOrderField();
};
+238
View File
@@ -0,0 +1,238 @@
document.addEventListener('DOMContentLoaded', function () {
var surveyEditor = document.querySelector('[data-survey-handorgel]');
var resetEditorViewport = function () {
if (!surveyEditor) {
return;
}
window.scrollTo(0, 0);
};
if (surveyEditor && window.history && 'scrollRestoration' in window.history) {
window.history.scrollRestoration = 'manual';
}
resetEditorViewport();
window.addEventListener('load', function () {
window.setTimeout(resetEditorViewport, 0);
}, { once: true });
document.querySelectorAll('[data-survey-range]').forEach(function (input) {
var targetId = input.getAttribute('data-survey-range-target');
var output = targetId ? document.getElementById(targetId) : null;
if (!output) {
return;
}
var update = function () {
output.textContent = input.value;
};
input.addEventListener('input', update);
update();
});
document.querySelectorAll('[data-survey-question-type]').forEach(function (select) {
var wrapper = select.closest('[data-survey-question-editor]');
if (!wrapper) {
return;
}
var toggle = function () {
var showRange = select.value === 'range';
var showYesNoMaybe = select.value === 'yes_no_maybe';
var showChoice = select.value === 'choice';
var isSection = select.value === 'section';
wrapper.querySelectorAll('.question-only-field').forEach(function (element) {
element.style.display = isSection ? 'none' : '';
});
wrapper.querySelectorAll('.section-only-field').forEach(function (element) {
element.style.display = isSection ? '' : 'none';
});
wrapper.querySelectorAll('.range-config-field').forEach(function (element) {
element.style.display = showRange ? '' : 'none';
});
wrapper.querySelectorAll('.yes-no-config-field').forEach(function (element) {
element.style.display = showYesNoMaybe ? '' : 'none';
});
wrapper.querySelectorAll('.choice-config-field').forEach(function (element) {
element.style.display = showChoice ? '' : 'none';
});
};
select.addEventListener('change', toggle);
toggle();
});
document.querySelectorAll('[data-copy-text]').forEach(function (button) {
var originalLabel = button.getAttribute('data-copy-label') || button.textContent;
var successLabel = button.getAttribute('data-copy-success') || originalLabel;
var copyText = function (value) {
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(value);
}
return new Promise(function (resolve, reject) {
var helper = document.createElement('textarea');
helper.value = value;
helper.setAttribute('readonly', 'readonly');
helper.style.position = 'absolute';
helper.style.left = '-9999px';
document.body.appendChild(helper);
helper.select();
try {
document.execCommand('copy');
document.body.removeChild(helper);
resolve();
} catch (error) {
document.body.removeChild(helper);
reject(error);
}
});
};
button.addEventListener('click', function () {
var text = button.getAttribute('data-copy-text') || '';
if (!text) {
return;
}
copyText(text).then(function () {
button.textContent = successLabel;
window.setTimeout(function () {
button.textContent = originalLabel;
}, 1800);
}).catch(function () {
button.textContent = originalLabel;
});
});
});
var surveyDirtyChecks = [];
var surveyIsSubmitting = false;
document.querySelectorAll('[data-survey-dirty-form]').forEach(function (form) {
var formId = form.getAttribute('id') || '';
var hint = form.querySelector('[data-survey-dirty-hint]');
if (!hint && formId) {
hint = document.querySelector('[data-survey-dirty-hint-for="' + formId + '"]');
}
var serializeForm = function () {
var entries = [];
var formData = new FormData(form);
formData.forEach(function (value, key) {
entries.push(key + '=' + String(value));
});
return entries.join('&');
};
var initialState = serializeForm();
var isDirty = function () {
return serializeForm() !== initialState;
};
surveyDirtyChecks.push(isDirty);
var updateDirtyState = function () {
if (hint) {
hint.hidden = !isDirty();
}
};
if (hint) {
hint.hidden = true;
}
form.addEventListener('input', updateDirtyState);
form.addEventListener('change', updateDirtyState);
form.addEventListener('reset', function () {
window.setTimeout(function () {
if (hint) {
hint.hidden = true;
}
}, 0);
});
});
if (surveyDirtyChecks.length > 0) {
// Ein bewusstes Absenden (Speichern oder eine Aktion) darf nicht warnen.
document.addEventListener('submit', function () {
surveyIsSubmitting = true;
}, true);
window.addEventListener('beforeunload', function (event) {
if (surveyIsSubmitting) {
return undefined;
}
var hasUnsavedChanges = surveyDirtyChecks.some(function (isDirty) {
return isDirty();
});
if (!hasUnsavedChanges) {
return undefined;
}
// Moderne Browser zeigen einen generischen Hinweis; der Text wird
// ignoriert, muss aber gesetzt werden, damit der Dialog erscheint.
event.preventDefault();
event.returnValue = '';
return '';
});
}
if (typeof handorgel === 'function') {
document.querySelectorAll('[data-survey-handorgel]').forEach(function (element) {
var accordion = new handorgel(element, {
collapsible: true,
multiSelectable: false,
initialOpenTransition: true,
initialOpenTransitionDelay: 0
});
if (accordion && Array.isArray(accordion.folds)) {
accordion.folds.forEach(function (fold) {
if (!fold) {
return;
}
var article = fold.header ? fold.header.closest('article') : null;
var shouldOpen = !!(article && article.hasAttribute('data-survey-open'));
if (shouldOpen && !fold.expanded) {
fold.open(false);
} else if (!shouldOpen && fold.expanded) {
fold.close();
}
});
var openArticle = element.querySelector('article[data-survey-open]');
if (openArticle && typeof openArticle.scrollIntoView === 'function') {
window.setTimeout(function () {
openArticle.scrollIntoView({ block: 'start', behavior: 'smooth' });
}, 50);
}
}
});
}
resetEditorViewport();
});
+97
View File
@@ -0,0 +1,97 @@
/*
* Rich-Text-Editor (Quill 2) für Beschreibungsfelder im Frontend-Editor.
*
* Jedes <textarea data-survey-rich-text> wird durch einen Quill-Editor mit
* reduzierter Werkzeugleiste ersetzt: Fett, Kursiv, Aufzählung, Nummerierung.
* Der HTML-Inhalt (p, br, ul, ol, li, strong, em) wird bei jeder Änderung in das
* versteckte Textarea zurückgeschrieben, damit Formular-Submit und das
* Dirty-Tracking unverändert funktionieren. Serverseitig wird zusätzlich über
* eine Whitelist bereinigt.
*/
(function () {
var FORMATS = ['bold', 'italic', 'list'];
var TOOLBAR = [['bold', 'italic'], [{ list: 'bullet' }, { list: 'ordered' }], ['clean']];
function toSemanticHtml(quill) {
var html = typeof quill.getSemanticHTML === 'function' ? quill.getSemanticHTML() : quill.root.innerHTML;
// Leerer Editor: kein "<p></p>" speichern.
if (/^\s*(<p>(\s|&nbsp;|<br\s*\/?>)*<\/p>\s*)*$/i.test(html)) {
return '';
}
return html;
}
function initRichText(textarea) {
if (textarea._surveyQuill || typeof Quill !== 'function') {
return;
}
var wrapper = document.createElement('div');
wrapper.className = 'survey-rich-editor';
var editorHost = document.createElement('div');
editorHost.className = 'survey-rich-editor__host';
wrapper.appendChild(editorHost);
textarea.parentNode.insertBefore(wrapper, textarea);
textarea.hidden = true;
textarea.setAttribute('aria-hidden', 'true');
textarea.tabIndex = -1;
var quill = new Quill(editorHost, {
theme: 'snow',
formats: FORMATS,
placeholder: textarea.getAttribute('placeholder') || '',
modules: {
toolbar: TOOLBAR,
clipboard: { matchVisual: false }
}
});
var initialHtml = (textarea.value || '').trim();
if (initialHtml !== '') {
// Klartext aus Altdaten in Absätze überführen, damit nichts zusammenläuft.
if (!/<[a-z][\s\S]*>/i.test(initialHtml)) {
initialHtml = initialHtml.split(/\n{2,}/).map(function (paragraph) {
return '<p>' + paragraph.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>') + '</p>';
}).join('');
}
var delta = quill.clipboard.convert({ html: initialHtml });
quill.setContents(delta, 'silent');
}
// Der gespeicherte Zustand soll dem entsprechen, was Quill selbst erzeugt,
// sonst meldet das Dirty-Tracking sofort "ungespeicherte Änderungen".
textarea.value = toSemanticHtml(quill);
quill.on('text-change', function () {
textarea.value = toSemanticHtml(quill);
textarea.dispatchEvent(new Event('input', { bubbles: true }));
textarea.dispatchEvent(new Event('change', { bubbles: true }));
});
var form = textarea.closest('form');
if (form) {
form.addEventListener('submit', function () {
textarea.value = toSemanticHtml(quill);
});
}
textarea._surveyQuill = quill;
}
function initAll() {
document.querySelectorAll('textarea[data-survey-rich-text]').forEach(initRichText);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initAll, { once: true });
} else {
initAll();
}
})();
@@ -21,7 +21,9 @@ use Mummert\SurveyBundle\Repository\SurveyEditorRepository;
use Mummert\SurveyBundle\Repository\SurveyQuestionRepository;
use Mummert\SurveyBundle\Repository\SurveyRepository;
use Mummert\SurveyBundle\Service\SurveyEditorService;
use Mummert\SurveyBundle\Service\SurveyStructureService;
use Mummert\SurveyBundle\Service\SurveySubmissionService;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
@@ -38,7 +40,10 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController
private readonly SurveyEditorRepository $surveyEditorRepository,
private readonly SurveyEditorService $surveyEditorService,
private readonly SurveySubmissionService $surveySubmissionService,
private readonly SurveyStructureService $surveyStructureService,
private readonly FormFactoryInterface $formFactory,
#[Autowire('%kernel.debug%')]
private readonly bool $kernelDebug = false,
) {
}
@@ -105,7 +110,7 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController
$template->set('backUrl', $this->resolveListUrl($model));
$template->set('publicUrl', null);
$template->set('survey', null);
$template->set('questions', []);
$template->set('outline', []);
$template->set('conditions', []);
$template->set('editors', []);
$template->set('submissions', []);
@@ -116,31 +121,35 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController
}
$structureLocked = $this->surveyEditorService->isStructureLocked($survey);
$surveyId = (int) $survey->id;
$questions = $this->surveyQuestionRepository->findAllBySurvey((int) $survey->id);
$activeQuestion = $this->resolveActiveQuestion($request, (int) $survey->id);
$content = $this->surveyQuestionRepository->findAllBySurvey($surveyId);
$outline = $this->surveyStructureService->buildOutline($content);
$questions = array_values(array_filter($content, static fn ($item): bool => !SurveyQuestionRepository::isSection($item)));
$activeQuestion = $this->resolveActiveQuestion($request, $surveyId);
$activeQuestionId = $activeQuestion ? (int) $activeQuestion->id : 0;
$questionFormViews = [];
$newQuestionFormView = null;
$newQuestionContext = $this->resolveNewQuestionContext($request, $outline);
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),
foreach ($content as $item) {
$itemId = (int) $item->id;
$questionForm = $this->formFactory->createNamed('survey_question_'.$itemId, SurveyQuestionEditorType::class, SurveyQuestionData::fromModel($item), [
'question_choices' => $this->buildQuestionChoices($questions, $itemId),
]);
$questionForm->handleRequest($request);
if ($questionForm->isSubmitted()) {
$activeQuestionId = $questionId;
$activeQuestionId = $itemId;
$createQuestionMode = false;
if ($questionForm->isValid()) {
try {
$this->surveyEditorService->saveQuestion($survey, $questionForm->getData(), $questionId);
$this->addFlash('success', 'Die Frage wurde aktualisiert.');
$this->surveyEditorService->saveQuestion($survey, $questionForm->getData(), $itemId);
$this->addFlash('success', SurveyQuestionRepository::isSection($item) ? 'Der Themenbereich wurde aktualisiert.' : 'Die Frage wurde aktualisiert.');
return new RedirectResponse($this->buildEditUrl($model, $request, (int) $survey->id));
return new RedirectResponse($this->buildEditUrl($model, $request, $surveyId));
} catch (\Throwable $exception) {
$this->addFlash('error', $exception->getMessage());
}
@@ -149,11 +158,15 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController
}
}
$questionFormViews[$questionId] = $questionForm->createView();
$questionFormViews[$itemId] = $questionForm->createView();
}
$newQuestionForm = $this->formFactory->createNamed('survey_question_new', SurveyQuestionEditorType::class, new SurveyQuestionData(), [
'question_choices' => $this->buildQuestionChoices((int) $survey->id),
$newQuestionData = new SurveyQuestionData();
$newQuestionData->type = $newQuestionContext['type'];
$newQuestionData->insertAfter = $newQuestionContext['insertAfter'];
$newQuestionForm = $this->formFactory->createNamed('survey_question_new', SurveyQuestionEditorType::class, $newQuestionData, [
'question_choices' => $this->buildQuestionChoices($questions),
]);
$newQuestionForm->handleRequest($request);
@@ -163,10 +176,13 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController
if ($newQuestionForm->isValid()) {
try {
$this->surveyEditorService->saveQuestion($survey, $newQuestionForm->getData(), 0);
$this->addFlash('success', 'Die Frage wurde angelegt.');
/** @var SurveyQuestionData $data */
$data = $newQuestionForm->getData();
$insertAfterId = (int) ($data->insertAfter ?? 0);
$this->surveyEditorService->saveQuestion($survey, $data, 0, $insertAfterId > 0 ? $insertAfterId : null);
$this->addFlash('success', SurveyQuestionRepository::TYPE_SECTION === $data->type ? 'Der Themenbereich wurde angelegt.' : 'Die Frage wurde angelegt.');
return new RedirectResponse($this->buildEditUrl($model, $request, (int) $survey->id));
return new RedirectResponse($this->buildEditUrl($model, $request, $surveyId));
} catch (\Throwable $exception) {
$this->addFlash('error', $exception->getMessage());
}
@@ -183,21 +199,24 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController
$template->set('createMode', false);
$template->set('structureLocked', $structureLocked);
$template->set('createQuestionMode', $createQuestionMode);
$template->set('newQuestionContext', $newQuestionContext);
$template->set('survey', $survey);
$template->set('surveyForm', $surveyForm->createView());
$template->set('questionForms', $questionFormViews);
$template->set('newQuestionForm', $newQuestionFormView);
$template->set('questions', $questions);
$template->set('canQuestionReorder', !$structureLocked && count($questions) > 1);
$conditionOverview = $this->surveyConditionRepository->findOverviewBySurvey((int) $survey->id);
$template->set('outline', $outline);
$template->set('hasSections', count($outline) !== count($questions));
$template->set('canQuestionReorder', !$structureLocked && count($content) > 1);
$conditionOverview = $this->surveyConditionRepository->findOverviewBySurvey($surveyId);
$template->set('conditions', $this->buildJumpRuleOverview($questions, $conditionOverview));
$template->set('editors', $this->surveyEditorRepository->findEditorsBySurvey((int) $survey->id));
$template->set('editors', $this->surveyEditorRepository->findEditorsBySurvey($surveyId));
$template->set('submissions', $this->surveySubmissionService->getSubmissionOverview($survey));
$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));
$template->set('metadataFormActionUrl', $this->buildEditUrl($model, $request, $surveyId));
$template->set('editBaseUrl', $this->buildEditUrl($model, $request, $surveyId));
return $template->getResponse();
}
@@ -253,15 +272,22 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController
return $memberIds[0] ?? 0;
}
/**
* Debug-Zugang ohne Login: ausschließlich im Debug-Modus des Kernels (ddev/lokal)
* und nur auf lokalen Hosts. In Produktion (kernel.debug = false) ist er tot
* unabhängig vom Host-Header.
*/
private function isDebugAccessAllowed(Request $request): bool
{
$host = strtolower((string) ($request->server->get('HTTP_HOST') ?: $request->getHost()));
if (!$this->kernelDebug || !$request->query->getBoolean('debugAccess')) {
return false;
}
return (
'localhost' === $host
$host = strtolower($request->getHost());
return 'localhost' === $host
|| '127.0.0.1' === $host
|| str_ends_with($host, '.ddev.site')
) && $request->query->getBoolean('debugAccess');
|| str_ends_with($host, '.ddev.site');
}
private function handleAction(SurveyModel $survey, Request $request, ModuleModel $model, int $memberId): Response
@@ -276,8 +302,9 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController
try {
if ('delete-question' === $action) {
$item = $this->surveyQuestionRepository->findByIdForSurvey((int) $survey->id, $itemId);
$this->surveyEditorService->deleteQuestion($survey, $itemId);
$this->addFlash('success', 'Die Frage wurde gelöscht.');
$this->addFlash('success', SurveyQuestionRepository::isSection($item) ? 'Der Themenbereich wurde gelöscht. Die zugehörigen Fragen bleiben erhalten.' : 'Die Frage wurde gelöscht.');
}
if ('reorder-questions' === $action) {
@@ -325,6 +352,47 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController
return 'new' === trim((string) $request->query->get('question', ''));
}
/**
* Kontext für das "Neu anlegen"-Panel: gewünschter Typ (Frage/Themenbereich)
* und die Position ("hinter Inhalt X" = am Ende eines Themenbereichs).
*
* @param list<array<string, mixed>> $outline
*
* @return array{type:string,insertAfter:int|null,sectionTitle:string|null}
*/
private function resolveNewQuestionContext(Request $request, array $outline): array
{
$type = trim((string) $request->query->get('newType', ''));
$insertAfter = (int) $request->query->get('after', 0);
$sectionTitle = null;
if (SurveyQuestionRepository::TYPE_SECTION !== $type) {
$type = 'yes_no_maybe';
}
if ($insertAfter > 0) {
$found = false;
foreach ($outline as $entry) {
if ($entry['id'] === $insertAfter) {
$found = true;
$sectionTitle = $entry['sectionTitle'];
break;
}
}
if (!$found) {
$insertAfter = 0;
}
}
return [
'type' => $type,
'insertAfter' => $insertAfter > 0 ? $insertAfter : null,
'sectionTitle' => $sectionTitle,
];
}
private function isEditorEntryAllowed(Request $request): bool
{
if ((int) $request->query->get('survey', 0) > 0) {
@@ -335,14 +403,18 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController
}
/**
* Sprungziel-Auswahl: nur Fragen (keine Themenbereiche), mit Positionsnummern.
*
* @param list<\Mummert\SurveyBundle\Model\SurveyContentModel> $questions
*
* @return array<int, string>
*/
private function buildQuestionChoices(int $surveyId, int $excludeQuestionId = 0): array
private function buildQuestionChoices(array $questions, int $excludeQuestionId = 0): array
{
$choices = [];
$position = 1;
foreach ($this->surveyQuestionRepository->findAllBySurvey($surveyId) as $question) {
foreach ($questions as $question) {
if ($excludeQuestionId > 0 && (int) $question->id === $excludeQuestionId) {
++$position;
continue;
@@ -507,4 +579,4 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController
return $page instanceof PageModel ? $page : null;
}
}
}
@@ -127,6 +127,7 @@ final class ShowSurveyController extends AbstractFrontendModuleController
$template->set('survey', $survey);
$template->set('progress', $this->surveyFlowService->getProgress($survey, $question));
$template->set('question', $question);
$template->set('isFirstQuestion', $this->surveyFlowService->isFirstQuestion($survey, $question));
$template->set('jumpHints', $this->buildJumpHints($survey, $question));
$template->set('surveyForm', $form->createView());
$template->set('isComplete', false);
@@ -224,6 +225,7 @@ final class ShowSurveyController extends AbstractFrontendModuleController
$template->set('survey', $survey);
$template->set('progress', $this->surveyFlowService->getProgress($survey, $question));
$template->set('question', $question);
$template->set('isFirstQuestion', $this->surveyFlowService->isFirstQuestion($survey, $question));
$template->set('jumpHints', $this->buildJumpHints($survey, $question));
$template->set('surveyForm', $form->createView());
$template->set('isComplete', false);
@@ -62,6 +62,7 @@ final class ShowSurveyResultsController extends AbstractFrontendModuleController
$template->set('downloadUrl', $this->buildDownloadUrl((string) $survey->alias));
$template->set('pdfDownloadUrl', $this->hasGotenbergConfiguration() ? $this->buildPdfDownloadUrl((string) $survey->alias) : null);
$template->set('questionResults', $resultsData['questionResults']);
$template->set('hasSections', $resultsData['hasSections'] ?? false);
return $template->getResponse();
}
@@ -56,6 +56,7 @@ final class SurveyResultsExportController
$survey,
$resultsData['completedSubmissionCount'],
$resultsData['questionResults'],
$resultsData['hasSections'] ?? false,
);
}
}
+129 -1
View File
@@ -21,6 +21,8 @@ use Mummert\SurveyBundle\Repository\SurveyQuestionRepository;
use Mummert\SurveyBundle\Repository\SurveyRepository;
use Mummert\SurveyBundle\Service\SurveyEditorService;
use Mummert\SurveyBundle\Service\SurveyCategoryAssignmentService;
use Mummert\SurveyBundle\Service\SurveyHtmlSanitizer;
use Mummert\SurveyBundle\Service\SurveyStructureService;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
@@ -29,6 +31,9 @@ final class SurveyDcaListener
private ?SurveyEditorService $surveyEditorService;
private ?RequestStack $requestStack;
/** @var array<int, array<int, array<string, mixed>>> Gliederung je Umfrage (Cache pro Request) */
private array $outlineCache = [];
public function __construct(
private readonly SurveyRepository $surveyRepository,
private readonly SurveyQuestionRepository $surveyQuestionRepository,
@@ -37,6 +42,8 @@ final class SurveyDcaListener
private readonly ContaoFramework $framework,
private readonly Connection $connection,
private readonly ContentUrlGenerator $contentUrlGenerator,
private readonly SurveyHtmlSanitizer $htmlSanitizer,
private readonly SurveyStructureService $surveyStructureService,
?SurveyEditorService $surveyEditorService = null,
?RequestStack $requestStack = null,
) {
@@ -197,7 +204,7 @@ final class SurveyDcaListener
$choices = [];
$position = 1;
foreach ($this->surveyQuestionRepository->findAllBySurvey($surveyId) as $question) {
foreach ($this->surveyQuestionRepository->findQuestionsBySurvey($surveyId) as $question) {
$choices[(int) $question->id] = sprintf('%d. %s', $position++, (string) $question->question);
}
@@ -428,10 +435,131 @@ final class SurveyDcaListener
'text' => (string) ($GLOBALS['TL_DCA']['tl_survey_content']['palettes']['text'] ?? $palette),
'choice' => (string) ($GLOBALS['TL_DCA']['tl_survey_content']['palettes']['choice'] ?? $palette),
'yes_no_maybe' => (string) ($GLOBALS['TL_DCA']['tl_survey_content']['palettes']['yes_no_maybe'] ?? $palette),
SurveyQuestionRepository::TYPE_SECTION => (string) ($GLOBALS['TL_DCA']['tl_survey_content']['palettes']['section'] ?? $palette),
default => $palette,
};
}
/**
* Rich-Text-Felder (Umfrage-Einleitung, Fragen-Beschreibung) im Backend über
* dieselbe Whitelist bereinigen wie im Frontend.
*/
public function sanitizeRichText(mixed $value): string
{
return $this->htmlSanitizer->sanitize(\is_string($value) ? $value : '');
}
public function validateRangeMax(mixed $value, DataContainer $dataContainer): mixed
{
$rangeMin = (int) (Input::post('rangeMin') ?? $dataContainer->activeRecord?->rangeMin ?? 0);
if ((int) $value < $rangeMin) {
throw new \RuntimeException($GLOBALS['TL_LANG']['tl_survey_content']['rangeMaxBelowMin'] ?? 'Der größte Wert muss mindestens so groß sein wie der kleinste Wert.');
}
return $value;
}
public function validateRangeStep(mixed $value, DataContainer $dataContainer): mixed
{
if ((int) $value < 1) {
throw new \RuntimeException($GLOBALS['TL_LANG']['tl_survey_content']['rangeStepInvalid'] ?? 'Die Schrittweite muss mindestens 1 sein.');
}
return $value;
}
/**
* oncreate_callback tl_survey_content: Der "Neu"-Button im Listenkopf legt in
* Contao oben an (mode=2 = "in Eltern einfügen"). Fragen und Themenbereiche
* sollen jedoch immer am Ende der Liste entstehen.
*
* @param array<string, mixed> $set
*/
public function placeNewContentAtEnd(string $table, int $insertId, array $set, DataContainer $dataContainer): void
{
if ('tl_survey_content' !== $table || '2' !== (string) Input::get('mode')) {
return;
}
$surveyId = (int) ($set['pid'] ?? 0);
if ($surveyId <= 0 || $insertId <= 0) {
return;
}
$maxSorting = (int) $this->connection->fetchOne(
'SELECT COALESCE(MAX(sorting), 0) FROM tl_survey_content WHERE pid = ? AND id <> ?',
[$surveyId, $insertId],
);
$this->connection->update('tl_survey_content', ['sorting' => $maxSorting + 128], ['id' => $insertId]);
unset($this->outlineCache[$surveyId]);
}
/**
* label_callback tl_survey_content (Parent-Ansicht): Themenbereiche als
* abgesetzte Gliederungszeile, Fragen darunter eingerückt mit Positionsnummer.
*
* @param array<string, mixed> $row
*/
public function renderSurveyContentLabel(array $row, string $label, DataContainer $dataContainer, array $args = []): string
{
$surveyId = (int) ($row['pid'] ?? 0);
$entry = $this->resolveOutlineEntry($surveyId, (int) ($row['id'] ?? 0));
$title = htmlspecialchars(trim((string) ($row['question'] ?? '')), ENT_QUOTES);
if (SurveyQuestionRepository::isSection($row)) {
return sprintf(
'<div class="survey-content-record survey-content-record--section"><span class="survey-content-record__kicker">%s</span><strong>%s</strong></div>',
htmlspecialchars((string) ($GLOBALS['TL_LANG']['tl_survey_content']['types']['section'] ?? 'Themenbereich'), ENT_QUOTES),
'' !== $title ? $title : '&nbsp;',
);
}
$typeLabel = htmlspecialchars((string) ($GLOBALS['TL_LANG']['tl_survey_content']['types'][$row['type'] ?? ''] ?? ($row['type'] ?? '')), ENT_QUOTES);
$badges = sprintf('<span class="survey-content-record__badge">%s</span>', $typeLabel);
if ('1' === (string) ($row['mandatory'] ?? '')) {
$badges .= sprintf('<span class="survey-content-record__badge survey-content-record__badge--mandatory">%s</span>', htmlspecialchars((string) ($GLOBALS['TL_LANG']['tl_survey_content']['mandatoryBadge'] ?? 'Pflicht'), ENT_QUOTES));
}
if ('1' !== (string) ($row['published'] ?? '')) {
$badges .= sprintf('<span class="survey-content-record__badge survey-content-record__badge--inactive">%s</span>', htmlspecialchars((string) ($GLOBALS['TL_LANG']['tl_survey_content']['inactiveBadge'] ?? 'inaktiv'), ENT_QUOTES));
}
$position = null !== $entry && null !== ($entry['position'] ?? null) ? (int) $entry['position'].'.' : '';
$indented = null !== $entry && (int) ($entry['sectionId'] ?? 0) > 0;
return sprintf(
'<div class="survey-content-record survey-content-record--question%s"><span class="survey-content-record__position">%s</span><strong>%s</strong>%s</div>',
$indented ? ' survey-content-record--indented' : '',
$position,
'' !== $title ? $title : '&nbsp;',
$badges,
);
}
/**
* @return array<string, mixed>|null
*/
private function resolveOutlineEntry(int $surveyId, int $contentId): ?array
{
if ($surveyId <= 0) {
return null;
}
if (!isset($this->outlineCache[$surveyId])) {
$this->outlineCache[$surveyId] = [];
foreach ($this->surveyStructureService->buildOutline($this->surveyQuestionRepository->findAllBySurvey($surveyId)) as $entry) {
$this->outlineCache[$surveyId][$entry['id']] = $entry;
}
}
return $this->outlineCache[$surveyId][$contentId] ?? null;
}
private function resolveSurveyId(string $table, ?DataContainer $dataContainer = null): int
{
if ($dataContainer?->activeRecord) {
+3 -3
View File
@@ -15,9 +15,9 @@ final class SurveyEditorData
{
$data = new self();
$data->title = (string) $survey->title;
// Beschreibung ist ein Klartextfeld; evtl. gespeicherte HTML-Tags (z. B. <p>)
// entfernen, damit das Textarea reinen Text zeigt.
$data->description = trim(strip_tags((string) $survey->description));
// Rich-Text (Absätze, Listen, fett, kursiv) wird beim Speichern serverseitig
// über die Whitelist des SurveyHtmlSanitizer bereinigt.
$data->description = trim((string) $survey->description);
return $data;
}
+7 -1
View File
@@ -31,6 +31,12 @@ final class SurveyQuestionData
public int $rangeMax = 10;
public int $rangeStep = 1;
/**
* Nur beim Anlegen: ID des Inhalts, hinter dem die neue Frage eingefügt werden
* soll ("Frage hinzufügen" am Ende eines Themenbereichs). 0/null = am Ende.
*/
public ?int $insertAfter = null;
public static function fromModel(SurveyContentModel $question): self
{
$data = new self();
@@ -83,4 +89,4 @@ final class SurveyQuestionData
return $payload;
}
}
}
+1 -1
View File
@@ -26,7 +26,7 @@ final class SurveyEditorType extends AbstractType
'label' => 'Beschreibung',
'required' => false,
'empty_data' => '',
'attr' => ['rows' => 5],
'attr' => ['rows' => 5, 'data-survey-rich-text' => '1'],
])
;
}
+53 -72
View File
@@ -6,9 +6,11 @@ namespace Mummert\SurveyBundle\Form;
use Mummert\SurveyBundle\Form\Model\SurveyQuestionData;
use Mummert\SurveyBundle\QuestionType\QuestionTypeRegistry;
use Mummert\SurveyBundle\Repository\SurveyQuestionRepository;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
@@ -30,7 +32,7 @@ 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->addEventListener(FormEvents::POST_SUBMIT, $this->validateTypeSpecificFields(...));
$builder
->add('type', ChoiceType::class, [
@@ -50,7 +52,11 @@ final class SurveyQuestionEditorType extends AbstractType
'label' => 'Beschreibung',
'required' => false,
'empty_data' => '',
'attr' => ['rows' => 4],
'attr' => ['rows' => 4, 'data-survey-rich-text' => '1'],
])
->add('insertAfter', HiddenType::class, [
'required' => false,
'empty_data' => '',
])
->add('mandatory', CheckboxType::class, [
'label' => 'Pflichtfrage',
@@ -86,83 +92,36 @@ final class SurveyQuestionEditorType extends AbstractType
'required' => false,
'attr' => ['data-question-editor-target' => 'choiceField'],
])
->add('answerOption1', TextType::class, [
'label' => 'Antwort 1',
;
for ($index = 1; $index <= 10; ++$index) {
$builder->add('answerOption'.$index, TextType::class, [
'label' => 'Antwort '.$index,
'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'],
])
]);
}
$builder
->add('rangeMin', IntegerType::class, [
'label' => 'Kleinster Wert',
'empty_data' => '0',
'constraints' => [new GreaterThanOrEqual(['value' => -100000])],
'attr' => ['data-question-editor-target' => 'rangeField'],
// Backend (rgxp "digit") erlaubt keine negativen Werte im Frontend deshalb auch nicht.
'constraints' => [new GreaterThanOrEqual(['value' => 0])],
'attr' => ['data-question-editor-target' => 'rangeField', 'min' => 0],
])
->add('rangeMax', IntegerType::class, [
'label' => 'Größter Wert',
'empty_data' => '10',
'constraints' => [new GreaterThanOrEqual(['value' => -100000])],
'attr' => ['data-question-editor-target' => 'rangeField'],
'constraints' => [new GreaterThanOrEqual(['value' => 0])],
'attr' => ['data-question-editor-target' => 'rangeField', 'min' => 0],
])
->add('rangeStep', IntegerType::class, [
'label' => 'Schrittweite',
'empty_data' => '1',
'constraints' => [new GreaterThanOrEqual(['value' => 1])],
'attr' => ['data-question-editor-target' => 'rangeField'],
'attr' => ['data-question-editor-target' => 'rangeField', 'min' => 1],
])
;
}
@@ -175,7 +134,7 @@ final class SurveyQuestionEditorType extends AbstractType
return;
}
$stringFields = ['description'];
$stringFields = ['description', 'insertAfter'];
for ($index = 1; $index <= 10; ++$index) {
$stringFields[] = 'answerOption'.$index;
@@ -196,21 +155,43 @@ final class SurveyQuestionEditorType extends AbstractType
$event->setData($data);
}
private function validateChoiceOptions(FormEvent $event): void
private function validateTypeSpecificFields(FormEvent $event): void
{
$data = $event->getData();
if (!$data instanceof SurveyQuestionData || 'choice' !== $data->type) {
if (!$data instanceof SurveyQuestionData) {
return;
}
for ($index = 1; $index <= 10; ++$index) {
if ('' !== trim((string) ($data->{'answerOption'.$index} ?? ''))) {
return;
if ('choice' === $data->type) {
$hasOption = false;
for ($index = 1; $index <= 10; ++$index) {
if ('' !== trim((string) ($data->{'answerOption'.$index} ?? ''))) {
$hasOption = true;
break;
}
}
if (!$hasOption) {
$event->getForm()->get('answerOption1')->addError(new FormError('Mindestens eine Antwortoption ist erforderlich.'));
}
}
$event->getForm()->get('answerOption1')->addError(new FormError('Mindestens eine Antwortoption ist erforderlich.'));
if ('range' === $data->type) {
if ($data->rangeMax < $data->rangeMin) {
$event->getForm()->get('rangeMax')->addError(new FormError('Der größte Wert muss mindestens so groß sein wie der kleinste Wert.'));
}
if ($data->rangeMax > $data->rangeMin && ($data->rangeMax - $data->rangeMin) < $data->rangeStep) {
$event->getForm()->get('rangeStep')->addError(new FormError('Die Schrittweite ist größer als der Wertebereich.'));
}
}
if (SurveyQuestionRepository::TYPE_SECTION === $data->type) {
$data->mandatory = false;
$data->published = true;
}
}
public function configureOptions(OptionsResolver $resolver): void
@@ -220,4 +201,4 @@ final class SurveyQuestionEditorType extends AbstractType
'question_choices' => [],
]);
}
}
}
@@ -41,6 +41,7 @@ final class QuestionTypeRegistry
'Offene Frage' => 'text',
'Bewertungsfrage' => 'range',
'Single/Multiple-Choice' => 'choice',
'Themenbereich (Abschnitt)' => 'section',
];
}
}
@@ -22,6 +22,7 @@ final class SurveyAnswerRepository
string $questionDescription,
int $questionSorting,
string $value,
string $questionSection = '',
): void
{
$existingId = $this->connection->fetchOne(
@@ -35,6 +36,7 @@ final class SurveyAnswerRepository
'questionLabel' => $questionLabel,
'questionDescription' => $questionDescription,
'questionSorting' => $questionSorting,
'questionSection' => $questionSection,
'value' => $value,
], [
'id' => (int) $existingId,
@@ -51,6 +53,7 @@ final class SurveyAnswerRepository
'questionLabel' => $questionLabel,
'questionDescription' => $questionDescription,
'questionSorting' => $questionSorting,
'questionSection' => $questionSection,
'value' => $value,
]);
}
@@ -110,6 +113,7 @@ final class SurveyAnswerRepository
a.questionLabel,
a.questionDescription,
a.questionSorting,
a.questionSection,
a.value
FROM tl_survey_answer a
INNER JOIN tl_survey_submission sub ON sub.id = a.submission
@@ -126,6 +130,7 @@ final class SurveyAnswerRepository
'questionLabel' => (string) ($row['questionLabel'] ?? ''),
'questionDescription' => (string) ($row['questionDescription'] ?? ''),
'questionSorting' => (int) ($row['questionSorting'] ?? 0),
'questionSection' => (string) ($row['questionSection'] ?? ''),
'value' => (string) $row['value'],
],
$rows,
+185 -78
View File
@@ -7,15 +7,41 @@ namespace Mummert\SurveyBundle\Repository;
use Contao\CoreBundle\Framework\ContaoFramework;
use Doctrine\DBAL\Connection;
use Mummert\SurveyBundle\Model\SurveyContentModel;
use Mummert\SurveyBundle\Service\SurveyHtmlSanitizer;
/**
* Zugriff auf tl_survey_content. Die Tabelle enthält zwei Arten von Zeilen:
*
* - Fragen (yes_no_maybe, text, range, choice)
* - Themenbereiche (type = "section"): reine Gliederungszeilen ohne Antwortfeld.
* Eine Frage gehört zu dem Themenbereich, der in der Sortierung zuletzt vor
* ihr steht. Fragen vor dem ersten Themenbereich haben keinen Bereich.
*
* "Content" = alle Zeilen inkl. Themenbereiche (Editor, Backend, Gliederung),
* "Questions" = nur Fragen (Ablauf, Sprungziele, Auswertung, Zählung).
*/
final class SurveyQuestionRepository
{
public const TYPE_SECTION = 'section';
public function __construct(
private readonly Connection $connection,
private readonly ContaoFramework $framework,
private readonly SurveyHtmlSanitizer $htmlSanitizer,
) {
}
public static function isSection(SurveyContentModel|array|null $content): bool
{
if (null === $content) {
return false;
}
$type = \is_array($content) ? ($content['type'] ?? '') : $content->type;
return self::TYPE_SECTION === (string) $type;
}
public function findById(int $id): ?SurveyContentModel
{
$adapter = $this->framework->getAdapter(SurveyContentModel::class);
@@ -39,92 +65,108 @@ final class SurveyQuestionRepository
}
/**
* Alle Inhalte (Fragen UND Themenbereiche) in Sortierreihenfolge.
* Themenbereiche werden auch bei $publishedOnly immer mitgeliefert.
*
* @return list<SurveyContentModel>
*/
public function findAllBySurvey(int $surveyId, bool $publishedOnly = false): array
{
$sql = 'SELECT id FROM tl_survey_content WHERE pid = ?';
// Zeilen ohne Typ sind im Backend begonnene, nie gespeicherte Entwürfe
// sie nehmen weder am Editor noch am Ablauf oder an der Auswertung teil.
$sql = "SELECT id FROM tl_survey_content WHERE pid = ? AND type <> ''";
if ($publishedOnly) {
$sql .= ' AND (published = 1 OR type = :section)';
}
$sql .= ' ORDER BY sorting ASC, id ASC';
$ids = $this->connection->fetchFirstColumn($sql, array_merge([$surveyId], $publishedOnly ? ['section' => self::TYPE_SECTION] : []));
return $this->hydrate($ids);
}
/**
* Nur Fragen (ohne Themenbereiche) in Sortierreihenfolge.
*
* @return list<SurveyContentModel>
*/
public function findQuestionsBySurvey(int $surveyId, bool $publishedOnly = false): array
{
$sql = "SELECT id FROM tl_survey_content WHERE pid = ? AND type <> ? AND type <> ''";
if ($publishedOnly) {
$sql .= ' AND published = 1';
}
$sql .= ' ORDER BY sorting ASC';
$sql .= ' ORDER BY sorting ASC, id ASC';
$ids = $this->connection->fetchFirstColumn($sql, [$surveyId]);
return $this->hydrate($ids);
return $this->hydrate($this->connection->fetchFirstColumn($sql, [$surveyId, self::TYPE_SECTION]));
}
public function create(int $surveyId, array $data): SurveyContentModel
/**
* @return list<SurveyContentModel>
*/
public function findSectionsBySurvey(int $surveyId): array
{
return $this->hydrate($this->connection->fetchFirstColumn(
'SELECT id FROM tl_survey_content WHERE pid = ? AND type = ? ORDER BY sorting ASC, id ASC',
[$surveyId, self::TYPE_SECTION],
));
}
public function hasSections(int $surveyId): bool
{
return false !== $this->connection->fetchOne(
'SELECT id FROM tl_survey_content WHERE pid = ? AND type = ? LIMIT 1',
[$surveyId, self::TYPE_SECTION],
);
}
/**
* Legt eine Frage bzw. einen Themenbereich an. Ohne $insertAfterId wird am
* Ende der Umfrage eingefügt; mit $insertAfterId direkt hinter diesem Element
* (z. B. "Frage hinzufügen" am Ende eines Themenbereichs).
*/
public function create(int $surveyId, array $data, ?int $insertAfterId = null): 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;
$needsRenumbering = false;
$this->connection->insert('tl_survey_content', [
'pid' => $surveyId,
'tstamp' => time(),
'sorting' => $sorting,
'type' => $type,
'question' => (string) ($data['question'] ?? ''),
'description' => (string) ($data['description'] ?? ''),
'mandatory' => !empty($data['mandatory']) ? '1' : '',
'published' => !empty($data['published']) ? '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,
]);
if (null !== $insertAfterId && $insertAfterId > 0) {
$afterSorting = $this->connection->fetchOne('SELECT sorting FROM tl_survey_content WHERE id = ? AND pid = ?', [$insertAfterId, $surveyId]);
if (false !== $afterSorting) {
$sorting = (int) $afterSorting + 1;
$needsRenumbering = true;
}
}
$this->connection->insert('tl_survey_content', array_merge(
[
'pid' => $surveyId,
'tstamp' => time(),
'sorting' => $sorting,
],
$this->normalizeData($data, null),
));
$id = (int) $this->connection->lastInsertId();
if ($needsRenumbering) {
$this->renumber($surveyId);
}
return $this->findById($id) ?? throw new \RuntimeException('Frage konnte nicht angelegt werden.');
}
public function update(SurveyContentModel $question, array $data): void
{
$type = (string) ($data['type'] ?? $question->type);
$isRangeType = 'range' === $type;
$this->connection->update('tl_survey_content', [
'tstamp' => time(),
'type' => $type,
'question' => (string) ($data['question'] ?? $question->question),
'description' => (string) ($data['description'] ?? $question->description),
'mandatory' => !empty($data['mandatory']) ? '1' : '',
'published' => !empty($data['published']) ? '1' : '',
'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,
], [
$this->connection->update('tl_survey_content', array_merge(
['tstamp' => time()],
$this->normalizeData($data, $question),
), [
'id' => (int) $question->id,
]);
}
@@ -159,31 +201,36 @@ final class SurveyQuestionRepository
]);
}
/**
* Anzahl der veröffentlichten Fragen (ohne Themenbereiche).
*/
public function countPublishedBySurvey(int $surveyId): int
{
return (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM tl_survey_content WHERE pid = ? AND published = 1',
[$surveyId],
"SELECT COUNT(*) FROM tl_survey_content WHERE pid = ? AND published = 1 AND type <> ? AND type <> ''",
[$surveyId, self::TYPE_SECTION],
);
}
/**
* @param list<int> $orderedQuestionIds
* Neue Reihenfolge aller Inhalte (Fragen und Themenbereiche) speichern.
*
* @param list<int> $orderedContentIds
*/
public function reorderQuestions(int $surveyId, array $orderedQuestionIds): void
public function reorderQuestions(int $surveyId, array $orderedContentIds): void
{
$orderedQuestionIds = array_values(array_unique(array_filter(
array_map('intval', $orderedQuestionIds),
static fn (int $questionId): bool => $questionId > 0,
$orderedContentIds = array_values(array_unique(array_filter(
array_map('intval', $orderedContentIds),
static fn (int $contentId): bool => $contentId > 0,
)));
$currentQuestionIds = array_map(static fn (SurveyContentModel $question): int => (int) $question->id, $this->findAllBySurvey($surveyId));
$currentContentIds = array_map(static fn (SurveyContentModel $content): int => (int) $content->id, $this->findAllBySurvey($surveyId));
if ($orderedQuestionIds === $currentQuestionIds || count($orderedQuestionIds) < 2) {
if ($orderedContentIds === $currentContentIds || count($orderedContentIds) < 2) {
return;
}
$sortedCurrentIds = $currentQuestionIds;
$sortedOrderedIds = $orderedQuestionIds;
$sortedCurrentIds = $currentContentIds;
$sortedOrderedIds = $orderedContentIds;
sort($sortedCurrentIds);
sort($sortedOrderedIds);
@@ -191,14 +238,14 @@ final class SurveyQuestionRepository
throw new \RuntimeException('Die neue Reihenfolge der Fragen ist ungültig.');
}
$this->connection->transactional(function () use ($surveyId, $orderedQuestionIds): void {
$this->connection->transactional(function () use ($surveyId, $orderedContentIds): void {
$sorting = 128;
foreach ($orderedQuestionIds as $questionId) {
foreach ($orderedContentIds as $contentId) {
$this->connection->update('tl_survey_content', [
'sorting' => $sorting,
], [
'id' => $questionId,
'id' => $contentId,
'pid' => $surveyId,
]);
@@ -207,6 +254,66 @@ final class SurveyQuestionRepository
});
}
/**
* Sortierwerte in 128er-Schritten neu vergeben (nach Einfügen "zwischen" Elementen).
*/
public function renumber(int $surveyId): void
{
$ids = $this->connection->fetchFirstColumn(
'SELECT id FROM tl_survey_content WHERE pid = ? ORDER BY sorting ASC, id ASC',
[$surveyId],
);
$this->connection->transactional(function () use ($ids): void {
$sorting = 128;
foreach ($ids as $id) {
$this->connection->update('tl_survey_content', ['sorting' => $sorting], ['id' => (int) $id]);
$sorting += 128;
}
});
}
/**
* Normalisiert die Felddaten typabhängig: typfremde Felder werden auf
* Defaults gesetzt, Themenbereiche tragen nur Titel und Beschreibung.
*
* @return array<string, mixed>
*/
private function normalizeData(array $data, ?SurveyContentModel $existing): array
{
$type = (string) ($data['type'] ?? $existing?->type ?? 'yes_no_maybe');
$isRangeType = 'range' === $type;
$isSection = self::TYPE_SECTION === $type;
$isYesNo = 'yes_no_maybe' === $type;
$isChoice = 'choice' === $type;
$description = $this->htmlSanitizer->sanitize((string) ($data['description'] ?? $existing?->description ?? ''));
$normalized = [
'type' => $type,
'question' => trim((string) ($data['question'] ?? $existing?->question ?? '')),
'description' => $description,
// Themenbereiche sind nie Pflicht und immer "aktiv" (sie haben kein eigenes Flag).
'mandatory' => !$isSection && !empty($data['mandatory']) ? '1' : '',
'published' => $isSection || !empty($data['published']) ? '1' : '',
'allowMaybe' => $isYesNo && !empty($data['allowMaybe']) ? '1' : '',
'jumpOnYes' => $isYesNo ? max(0, (int) ($data['jumpOnYes'] ?? $existing?->jumpOnYes ?? 0)) : 0,
'jumpOnNo' => $isYesNo ? max(0, (int) ($data['jumpOnNo'] ?? $existing?->jumpOnNo ?? 0)) : 0,
'allowMultiple' => $isChoice && !empty($data['allowMultiple']) ? '1' : '',
'rangeMin' => $isRangeType ? (int) ($data['rangeMin'] ?? $existing?->rangeMin ?? 0) : 0,
'rangeMax' => $isRangeType ? (int) ($data['rangeMax'] ?? $existing?->rangeMax ?? 10) : 10,
'rangeStep' => $isRangeType ? max(1, (int) ($data['rangeStep'] ?? $existing?->rangeStep ?? 1)) : 1,
];
for ($index = 1; $index <= 10; ++$index) {
$field = 'answerOption'.$index;
$normalized[$field] = $isChoice ? trim((string) ($data[$field] ?? $existing?->{$field} ?? '')) : '';
}
return $normalized;
}
private function hydrate(array $ids): array
{
$models = [];
@@ -221,4 +328,4 @@ final class SurveyQuestionRepository
return $models;
}
}
}
+5 -3
View File
@@ -9,6 +9,7 @@ use Contao\StringUtil;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Connection;
use Mummert\SurveyBundle\Model\SurveyModel;
use Mummert\SurveyBundle\Service\SurveyHtmlSanitizer;
final class SurveyRepository
{
@@ -17,6 +18,7 @@ final class SurveyRepository
public function __construct(
private readonly Connection $connection,
private readonly ContaoFramework $framework,
private readonly SurveyHtmlSanitizer $htmlSanitizer,
) {
}
@@ -96,7 +98,7 @@ final class SurveyRepository
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_content q ON q.pid = s.id AND q.type <> 'section'
LEFT JOIN tl_survey_submission sub ON sub.survey = s.id
WHERE s.isTemplate = :isTemplate
GROUP BY s.id
@@ -116,7 +118,7 @@ final class SurveyRepository
'title' => $title,
'alias' => $this->ensurePublicAlias(null),
'category' => serialize($this->normalizeCategoryIds($data['category'] ?? [])),
'description' => (string) ($data['description'] ?? ''),
'description' => $this->htmlSanitizer->sanitize((string) ($data['description'] ?? '')),
'internalNote' => trim((string) ($data['internalNote'] ?? '')),
'published' => '',
'isActive' => '1',
@@ -143,7 +145,7 @@ final class SurveyRepository
'title' => $title,
'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),
'description' => $this->htmlSanitizer->sanitize((string) ($data['description'] ?? $survey->description)),
'internalNote' => trim((string) ($data['internalNote'] ?? $survey->internalNote)),
'published' => array_key_exists('published', $data) ? (!empty($data['published']) ? '1' : '') : (string) $survey->published,
'isActive' => array_key_exists('isActive', $data) ? (!empty($data['isActive']) ? '1' : '') : (string) ($survey->isActive ?? ''),
@@ -80,6 +80,42 @@
color: #555e68;
}
.rich-text p,
.rich-text ul,
.rich-text ol {
margin: 0 0 6px;
}
.rich-text ul,
.rich-text ol {
padding-left: 18px;
}
.section-head {
margin: 18px 0 10px;
padding: 8px 12px;
border-left: 5px solid #ec7c32;
background: #fff6ee;
border-radius: 0 10px 10px 0;
page-break-after: avoid;
break-after: avoid;
}
.section-head .section-kicker {
display: block;
font-size: 8px;
font-weight: bold;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #ec7c32;
}
.section-head h2 {
font-size: 17px;
font-family: "Blogger Sans", sans-serif;
color: #0f4f88;
}
.chart-box {
margin: 12px 0 14px;
padding: 10px;
@@ -205,7 +241,11 @@
<body>
<header class="page-header">
<h1>{{ survey.title|striptags|trim }}</h1>
<p>{{ (survey.description|striptags|trim) ?: 'survey.pdf.no_description'|trans }}</p>
{% if survey.description|survey_has_text %}
<div class="rich-text">{{ survey.description|survey_rich }}</div>
{% else %}
<p>{{ 'survey.pdf.no_description'|trans }}</p>
{% endif %}
<div class="meta-row">
<span class="meta-pill">{{ 'survey.pdf.participations'|trans({'%count%': completedSubmissionCount}) }}</span>
@@ -214,12 +254,21 @@
</div>
</header>
{% set previousSectionId = null %}
{% for question in questionResults %}
{% set sectionId = question.section.id|default(0) %}
{% if hasSections|default(false) and sectionId != previousSectionId %}
<div class="section-head">
<span class="section-kicker">{{ 'survey.pdf.section_kicker'|trans }}</span>
<h2>{{ question.section.title|default('') ?: 'survey.pdf.section_default'|trans }}</h2>
</div>
{% set previousSectionId = sectionId %}
{% endif %}
<section class="question-card">
<h2>{{ question.question|striptags|trim }}</h2>
{% if question.description|striptags|trim %}
<p class="question-description">{{ question.description|striptags|trim }}</p>
{% if question.description|survey_has_text %}
<div class="question-description rich-text">{{ question.description|survey_rich }}</div>
{% endif %}
<div class="meta-row">
+2 -2
View File
@@ -65,7 +65,7 @@ final class SurveyEditorService
return '1' === (string) $survey->published || '1' === (string) $survey->isLocked;
}
public function saveQuestion(SurveyModel $survey, SurveyQuestionData $data, int $questionId = 0): int
public function saveQuestion(SurveyModel $survey, SurveyQuestionData $data, int $questionId = 0, ?int $insertAfterId = null): int
{
$this->assertUnlocked($survey);
@@ -82,7 +82,7 @@ final class SurveyEditorService
return (int) $question->id;
}
$question = $this->surveyQuestionRepository->create((int) $survey->id, $data->toArray());
$question = $this->surveyQuestionRepository->create((int) $survey->id, $data->toArray(), $insertAfterId);
$this->surveyRepository->touch((int) $survey->id);
return (int) $question->id;
+44 -23
View File
@@ -7,8 +7,8 @@ namespace Mummert\SurveyBundle\Service;
use Mummert\SurveyBundle\Model\SurveyContentModel;
use Mummert\SurveyBundle\Model\SurveyModel;
use Mummert\SurveyBundle\Model\SurveySubmissionModel;
use Mummert\SurveyBundle\Repository\SurveyRepository;
use Mummert\SurveyBundle\Repository\SurveyQuestionRepository;
use Mummert\SurveyBundle\Repository\SurveyRepository;
final class SurveyFlowService
{
@@ -16,6 +16,7 @@ final class SurveyFlowService
private readonly SurveyEngine $surveyEngine,
private readonly SurveyRepository $surveyRepository,
private readonly SurveyQuestionRepository $surveyQuestionRepository,
private readonly SurveyStructureService $surveyStructureService,
) {
}
@@ -51,6 +52,12 @@ final class SurveyFlowService
$conditionalTarget = $this->surveyEngine->resolveConditionalTarget($survey, $currentQuestion, $normalizedAnswer);
if ($conditionalTarget instanceof SurveyContentModel) {
// Zeigt eine Bedingung (versehentlich) auf einen Themenbereich, gilt die
// erste Frage dieses Bereichs als Ziel.
if (SurveyQuestionRepository::isSection($conditionalTarget)) {
return $this->findFirstQuestionAfterSorting($questions, (int) $conditionalTarget->sorting);
}
return $conditionalTarget;
}
@@ -70,6 +77,17 @@ final class SurveyFlowService
return $questions[0] ?? null;
}
public function isFirstQuestion(SurveyModel $survey, ?SurveyContentModel $question): bool
{
if (!$question instanceof SurveyContentModel) {
return false;
}
$first = $this->getFirstQuestion($survey);
return $first instanceof SurveyContentModel && (int) $first->id === (int) $question->id;
}
public function resolveQuestionById(SurveyModel $survey, int $questionId): ?SurveyContentModel
{
if ($questionId <= 0) {
@@ -86,30 +104,16 @@ final class SurveyFlowService
}
/**
* @return array{current:int,total:int,percentage:int}
* Fortschritt inkl. Themenbereich-Kontext ("Frage n von m aus Bereich'").
*
* @return array{current:int,total:int,percentage:int,sectionId:int,sectionTitle:string|null,sectionCurrent:int,sectionTotal:int,sectionNumber:int|null,sectionCount:int}
*/
public function getProgress(SurveyModel $survey, ?SurveyContentModel $currentQuestion): array
{
$questions = $this->getOrderedQuestions($survey);
$total = max(1, count($questions));
$current = 1;
$content = $this->surveyQuestionRepository->findAllBySurvey((int) $survey->id);
if ($currentQuestion instanceof SurveyContentModel) {
foreach ($questions as $index => $question) {
if ((int) $question->id === (int) $currentQuestion->id) {
$current = $index + 1;
break;
}
}
} else {
$current = $total;
}
return [
'current' => $current,
'total' => $total,
'percentage' => (int) round(($current / $total) * 100),
];
return $this->surveyStructureService->describeQuestionContext($content, $questions, $currentQuestion);
}
/**
@@ -136,6 +140,9 @@ final class SurveyFlowService
}
/**
* Fragen des Ablaufs (ohne Themenbereiche). Gibt es mindestens eine
* veröffentlichte Frage, laufen nur veröffentlichte Fragen.
*
* @return list<SurveyContentModel>
*/
private function getOrderedQuestions(SurveyModel $survey): array
@@ -143,10 +150,10 @@ final class SurveyFlowService
$surveyId = (int) $survey->id;
if ($this->surveyQuestionRepository->countPublishedBySurvey($surveyId) > 0) {
return $this->surveyQuestionRepository->findAllBySurvey($surveyId, true);
return $this->surveyQuestionRepository->findQuestionsBySurvey($surveyId, true);
}
return $this->surveyQuestionRepository->findAllBySurvey($surveyId);
return $this->surveyQuestionRepository->findQuestionsBySurvey($surveyId);
}
/**
@@ -176,4 +183,18 @@ final class SurveyFlowService
return null;
}
}
/**
* @param list<SurveyContentModel> $questions
*/
private function findFirstQuestionAfterSorting(array $questions, int $sorting): ?SurveyContentModel
{
foreach ($questions as $question) {
if ((int) $question->sorting > $sorting) {
return $question;
}
}
return null;
}
}
+119
View File
@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Service;
use Symfony\Component\HtmlSanitizer\HtmlSanitizer;
use Symfony\Component\HtmlSanitizer\HtmlSanitizerAction;
use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig;
/**
* Serverseitige Whitelist für Rich-Text-Beschreibungen (Umfrage-Einleitung,
* Fragen-Beschreibung): Absätze, Zeilenumbrüche, Listen, Fett, Kursiv.
* Alles andere (Links, Bilder, Schriftarten, Inline-Styles, Skripte) wird entfernt.
*/
final class SurveyHtmlSanitizer
{
private const ALLOWED_ELEMENTS = ['p', 'br', 'ul', 'ol', 'li', 'strong', 'em', 'b', 'i'];
private const DROPPED_ELEMENTS = ['script', 'style', 'head', 'title', 'iframe', 'object', 'embed', 'textarea', 'select', 'template', 'noscript', 'svg', 'math'];
private ?HtmlSanitizer $sanitizer = null;
public function sanitize(?string $html): string
{
$html = trim((string) $html);
if ('' === $html) {
return '';
}
// Klartext ohne Tags (Altdaten aus dem bisherigen Textarea): Absätze aus
// Leerzeilen bilden, einfache Umbrüche als <br> erhalten.
if (!preg_match('#<[a-z/!]#i', $html)) {
$html = $this->convertPlainTextToHtml($html);
}
// Fremde Blockelemente (z. B. aus dem Backend-TinyMCE oder aus Word-Kopien)
// in Absätze überführen, damit der Text nicht zusammenläuft.
$html = (string) preg_replace('#<(div|h[1-6]|blockquote|section|article|header|footer|pre|address)\b[^>]*>#i', '<p>', $html);
$html = (string) preg_replace('#</(div|h[1-6]|blockquote|section|article|header|footer|pre|address)>#i', '</p>', $html);
$clean = $this->getSanitizer()->sanitize($html);
// Leere Hüllen (z. B. "<p></p>", "<p><br></p>") entfernen.
$clean = (string) preg_replace('#<p>(\s|&nbsp;|<br\s*/?>)*</p>#i', '', $clean);
return trim($clean);
}
/**
* Klartext-Variante für Excel & Co.: Block-Elemente werden zu Zeilenumbrüchen,
* Listenpunkte erhalten einen Spiegelstrich.
*/
public function toPlainText(?string $html): string
{
$html = (string) $html;
if ('' === trim($html)) {
return '';
}
$text = (string) preg_replace('#<li[^>]*>#i', "\n- ", $html);
$text = (string) preg_replace('#</(p|li|ul|ol)>#i', "\n", $text);
$text = (string) preg_replace('#<br\s*/?>#i', "\n", $text);
$text = html_entity_decode(strip_tags($text), ENT_QUOTES | ENT_HTML5, 'UTF-8');
$text = (string) preg_replace("/[ \t]+\n/", "\n", $text);
$text = (string) preg_replace("/\n{3,}/", "\n\n", $text);
return trim($text);
}
public function isEmpty(?string $html): bool
{
return '' === trim(strip_tags((string) $html));
}
private function convertPlainTextToHtml(string $text): string
{
$paragraphs = preg_split("/\R{2,}/", trim($text)) ?: [];
$html = '';
foreach ($paragraphs as $paragraph) {
$paragraph = trim($paragraph);
if ('' === $paragraph) {
continue;
}
$html .= '<p>'.nl2br(htmlspecialchars($paragraph, ENT_QUOTES | ENT_HTML5, 'UTF-8'), false).'</p>';
}
return $html;
}
private function getSanitizer(): HtmlSanitizer
{
if ($this->sanitizer instanceof HtmlSanitizer) {
return $this->sanitizer;
}
// Unbekannte Elemente werden "geblockt": das Tag verschwindet, der Textinhalt
// bleibt erhalten. Elemente, deren Inhalt nie als Text sinnvoll ist, werden
// komplett entfernt.
$config = (new HtmlSanitizerConfig())
->defaultAction(HtmlSanitizerAction::Block)
->withMaxInputLength(200000)
;
foreach (self::DROPPED_ELEMENTS as $element) {
$config = $config->dropElement($element);
}
foreach (self::ALLOWED_ELEMENTS as $element) {
$config = $config->allowElement($element, []);
}
return $this->sanitizer = new HtmlSanitizer($config);
}
}
+100 -16
View File
@@ -21,6 +21,7 @@ final class SurveyResultsExportService
public function __construct(
private readonly SurveyQuestionRepository $surveyQuestionRepository,
private readonly SurveyAnswerRepository $surveyAnswerRepository,
private readonly SurveyStructureService $surveyStructureService,
) {
}
@@ -29,7 +30,12 @@ final class SurveyResultsExportService
*/
public function createExcelExportResponse(SurveyModel $survey, array $finishedSubmissions): Response
{
[$questionSpans, $headers, $columns] = $this->buildExportColumns($this->surveyQuestionRepository->findAllBySurvey((int) $survey->id));
$content = $this->surveyQuestionRepository->findAllBySurvey((int) $survey->id);
$questions = array_values(array_filter($content, static fn (SurveyContentModel $item): bool => !SurveyQuestionRepository::isSection($item)));
$sectionMap = $this->surveyStructureService->buildSectionMap($content);
$hasSections = count($content) > count($questions);
[$questionSpans, $headers, $columns] = $this->buildExportColumns($questions);
// Eigene Zeile mit dem Fragetext oberhalb der Antwortspalten; die Zellen
// werden je Frage über alle zugehörigen Spalten zusammengeführt.
@@ -39,11 +45,23 @@ final class SurveyResultsExportService
$questionRow[$span['start'] - 1] = $span['label'];
}
$rows = [
[(string) $survey->title],
$questionRow,
$headers,
];
$rows = [[(string) $survey->title]];
$sectionSpans = [];
// Bei Themenbereichen zusätzlich eine Bereichszeile über den Fragetexten.
if ($hasSections) {
$sectionSpans = $this->buildSectionSpans($questionSpans, $sectionMap);
$sectionRow = array_fill(0, max(1, count($headers)), '');
foreach ($sectionSpans as $span) {
$sectionRow[$span['start'] - 1] = $span['label'];
}
$rows[] = $sectionRow;
}
$rows[] = $questionRow;
$rows[] = $headers;
foreach ($finishedSubmissions as $submission) {
$answers = $this->surveyAnswerRepository->findAnswersBySubmission((int) $submission['id']);
@@ -76,7 +94,7 @@ final class SurveyResultsExportService
$rows[] = $row;
}
$response = new Response($this->buildSpreadsheetContent($rows, $questionSpans));
$response = new Response($this->buildSpreadsheetContent($rows, $questionSpans, $sectionSpans));
$disposition = $response->headers->makeDisposition(
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
$this->buildExportFilename((string) $survey->title)
@@ -94,7 +112,7 @@ final class SurveyResultsExportService
/**
* @param list<SurveyContentModel> $questions
*
* @return array{0:list<array{label:string,start:int,end:int}>,1:list<string>,2:list<array{questionId:int,kind:string,value:string}>}
* @return array{0:list<array{label:string,start:int,end:int,questionId:int}>,1:list<string>,2:list<array{questionId:int,kind:string,value:string}>}
*/
private function buildExportColumns(array $questions): array
{
@@ -156,6 +174,7 @@ final class SurveyResultsExportService
'label' => sprintf('%s: %s', $prefix, '' !== $questionLabel ? $questionLabel : 'Frage #'.(int) $question->id),
'start' => $firstColumnIndex,
'end' => count($headers),
'questionId' => (int) $question->id,
];
}
@@ -197,16 +216,57 @@ final class SurveyResultsExportService
return $labels;
}
/**
* Bereichsspannen über den Fragespalten (zusammenhängende Fragen desselben Bereichs).
*
* @param list<array{label:string,start:int,end:int,questionId:int}> $questionSpans
* @param array<int, array{id:int,title:string|null}> $sectionMap
*
* @return list<array{label:string,start:int,end:int}>
*/
private function buildSectionSpans(array $questionSpans, array $sectionMap): array
{
$sectionSpans = [];
$currentSectionId = null;
foreach ($questionSpans as $span) {
$section = $sectionMap[$span['questionId']] ?? ['id' => 0, 'title' => null];
$sectionId = (int) $section['id'];
if ($sectionId !== $currentSectionId) {
$sectionSpans[] = [
'label' => $sectionId > 0 ? (string) $section['title'] : 'Allgemeine Fragen',
'start' => $span['start'],
'end' => $span['end'],
];
$currentSectionId = $sectionId;
continue;
}
$sectionSpans[count($sectionSpans) - 1]['end'] = $span['end'];
}
return $sectionSpans;
}
/**
* @param list<list<int|string|float>> $rows
* @param list<array{label:string,start:int,end:int}> $questionSpans
* @param list<array{label:string,start:int,end:int}> $sectionSpans
*/
private function buildSpreadsheetContent(array $rows, array $questionSpans): string
private function buildSpreadsheetContent(array $rows, array $questionSpans, array $sectionSpans = []): string
{
$spreadsheet = new Spreadsheet();
$worksheet = $spreadsheet->getActiveSheet();
$worksheet->setTitle('Ergebnisse');
// Zeilenlayout: 1 Titel, [2 Themenbereiche], n Fragetexte, n+1 Spaltenköpfe, danach Daten.
$hasSectionRow = [] !== $sectionSpans;
$sectionRowIndex = $hasSectionRow ? 2 : 0;
$questionRowIndex = $hasSectionRow ? 3 : 2;
$headerRowIndex = $questionRowIndex + 1;
foreach ($rows as $rowIndex => $row) {
foreach ($row as $columnIndex => $cellValue) {
$coordinate = Coordinate::stringFromColumnIndex($columnIndex + 1).($rowIndex + 1);
@@ -225,21 +285,45 @@ final class SurveyResultsExportService
foreach ($questionSpans as $span) {
if ($span['end'] > $span['start']) {
$worksheet->mergeCells(sprintf(
'%s2:%s2',
'%s%d:%s%d',
Coordinate::stringFromColumnIndex($span['start']),
$questionRowIndex,
Coordinate::stringFromColumnIndex($span['end']),
$questionRowIndex,
));
}
}
$columnCount = max(1, count($rows[2] ?? $rows[0] ?? []));
// Themenbereich-Zellen über alle Fragen des Bereichs zusammenführen.
foreach ($sectionSpans as $span) {
if ($span['end'] > $span['start']) {
$worksheet->mergeCells(sprintf(
'%s%d:%s%d',
Coordinate::stringFromColumnIndex($span['start']),
$sectionRowIndex,
Coordinate::stringFromColumnIndex($span['end']),
$sectionRowIndex,
));
}
}
$columnCount = max(1, count($rows[$headerRowIndex - 1] ?? $rows[0] ?? []));
$lastColumn = Coordinate::stringFromColumnIndex($columnCount);
$worksheet->getStyle('A1')->getFont()->setBold(true)->setSize(14);
$worksheet->getStyle('A2:'.$lastColumn.'2')->getFont()->setBold(true);
$worksheet->getStyle('A2:'.$lastColumn.'2')->getAlignment()->setWrapText(true)->setVertical(\PhpOffice\PhpSpreadsheet\Style\Alignment::VERTICAL_TOP);
$worksheet->getRowDimension(2)->setRowHeight(42);
$worksheet->getStyle('A3:'.$lastColumn.'3')->getFont()->setBold(true);
$worksheet->freezePane('A4');
if ($hasSectionRow) {
$sectionRange = 'A'.$sectionRowIndex.':'.$lastColumn.$sectionRowIndex;
$worksheet->getStyle($sectionRange)->getFont()->setBold(true)->getColor()->setRGB('0F4F88');
$worksheet->getStyle($sectionRange)->getFill()->setFillType(\PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID)->getStartColor()->setRGB('EAF2FA');
$worksheet->getStyle($sectionRange)->getAlignment()->setHorizontal(\PhpOffice\PhpSpreadsheet\Style\Alignment::HORIZONTAL_CENTER);
}
$questionRange = 'A'.$questionRowIndex.':'.$lastColumn.$questionRowIndex;
$worksheet->getStyle($questionRange)->getFont()->setBold(true);
$worksheet->getStyle($questionRange)->getAlignment()->setWrapText(true)->setVertical(\PhpOffice\PhpSpreadsheet\Style\Alignment::VERTICAL_TOP);
$worksheet->getRowDimension($questionRowIndex)->setRowHeight(42);
$worksheet->getStyle('A'.$headerRowIndex.':'.$lastColumn.$headerRowIndex)->getFont()->setBold(true);
$worksheet->freezePane('A'.($headerRowIndex + 1));
for ($column = 1; $column <= $columnCount; ++$column) {
$worksheet->getColumnDimension(Coordinate::stringFromColumnIndex($column))->setAutoSize(true);
+4 -2
View File
@@ -31,7 +31,7 @@ final class SurveyResultsPdfService
/**
* @param list<array<string, mixed>> $questionResults
*/
public function createPdfExportResponse(SurveyModel $survey, int $completedSubmissionCount, array $questionResults): Response
public function createPdfExportResponse(SurveyModel $survey, int $completedSubmissionCount, array $questionResults, bool $hasSections = false): Response
{
$gotenbergBaseUrl = $this->getGotenbergBaseUrl();
@@ -43,6 +43,7 @@ final class SurveyResultsPdfService
$survey,
$completedSubmissionCount,
$this->prepareQuestionResults($questionResults),
$hasSections,
);
$filename = $this->buildFilename((string) $survey->title);
$pdfContent = $this->requestPdfFromGotenberg($gotenbergBaseUrl, $html, $filename);
@@ -179,7 +180,7 @@ final class SurveyResultsPdfService
return $legend;
}
private function renderTemplate(SurveyModel $survey, int $completedSubmissionCount, array $questionResults): string
private function renderTemplate(SurveyModel $survey, int $completedSubmissionCount, array $questionResults, bool $hasSections = false): string
{
$templatePath = __DIR__.'/../Resources/views/pdf/survey_results.html.twig';
$templateContent = file_get_contents($templatePath);
@@ -192,6 +193,7 @@ final class SurveyResultsPdfService
'survey' => $survey,
'completedSubmissionCount' => $completedSubmissionCount,
'questionResults' => $questionResults,
'hasSections' => $hasSections,
'exportedAt' => (new \DateTimeImmutable())->format('d.m.Y H:i'),
'documentTitle' => $this->buildDocumentTitle((string) $survey->title),
'fontFaceCss' => $this->buildEmbeddedFontFaceCss(),
+30 -9
View File
@@ -15,11 +15,16 @@ final class SurveyResultsViewService
private readonly SurveyQuestionRepository $surveyQuestionRepository,
private readonly SurveyAnswerRepository $surveyAnswerRepository,
private readonly SurveySubmissionService $surveySubmissionService,
private readonly SurveyStructureService $surveyStructureService,
) {
}
/**
* @return array{completedSubmissionCount:int,questionResults:list<array<string, mixed>>}
* Ergebnis je Frage (flach, in Sortierreihenfolge) jeder Eintrag trägt unter
* "section" den Themenbereich (id 0 = ohne Bereich). "hasSections" zeigt an,
* ob die Umfrage überhaupt Themenbereiche hat (sonst keine Zwischenüberschriften).
*
* @return array{completedSubmissionCount:int,questionResults:list<array<string, mixed>>,hasSections:bool,sectionCount:int}
*/
public function buildForSurvey(SurveyModel $survey): array
{
@@ -30,22 +35,27 @@ final class SurveyResultsViewService
));
$completedSubmissionCount = count($finishedSubmissions);
$questions = $this->surveyQuestionRepository->findAllBySurvey((int) $survey->id);
$content = $this->surveyQuestionRepository->findAllBySurvey((int) $survey->id);
$questions = array_values(array_filter($content, static fn (SurveyContentModel $item): bool => !SurveyQuestionRepository::isSection($item)));
$answers = $this->surveyAnswerRepository->findFinishedAnswersBySurvey((int) $survey->id);
$sectionCount = count($content) - count($questions);
return [
'completedSubmissionCount' => $completedSubmissionCount,
'questionResults' => $this->buildQuestionResults($questions, $answers, $completedSubmissionCount),
'questionResults' => $this->buildQuestionResults($content, $questions, $answers, $completedSubmissionCount),
'hasSections' => $sectionCount > 0,
'sectionCount' => $sectionCount,
];
}
/**
* @param list<SurveyContentModel> $questions
* @param list<array{questionId:int,questionType:string,questionLabel:string,questionDescription:string,questionSorting:int,value:string}> $answers
* @param list<SurveyContentModel> $content alle Inhalte inkl. Themenbereiche
* @param list<SurveyContentModel> $questions nur Fragen
* @param list<array{questionId:int,questionType:string,questionLabel:string,questionDescription:string,questionSorting:int,questionSection?:string,value:string}> $answers
*
* @return list<array<string, mixed>>
*/
private function buildQuestionResults(array $questions, array $answers, int $completedSubmissionCount): array
private function buildQuestionResults(array $content, array $questions, array $answers, int $completedSubmissionCount): array
{
$results = [];
@@ -101,11 +111,22 @@ final class SurveyResultsViewService
return strcasecmp((string) ($left['question'] ?? ''), (string) ($right['question'] ?? ''));
});
foreach ($results as $key => $result) {
$results[$key] = $this->finalizeQuestionResult($result, $completedSubmissionCount);
// Themenbereich je Ergebnis über die Sortierposition ermitteln (funktioniert
// auch für gelöschte Fragen, die nur noch als Snapshot existieren).
$ordered = [];
foreach ($this->surveyStructureService->groupBySection($content, array_values($results)) as $group) {
foreach ($group['entries'] as $entry) {
$entry['section'] = [
'id' => $group['id'],
'title' => $group['title'],
'isDefault' => $group['isDefault'],
];
$ordered[] = $this->finalizeQuestionResult($entry, $completedSubmissionCount);
}
}
return array_values($results);
return $ordered;
}
private function buildQuestionResultKey(int $questionId, string $questionType): string
+255
View File
@@ -0,0 +1,255 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Service;
use Mummert\SurveyBundle\Model\SurveyContentModel;
use Mummert\SurveyBundle\Repository\SurveyQuestionRepository;
/**
* Leitet aus der flachen, sortierten Inhaltsliste einer Umfrage die Gliederung
* nach Themenbereichen ab. Die Zuordnung ist rein positionsbasiert: Eine Frage
* gehört zum zuletzt vorangehenden Themenbereich (type = "section").
*/
final class SurveyStructureService
{
/**
* Gliederungszeile je Inhalt für Editor und Backend.
*
* @param list<SurveyContentModel> $content alle Inhalte in Sortierreihenfolge
*
* @return list<array{
* model:SurveyContentModel,
* id:int,
* isSection:bool,
* position:int|null,
* sectionId:int,
* sectionTitle:string|null,
* sectionNumber:int|null,
* isLastInSection:bool
* }>
*/
public function buildOutline(array $content): array
{
$outline = [];
$position = 0;
$sectionNumber = 0;
$currentSectionId = 0;
$currentSectionTitle = null;
foreach ($content as $item) {
$isSection = SurveyQuestionRepository::isSection($item);
if ($isSection) {
++$sectionNumber;
$currentSectionId = (int) $item->id;
$currentSectionTitle = trim((string) $item->question);
} else {
++$position;
}
$outline[] = [
'model' => $item,
'id' => (int) $item->id,
'isSection' => $isSection,
'position' => $isSection ? null : $position,
'sectionId' => $currentSectionId,
'sectionTitle' => $currentSectionTitle,
'sectionNumber' => 0 === $currentSectionId ? null : $sectionNumber,
'isLastInSection' => false,
];
}
// Letztes Element je Bereich markieren (für "Frage hinzufügen"-Buttons).
$lastIndexBySection = [];
foreach ($outline as $index => $entry) {
$lastIndexBySection[$entry['sectionId']] = $index;
}
foreach ($lastIndexBySection as $index) {
$outline[$index]['isLastInSection'] = true;
}
return $outline;
}
/**
* Positionsnummern (1-basiert, nur Fragen) je Frage-ID.
*
* @param list<SurveyContentModel> $content
*
* @return array<int, int>
*/
public function buildQuestionPositions(array $content): array
{
$positions = [];
foreach ($this->buildOutline($content) as $entry) {
if (null !== $entry['position']) {
$positions[$entry['id']] = $entry['position'];
}
}
return $positions;
}
/**
* Themenbereich je Frage-ID (0 = ohne Bereich).
*
* @param list<SurveyContentModel> $content
*
* @return array<int, array{id:int,title:string|null}>
*/
public function buildSectionMap(array $content): array
{
$map = [];
foreach ($this->buildOutline($content) as $entry) {
if (!$entry['isSection']) {
$map[$entry['id']] = ['id' => $entry['sectionId'], 'title' => $entry['sectionTitle']];
}
}
return $map;
}
/**
* Kontext einer Frage im Ablauf (Reader-Kopfzeile):
* "Frage n von m aus Bereich'" sowie "Frage x / y insgesamt".
*
* @param list<SurveyContentModel> $content alle Inhalte in Sortierreihenfolge
* @param list<SurveyContentModel> $flowQuestions die Fragen des Ablaufs (ggf. nur veröffentlichte)
*
* @return array{
* current:int,
* total:int,
* percentage:int,
* sectionId:int,
* sectionTitle:string|null,
* sectionCurrent:int,
* sectionTotal:int,
* sectionNumber:int|null,
* sectionCount:int
* }
*/
public function describeQuestionContext(array $content, array $flowQuestions, ?SurveyContentModel $question): array
{
$sectionMap = $this->buildSectionMap($content);
$sectionCount = 0;
$sectionNumbers = [];
foreach ($content as $item) {
if (SurveyQuestionRepository::isSection($item)) {
$sectionNumbers[(int) $item->id] = ++$sectionCount;
}
}
$total = max(1, count($flowQuestions));
$current = $total;
$sectionId = 0;
$sectionTitle = null;
if ($question instanceof SurveyContentModel) {
foreach ($flowQuestions as $index => $flowQuestion) {
if ((int) $flowQuestion->id === (int) $question->id) {
$current = $index + 1;
break;
}
}
$sectionId = (int) ($sectionMap[(int) $question->id]['id'] ?? 0);
$sectionTitle = $sectionMap[(int) $question->id]['title'] ?? null;
}
$sectionCurrent = 0;
$sectionTotal = 0;
if ($question instanceof SurveyContentModel && $sectionId > 0) {
foreach ($flowQuestions as $flowQuestion) {
if ((int) ($sectionMap[(int) $flowQuestion->id]['id'] ?? 0) !== $sectionId) {
continue;
}
++$sectionTotal;
if ((int) $flowQuestion->id === (int) $question->id) {
$sectionCurrent = $sectionTotal;
}
}
}
return [
'current' => $current,
'total' => $total,
'percentage' => (int) round(($current / $total) * 100),
'sectionId' => $sectionId,
'sectionTitle' => '' !== (string) $sectionTitle ? $sectionTitle : null,
'sectionCurrent' => $sectionCurrent,
'sectionTotal' => $sectionTotal,
'sectionNumber' => $sectionNumbers[$sectionId] ?? null,
'sectionCount' => $sectionCount,
];
}
/**
* Gruppiert beliebige Einträge (z. B. Ergebnis-Blöcke) nach Themenbereich.
* Einträge ohne bekannten Bereich landen in Sortierreihenfolge in dem
* Bereich, in dessen Sortierspanne ihr $sorting fällt (wichtig für gelöschte
* Fragen, die nur noch als Antwort-Snapshot existieren).
*
* @param list<SurveyContentModel> $content
* @param list<array<string, mixed>> $entries jeder Eintrag braucht "id" und "_sorting"
*
* @return list<array{id:int,title:string|null,isDefault:bool,entries:list<array<string, mixed>>}>
*/
public function groupBySection(array $content, array $entries): array
{
$sections = [[
'id' => 0,
'title' => null,
'isDefault' => true,
'minSorting' => PHP_INT_MIN,
'entries' => [],
]];
foreach ($content as $item) {
if (SurveyQuestionRepository::isSection($item)) {
$sections[] = [
'id' => (int) $item->id,
'title' => trim((string) $item->question),
'isDefault' => false,
'minSorting' => (int) $item->sorting,
'entries' => [],
];
}
}
foreach ($entries as $entry) {
$sorting = (int) ($entry['_sorting'] ?? 0);
$targetIndex = 0;
foreach ($sections as $index => $section) {
if ($section['minSorting'] <= $sorting) {
$targetIndex = $index;
}
}
$sections[$targetIndex]['entries'][] = $entry;
}
$groups = [];
foreach ($sections as $section) {
if ([] === $section['entries'] && $section['isDefault']) {
continue;
}
unset($section['minSorting']);
$groups[] = $section;
}
return $groups;
}
}
+6
View File
@@ -10,6 +10,7 @@ use Mummert\SurveyBundle\Model\SurveyModel;
use Mummert\SurveyBundle\Model\SurveySubmissionModel;
use Mummert\SurveyBundle\QuestionType\QuestionTypeRegistry;
use Mummert\SurveyBundle\Repository\SurveyAnswerRepository;
use Mummert\SurveyBundle\Repository\SurveyQuestionRepository;
use Mummert\SurveyBundle\Repository\SurveyRepository;
use Mummert\SurveyBundle\Repository\SurveySubmissionRepository;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
@@ -30,6 +31,8 @@ final class SurveySubmissionService
private readonly SurveyRepository $surveyRepository,
private readonly QuestionTypeRegistry $questionTypeRegistry,
private readonly TranslatorInterface $translator,
private readonly SurveyQuestionRepository $surveyQuestionRepository,
private readonly SurveyStructureService $surveyStructureService,
) {
}
@@ -76,6 +79,8 @@ final class SurveySubmissionService
$normalizedAnswer = $this->normalizeAnswerForQuestion($question, $rawAnswer);
$surveyHadAnswers = $this->surveyAnswerRepository->hasAnswersForSurvey((int) $survey->id);
$sectionMap = $this->surveyStructureService->buildSectionMap($this->surveyQuestionRepository->findAllBySurvey((int) $survey->id));
$this->surveyAnswerRepository->saveAnswer(
(int) $submission->id,
(int) $question->id,
@@ -84,6 +89,7 @@ final class SurveySubmissionService
trim((string) ($question->description ?? '')),
(int) ($question->sorting ?? 0),
$normalizedAnswer,
trim((string) ($sectionMap[(int) $question->id]['title'] ?? '')),
);
if (!$surveyHadAnswers && '1' !== (string) $survey->isLocked) {
+46
View File
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Twig;
use Mummert\SurveyBundle\Service\SurveyHtmlSanitizer;
use Twig\Extension\AbstractExtension;
use Twig\Markup;
use Twig\TwigFilter;
/**
* survey_rich: Rich-Text-Beschreibung sicher als HTML ausgeben (Whitelist-Sanitizer,
* auch für Altdaten aus dem Backend-TinyMCE).
* survey_plain: Beschreibung als Klartext (Meta-Angaben, Excel, Attribute).
*/
final class SurveyTwigExtension extends AbstractExtension
{
public function __construct(private readonly SurveyHtmlSanitizer $htmlSanitizer)
{
}
public function getFilters(): array
{
return [
new TwigFilter('survey_rich', $this->renderRichText(...), ['is_safe' => ['html']]),
new TwigFilter('survey_plain', $this->renderPlainText(...)),
new TwigFilter('survey_has_text', $this->hasText(...)),
];
}
public function renderRichText(?string $html): Markup
{
return new Markup($this->htmlSanitizer->sanitize($html), 'UTF-8');
}
public function renderPlainText(?string $html): string
{
return $this->htmlSanitizer->toPlainText($html);
}
public function hasText(?string $html): bool
{
return !$this->htmlSanitizer->isEmpty($html);
}
}
+26
View File
@@ -27,6 +27,24 @@ survey:
publish_help: "Umfrage veröffentlichen. Danach sind keine Änderungen mehr möglich."
empty: "Dem eingeloggten Mitglied ist aktuell keine Umfrage zugewiesen."
edit:
label_intro: "Einleitungstext (erscheint nur auf der ersten Seite)"
intro_hint: "Formatierungen: Absätze, Aufzählungen, Fett- und Kursivschrift. Schriftart und -größe sind durch das einheitliche Erscheinungsbild vorgegeben."
add_section: "+ Themenbereich hinzufügen"
sections_intro: "Mit Themenbereichen gliedern Sie die Fragen (z. B. Konzeption, Personal). Alle Fragen unterhalb eines Themenbereichs gehören dazu bis zum nächsten Bereich."
section_badge: "Themenbereich"
section_fallback: "Themenbereich ohne Titel"
reorder_section: "Themenbereich verschieben"
section_locked_hint: "Dieser Themenbereich gliedert die folgenden Fragen."
save_this_section: "Diesen Themenbereich speichern"
delete_section_confirm: "Themenbereich wirklich löschen? Die zugehörigen Fragen bleiben erhalten und rücken in den vorherigen Bereich."
add_question_to_section: "+ Frage in „%section%“ hinzufügen"
add_question_before_sections: "+ Frage vor dem ersten Themenbereich hinzufügen"
new_section: "Neuen Themenbereich anlegen"
save_new_section: "Neuen Themenbereich speichern"
label_section_title: "Titel des Themenbereichs"
section_hint: "Ein Themenbereich ist keine Frage, sondern eine Überschrift: Alle folgenden Fragen bis zum nächsten Themenbereich gehören dazu. Teilnehmende sehen den Bereich oberhalb jeder Frage."
insert_into_section: "Die neue Frage wird am Ende des Themenbereichs „%section%“ eingefügt."
insert_before_first_section: "Die neue Frage wird vor dem ersten Themenbereich eingefügt."
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"
@@ -101,6 +119,10 @@ survey:
delete_condition_confirm: "Bedingung wirklich löschen?"
no_conditions: "Noch keine Bedingungen definiert."
survey:
section_kicker: "Themenbereich %number% von %count%"
section_progress: "Frage %current% von %total% in diesem Bereich"
overall_progress: "Frage %current% von %total% insgesamt"
question_type_section: "Themenbereich"
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."
@@ -120,6 +142,8 @@ survey:
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:
section_kicker: "Themenbereich"
section_default: "Allgemeine Fragen"
no_description: "Keine Beschreibung hinterlegt."
completed_submissions: "%count% abgeschlossene Teilnahmen"
participations: "%count% Teilnahmen"
@@ -138,6 +162,8 @@ survey:
no_text_responses: "Noch keine Freitextantworten vorhanden."
no_questions: "Für diese Umfrage sind noch keine Fragen angelegt."
pdf:
section_kicker: "Themenbereich"
section_default: "Allgemeine Fragen"
no_description: "Keine Beschreibung hinterlegt."
completed_submissions: "%count% abgeschlossene Teilnahmen"
participations: "%count% Teilnahmen"
+26
View File
@@ -27,6 +27,24 @@ survey:
publish_help: "Publish survey. No further changes will be possible afterwards."
empty: "There is currently no survey assigned to the logged-in member."
edit:
label_intro: "Introduction (shown on the first page only)"
intro_hint: "Formatting: paragraphs, lists, bold and italic. Font family and size follow the corporate design."
add_section: "+ Add topic section"
sections_intro: "Topic sections group your questions (e.g. concept, staff). Every question below a section belongs to it up to the next section."
section_badge: "Topic section"
section_fallback: "Untitled topic section"
reorder_section: "Move topic section"
section_locked_hint: "This topic section groups the following questions."
save_this_section: "Save this topic section"
delete_section_confirm: "Really delete this topic section? Its questions are kept and move to the previous section."
add_question_to_section: "+ Add question to “%section%”"
add_question_before_sections: "+ Add question before the first topic section"
new_section: "Create new topic section"
save_new_section: "Save new topic section"
label_section_title: "Title of the topic section"
section_hint: "A topic section is not a question but a heading: all following questions up to the next section belong to it. Participants see the section above every question."
insert_into_section: "The new question will be added at the end of the topic section “%section%”."
insert_before_first_section: "The new question will be inserted before the first topic section."
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"
@@ -101,6 +119,10 @@ survey:
delete_condition_confirm: "Delete this condition?"
no_conditions: "No conditions defined yet."
survey:
section_kicker: "Topic section %number% of %count%"
section_progress: "Question %current% of %total% in this section"
overall_progress: "Question %current% of %total% overall"
question_type_section: "Topic section"
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."
@@ -120,6 +142,8 @@ survey:
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:
section_kicker: "Topic section"
section_default: "General questions"
no_description: "No description available."
completed_submissions: "%count% completed participations"
participations: "%count% participations"
@@ -138,6 +162,8 @@ survey:
no_text_responses: "No free-text responses available yet."
no_questions: "No questions have been created for this survey yet."
pdf:
section_kicker: "Topic section"
section_default: "General questions"
no_description: "No description available."
completed_submissions: "%count% completed participations"
participations: "%count% participations"