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');