Refine survey editor flow and auth styling

This commit is contained in:
Jürgen Mummert
2026-05-25 16:02:47 +02:00
parent e204da60b6
commit a893078945
18 changed files with 1649 additions and 313 deletions
@@ -11,10 +11,8 @@ use Contao\CoreBundle\Twig\FragmentTemplate;
use Contao\FrontendUser;
use Contao\ModuleModel;
use Contao\PageModel;
use Mummert\SurveyBundle\Form\Model\SurveyConditionData;
use Mummert\SurveyBundle\Form\Model\SurveyEditorData;
use Mummert\SurveyBundle\Form\Model\SurveyQuestionData;
use Mummert\SurveyBundle\Form\SurveyConditionEditorType;
use Mummert\SurveyBundle\Form\SurveyEditorType;
use Mummert\SurveyBundle\Form\SurveyQuestionEditorType;
use Mummert\SurveyBundle\Model\SurveyModel;
@@ -22,9 +20,10 @@ use Mummert\SurveyBundle\Repository\SurveyConditionRepository;
use Mummert\SurveyBundle\Repository\SurveyEditorRepository;
use Mummert\SurveyBundle\Repository\SurveyQuestionRepository;
use Mummert\SurveyBundle\Repository\SurveyRepository;
use Mummert\SurveyBundle\Security\SurveyEditorVoter;
use Mummert\SurveyBundle\Service\SurveyEditorService;
use Mummert\SurveyBundle\Service\SurveySubmissionService;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -39,43 +38,57 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController
private readonly SurveyEditorRepository $surveyEditorRepository,
private readonly SurveyEditorService $surveyEditorService,
private readonly SurveySubmissionService $surveySubmissionService,
private readonly FormFactoryInterface $formFactory,
) {
}
protected function getResponse(FragmentTemplate $template, ModuleModel $model, Request $request): Response
{
$user = $this->getUser();
$memberId = $this->resolveEditorMemberId($request);
if (!$user instanceof FrontendUser) {
if ($memberId <= 0) {
$template->set('loginRequired', true);
$template->set('entryNotAllowed', false);
$template->set('backUrl', $this->resolveListUrl($model));
$template->set('createQuestionMode', false);
return $template->getResponse();
}
$survey = $this->resolveSurvey($request, $user);
if (!$this->isEditorEntryAllowed($request)) {
$template->set('loginRequired', false);
$template->set('entryNotAllowed', true);
$template->set('createMode', false);
$template->set('survey', null);
$template->set('backUrl', $this->resolveListUrl($model));
$template->set('createQuestionMode', false);
if ($survey instanceof SurveyModel && $request->isMethod('POST') && $request->request->has('_survey_action')) {
return $this->handleAction($survey, $request, $model);
return $template->getResponse();
}
$questionChoices = $survey instanceof SurveyModel ? $this->buildQuestionChoices((int) $survey->id) : [];
$surveyForm = $this->createNamed('survey_meta', SurveyEditorType::class, $survey instanceof SurveyModel ? SurveyEditorData::fromModel($survey) : new SurveyEditorData());
$survey = $this->resolveSurvey($request, $memberId);
$createQuestionMode = $this->isCreateQuestionRequested($request);
if ($survey instanceof SurveyModel && $request->isMethod('POST') && $request->request->has('_survey_action')) {
return $this->handleAction($survey, $request, $model, $memberId);
}
$surveyForm = $this->formFactory->createNamed('survey_meta', SurveyEditorType::class, $survey instanceof SurveyModel ? SurveyEditorData::fromModel($survey) : new SurveyEditorData());
$surveyForm->handleRequest($request);
if ($surveyForm->isSubmitted() && $surveyForm->isValid()) {
try {
if ($survey instanceof SurveyModel) {
$this->surveyEditorService->updateSurvey($survey, (int) $user->id, $surveyForm->getData());
$this->surveyEditorService->updateSurvey($survey, $memberId, $surveyForm->getData());
$this->addFlash('success', 'Die Umfrage wurde gespeichert.');
return new RedirectResponse($this->buildEditUrl($model, (int) $survey->id));
return new RedirectResponse($this->buildEditUrl($model, $request, (int) $survey->id));
}
$createdSurvey = $this->surveyEditorService->createSurvey((int) $user->id, $surveyForm->getData());
$createdSurvey = $this->surveyEditorService->createSurvey($memberId, $surveyForm->getData());
$this->addFlash('success', 'Die Umfrage wurde angelegt.');
return new RedirectResponse($this->buildEditUrl($model, (int) $createdSurvey->id));
return new RedirectResponse($this->buildEditUrl($model, $request, (int) $createdSurvey->id));
} catch (\Throwable $exception) {
$this->addFlash('error', $exception->getMessage());
}
@@ -83,76 +96,101 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController
if (!$survey instanceof SurveyModel) {
$template->set('loginRequired', false);
$template->set('entryNotAllowed', false);
$template->set('createMode', true);
$template->set('surveyForm', $surveyForm->createView());
$template->set('backUrl', $this->resolveListUrl($model));
$template->set('publicUrl', null);
$template->set('survey', null);
$template->set('questionForm', null);
$template->set('conditionForm', null);
$template->set('questions', []);
$template->set('conditions', []);
$template->set('editors', []);
$template->set('submissions', []);
$template->set('activeQuestionId', 0);
$template->set('activeConditionId', 0);
$template->set('createQuestionMode', false);
return $template->getResponse();
}
$questions = $this->surveyQuestionRepository->findAllBySurvey((int) $survey->id);
$activeQuestion = $this->resolveActiveQuestion($request, (int) $survey->id);
$activeCondition = $this->resolveActiveCondition($request, (int) $survey->id);
$questionForm = $this->createNamed('survey_question', SurveyQuestionEditorType::class, $activeQuestion ? SurveyQuestionData::fromModel($activeQuestion) : new SurveyQuestionData(), [
'question_choices' => $this->buildQuestionChoices((int) $survey->id),
]);
$conditionForm = $this->createNamed('survey_condition', SurveyConditionEditorType::class, $activeCondition ? SurveyConditionData::fromModel($activeCondition) : new SurveyConditionData(), [
'question_choices' => $questionChoices,
]);
$activeQuestionId = $activeQuestion ? (int) $activeQuestion->id : 0;
$questionFormViews = [];
$questionForm->handleRequest($request);
$conditionForm->handleRequest($request);
foreach ($questions as $question) {
$questionId = (int) $question->id;
$questionForm = $this->formFactory->createNamed('survey_question_'.$questionId, SurveyQuestionEditorType::class, SurveyQuestionData::fromModel($question), [
'question_choices' => $this->buildQuestionChoices((int) $survey->id, $questionId),
]);
$questionForm->handleRequest($request);
if ($questionForm->isSubmitted() && $questionForm->isValid()) {
try {
$this->surveyEditorService->saveQuestion($survey, $questionForm->getData(), $activeQuestion ? (int) $activeQuestion->id : 0);
$this->addFlash('success', $activeQuestion ? 'Die Frage wurde aktualisiert.' : 'Die Frage wurde angelegt.');
if ($questionForm->isSubmitted()) {
$activeQuestionId = $questionId;
$createQuestionMode = false;
return new RedirectResponse($this->buildEditUrl($model, (int) $survey->id));
} catch (\Throwable $exception) {
$this->addFlash('error', $exception->getMessage());
if ($questionForm->isValid()) {
try {
$savedQuestionId = $this->surveyEditorService->saveQuestion($survey, $questionForm->getData(), $questionId);
$this->addFlash('success', 'Die Frage wurde aktualisiert.');
return new RedirectResponse($this->buildEditUrl($model, $request, (int) $survey->id));
} catch (\Throwable $exception) {
$this->addFlash('error', $exception->getMessage());
}
} else {
$this->addFlash('error', 'Frage konnte nicht gespeichert werden: '.$this->describeFormErrors($questionForm));
}
}
$questionFormViews[$questionId] = $questionForm->createView();
}
if ($conditionForm->isSubmitted() && $conditionForm->isValid()) {
try {
$this->surveyEditorService->saveCondition($survey, $conditionForm->getData(), $activeCondition ? (int) $activeCondition->id : 0);
$this->addFlash('success', $activeCondition ? 'Die Bedingung wurde aktualisiert.' : 'Die Bedingung wurde angelegt.');
$newQuestionForm = $this->formFactory->createNamed('survey_question_new', SurveyQuestionEditorType::class, new SurveyQuestionData(), [
'question_choices' => $this->buildQuestionChoices((int) $survey->id),
]);
$newQuestionForm->handleRequest($request);
return new RedirectResponse($this->buildEditUrl($model, (int) $survey->id));
} catch (\Throwable $exception) {
$this->addFlash('error', $exception->getMessage());
if ($newQuestionForm->isSubmitted()) {
$activeQuestionId = 0;
$createQuestionMode = true;
if ($newQuestionForm->isValid()) {
try {
$savedQuestionId = $this->surveyEditorService->saveQuestion($survey, $newQuestionForm->getData(), 0);
$this->addFlash('success', 'Die Frage wurde angelegt.');
return new RedirectResponse($this->buildEditUrl($model, $request, (int) $survey->id));
} catch (\Throwable $exception) {
$this->addFlash('error', $exception->getMessage());
}
} else {
$this->addFlash('error', 'Neue Frage konnte nicht gespeichert werden: '.$this->describeFormErrors($newQuestionForm));
}
}
$template->set('loginRequired', false);
$template->set('entryNotAllowed', false);
$template->set('createMode', false);
$template->set('createQuestionMode', $createQuestionMode);
$template->set('survey', $survey);
$template->set('surveyForm', $surveyForm->createView());
$template->set('questionForm', $questionForm->createView());
$template->set('conditionForm', $conditionForm->createView());
$template->set('questions', $this->surveyQuestionRepository->findAllBySurvey((int) $survey->id));
$template->set('conditions', $this->surveyConditionRepository->findOverviewBySurvey((int) $survey->id));
$template->set('questionForms', $questionFormViews);
$template->set('newQuestionForm', $newQuestionForm->createView());
$template->set('questions', $questions);
$conditionOverview = $this->surveyConditionRepository->findOverviewBySurvey((int) $survey->id);
$template->set('conditions', $this->buildJumpRuleOverview($questions, $conditionOverview));
$template->set('editors', $this->surveyEditorRepository->findEditorsBySurvey((int) $survey->id));
$template->set('submissions', $this->surveySubmissionService->getSubmissionOverview($survey));
$template->set('activeQuestionId', $activeQuestion ? (int) $activeQuestion->id : 0);
$template->set('activeConditionId', $activeCondition ? (int) $activeCondition->id : 0);
$template->set('activeQuestionId', $activeQuestionId);
$template->set('backUrl', $this->resolveListUrl($model));
$template->set('publicUrl', $this->resolveReaderUrl($model, (string) $survey->alias));
$template->set('metadataFormActionUrl', $this->buildEditUrl($model, $request, (int) $survey->id));
return $template->getResponse();
}
private function resolveSurvey(Request $request, FrontendUser $user): ?SurveyModel
private function resolveSurvey(Request $request, int $memberId): ?SurveyModel
{
$surveyId = (int) $request->query->get('survey', 0);
@@ -166,12 +204,55 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController
throw new \RuntimeException('Die Umfrage wurde nicht gefunden.');
}
$this->denyAccessUnlessGranted(SurveyEditorVoter::EDIT, $survey);
if (!$this->surveyEditorRepository->isEditor((int) $survey->id, $memberId)) {
throw new \RuntimeException('Sie duerfen diese Umfrage nicht bearbeiten.');
}
return $survey;
}
private function handleAction(SurveyModel $survey, Request $request, ModuleModel $model): Response
private function resolveEditorMemberId(Request $request): int
{
$user = $this->getUser();
if ($user instanceof FrontendUser) {
return (int) $user->id;
}
return $this->isDebugAccessAllowed($request) ? $this->resolveDebugMemberId($request) : 0;
}
private function resolveDebugMemberId(Request $request): int
{
$memberId = (int) $request->query->get('debugMember', 0);
if ($memberId > 0) {
return $memberId;
}
$surveyId = (int) $request->query->get('survey', 0);
if ($surveyId <= 0) {
return 0;
}
$memberIds = $this->surveyEditorRepository->findMemberIdsBySurvey($surveyId);
return $memberIds[0] ?? 0;
}
private function isDebugAccessAllowed(Request $request): bool
{
$host = strtolower((string) ($request->server->get('HTTP_HOST') ?: $request->getHost()));
return (
'localhost' === $host
|| '127.0.0.1' === $host
|| str_ends_with($host, '.ddev.site')
) && $request->query->getBoolean('debugAccess');
}
private function handleAction(SurveyModel $survey, Request $request, ModuleModel $model, int $memberId): Response
{
$action = (string) $request->request->get('_survey_action');
$itemId = (int) $request->request->get('item_id', 0);
@@ -182,6 +263,11 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController
}
try {
if ('toggle-published' === $action) {
$isPublished = $this->surveyEditorService->toggleSurveyPublished($survey, $memberId);
$this->addFlash('success', $isPublished ? 'Die Umfrage ist jetzt veroeffentlicht.' : 'Die Umfrage ist jetzt nicht mehr veroeffentlicht.');
}
if ('delete-question' === $action) {
$this->surveyEditorService->deleteQuestion($survey, $itemId);
$this->addFlash('success', 'Die Frage wurde geloescht.');
@@ -195,7 +281,7 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController
$this->addFlash('error', $exception->getMessage());
}
return new RedirectResponse($this->buildEditUrl($model, (int) $survey->id));
return new RedirectResponse($this->buildEditUrl($model, $request, (int) $survey->id));
}
private function resolveActiveQuestion(Request $request, int $surveyId): mixed
@@ -205,16 +291,18 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController
return $questionId > 0 ? $this->surveyQuestionRepository->findByIdForSurvey($surveyId, $questionId) : null;
}
private function resolveActiveCondition(Request $request, int $surveyId): mixed
private function isCreateQuestionRequested(Request $request): bool
{
$conditionId = (int) $request->query->get('condition', 0);
$condition = $conditionId > 0 ? $this->surveyConditionRepository->findById($conditionId) : null;
return 'new' === trim((string) $request->query->get('question', ''));
}
if (null === $condition || (int) $condition->pid !== $surveyId) {
return null;
private function isEditorEntryAllowed(Request $request): bool
{
if ((int) $request->query->get('survey', 0) > 0) {
return true;
}
return $condition;
return $request->query->getBoolean('create');
}
/**
@@ -235,6 +323,84 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController
return $choices;
}
/**
* @param array<int, mixed> $questions
* @param list<array<string, mixed>> $conditionOverview
*
* @return list<array<string, mixed>>
*/
private function buildJumpRuleOverview(array $questions, array $conditionOverview): array
{
$questionLabels = [];
$questionSortings = [];
$rules = [];
foreach ($questions as $question) {
$questionId = (int) ($question->id ?? 0);
if ($questionId <= 0) {
continue;
}
$questionLabels[$questionId] = (string) ($question->question ?? '');
$questionSortings[$questionId] = (int) ($question->sorting ?? 0);
}
foreach ($questions as $question) {
$questionId = (int) ($question->id ?? 0);
if ($questionId <= 0) {
continue;
}
if ('yes_no_maybe' !== (string) ($question->type ?? '')) {
continue;
}
foreach (['Ja' => (int) ($question->jumpOnYes ?? 0), 'Nein' => (int) ($question->jumpOnNo ?? 0)] as $answerValue => $targetQuestionId) {
if ($targetQuestionId <= 0 || !isset($questionLabels[$targetQuestionId])) {
continue;
}
$rules[] = [
'id' => 'question-'.$questionId.'-'.$targetQuestionId.'-'.$answerValue,
'answerValue' => $answerValue,
'sourceQuestion' => $questionId,
'targetQuestion' => $targetQuestionId,
'sourceQuestionLabel' => (string) ($question->question ?? ''),
'targetQuestionLabel' => $questionLabels[$targetQuestionId] ?? '',
'sourceSorting' => $questionSortings[$questionId] ?? 0,
];
}
}
foreach ($conditionOverview as $condition) {
$sourceQuestionId = (int) ($condition['sourceQuestion'] ?? 0);
$rules[] = [
'id' => $condition['id'] ?? 0,
'answerValue' => (string) ($condition['answerValue'] ?? ''),
'sourceQuestion' => $sourceQuestionId,
'targetQuestion' => (int) ($condition['targetQuestion'] ?? 0),
'sourceQuestionLabel' => (string) ($condition['sourceQuestionLabel'] ?? ''),
'targetQuestionLabel' => (string) ($condition['targetQuestionLabel'] ?? ''),
'sourceSorting' => $questionSortings[$sourceQuestionId] ?? 0,
];
}
usort($rules, static function (array $left, array $right): int {
$sortingComparison = ((int) ($left['sourceSorting'] ?? 0)) <=> ((int) ($right['sourceSorting'] ?? 0));
if (0 !== $sortingComparison) {
return $sortingComparison;
}
return ((int) ($left['targetQuestion'] ?? 0)) <=> ((int) ($right['targetQuestion'] ?? 0));
});
return $rules;
}
private function resolveListUrl(ModuleModel $model): string
{
$page = $this->resolvePage((int) ($model->surveyListPage ?? 0)) ?? $this->getPageModel();
@@ -253,11 +419,44 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController
return $this->generateContentUrl($page, ['auto_item' => $publicAlias]);
}
private function buildEditUrl(ModuleModel $model, int $surveyId): string
private function buildEditUrl(ModuleModel $model, Request $request, int $surveyId): string
{
$page = $this->getPageModel();
$parameters = ['survey' => $surveyId];
return $page instanceof PageModel ? $this->generateContentUrl($page, ['survey' => $surveyId]) : $this->resolveListUrl($model);
if ($request->query->getBoolean('debugAccess') && $this->isDebugAccessAllowed($request)) {
$parameters['debugAccess'] = '1';
}
$debugMemberId = (int) $request->query->get('debugMember', 0);
if ($debugMemberId > 0) {
$parameters['debugMember'] = (string) $debugMemberId;
}
$basePath = $request->getBaseUrl().$request->getPathInfo();
if ('' !== $basePath) {
$queryString = http_build_query($parameters);
$url = $basePath.('' !== $queryString ? '?'.$queryString : '');
} else {
$page = $this->getPageModel();
$url = $page instanceof PageModel ? $this->generateContentUrl($page, $parameters) : $this->resolveListUrl($model);
}
return $url;
}
private function describeFormErrors(FormInterface $form): string
{
$messages = [];
foreach ($form->getErrors(true, true) as $error) {
$origin = $error->getOrigin();
$name = $origin instanceof FormInterface ? $origin->getName() : 'form';
$messages[] = sprintf('%s: %s', $name, $error->getMessage());
}
return [] !== $messages ? implode(' | ', array_unique($messages)) : 'Unbekannter Formularfehler.';
}
private function resolvePage(int $pageId): ?PageModel
@@ -11,8 +11,11 @@ use Contao\CoreBundle\Twig\FragmentTemplate;
use Contao\FrontendUser;
use Contao\ModuleModel;
use Contao\PageModel;
use Mummert\SurveyBundle\Model\SurveyModel;
use Mummert\SurveyBundle\Repository\SurveyEditorRepository;
use Mummert\SurveyBundle\Repository\SurveyRepository;
use Mummert\SurveyBundle\Service\SurveySubmissionService;
use Mummert\SurveyBundle\Service\SurveyEditorService;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -21,7 +24,8 @@ final class MemberSurveyListController extends AbstractFrontendModuleController
{
public function __construct(
private readonly SurveyRepository $surveyRepository,
private readonly SurveySubmissionService $surveySubmissionService,
private readonly SurveyEditorRepository $surveyEditorRepository,
private readonly SurveyEditorService $surveyEditorService,
) {
}
@@ -37,6 +41,10 @@ final class MemberSurveyListController extends AbstractFrontendModuleController
return $template->getResponse();
}
if ($request->isMethod('POST') && $request->request->has('_survey_action')) {
return $this->handleAction($request, (int) $user->id);
}
$editPage = $this->resolvePage((int) ($model->surveyEditPage ?? 0));
$readerPage = $this->resolvePage((int) $this->getContaoAdapter(Config::class)->get('surveyReaderPage'));
$resultsPage = $this->resolvePage((int) $this->getContaoAdapter(Config::class)->get('surveyResultsPage'));
@@ -44,7 +52,6 @@ final class MemberSurveyListController extends AbstractFrontendModuleController
foreach ($this->surveyRepository->findEditableByMember((int) $user->id) as $survey) {
$surveyId = (int) $survey['id'];
$isCompletedInCurrentSession = $this->surveySubmissionService->hasFinishedSubmissionForSurvey($surveyId, $request->getSession());
$items[] = [
'id' => $surveyId,
@@ -53,20 +60,60 @@ final class MemberSurveyListController extends AbstractFrontendModuleController
'isLocked' => !empty($survey['isLocked']),
'published' => !empty($survey['published']),
'questionCount' => (int) ($survey['questionCount'] ?? 0),
'answerCount' => (int) ($survey['answerCount'] ?? 0),
'participationCount' => (int) ($survey['participationCount'] ?? 0),
'editUrl' => $editPage instanceof PageModel ? $this->generateContentUrl($editPage, ['survey' => $surveyId]) : null,
'publicUrl' => $readerPage instanceof PageModel ? $this->generateContentUrl($readerPage, ['auto_item' => (string) $survey['alias']]) : null,
'resultsUrl' => !$isCompletedInCurrentSession && $resultsPage instanceof PageModel ? $this->generateContentUrl($resultsPage, ['auto_item' => (string) $survey['alias']]) : null,
'resultsUrl' => $resultsPage instanceof PageModel ? $this->generateContentUrl($resultsPage, ['auto_item' => (string) $survey['alias']]) : null,
];
}
$template->set('loginRequired', false);
$template->set('surveys', $items);
$template->set('createUrl', $editPage instanceof PageModel ? $this->generateContentUrl($editPage) : null);
$template->set('createUrl', $editPage instanceof PageModel ? $this->generateContentUrl($editPage, ['create' => '1']) : null);
return $template->getResponse();
}
private function handleAction(Request $request, int $memberId): Response
{
$surveyId = (int) $request->request->get('item_id', 0);
$action = (string) $request->request->get('_survey_action');
$token = (string) $request->request->get('_token');
if ($surveyId <= 0 || !$this->isCsrfTokenValid($action.'-'.$surveyId, $token)) {
throw new \RuntimeException('Ungueltiger CSRF-Token.');
}
$survey = $this->resolveEditableSurvey($surveyId, $memberId);
try {
if ('toggle-published' === $action) {
$isPublished = $this->surveyEditorService->toggleSurveyPublished($survey, $memberId);
$this->addFlash('success', $isPublished ? 'Die Umfrage ist jetzt veroeffentlicht.' : 'Die Umfrage ist jetzt nicht mehr veroeffentlicht.');
}
if ('delete-survey' === $action) {
$this->surveyEditorService->deleteSurvey($survey);
$this->addFlash('success', 'Die Umfrage mit allen Fragen, Antworten und Ergebnissen wurde geloescht.');
}
} catch (\Throwable $exception) {
$this->addFlash('error', $exception->getMessage());
}
return new RedirectResponse($request->getBaseUrl().$request->getPathInfo());
}
private function resolveEditableSurvey(int $surveyId, int $memberId): SurveyModel
{
$survey = $this->surveyRepository->findById($surveyId);
if (!$survey instanceof SurveyModel || !$this->surveyEditorRepository->isEditor($surveyId, $memberId)) {
throw new \RuntimeException('Sie duerfen diese Umfrage nicht bearbeiten.');
}
return $survey;
}
private function resolvePage(int $pageId): ?PageModel
{
if ($pageId <= 0) {