Refine survey editor workflow and publication handling

This commit is contained in:
Jürgen Mummert
2026-05-29 10:19:19 +02:00
parent 6bbded4181
commit 1174410c1b
40 changed files with 1121 additions and 335 deletions
@@ -98,6 +98,7 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController
$template->set('loginRequired', false);
$template->set('entryNotAllowed', false);
$template->set('createMode', true);
$template->set('structureLocked', false);
$template->set('surveyForm', $surveyForm->createView());
$template->set('backUrl', $this->resolveListUrl($model));
$template->set('publicUrl', null);
@@ -112,70 +113,78 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController
return $template->getResponse();
}
$structureLocked = $this->surveyEditorService->isStructureLocked($survey);
$questions = $this->surveyQuestionRepository->findAllBySurvey((int) $survey->id);
$activeQuestion = $this->resolveActiveQuestion($request, (int) $survey->id);
$activeQuestionId = $activeQuestion ? (int) $activeQuestion->id : 0;
$questionFormViews = [];
$newQuestionFormView = null;
foreach ($questions as $question) {
$questionId = (int) $question->id;
$questionForm = $this->formFactory->createNamed('survey_question_'.$questionId, SurveyQuestionEditorType::class, SurveyQuestionData::fromModel($question), [
'question_choices' => $this->buildQuestionChoices((int) $survey->id, $questionId),
if (!$structureLocked) {
foreach ($questions as $question) {
$questionId = (int) $question->id;
$questionForm = $this->formFactory->createNamed('survey_question_'.$questionId, SurveyQuestionEditorType::class, SurveyQuestionData::fromModel($question), [
'question_choices' => $this->buildQuestionChoices((int) $survey->id, $questionId),
]);
$questionForm->handleRequest($request);
if ($questionForm->isSubmitted()) {
$activeQuestionId = $questionId;
$createQuestionMode = false;
if ($questionForm->isValid()) {
try {
$this->surveyEditorService->saveQuestion($survey, $questionForm->getData(), $questionId);
$this->addFlash('success', 'Die Frage wurde aktualisiert.');
return new RedirectResponse($this->buildEditUrl($model, $request, (int) $survey->id));
} catch (\Throwable $exception) {
$this->addFlash('error', $exception->getMessage());
}
} else {
$this->addFlash('error', 'Frage konnte nicht gespeichert werden: '.$this->describeFormErrors($questionForm));
}
}
$questionFormViews[$questionId] = $questionForm->createView();
}
$newQuestionForm = $this->formFactory->createNamed('survey_question_new', SurveyQuestionEditorType::class, new SurveyQuestionData(), [
'question_choices' => $this->buildQuestionChoices((int) $survey->id),
]);
$questionForm->handleRequest($request);
$newQuestionForm->handleRequest($request);
if ($questionForm->isSubmitted()) {
$activeQuestionId = $questionId;
$createQuestionMode = false;
if ($newQuestionForm->isSubmitted()) {
$activeQuestionId = 0;
$createQuestionMode = true;
if ($questionForm->isValid()) {
if ($newQuestionForm->isValid()) {
try {
$savedQuestionId = $this->surveyEditorService->saveQuestion($survey, $questionForm->getData(), $questionId);
$this->addFlash('success', 'Die Frage wurde aktualisiert.');
$this->surveyEditorService->saveQuestion($survey, $newQuestionForm->getData(), 0);
$this->addFlash('success', 'Die Frage wurde angelegt.');
return new RedirectResponse($this->buildEditUrl($model, $request, (int) $survey->id));
} catch (\Throwable $exception) {
$this->addFlash('error', $exception->getMessage());
}
} else {
$this->addFlash('error', 'Frage konnte nicht gespeichert werden: '.$this->describeFormErrors($questionForm));
$this->addFlash('error', 'Neue Frage konnte nicht gespeichert werden: '.$this->describeFormErrors($newQuestionForm));
}
}
$questionFormViews[$questionId] = $questionForm->createView();
}
$newQuestionForm = $this->formFactory->createNamed('survey_question_new', SurveyQuestionEditorType::class, new SurveyQuestionData(), [
'question_choices' => $this->buildQuestionChoices((int) $survey->id),
]);
$newQuestionForm->handleRequest($request);
if ($newQuestionForm->isSubmitted()) {
$activeQuestionId = 0;
$createQuestionMode = true;
if ($newQuestionForm->isValid()) {
try {
$savedQuestionId = $this->surveyEditorService->saveQuestion($survey, $newQuestionForm->getData(), 0);
$this->addFlash('success', 'Die Frage wurde angelegt.');
return new RedirectResponse($this->buildEditUrl($model, $request, (int) $survey->id));
} catch (\Throwable $exception) {
$this->addFlash('error', $exception->getMessage());
}
} else {
$this->addFlash('error', 'Neue Frage konnte nicht gespeichert werden: '.$this->describeFormErrors($newQuestionForm));
}
$newQuestionFormView = $newQuestionForm->createView();
}
$template->set('loginRequired', false);
$template->set('entryNotAllowed', false);
$template->set('createMode', false);
$template->set('structureLocked', $structureLocked);
$template->set('createQuestionMode', $createQuestionMode);
$template->set('survey', $survey);
$template->set('surveyForm', $surveyForm->createView());
$template->set('questionForms', $questionFormViews);
$template->set('newQuestionForm', $newQuestionForm->createView());
$template->set('newQuestionForm', $newQuestionFormView);
$template->set('questions', $questions);
$conditionOverview = $this->surveyConditionRepository->findOverviewBySurvey((int) $survey->id);
@@ -205,7 +214,7 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController
}
if (!$this->surveyEditorRepository->isEditor((int) $survey->id, $memberId)) {
throw new \RuntimeException('Sie duerfen diese Umfrage nicht bearbeiten.');
throw new \RuntimeException('Sie dürfen diese Umfrage nicht bearbeiten.');
}
return $survey;
@@ -259,23 +268,28 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController
$token = (string) $request->request->get('_token');
if (!$this->isCsrfTokenValid($action.'-'.$itemId, $token)) {
throw new \RuntimeException('Ungueltiger CSRF-Token.');
throw new \RuntimeException('Ungültiger CSRF-Token.');
}
try {
if ('toggle-published' === $action) {
$isPublished = $this->surveyEditorService->toggleSurveyPublished($survey, $memberId);
$this->addFlash('success', $isPublished ? 'Die Umfrage ist jetzt veroeffentlicht.' : 'Die Umfrage ist jetzt nicht mehr veroeffentlicht.');
if ('publish-survey' === $action) {
$this->surveyEditorService->publishSurvey($survey, $memberId);
$this->addFlash('success', 'Die Umfrage ist jetzt veröffentlicht.');
}
if ('toggle-active' === $action) {
$isActive = $this->surveyEditorService->toggleSurveyActive($survey, $memberId);
$this->addFlash('success', $isActive ? 'Die Umfrage ist jetzt wieder aktiv.' : 'Die Umfrage wurde vorübergehend deaktiviert.');
}
if ('delete-question' === $action) {
$this->surveyEditorService->deleteQuestion($survey, $itemId);
$this->addFlash('success', 'Die Frage wurde geloescht.');
$this->addFlash('success', 'Die Frage wurde gelöscht.');
}
if ('delete-condition' === $action) {
$this->surveyEditorService->deleteCondition($survey, $itemId);
$this->addFlash('success', 'Die Bedingung wurde geloescht.');
$this->addFlash('success', 'Die Bedingung wurde gelöscht.');
}
} catch (\Throwable $exception) {
$this->addFlash('error', $exception->getMessage());
@@ -52,18 +52,21 @@ final class MemberSurveyListController extends AbstractFrontendModuleController
foreach ($this->surveyRepository->findEditableByMember((int) $user->id) as $survey) {
$surveyId = (int) $survey['id'];
$alias = (string) $survey['alias'];
$items[] = [
'id' => $surveyId,
'title' => (string) $survey['title'],
'description' => (string) ($survey['description'] ?? ''),
'isActive' => !empty($survey['isActive']),
'isLocked' => !empty($survey['isLocked']),
'published' => !empty($survey['published']),
'questionCount' => (int) ($survey['questionCount'] ?? 0),
'participationCount' => (int) ($survey['participationCount'] ?? 0),
'editUrl' => $editPage instanceof PageModel ? $this->generateContentUrl($editPage, ['survey' => $surveyId]) : null,
'publicUrl' => $readerPage instanceof PageModel ? $this->generateContentUrl($readerPage, ['auto_item' => (string) $survey['alias']]) : null,
'resultsUrl' => $resultsPage instanceof PageModel ? $this->generateContentUrl($resultsPage, ['auto_item' => (string) $survey['alias']]) : null,
'publicUrl' => $readerPage instanceof PageModel ? $this->generateContentUrl($readerPage, ['auto_item' => $alias]) : null,
'draftUrl' => $readerPage instanceof PageModel ? $this->generateContentUrl($readerPage, ['auto_item' => $alias, 'preview' => '1']) : null,
'resultsUrl' => $resultsPage instanceof PageModel ? $this->generateContentUrl($resultsPage, ['auto_item' => $alias]) : null,
];
}
@@ -81,20 +84,15 @@ final class MemberSurveyListController extends AbstractFrontendModuleController
$token = (string) $request->request->get('_token');
if ($surveyId <= 0 || !$this->isCsrfTokenValid($action.'-'.$surveyId, $token)) {
throw new \RuntimeException('Ungueltiger CSRF-Token.');
throw new \RuntimeException('Ungültiger CSRF-Token.');
}
$survey = $this->resolveEditableSurvey($surveyId, $memberId);
try {
if ('toggle-published' === $action) {
$isPublished = $this->surveyEditorService->toggleSurveyPublished($survey, $memberId);
$this->addFlash('success', $isPublished ? 'Die Umfrage ist jetzt veroeffentlicht.' : 'Die Umfrage ist jetzt nicht mehr veroeffentlicht.');
}
if ('delete-survey' === $action) {
$this->surveyEditorService->deleteSurvey($survey);
$this->addFlash('success', 'Die Umfrage mit allen Fragen, Antworten und Ergebnissen wurde geloescht.');
$this->addFlash('success', 'Die Umfrage mit allen Fragen, Antworten und Ergebnissen wurde gelöscht.');
}
} catch (\Throwable $exception) {
$this->addFlash('error', $exception->getMessage());
@@ -108,7 +106,7 @@ final class MemberSurveyListController extends AbstractFrontendModuleController
$survey = $this->surveyRepository->findById($surveyId);
if (!$survey instanceof SurveyModel || !$this->surveyEditorRepository->isEditor($surveyId, $memberId)) {
throw new \RuntimeException('Sie duerfen diese Umfrage nicht bearbeiten.');
throw new \RuntimeException('Sie dürfen diese Umfrage nicht bearbeiten.');
}
return $survey;
@@ -8,6 +8,8 @@ use Contao\CoreBundle\Controller\FrontendModule\AbstractFrontendModuleController
use Contao\CoreBundle\DependencyInjection\Attribute\AsFrontendModule;
use Contao\CoreBundle\Twig\FragmentTemplate;
use Contao\ModuleModel;
use Mummert\SurveyBundle\Model\SurveyContentModel;
use Mummert\SurveyBundle\Model\SurveyModel;
use Mummert\SurveyBundle\Form\Model\SurveyAnswerData;
use Mummert\SurveyBundle\Form\SurveyQuestionAnswerType;
use Mummert\SurveyBundle\Repository\SurveyRepository;
@@ -30,48 +32,57 @@ final class ShowSurveyController extends AbstractFrontendModuleController
protected function getResponse(FragmentTemplate $template, ModuleModel $model, Request $request): Response
{
$publicAlias = $this->resolvePublicAlias($request);
$previewMode = $request->query->getBoolean('preview');
if ('' === $publicAlias) {
$template->set('errorMessage', 'Es wurde kein oeffentlicher Link auf der Anzeigeseite gefunden.');
$template->set('question', null);
$template->set('surveyForm', null);
$template->set('isComplete', false);
$template->set('answers', []);
$template->set('survey', null);
$template->set('progress', ['current' => 0, 'total' => 0, 'percentage' => 0]);
$this->applyEmptyState($template, 'Es wurde kein öffentlicher Link auf der Anzeigeseite gefunden.', $previewMode);
return $template->getResponse();
}
$survey = $this->surveyRepository->findByPublicAlias($publicAlias, true);
$survey = $this->surveyRepository->findByPublicAlias($publicAlias, !$previewMode);
if (null === $survey) {
$template->set('errorMessage', 'Die Umfrage wurde nicht gefunden oder ist nicht veroeffentlicht.');
$template->set('question', null);
$template->set('surveyForm', null);
$template->set('isComplete', false);
$template->set('answers', []);
$template->set('survey', null);
$template->set('progress', ['current' => 0, 'total' => 0, 'percentage' => 0]);
$this->applyEmptyState($template, 'Die Umfrage wurde nicht gefunden, ist nicht veröffentlicht oder aktuell deaktiviert.', $previewMode);
return $template->getResponse();
}
if ($previewMode && '1' === (string) $survey->published) {
$this->applyEmptyState($template, 'Die Entwurfsansicht ist für bereits veröffentlichte Umfragen nicht verfügbar.', true, $survey);
return $template->getResponse();
}
if ($htmlHeadBag = $this->getHtmlHeadBag()) {
$htmlHeadBag->setTitle((string) $survey->title);
$htmlHeadBag->setTitle((string) $survey->title.($previewMode ? ' - Entwurfsansicht' : ''));
}
if ($previewMode) {
return $this->buildPreviewResponse($template, $survey, $request);
}
$submission = $this->surveySubmissionService->resolveActiveSubmission($survey, $request->getSession(), null);
if ($request->isMethod('POST') && 'back' === (string) $request->request->get('_survey_navigation')) {
$this->assertNavigationTokenValid($request, 'survey-back-'.(int) $survey->id);
if ($this->surveySubmissionService->goBack($survey, $submission, $request->getSession())) {
return new RedirectResponse($request->getUri());
}
}
$question = $this->surveyFlowService->resolveCurrentQuestion($survey, $submission);
if (null === $question && '1' !== (string) $submission->isFinished) {
$template->set('errorMessage', 'Diese Umfrage ist noch nicht vollstaendig eingerichtet. Es fehlt eine gueltige erste Frage oder eine verknuepfte Folgefrage ist nicht mehr verfuegbar.');
$template->set('errorMessage', 'Diese Umfrage ist noch nicht vollständig eingerichtet. Es fehlt eine gültige erste Frage oder eine verknüpfte Folgefrage ist nicht mehr verfügbar.');
$template->set('question', null);
$template->set('surveyForm', null);
$template->set('isComplete', false);
$template->set('answers', []);
$template->set('survey', $survey);
$template->set('progress', ['current' => 0, 'total' => 0, 'percentage' => 0]);
$template->set('isPreview', false);
return $template->getResponse();
}
@@ -84,17 +95,28 @@ final class ShowSurveyController extends AbstractFrontendModuleController
$template->set('surveyForm', null);
$template->set('isComplete', true);
$template->set('answers', $this->surveySubmissionService->getAnswersForSubmission($submission));
$template->set('isPreview', false);
$template->set('canGoBack', false);
$template->set('historyState', '');
return $template->getResponse();
}
$answerData = new SurveyAnswerData();
$answerData = $this->surveySubmissionService->createAnswerData(
$question,
$this->surveySubmissionService->getStoredAnswerValue($submission, $question),
);
$form = $this->createForm(SurveyQuestionAnswerType::class, $answerData, ['question' => $question]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$normalizedAnswer = $this->surveySubmissionService->storeAnswer($survey, $submission, $question, $answerData->answer);
$nextQuestion = $this->surveyFlowService->determineNextQuestion($survey, $question, $normalizedAnswer);
if ($nextQuestion instanceof SurveyContentModel) {
$this->surveySubmissionService->rememberQuestion($survey, $request->getSession(), $question);
}
$this->surveySubmissionService->advance($submission, $nextQuestion);
return new RedirectResponse($request->getUri());
@@ -107,10 +129,140 @@ final class ShowSurveyController extends AbstractFrontendModuleController
$template->set('surveyForm', $form->createView());
$template->set('isComplete', false);
$template->set('answers', []);
$template->set('isPreview', false);
$template->set('canGoBack', $this->surveySubmissionService->canGoBack($survey, $request->getSession()));
$template->set('historyState', '');
return $template->getResponse();
}
private function buildPreviewResponse(FragmentTemplate $template, SurveyModel $survey, Request $request): Response
{
$history = $this->decodePreviewHistory((string) $request->request->get('preview_history', ''));
$question = $this->resolvePreviewQuestion($survey, (int) $request->request->get('preview_question', 0));
if ($request->isMethod('POST') && 'back' === (string) $request->request->get('_survey_navigation')) {
$this->assertNavigationTokenValid($request, 'survey-preview-back-'.(int) $survey->id);
if ([] !== $history) {
$question = $this->resolvePreviewQuestion($survey, (int) array_pop($history));
}
}
$question ??= $this->surveyFlowService->getFirstQuestion($survey);
if (!$question instanceof SurveyContentModel) {
$template->set('errorMessage', 'Diese Umfrage ist noch nicht vollständig eingerichtet. Es fehlt eine gültige erste Frage oder eine verknüpfte Folgefrage ist nicht mehr verfügbar.');
$template->set('question', null);
$template->set('surveyForm', null);
$template->set('isComplete', false);
$template->set('answers', []);
$template->set('survey', $survey);
$template->set('progress', ['current' => 0, 'total' => 0, 'percentage' => 0]);
$template->set('isPreview', true);
$template->set('canGoBack', false);
$template->set('historyState', '');
return $template->getResponse();
}
$answerData = new SurveyAnswerData();
$form = $this->createForm(SurveyQuestionAnswerType::class, $answerData, ['question' => $question]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$normalizedAnswer = $this->surveySubmissionService->normalizeAnswerForQuestion($question, $answerData->answer);
$nextQuestion = $this->surveyFlowService->determineNextQuestion($survey, $question, $normalizedAnswer);
if ($nextQuestion instanceof SurveyContentModel) {
$history[] = (int) $question->id;
$question = $nextQuestion;
$form = $this->createForm(SurveyQuestionAnswerType::class, new SurveyAnswerData(), ['question' => $question]);
} else {
$template->set('errorMessage', null);
$template->set('survey', $survey);
$template->set('progress', $this->surveyFlowService->getProgress($survey, null));
$template->set('question', null);
$template->set('surveyForm', null);
$template->set('isComplete', true);
$template->set('answers', []);
$template->set('isPreview', true);
$template->set('canGoBack', false);
$template->set('historyState', '');
return $template->getResponse();
}
}
$template->set('errorMessage', null);
$template->set('survey', $survey);
$template->set('progress', $this->surveyFlowService->getProgress($survey, $question));
$template->set('question', $question);
$template->set('surveyForm', $form->createView());
$template->set('isComplete', false);
$template->set('answers', []);
$template->set('isPreview', true);
$template->set('canGoBack', !empty($history));
$template->set('historyState', $this->encodePreviewHistory($history));
return $template->getResponse();
}
private function applyEmptyState(FragmentTemplate $template, string $message, bool $previewMode, ?SurveyModel $survey = null): void
{
$template->set('errorMessage', $message);
$template->set('question', null);
$template->set('surveyForm', null);
$template->set('isComplete', false);
$template->set('answers', []);
$template->set('survey', $survey);
$template->set('progress', ['current' => 0, 'total' => 0, 'percentage' => 0]);
$template->set('isPreview', $previewMode);
$template->set('canGoBack', false);
$template->set('historyState', '');
}
private function resolvePreviewQuestion(SurveyModel $survey, int $questionId): ?SurveyContentModel
{
if ($questionId <= 0) {
return null;
}
return $this->surveyFlowService->resolveQuestionById($survey, $questionId);
}
/**
* @return list<int>
*/
private function decodePreviewHistory(string $historyState): array
{
if ('' === trim($historyState)) {
return [];
}
return array_values(array_filter(
array_map(static fn (string $entry): int => (int) trim($entry), explode(',', $historyState)),
static fn (int $entry): bool => $entry > 0,
));
}
/**
* @param list<int> $history
*/
private function encodePreviewHistory(array $history): string
{
return implode(',', $history);
}
private function assertNavigationTokenValid(Request $request, string $tokenId): void
{
$token = (string) $request->request->get('_navigation_token', '');
if (!$this->isCsrfTokenValid($tokenId, $token)) {
throw new \RuntimeException('Ungültiger CSRF-Token.');
}
}
private function resolvePublicAlias(Request $request): string
{
$candidate = $request->attributes->get('auto_item');
+174 -16
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Mummert\SurveyBundle\EventListener;
use Contao\Backend;
use Contao\Config;
use Contao\CoreBundle\Routing\ContentUrlGenerator;
use Contao\CoreBundle\Framework\ContaoFramework;
@@ -11,6 +12,7 @@ use Contao\DataContainer;
use Contao\Image;
use Contao\Input;
use Contao\PageModel;
use Contao\StringUtil;
use Doctrine\DBAL\Connection;
use Mummert\SurveyBundle\Repository\SurveyEditorRepository;
use Mummert\SurveyBundle\Repository\SurveyQuestionRepository;
@@ -40,12 +42,12 @@ final class SurveyDcaListener
{
$publicUrl = $this->buildSurveyPageUrl((string) ($row['alias'] ?? ''), 'surveyReaderPage');
$label = 'Link kopieren';
$title = 'Den oeffentlichen Link in die Zwischenablage kopieren';
$title = 'Den öffentlichen Link in die Zwischenablage kopieren';
if (null === $publicUrl) {
return sprintf(
'<span title="%s">%s</span>',
htmlspecialchars('In den Contao-Einstellungen ist noch keine Reader-Seite hinterlegt oder der Link-Schluessel ist ungueltig.', ENT_QUOTES),
htmlspecialchars('In den Contao-Einstellungen ist noch keine Reader-Seite hinterlegt oder der Link-Schlüssel ist ungültig.', ENT_QUOTES),
Image::getHtml($icon ?? 'copy.svg', $label)
);
}
@@ -53,8 +55,8 @@ final class SurveyDcaListener
$copyScript = sprintf(
'navigator.clipboard.writeText(%s).then(function(){Contao.flashMessage(%s, %s, "confirmation");});return false;',
json_encode($publicUrl, JSON_THROW_ON_ERROR),
json_encode('Oeffentlicher Link wurde in die Zwischenablage kopiert.', JSON_THROW_ON_ERROR),
json_encode('Bestaetigung', JSON_THROW_ON_ERROR),
json_encode('Öffentlicher Link wurde in die Zwischenablage kopiert.', JSON_THROW_ON_ERROR),
json_encode('Bestätigung', JSON_THROW_ON_ERROR),
);
return sprintf(
@@ -75,7 +77,7 @@ final class SurveyDcaListener
if (null === $resultsUrl) {
return sprintf(
'<span title="%s">%s</span>',
htmlspecialchars('In den Contao-Einstellungen ist noch keine Ergebnisseite hinterlegt oder der Link-Schluessel ist ungueltig.', ENT_QUOTES),
htmlspecialchars('In den Contao-Einstellungen ist noch keine Ergebnisseite hinterlegt oder der Link-Schlüssel ist ungültig.', ENT_QUOTES),
Image::getHtml($icon ?? 'show.svg', $label)
);
}
@@ -89,6 +91,88 @@ final class SurveyDcaListener
);
}
public function generatePublishSurveyButton(array $row, ?string $href, string $label, string $title, ?string $icon, string $attributes): string
{
if ('1' === (string) ($row['published'] ?? '')) {
return sprintf(
'<span title="%s">%s</span>',
htmlspecialchars('Die Umfrage ist bereits veröffentlicht.', ENT_QUOTES),
Image::getHtml(str_replace('.svg', '--disabled.svg', $icon ?? 'visible.svg'), $label)
);
}
return sprintf(
'<a href="%s" title="%s" onclick="if(!confirm(\'%s\'))return false;Backend.getScrollOffset()"%s>%s</a>',
htmlspecialchars(Backend::addToUrl(($href ?? '').'&amp;id='.(int) ($row['id'] ?? 0)), ENT_QUOTES),
htmlspecialchars($title, ENT_QUOTES),
StringUtil::specialchars('Wirklich sicher? Nachdem die Umfrage veröffentlicht wurde sind keine Änderungen mehr möglich!'),
$attributes,
Image::getHtml($icon ?? 'visible.svg', $label)
);
}
public function generateToggleActiveButton(array $row, ?string $href, string $label, string $title, ?string $icon, string $attributes): string
{
if ('1' !== (string) ($row['published'] ?? '')) {
return sprintf(
'<span title="%s">%s</span>',
htmlspecialchars('Die Umfrage muss zuerst veröffentlicht werden.', ENT_QUOTES),
Image::getHtml('visible--disabled.svg', $label)
);
}
$isActive = '1' === (string) ($row['isActive'] ?? '');
$icon = $isActive ? 'visible.svg' : 'invisible.svg';
$title = $isActive ? 'Umfrage vorübergehend deaktivieren' : 'Umfrage wieder aktivieren';
return sprintf(
'<a href="%s" title="%s"%s>%s</a>',
htmlspecialchars(Backend::addToUrl(($href ?? '').'&amp;id='.(int) ($row['id'] ?? 0)), ENT_QUOTES),
htmlspecialchars($title, ENT_QUOTES),
$attributes,
Image::getHtml($icon, $label)
);
}
public function savePublishedState(mixed $value, DataContainer $dataContainer): string
{
$surveyId = (int) ($dataContainer->id ?? 0);
$survey = $this->surveyRepository->findById($surveyId);
if (null === $survey) {
return !empty($value) ? '1' : '';
}
if ('1' === (string) $survey->published) {
return '1';
}
if (!empty($value)) {
$now = time();
$this->connection->update('tl_survey', [
'isActive' => '1',
'isLocked' => '1',
'tstamp' => $now,
'updatedAt' => $now,
], ['id' => $surveyId]);
return '1';
}
return '';
}
public function saveActiveState(mixed $value, DataContainer $dataContainer): string
{
$survey = $this->surveyRepository->findById((int) ($dataContainer->id ?? 0));
if (null === $survey || '1' !== (string) $survey->published) {
return '';
}
return !empty($value) ? '1' : '';
}
/**
* @return array<int, string>
*/
@@ -145,6 +229,50 @@ final class SurveyDcaListener
return $options;
}
/**
* @return array<int, string>
*/
public function getUsedCategoryFilterOptions(): array
{
$usedCategoryIds = [];
foreach ($this->connection->fetchFirstColumn('SELECT category FROM tl_survey') as $rawCategoryValue) {
foreach (StringUtil::deserialize($rawCategoryValue, true) as $categoryId) {
$categoryId = (int) $categoryId;
if ($categoryId > 0) {
$usedCategoryIds[$categoryId] = $categoryId;
}
}
}
if ([] === $usedCategoryIds) {
return [];
}
$allCategoryOptions = $this->getCategoryOptions();
$options = [];
foreach (array_values($usedCategoryIds) as $categoryId) {
if (isset($allCategoryOptions[$categoryId])) {
$options[$categoryId] = $allCategoryOptions[$categoryId];
}
}
return $options;
}
/**
* @return array<string, string>
*/
public function getTemplateFilterOptions(): array
{
return [
'1' => $GLOBALS['TL_LANG']['tl_survey']['template_yes'] ?? 'Ja',
'0' => $GLOBALS['TL_LANG']['tl_survey']['template_no'] ?? 'Nein',
];
}
/**
* @return array<int, string>
*/
@@ -190,7 +318,6 @@ final class SurveyDcaListener
public function guardEditorSave(mixed $value, DataContainer $dataContainer): mixed
{
$surveyId = $this->resolveSurveyId('tl_survey_editor', $dataContainer);
$this->assertSurveyUnlocked($surveyId);
$this->surveyCategoryAssignmentService->applyMemberCategoriesToSurvey((int) $value, $surveyId);
return $value;
@@ -239,10 +366,6 @@ final class SurveyDcaListener
$previousSurveyIds = $this->surveyEditorRepository->findSurveyIdsByMember($memberId);
if ($previousSurveyIds !== $surveyIds) {
foreach (array_unique(array_merge($previousSurveyIds, $surveyIds)) as $surveyId) {
$this->assertSurveyUnlocked((int) $surveyId);
}
$this->surveyEditorRepository->syncSurveysForMember($memberId, $surveyIds);
}
@@ -273,7 +396,6 @@ final class SurveyDcaListener
$previousMemberIds = $this->surveyEditorRepository->findMemberIdsBySurvey($surveyId);
if ($previousMemberIds !== $memberIds) {
$this->assertSurveyUnlocked($surveyId);
$this->surveyEditorRepository->syncMembersForSurvey($surveyId, $memberIds);
$this->surveyCategoryAssignmentService->syncSurvey($surveyId);
}
@@ -361,13 +483,36 @@ final class SurveyDcaListener
public function guardEditorDelete(DataContainer $dataContainer): void
{
$surveyId = $this->resolveSurveyId('tl_survey_editor', $dataContainer);
$this->assertSurveyUnlocked($surveyId);
if ($dataContainer->activeRecord?->member) {
$this->surveyCategoryAssignmentService->syncSurveyWithoutMember($surveyId, (int) $dataContainer->activeRecord->member);
}
}
public function restrictSurveyContentEditing(): void
{
if (!$this->isSurveyStructureLocked($this->resolveSurveyId('tl_survey_content'))) {
return;
}
$GLOBALS['TL_DCA']['tl_survey_content']['config']['notCreatable'] = true;
$GLOBALS['TL_DCA']['tl_survey_content']['config']['notCopyable'] = true;
$GLOBALS['TL_DCA']['tl_survey_content']['config']['notEditable'] = true;
$GLOBALS['TL_DCA']['tl_survey_content']['config']['notDeletable'] = true;
}
public function restrictSurveyConditionEditing(): void
{
if (!$this->isSurveyStructureLocked($this->resolveSurveyId('tl_survey_condition'))) {
return;
}
$GLOBALS['TL_DCA']['tl_survey_condition']['config']['notCreatable'] = true;
$GLOBALS['TL_DCA']['tl_survey_condition']['config']['notCopyable'] = true;
$GLOBALS['TL_DCA']['tl_survey_condition']['config']['notEditable'] = true;
$GLOBALS['TL_DCA']['tl_survey_condition']['config']['notDeletable'] = true;
}
public function guardSurveyStructureSave(mixed $value, DataContainer $dataContainer): mixed
{
$surveyId = (int) ($dataContainer->id ?? 0);
@@ -395,15 +540,24 @@ final class SurveyDcaListener
private function assertSurveyUnlocked(int $surveyId): void
{
if ($this->isSurveyStructureLocked($surveyId)) {
throw new \RuntimeException('Die Fragen dieser Umfrage können nach der Veröffentlichung oder nach ersten Teilnahmen nicht mehr geändert werden.');
}
}
private function isSurveyStructureLocked(int $surveyId): bool
{
if ($surveyId <= 0) {
return false;
}
$survey = $this->surveyRepository->findById($surveyId);
if (null === $survey) {
return;
return false;
}
if ('1' === (string) $survey->isLocked) {
throw new \RuntimeException('Die Umfrage ist gesperrt, weil bereits Antworten existieren.');
}
return '1' === (string) $survey->published || '1' === (string) $survey->isLocked;
}
private function resolveSurveyId(string $table, ?DataContainer $dataContainer = null): int
@@ -430,6 +584,10 @@ final class SurveyDcaListener
return 0;
}
if ($table === (string) Input::get('table') && '' === (string) Input::get('act')) {
return $recordId;
}
if ('survey' === $table) {
$value = $this->connection->fetchOne(sprintf('SELECT %s FROM %s WHERE id = ?', 'id', $table), [$recordId]);
+1 -1
View File
@@ -31,7 +31,7 @@ final class SurveyConditionEditorType extends AbstractType
'constraints' => [new NotBlank(), new Length(['max' => 255])],
])
->add('targetQuestion', ChoiceType::class, [
'label' => 'Naechste Frage',
'label' => 'Nächste Frage',
'choices' => array_flip($questionChoices),
'constraints' => [new NotBlank()],
])
+1 -1
View File
@@ -30,7 +30,7 @@ final class SurveyEditorType extends AbstractType
'attr' => ['rows' => 5],
])
->add('published', CheckboxType::class, [
'label' => 'Veroeffentlicht',
'label' => 'Veröffentlicht',
'required' => false,
])
;
+1 -1
View File
@@ -22,7 +22,7 @@ final class SurveyQuestionAnswerType extends AbstractType
$question = $options['question'];
if (!$question instanceof SurveyContentModel) {
throw new \InvalidArgumentException('Die Frage fehlt fuer das Antwortformular.');
throw new \InvalidArgumentException('Die Frage fehlt für das Antwortformular.');
}
$this->questionTypeRegistry->get((string) $question->type)->buildField($builder, $question);
+1 -1
View File
@@ -153,7 +153,7 @@ final class SurveyQuestionEditorType extends AbstractType
'attr' => ['data-question-editor-target' => 'rangeField'],
])
->add('rangeMax', IntegerType::class, [
'label' => 'Groesster Wert',
'label' => 'Größter Wert',
'empty_data' => '10',
'constraints' => [new GreaterThanOrEqual(['value' => -100000])],
'attr' => ['data-question-editor-target' => 'rangeField'],
+58 -6
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Mummert\SurveyBundle\Repository;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Connection;
final class SurveyAnswerRepository
@@ -12,7 +13,15 @@ final class SurveyAnswerRepository
{
}
public function saveAnswer(int $submissionId, int $questionId, string $questionType, string $value): void
public function saveAnswer(
int $submissionId,
int $questionId,
string $questionType,
string $questionLabel,
string $questionDescription,
int $questionSorting,
string $value,
): void
{
$existingId = $this->connection->fetchOne(
'SELECT id FROM tl_survey_answer WHERE submission = ? AND question = ? LIMIT 1',
@@ -22,6 +31,9 @@ final class SurveyAnswerRepository
if (false !== $existingId) {
$this->connection->update('tl_survey_answer', [
'questionType' => $questionType,
'questionLabel' => $questionLabel,
'questionDescription' => $questionDescription,
'questionSorting' => $questionSorting,
'value' => $value,
], [
'id' => (int) $existingId,
@@ -35,6 +47,9 @@ final class SurveyAnswerRepository
'submission' => $submissionId,
'question' => $questionId,
'questionType' => $questionType,
'questionLabel' => $questionLabel,
'questionDescription' => $questionDescription,
'questionSorting' => $questionSorting,
'value' => $value,
]);
}
@@ -53,8 +68,36 @@ final class SurveyAnswerRepository
);
}
public function findStoredAnswerValue(int $submissionId, int $questionId): ?string
{
$value = $this->connection->fetchOne(
'SELECT value FROM tl_survey_answer WHERE submission = ? AND question = ? LIMIT 1',
[$submissionId, $questionId],
);
return false === $value ? null : (string) $value;
}
/**
* @return list<array{questionId:int,value:string}>
* @param list<int> $questionIds
*/
public function deleteAnswersForSubmissionExceptQuestions(int $submissionId, array $questionIds): void
{
if ([] === $questionIds) {
$this->connection->delete('tl_survey_answer', ['submission' => $submissionId]);
return;
}
$this->connection->executeStatement(
'DELETE FROM tl_survey_answer WHERE submission = ? AND question NOT IN (?)',
[$submissionId, array_values(array_unique(array_map('intval', $questionIds)))],
[\PDO::PARAM_INT, ArrayParameterType::INTEGER],
);
}
/**
* @return list<array{questionId:int,questionType:string,questionLabel:string,questionDescription:string,questionSorting:int,value:string}>
*/
public function findFinishedAnswersBySurvey(int $surveyId): array
{
@@ -62,11 +105,15 @@ final class SurveyAnswerRepository
<<<'SQL'
SELECT
a.question AS questionId,
a.questionType,
a.questionLabel,
a.questionDescription,
a.questionSorting,
a.value
FROM tl_survey_answer a
INNER JOIN tl_survey_submission sub ON sub.id = a.submission
WHERE sub.survey = :survey AND sub.isFinished = 1
ORDER BY a.question ASC, a.id ASC
ORDER BY a.questionSorting ASC, a.question ASC, a.id ASC
SQL,
['survey' => $surveyId],
);
@@ -74,6 +121,10 @@ final class SurveyAnswerRepository
return array_map(
static fn (array $row): array => [
'questionId' => (int) $row['questionId'],
'questionType' => (string) ($row['questionType'] ?? ''),
'questionLabel' => (string) ($row['questionLabel'] ?? ''),
'questionDescription' => (string) ($row['questionDescription'] ?? ''),
'questionSorting' => (int) ($row['questionSorting'] ?? 0),
'value' => (string) $row['value'],
],
$rows,
@@ -92,11 +143,12 @@ final class SurveyAnswerRepository
a.question AS questionId,
a.value,
a.questionType,
q.question
COALESCE(NULLIF(a.questionLabel, ''), q.question, CONCAT('Frage #', a.question)) AS question,
COALESCE(a.questionDescription, q.description, '') AS questionDescription
FROM tl_survey_answer a
INNER JOIN tl_survey_content q ON q.id = a.question
LEFT JOIN tl_survey_content q ON q.id = a.question
WHERE a.submission = :submission
ORDER BY q.sorting ASC
ORDER BY COALESCE(NULLIF(a.questionSorting, 0), q.sorting, 0) ASC, a.id ASC
SQL,
['submission' => $submissionId],
);
+38 -2
View File
@@ -41,6 +41,7 @@ final class SurveyRepository
if ($publishedOnly) {
$qb->andWhere('published = 1');
$qb->andWhere('isActive = 1');
}
$id = $qb->executeQuery()->fetchOne();
@@ -65,6 +66,7 @@ final class SurveyRepository
s.alias,
s.description,
s.published,
s.isActive,
s.isLocked,
s.updatedAt,
COUNT(DISTINCT q.id) AS questionCount,
@@ -92,6 +94,7 @@ final class SurveyRepository
'category' => serialize($this->normalizeCategoryIds($data['category'] ?? [])),
'description' => (string) ($data['description'] ?? ''),
'published' => '',
'isActive' => '',
'isLocked' => '',
'createdBy' => $memberId,
'updatedBy' => $memberId,
@@ -115,7 +118,8 @@ final class SurveyRepository
'alias' => $this->ensurePublicAlias((string) $survey->alias, (int) $survey->id),
'category' => serialize($this->normalizeCategoryIds($data['category'] ?? StringUtil::deserialize($survey->category, true))),
'description' => (string) ($data['description'] ?? $survey->description),
'published' => !empty($data['published']) ? '1' : '',
'published' => array_key_exists('published', $data) ? (!empty($data['published']) ? '1' : '') : (string) $survey->published,
'isActive' => array_key_exists('isActive', $data) ? (!empty($data['isActive']) ? '1' : '') : (string) ($survey->isActive ?? ''),
'updatedBy' => $memberId,
'updatedAt' => time(),
], [
@@ -182,6 +186,8 @@ final class SurveyRepository
$this->connection->update('tl_survey', [
'categoryFilter' => implode(',', $categoryIds),
'categorySummary' => implode(', ', $categoryTitles),
'templateFilter' => '1' === (string) $survey->isTemplate ? '1' : '0',
'templateSummary' => '1' === (string) $survey->isTemplate ? 'Ja' : 'Nein',
'assignedMembersFilter' => implode(',', $memberIds),
'assignedMembersSummary' => implode(', ', $memberNames),
], [
@@ -212,6 +218,36 @@ final class SurveyRepository
]);
}
public function publish(int $surveyId, int $memberId): void
{
$now = time();
$this->connection->update('tl_survey', [
'published' => '1',
'isActive' => '1',
'isLocked' => '1',
'tstamp' => $now,
'updatedBy' => $memberId,
'updatedAt' => $now,
], [
'id' => $surveyId,
]);
}
public function setActive(int $surveyId, int $memberId, bool $isActive): void
{
$now = time();
$this->connection->update('tl_survey', [
'isActive' => $isActive ? '1' : '',
'tstamp' => $now,
'updatedBy' => $memberId,
'updatedAt' => $now,
], [
'id' => $surveyId,
]);
}
public function deleteCascade(int $surveyId): void
{
$this->connection->transactional(function () use ($surveyId): void {
@@ -347,7 +383,7 @@ final class SurveyRepository
);
return $this->hasListMetadataColumns = !array_diff(
['categoryfilter', 'categorysummary', 'assignedmembersfilter', 'assignedmemberssummary'],
['categoryfilter', 'categorysummary', 'templatefilter', 'templatesummary', 'assignedmembersfilter', 'assignedmemberssummary'],
$columnNames,
);
}
@@ -91,6 +91,17 @@ final class SurveySubmissionRepository
]);
}
public function reopen(int $submissionId, int $questionId): void
{
$this->connection->update('tl_survey_submission', [
'isFinished' => '',
'completedAt' => 0,
'currentQuestion' => $questionId,
], [
'id' => $submissionId,
]);
}
/**
* @return list<array<string, mixed>>
*/
+23 -7
View File
@@ -41,14 +41,30 @@ final class SurveyEditorService
$this->surveyRepository->update($survey, $memberId, $data->toArray());
}
public function toggleSurveyPublished(SurveyModel $survey, int $memberId): bool
public function publishSurvey(SurveyModel $survey, int $memberId): void
{
$data = SurveyEditorData::fromModel($survey);
$data->published = !$data->published;
if ('1' === (string) $survey->published) {
throw new \RuntimeException('Die Umfrage ist bereits veröffentlicht.');
}
$this->surveyRepository->update($survey, $memberId, $data->toArray());
$this->surveyRepository->publish((int) $survey->id, $memberId);
}
return $data->published;
public function toggleSurveyActive(SurveyModel $survey, int $memberId): bool
{
if ('1' !== (string) $survey->published) {
throw new \RuntimeException('Die Umfrage muss zuerst veröffentlicht werden.');
}
$isActive = '1' !== (string) ($survey->isActive ?? '');
$this->surveyRepository->setActive((int) $survey->id, $memberId, $isActive);
return $isActive;
}
public function isStructureLocked(SurveyModel $survey): bool
{
return '1' === (string) $survey->published || '1' === (string) $survey->isLocked;
}
public function saveQuestion(SurveyModel $survey, SurveyQuestionData $data, int $questionId = 0): int
@@ -117,8 +133,8 @@ final class SurveyEditorService
private function assertUnlocked(SurveyModel $survey): void
{
if ('1' === (string) $survey->isLocked) {
throw new \RuntimeException('Die Umfrage ist gesperrt, weil bereits Antworten existieren.');
if ($this->isStructureLocked($survey)) {
throw new \RuntimeException('Die Fragen dieser Umfrage können nach der Veröffentlichung oder nach ersten Teilnahmen nicht mehr geändert werden.');
}
}
}
+22
View File
@@ -63,6 +63,28 @@ final class SurveyFlowService
return null;
}
public function getFirstQuestion(SurveyModel $survey): ?SurveyContentModel
{
$questions = $this->getOrderedQuestions($survey);
return $questions[0] ?? null;
}
public function resolveQuestionById(SurveyModel $survey, int $questionId): ?SurveyContentModel
{
if ($questionId <= 0) {
return null;
}
foreach ($this->getOrderedQuestions($survey) as $question) {
if ((int) $question->id === $questionId) {
return $question;
}
}
return null;
}
/**
* @return array{current:int,total:int,percentage:int}
*/
+1 -1
View File
@@ -23,7 +23,7 @@ final class SurveyPermissionService
public function assertEditorAccess(SurveyModel $survey, FrontendUser $member): void
{
if (!$this->canEditSurvey($survey, $member)) {
throw new AccessDeniedHttpException('Sie duerfen diese Umfrage nicht bearbeiten.');
throw new AccessDeniedHttpException('Sie dürfen diese Umfrage nicht bearbeiten.');
}
}
}
+174 -101
View File
@@ -41,118 +41,191 @@ final class SurveyResultsViewService
/**
* @param list<SurveyContentModel> $questions
* @param list<array{questionId:int,value:string}> $answers
* @param list<array{questionId:int,questionType:string,questionLabel:string,questionDescription:string,questionSorting:int,value:string}> $answers
*
* @return list<array<string, mixed>>
*/
private function buildQuestionResults(array $questions, array $answers, int $completedSubmissionCount): array
{
$answersByQuestion = [];
foreach ($answers as $answer) {
$answersByQuestion[$answer['questionId']][] = $answer['value'];
}
$results = [];
foreach ($questions as $question) {
$questionId = (int) $question->id;
$values = $answersByQuestion[$questionId] ?? [];
$totalAnswers = count($values);
$responseRate = $completedSubmissionCount > 0 ? (int) round(($totalAnswers / $completedSubmissionCount) * 100) : 0;
$key = $this->buildQuestionResultKey(
(int) $question->id,
(string) $question->type,
(string) $question->question,
(string) ($question->description ?? ''),
);
$result = [
'id' => $questionId,
'question' => (string) $question->question,
'description' => (string) ($question->description ?? ''),
'type' => (string) $question->type,
'totalAnswers' => $totalAnswers,
'responseRate' => $responseRate,
'chart' => null,
];
switch ((string) $question->type) {
case 'yes_no_maybe':
$optionCounts = [
'Ja' => 0,
'Nein' => 0,
];
if ('1' === (string) $question->allowMaybe) {
$optionCounts['Vielleicht'] = 0;
}
foreach ($values as $value) {
$normalizedValue = mb_strtolower(trim($value));
if ('yes' === $normalizedValue) {
++$optionCounts['Ja'];
} elseif ('no' === $normalizedValue) {
++$optionCounts['Nein'];
} elseif ('maybe' === $normalizedValue && isset($optionCounts['Vielleicht'])) {
++$optionCounts['Vielleicht'];
}
}
$result['options'] = $this->buildOptionStats($optionCounts);
$result['chart'] = $this->buildPieChartConfig($result['options']);
break;
case 'choice':
$optionCounts = [];
for ($index = 1; $index <= 10; ++$index) {
$option = trim((string) $question->{'answerOption'.$index});
if ('' !== $option) {
$optionCounts[$option] = 0;
}
}
foreach ($values as $value) {
foreach (array_filter(array_map('trim', explode(' | ', $value)), static fn (string $entry): bool => '' !== $entry) as $selectedOption) {
if (!array_key_exists($selectedOption, $optionCounts)) {
$optionCounts[$selectedOption] = 0;
}
++$optionCounts[$selectedOption];
}
}
$result['options'] = $this->buildOptionStats($optionCounts);
$result['chart'] = $this->buildPieChartConfig($result['options']);
break;
case 'range':
$numericValues = array_values(array_map(static fn (string $value): int => (int) $value, $values));
$distribution = [];
foreach ($numericValues as $numericValue) {
$label = (string) $numericValue;
$distribution[$label] = ($distribution[$label] ?? 0) + 1;
}
ksort($distribution, SORT_NATURAL);
$result['minimum'] = [] !== $numericValues ? min($numericValues) : null;
$result['maximum'] = [] !== $numericValues ? max($numericValues) : null;
$result['average'] = [] !== $numericValues ? round(array_sum($numericValues) / count($numericValues), 2) : null;
$result['distribution'] = $this->buildOptionStats($distribution);
$result['chart'] = $this->buildRangeChartConfig($result['distribution']);
break;
case 'text':
default:
$result['responses'] = array_values(array_filter($values, static fn (string $value): bool => '' !== trim($value)));
$result['responseSummary'] = $this->buildTextResponseSummary($result['responses']);
$result['chart'] = $this->buildPieChartConfig($result['responseSummary']);
break;
}
$results[] = $result;
$results[$key] = $this->buildResultSkeleton(
(int) $question->id,
(string) $question->type,
(string) $question->question,
(string) ($question->description ?? ''),
(int) ($question->sorting ?? 0),
);
$results[$key]['_choiceOptions'] = $this->getChoiceOptions($question);
}
return $results;
foreach ($answers as $answer) {
$questionId = (int) ($answer['questionId'] ?? 0);
$questionType = (string) ($answer['questionType'] ?? 'text');
$questionLabel = trim((string) ($answer['questionLabel'] ?? '')) ?: 'Frage #'.$questionId;
$questionDescription = (string) ($answer['questionDescription'] ?? '');
$key = $this->buildQuestionResultKey($questionId, $questionType, $questionLabel, $questionDescription);
if (!isset($results[$key])) {
$results[$key] = $this->buildResultSkeleton(
$questionId,
$questionType,
$questionLabel,
$questionDescription,
(int) ($answer['questionSorting'] ?? 0),
);
}
$results[$key]['_values'][] = (string) ($answer['value'] ?? '');
}
foreach ($results as $key => $result) {
$results[$key] = $this->finalizeQuestionResult($result, $completedSubmissionCount);
}
uasort($results, static function (array $left, array $right): int {
$sortingComparison = ((int) ($left['_sorting'] ?? 0)) <=> ((int) ($right['_sorting'] ?? 0));
if (0 !== $sortingComparison) {
return $sortingComparison;
}
return strcasecmp((string) ($left['question'] ?? ''), (string) ($right['question'] ?? ''));
});
return array_values($results);
}
private function buildQuestionResultKey(int $questionId, string $questionType, string $questionLabel, string $questionDescription): string
{
return implode('|', [$questionId, $questionType, md5($questionLabel), md5($questionDescription)]);
}
/**
* @return array<string, mixed>
*/
private function buildResultSkeleton(int $questionId, string $questionType, string $questionLabel, string $questionDescription, int $sorting): array
{
return [
'id' => $questionId,
'question' => $questionLabel,
'description' => $questionDescription,
'type' => $questionType,
'totalAnswers' => 0,
'responseRate' => 0,
'chart' => null,
'_sorting' => $sorting,
'_values' => [],
'_choiceOptions' => [],
];
}
/**
* @param array<string, mixed> $result
*
* @return array<string, mixed>
*/
private function finalizeQuestionResult(array $result, int $completedSubmissionCount): array
{
$values = $result['_values'] ?? [];
$values = \is_array($values) ? array_values(array_map('strval', $values)) : [];
$totalAnswers = count($values);
$result['totalAnswers'] = $totalAnswers;
$result['responseRate'] = $completedSubmissionCount > 0 ? (int) round(($totalAnswers / $completedSubmissionCount) * 100) : 0;
switch ((string) ($result['type'] ?? 'text')) {
case 'yes_no_maybe':
$optionCounts = ['Ja' => 0, 'Nein' => 0];
foreach ($values as $value) {
$normalizedValue = mb_strtolower(trim($value));
if ('yes' === $normalizedValue) {
++$optionCounts['Ja'];
} elseif ('no' === $normalizedValue) {
++$optionCounts['Nein'];
} elseif ('maybe' === $normalizedValue) {
$optionCounts['Vielleicht'] = ($optionCounts['Vielleicht'] ?? 0) + 1;
}
}
$result['options'] = $this->buildOptionStats($optionCounts);
$result['chart'] = $this->buildPieChartConfig($result['options']);
break;
case 'choice':
$optionCounts = [];
foreach (($result['_choiceOptions'] ?? []) as $choiceOption) {
$optionCounts[(string) $choiceOption] = 0;
}
foreach ($values as $value) {
foreach (array_filter(array_map('trim', explode(' | ', $value)), static fn (string $entry): bool => '' !== $entry) as $selectedOption) {
$optionCounts[$selectedOption] = ($optionCounts[$selectedOption] ?? 0) + 1;
}
}
$result['options'] = $this->buildOptionStats($optionCounts);
$result['chart'] = $this->buildPieChartConfig($result['options']);
break;
case 'range':
$numericValues = array_values(array_map(static fn (string $value): int => (int) $value, $values));
$distribution = [];
foreach ($numericValues as $numericValue) {
$label = (string) $numericValue;
$distribution[$label] = ($distribution[$label] ?? 0) + 1;
}
ksort($distribution, SORT_NATURAL);
$result['minimum'] = [] !== $numericValues ? min($numericValues) : null;
$result['maximum'] = [] !== $numericValues ? max($numericValues) : null;
$result['average'] = [] !== $numericValues ? round(array_sum($numericValues) / count($numericValues), 2) : null;
$result['distribution'] = $this->buildOptionStats($distribution);
$result['chart'] = $this->buildRangeChartConfig($result['distribution']);
break;
case 'text':
default:
$result['responses'] = array_values(array_filter($values, static fn (string $value): bool => '' !== trim($value)));
$result['responseSummary'] = $this->buildTextResponseSummary($result['responses']);
$result['chart'] = null;
break;
}
unset($result['_sorting'], $result['_values'], $result['_choiceOptions']);
return $result;
}
/**
* @return list<string>
*/
private function getChoiceOptions(SurveyContentModel $question): array
{
$options = [];
for ($index = 1; $index <= 10; ++$index) {
$option = trim((string) $question->{'answerOption'.$index});
if ('' !== $option) {
$options[] = $option;
}
}
return $options;
}
/**
+92 -2
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Mummert\SurveyBundle\Service;
use Mummert\SurveyBundle\Model\SurveyContentModel;
use Mummert\SurveyBundle\Form\Model\SurveyAnswerData;
use Mummert\SurveyBundle\Model\SurveyModel;
use Mummert\SurveyBundle\Model\SurveySubmissionModel;
use Mummert\SurveyBundle\QuestionType\QuestionTypeRegistry;
@@ -42,20 +43,23 @@ final class SurveySubmissionService
$token = (string) new Ulid();
$submission = $this->surveySubmissionRepository->create((int) $survey->id, $token, $firstQuestion?->id ? (int) $firstQuestion->id : null);
$session->set($sessionKey, $token);
$session->set($this->buildHistoryKey((int) $survey->id), []);
return $submission;
}
public function storeAnswer(SurveyModel $survey, SurveySubmissionModel $submission, SurveyContentModel $question, mixed $rawAnswer): string
{
$questionType = $this->questionTypeRegistry->get((string) $question->type);
$normalizedAnswer = $questionType->normalizeAnswer($rawAnswer, $question);
$normalizedAnswer = $this->normalizeAnswerForQuestion($question, $rawAnswer);
$surveyHadAnswers = $this->surveyAnswerRepository->hasAnswersForSurvey((int) $survey->id);
$this->surveyAnswerRepository->saveAnswer(
(int) $submission->id,
(int) $question->id,
(string) $question->type,
trim((string) $question->question),
trim((string) ($question->description ?? '')),
(int) ($question->sorting ?? 0),
$normalizedAnswer,
);
@@ -66,6 +70,73 @@ final class SurveySubmissionService
return $normalizedAnswer;
}
public function normalizeAnswerForQuestion(SurveyContentModel $question, mixed $rawAnswer): string
{
return $this->questionTypeRegistry->get((string) $question->type)->normalizeAnswer($rawAnswer, $question);
}
public function createAnswerData(SurveyContentModel $question, mixed $storedValue = null): SurveyAnswerData
{
$answerData = new SurveyAnswerData();
if (null === $storedValue || '' === trim((string) $storedValue)) {
return $answerData;
}
$normalizedValue = (string) $storedValue;
$answerData->answer = match ((string) $question->type) {
'choice' => '1' === (string) $question->allowMultiple
? array_values(array_filter(array_map('trim', explode(' | ', $normalizedValue)), static fn (string $entry): bool => '' !== $entry))
: $normalizedValue,
'range' => (int) $normalizedValue,
default => $normalizedValue,
};
return $answerData;
}
public function getStoredAnswerValue(SurveySubmissionModel $submission, SurveyContentModel $question): ?string
{
return $this->surveyAnswerRepository->findStoredAnswerValue((int) $submission->id, (int) $question->id);
}
public function rememberQuestion(SurveyModel $survey, SessionInterface $session, SurveyContentModel $question): void
{
$history = $this->getHistory($survey, $session);
$questionId = (int) $question->id;
if (($history[array_key_last($history)] ?? null) !== $questionId) {
$history[] = $questionId;
}
$session->set($this->buildHistoryKey((int) $survey->id), $history);
}
public function canGoBack(SurveyModel $survey, SessionInterface $session): bool
{
return [] !== $this->getHistory($survey, $session);
}
public function goBack(SurveyModel $survey, SurveySubmissionModel $submission, SessionInterface $session): bool
{
$history = $this->getHistory($survey, $session);
if ([] === $history) {
return false;
}
$previousQuestionId = (int) array_pop($history);
$this->surveySubmissionRepository->reopen((int) $submission->id, $previousQuestionId);
$this->surveyAnswerRepository->deleteAnswersForSubmissionExceptQuestions(
(int) $submission->id,
array_merge($history, [$previousQuestionId]),
);
$session->set($this->buildHistoryKey((int) $survey->id), $history);
return true;
}
public function advance(SurveySubmissionModel $submission, ?SurveyContentModel $nextQuestion): void
{
if ($nextQuestion instanceof SurveyContentModel) {
@@ -100,6 +171,25 @@ final class SurveySubmissionService
return 'mummert_survey_submission_'.$surveyId;
}
private function buildHistoryKey(int $surveyId): string
{
return 'mummert_survey_history_'.$surveyId;
}
/**
* @return list<int>
*/
private function getHistory(SurveyModel $survey, SessionInterface $session): array
{
$history = $session->get($this->buildHistoryKey((int) $survey->id), []);
if (!\is_array($history)) {
return [];
}
return array_values(array_filter(array_map('intval', $history), static fn (int $questionId): bool => $questionId > 0));
}
/**
* @param array<string, mixed> $answer
*