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:
@@ -658,3 +658,17 @@ Backend:
|
||||
- Ergebnisdarstellung: `SurveyResultsViewService`
|
||||
- Exportlogik: `SurveyResultsExportService`, `SurveyResultsPdfService`
|
||||
- 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).
|
||||
|
||||
@@ -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' => [
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
],
|
||||
|
||||
@@ -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.'];
|
||||
@@ -30,3 +31,7 @@ $GLOBALS['TL_LANG']['tl_survey_content']['answerOption10'] = ['Antwort 10', 'Zeh
|
||||
$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']['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.';
|
||||
|
||||
@@ -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.'];
|
||||
@@ -30,3 +31,7 @@ $GLOBALS['TL_LANG']['tl_survey_content']['answerOption10'] = ['Answer 10', 'Tent
|
||||
$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']['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>
|
||||
@@ -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 }}&question=new&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 }}&question=new&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>
|
||||
|
||||
|
||||
@@ -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 }}">
|
||||
|
||||
@@ -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
@@ -43,3 +43,71 @@ body.survey-question-list-page.survey-question-list-clipboard-active .parent_vie
|
||||
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
Vendored
+14
File diff suppressed because one or more lines are too long
Vendored
+3
File diff suppressed because one or more lines are too long
@@ -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();
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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| |<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, '&').replace(/</g, '<').replace(/>/g, '>').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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 : ' ',
|
||||
);
|
||||
}
|
||||
|
||||
$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 : ' ',
|
||||
$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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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'],
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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| |<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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user