diff --git a/contao/dca/tl_survey.php b/contao/dca/tl_survey.php index c322520..6b563a3 100644 --- a/contao/dca/tl_survey.php +++ b/contao/dca/tl_survey.php @@ -12,6 +12,7 @@ $GLOBALS['TL_DCA']['tl_survey'] = [ 'ctable' => ['tl_survey_content', 'tl_survey_condition', 'tl_survey_editor', 'tl_survey_submission'], 'enableVersioning' => true, 'onload_callback' => [ + [SurveyDcaListener::class, 'handleDuplicateSurveyAction'], [SurveyDcaListener::class, 'refreshSurveyBackendStateIndex'], ], 'onsubmit_callback' => [ @@ -69,6 +70,12 @@ $GLOBALS['TL_DCA']['tl_survey'] = [ 'primary' => true, 'button_callback' => [SurveyDcaListener::class, 'generatePublishedWarningButton'], ], + 'duplicateSurvey' => [ + 'label' => &$GLOBALS['TL_LANG']['tl_survey']['duplicateSurvey'], + 'href' => 'key=duplicateSurvey', + 'icon' => 'copy.svg', + 'button_callback' => [SurveyDcaListener::class, 'generateDuplicateSurveyButton'], + ], 'delete' => [ 'label' => ['Löschen', 'Eintrag wirklich löschen?'], 'href' => 'act=delete', diff --git a/contao/languages/de/tl_survey.php b/contao/languages/de/tl_survey.php index 92f51f5..e1c7a66 100644 --- a/contao/languages/de/tl_survey.php +++ b/contao/languages/de/tl_survey.php @@ -25,6 +25,9 @@ $GLOBALS['TL_LANG']['tl_survey']['assignedMembers'] = ['Zugewiesene Mitglieder', $GLOBALS['TL_LANG']['tl_survey']['assignedMembersFilter'] = ['Zugewiesene Benutzer', 'Filtert die Liste nach einem zugewiesenen Benutzer.']; $GLOBALS['TL_LANG']['tl_survey']['assignedMembersSummary'] = ['Zugewiesene Benutzer', 'Kommagetrennte Liste der zugewiesenen Benutzer.']; $GLOBALS['TL_LANG']['tl_survey']['copyPublicLink'] = ['Link kopieren', 'Den öffentlichen Link in die Zwischenablage kopieren']; +$GLOBALS['TL_LANG']['tl_survey']['duplicateSurvey'] = ['Duplizieren', 'Diese Umfrage mit allen Fragen duplizieren']; +$GLOBALS['TL_LANG']['tl_survey']['duplicateSurveyConfirm'] = 'Sind Sie sicher, dass Sie die Umfrage "%s" mit allen Fragen duplizieren wollen?'; +$GLOBALS['TL_LANG']['tl_survey']['duplicateSurveyDone'] = 'Die Umfrage "%s" wurde dupliziert.'; $GLOBALS['TL_LANG']['tl_survey']['showResults'] = ['Ergebnisse anzeigen', 'Die Ergebnisse dieser Umfrage im Frontend anzeigen']; $GLOBALS['TL_LANG']['tl_survey']['createdBy'] = ['Angelegt von', 'ID des erstellenden Mitglieds.']; $GLOBALS['TL_LANG']['tl_survey']['updatedBy'] = ['Aktualisiert von', 'ID des zuletzt bearbeitenden Mitglieds.']; diff --git a/contao/languages/en/tl_survey.php b/contao/languages/en/tl_survey.php index 7bf1b17..6e8a029 100644 --- a/contao/languages/en/tl_survey.php +++ b/contao/languages/en/tl_survey.php @@ -25,6 +25,9 @@ $GLOBALS['TL_LANG']['tl_survey']['assignedMembers'] = ['Assigned members', 'Choo $GLOBALS['TL_LANG']['tl_survey']['assignedMembersFilter'] = ['Assigned users', 'Filters the list by an assigned user.']; $GLOBALS['TL_LANG']['tl_survey']['assignedMembersSummary'] = ['Assigned users', 'Comma-separated list of assigned users.']; $GLOBALS['TL_LANG']['tl_survey']['copyPublicLink'] = ['Copy link', 'Copy the public link to the clipboard']; +$GLOBALS['TL_LANG']['tl_survey']['duplicateSurvey'] = ['Duplicate', 'Duplicate this survey with all questions']; +$GLOBALS['TL_LANG']['tl_survey']['duplicateSurveyConfirm'] = 'Are you sure you want to duplicate the survey "%s" with all questions?'; +$GLOBALS['TL_LANG']['tl_survey']['duplicateSurveyDone'] = 'The survey "%s" has been duplicated.'; $GLOBALS['TL_LANG']['tl_survey']['showResults'] = ['Show results', 'Display the results of this survey in the frontend']; $GLOBALS['TL_LANG']['tl_survey']['createdBy'] = ['Created by', 'ID of the creating member.']; $GLOBALS['TL_LANG']['tl_survey']['updatedBy'] = ['Updated by', 'ID of the member who last edited it.']; diff --git a/contao/templates/frontend/_survey_assets.html.twig b/contao/templates/frontend/_survey_assets.html.twig index d6f806c..80e18c0 100644 --- a/contao/templates/frontend/_survey_assets.html.twig +++ b/contao/templates/frontend/_survey_assets.html.twig @@ -326,10 +326,11 @@ .survey-list-card__footer { display: flex; - align-items: center; + align-items: baseline; justify-content: space-between; gap: 0.75rem; flex-wrap: wrap; + margin-top: .3em; } .survey-list-card__stats { @@ -409,29 +410,25 @@ } .survey-visibility-dot { - width: 2.2rem; - height: 2.2rem; + aspect-ratio: 1; + height: 42px; border-radius: 999px; - border: 1px solid rgba(162, 168, 180, 0.62); - background: #7f8793; + background: #a6adb7; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; - transition: background 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease; + transition: background 0.18s ease; } .survey-visibility-dot.is-on { - background: #0a8378; - border-color: rgba(0, 174, 151, 0.68); - box-shadow: 0 0 0 0.18rem rgba(0, 174, 151, 0.14); + background: #0a832e; } .survey-button-row { display: flex; flex-wrap: wrap; gap: 0.5rem; - margin-top: 0.65rem; } .survey-button-row--form-actions { diff --git a/contao/templates/frontend/member_survey_list.html.twig b/contao/templates/frontend/member_survey_list.html.twig index a13648c..8da4cd8 100644 --- a/contao/templates/frontend/member_survey_list.html.twig +++ b/contao/templates/frontend/member_survey_list.html.twig @@ -61,6 +61,13 @@ {% if survey.resultsUrl %} {{ 'survey.list.show_results'|trans }} {% endif %} +
+ + + + + +
diff --git a/src/Controller/FrontendModule/MemberSurveyListController.php b/src/Controller/FrontendModule/MemberSurveyListController.php index bfae25c..60ff64b 100644 --- a/src/Controller/FrontendModule/MemberSurveyListController.php +++ b/src/Controller/FrontendModule/MemberSurveyListController.php @@ -90,6 +90,11 @@ final class MemberSurveyListController extends AbstractFrontendModuleController $survey = $this->resolveEditableSurvey($surveyId, $memberId); try { + if ('duplicate-survey' === $action) { + $duplicate = $this->surveyEditorService->duplicateSurvey($survey, $memberId); + $this->addFlash('success', sprintf('Die Umfrage "%s" wurde als Kopie angelegt.', (string) $duplicate->title)); + } + if ('publish-survey' === $action) { $this->surveyEditorService->publishSurvey($survey, $memberId); $this->addFlash('success', 'Die Umfrage ist jetzt veröffentlicht.'); diff --git a/src/EventListener/SurveyDcaListener.php b/src/EventListener/SurveyDcaListener.php index a2ad923..9c61557 100644 --- a/src/EventListener/SurveyDcaListener.php +++ b/src/EventListener/SurveyDcaListener.php @@ -6,22 +6,30 @@ namespace Mummert\SurveyBundle\EventListener; use Contao\Config; use Contao\CoreBundle\DataContainer\DataContainerOperation; +use Contao\CoreBundle\Exception\RedirectResponseException; use Contao\CoreBundle\Routing\ContentUrlGenerator; use Contao\CoreBundle\Framework\ContaoFramework; use Contao\DataContainer; use Contao\Image; use Contao\Input; +use Contao\Message; use Contao\PageModel; use Contao\StringUtil; +use Contao\System; use Doctrine\DBAL\Connection; use Mummert\SurveyBundle\Repository\SurveyEditorRepository; use Mummert\SurveyBundle\Repository\SurveyQuestionRepository; use Mummert\SurveyBundle\Repository\SurveyRepository; +use Mummert\SurveyBundle\Service\SurveyEditorService; use Mummert\SurveyBundle\Service\SurveyCategoryAssignmentService; +use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; final class SurveyDcaListener { + private ?SurveyEditorService $surveyEditorService; + private ?RequestStack $requestStack; + public function __construct( private readonly SurveyRepository $surveyRepository, private readonly SurveyQuestionRepository $surveyQuestionRepository, @@ -30,7 +38,11 @@ final class SurveyDcaListener private readonly ContaoFramework $framework, private readonly Connection $connection, private readonly ContentUrlGenerator $contentUrlGenerator, + ?SurveyEditorService $surveyEditorService = null, + ?RequestStack $requestStack = null, ) { + $this->surveyEditorService = $surveyEditorService; + $this->requestStack = $requestStack; } public function generateSurveyAlias(mixed $value, DataContainer $dataContainer): string @@ -114,6 +126,51 @@ final class SurveyDcaListener )); } + public function generateDuplicateSurveyButton(DataContainerOperation $operation): void + { + $row = $operation->getRecord() ?? []; + $title = trim(strip_tags((string) ($row['title'] ?? ''))); + $confirmText = sprintf( + $GLOBALS['TL_LANG']['tl_survey']['duplicateSurveyConfirm'] ?? 'Sind Sie sicher, dass Sie die Umfrage "%s" mit allen Fragen duplizieren wollen?', + '' !== $title ? $title : '#'.(string) ($row['id'] ?? 0), + ); + + $operation['label'] = $GLOBALS['TL_LANG']['tl_survey']['duplicateSurvey'][0] ?? 'Duplizieren'; + $operation['title'] = $GLOBALS['TL_LANG']['tl_survey']['duplicateSurvey'][1] ?? 'Diese Umfrage mit allen Fragen duplizieren'; + $operation['icon'] = 'copy.svg'; + $operation['attributes'] + ->set('onclick', sprintf('if(!confirm(%s))return false;Backend.getScrollOffset()', json_encode($confirmText, JSON_THROW_ON_ERROR))) + ->set('data-contao--tooltips-target', 'tooltip') + ; + } + + public function handleDuplicateSurveyAction(): void + { + if ('duplicateSurvey' !== (string) Input::get('key')) { + return; + } + + $surveyId = (int) Input::get('id'); + + try { + $survey = $this->surveyRepository->findById($surveyId); + + if (null === $survey) { + throw new \RuntimeException('Die Umfrage wurde nicht gefunden.'); + } + + $duplicate = $this->getSurveyEditorService()->duplicateSurvey($survey); + Message::addConfirmation(sprintf( + $GLOBALS['TL_LANG']['tl_survey']['duplicateSurveyDone'] ?? 'Die Umfrage "%s" wurde dupliziert.', + (string) $duplicate->title, + )); + } catch (\Throwable $exception) { + Message::addError($exception->getMessage()); + } + + throw new RedirectResponseException($this->buildBackendListUrl()); + } + /** * @return array */ @@ -496,4 +553,54 @@ final class SurveyDcaListener { return (int) $this->framework->getAdapter(Config::class)->get($settingKey); } + + private function buildBackendListUrl(): string + { + $request = $this->getRequestStack()->getCurrentRequest(); + + if (null === $request) { + return '/contao'; + } + + $query = $request->query->all(); + unset($query['key'], $query['id']); + + $url = $request->getBaseUrl().$request->getPathInfo(); + + if ([] !== $query) { + $url .= '?'.http_build_query($query); + } + + return $url; + } + + private function getSurveyEditorService(): SurveyEditorService + { + if ($this->surveyEditorService instanceof SurveyEditorService) { + return $this->surveyEditorService; + } + + $service = System::getContainer()->get(SurveyEditorService::class); + + if (!$service instanceof SurveyEditorService) { + throw new \RuntimeException('SurveyEditorService konnte nicht geladen werden.'); + } + + return $this->surveyEditorService = $service; + } + + private function getRequestStack(): RequestStack + { + if ($this->requestStack instanceof RequestStack) { + return $this->requestStack; + } + + $service = System::getContainer()->get('request_stack'); + + if (!$service instanceof RequestStack) { + throw new \RuntimeException('RequestStack konnte nicht geladen werden.'); + } + + return $this->requestStack = $service; + } } \ No newline at end of file diff --git a/src/Repository/SurveyRepository.php b/src/Repository/SurveyRepository.php index bc7b93a..be65552 100644 --- a/src/Repository/SurveyRepository.php +++ b/src/Repository/SurveyRepository.php @@ -94,8 +94,9 @@ final class SurveyRepository 'category' => serialize($this->normalizeCategoryIds($data['category'] ?? [])), 'description' => (string) ($data['description'] ?? ''), 'published' => '', - 'isActive' => '', + 'isActive' => '1', 'isLocked' => '', + 'isTemplate' => !empty($data['isTemplate']) ? '1' : '', 'createdBy' => $memberId, 'updatedBy' => $memberId, 'createdAt' => $now, diff --git a/src/Service/SurveyEditorService.php b/src/Service/SurveyEditorService.php index ccdd0e4..b858e5b 100644 --- a/src/Service/SurveyEditorService.php +++ b/src/Service/SurveyEditorService.php @@ -4,9 +4,11 @@ declare(strict_types=1); namespace Mummert\SurveyBundle\Service; +use Doctrine\DBAL\Connection; use Mummert\SurveyBundle\Form\Model\SurveyConditionData; use Mummert\SurveyBundle\Form\Model\SurveyEditorData; use Mummert\SurveyBundle\Form\Model\SurveyQuestionData; +use Mummert\SurveyBundle\Model\SurveyContentModel; use Mummert\SurveyBundle\Model\SurveyModel; use Mummert\SurveyBundle\Repository\SurveyConditionRepository; use Mummert\SurveyBundle\Repository\SurveyEditorRepository; @@ -21,6 +23,7 @@ final class SurveyEditorService private readonly SurveyConditionRepository $surveyConditionRepository, private readonly SurveyEditorRepository $surveyEditorRepository, private readonly SurveyCategoryAssignmentService $surveyCategoryAssignmentService, + private readonly Connection $connection, ) { } @@ -99,6 +102,113 @@ final class SurveyEditorService $this->surveyRepository->deleteCascade((int) $survey->id); } + public function duplicateSurvey(SurveyModel $survey, ?int $actorMemberId = null): SurveyModel + { + return $this->connection->transactional(function () use ($survey, $actorMemberId): SurveyModel { + $sourceSurveyId = (int) $survey->id; + $memberIds = $this->surveyEditorRepository->findMemberIdsBySurvey($sourceSurveyId); + $duplicate = $this->surveyRepository->create($actorMemberId ?? (int) ($survey->createdBy ?? 0), [ + 'title' => $this->buildDuplicateTitle((string) $survey->title), + 'description' => (string) ($survey->description ?? ''), + 'category' => $survey->category, + 'isTemplate' => '1' === (string) ($survey->isTemplate ?? ''), + ]); + + $duplicateSurveyId = (int) $duplicate->id; + + if ([] !== $memberIds) { + $this->surveyEditorRepository->syncMembersForSurvey($duplicateSurveyId, $memberIds); + } + + $duplicateQuestions = []; + $questionIdMap = []; + $sourceQuestions = $this->surveyQuestionRepository->findAllBySurvey($sourceSurveyId); + + foreach ($sourceQuestions as $sourceQuestion) { + $newQuestion = $this->surveyQuestionRepository->create($duplicateSurveyId, [ + 'type' => (string) $sourceQuestion->type, + 'question' => (string) $sourceQuestion->question, + 'description' => (string) ($sourceQuestion->description ?? ''), + 'mandatory' => '1' === (string) $sourceQuestion->mandatory, + 'published' => '1' === (string) $sourceQuestion->published, + 'allowMaybe' => '1' === (string) ($sourceQuestion->allowMaybe ?? ''), + 'allowMultiple' => '1' === (string) ($sourceQuestion->allowMultiple ?? ''), + 'answerOption1' => (string) ($sourceQuestion->answerOption1 ?? ''), + 'answerOption2' => (string) ($sourceQuestion->answerOption2 ?? ''), + 'answerOption3' => (string) ($sourceQuestion->answerOption3 ?? ''), + 'answerOption4' => (string) ($sourceQuestion->answerOption4 ?? ''), + 'answerOption5' => (string) ($sourceQuestion->answerOption5 ?? ''), + 'answerOption6' => (string) ($sourceQuestion->answerOption6 ?? ''), + 'answerOption7' => (string) ($sourceQuestion->answerOption7 ?? ''), + 'answerOption8' => (string) ($sourceQuestion->answerOption8 ?? ''), + 'answerOption9' => (string) ($sourceQuestion->answerOption9 ?? ''), + 'answerOption10' => (string) ($sourceQuestion->answerOption10 ?? ''), + 'rangeMin' => (int) ($sourceQuestion->rangeMin ?? 0), + 'rangeMax' => (int) ($sourceQuestion->rangeMax ?? 10), + 'rangeStep' => (int) ($sourceQuestion->rangeStep ?? 1), + 'jumpOnYes' => 0, + 'jumpOnNo' => 0, + ]); + + $sourceQuestionId = (int) $sourceQuestion->id; + $questionIdMap[$sourceQuestionId] = (int) $newQuestion->id; + $duplicateQuestions[$sourceQuestionId] = $newQuestion; + } + + foreach ($sourceQuestions as $sourceQuestion) { + $sourceQuestionId = (int) $sourceQuestion->id; + $duplicateQuestion = $duplicateQuestions[$sourceQuestionId] ?? null; + + if (!$duplicateQuestion instanceof SurveyContentModel) { + throw new \RuntimeException('Fragenkopie konnte nicht vervollständigt werden.'); + } + + $this->surveyQuestionRepository->update($duplicateQuestion, [ + 'type' => (string) $sourceQuestion->type, + 'question' => (string) $sourceQuestion->question, + 'description' => (string) ($sourceQuestion->description ?? ''), + 'mandatory' => '1' === (string) $sourceQuestion->mandatory, + 'published' => '1' === (string) $sourceQuestion->published, + 'allowMaybe' => '1' === (string) ($sourceQuestion->allowMaybe ?? ''), + 'allowMultiple' => '1' === (string) ($sourceQuestion->allowMultiple ?? ''), + 'answerOption1' => (string) ($sourceQuestion->answerOption1 ?? ''), + 'answerOption2' => (string) ($sourceQuestion->answerOption2 ?? ''), + 'answerOption3' => (string) ($sourceQuestion->answerOption3 ?? ''), + 'answerOption4' => (string) ($sourceQuestion->answerOption4 ?? ''), + 'answerOption5' => (string) ($sourceQuestion->answerOption5 ?? ''), + 'answerOption6' => (string) ($sourceQuestion->answerOption6 ?? ''), + 'answerOption7' => (string) ($sourceQuestion->answerOption7 ?? ''), + 'answerOption8' => (string) ($sourceQuestion->answerOption8 ?? ''), + 'answerOption9' => (string) ($sourceQuestion->answerOption9 ?? ''), + 'answerOption10' => (string) ($sourceQuestion->answerOption10 ?? ''), + 'rangeMin' => (int) ($sourceQuestion->rangeMin ?? 0), + 'rangeMax' => (int) ($sourceQuestion->rangeMax ?? 10), + 'rangeStep' => (int) ($sourceQuestion->rangeStep ?? 1), + 'jumpOnYes' => $questionIdMap[(int) ($sourceQuestion->jumpOnYes ?? 0)] ?? 0, + 'jumpOnNo' => $questionIdMap[(int) ($sourceQuestion->jumpOnNo ?? 0)] ?? 0, + ]); + } + + foreach ($this->surveyConditionRepository->findOverviewBySurvey($sourceSurveyId) as $condition) { + $sourceQuestionId = $questionIdMap[(int) ($condition['sourceQuestion'] ?? 0)] ?? 0; + + if ($sourceQuestionId <= 0) { + continue; + } + + $this->surveyConditionRepository->create($duplicateSurveyId, [ + 'sourceQuestion' => $sourceQuestionId, + 'answerValue' => (string) ($condition['answerValue'] ?? ''), + 'targetQuestion' => $questionIdMap[(int) ($condition['targetQuestion'] ?? 0)] ?? 0, + ]); + } + + $this->surveyRepository->refreshListMetadata($duplicateSurveyId); + + return $this->surveyRepository->findById($duplicateSurveyId) ?? throw new \RuntimeException('Umfragekopie konnte nicht angelegt werden.'); + }); + } + public function saveCondition(SurveyModel $survey, SurveyConditionData $data, int $conditionId = 0): void { $this->assertUnlocked($survey); @@ -133,4 +243,15 @@ final class SurveyEditorService throw new \RuntimeException('Die Fragen dieser Umfrage können nach der Veröffentlichung oder nach ersten Teilnahmen nicht mehr geändert werden.'); } } + + private function buildDuplicateTitle(string $title): string + { + $title = trim($title); + + if ('' === $title) { + $title = 'Neue Umfrage'; + } + + return mb_substr($title.' - Kopie', 0, 255); + } } \ No newline at end of file diff --git a/translations/messages.de.yaml b/translations/messages.de.yaml index 5c9f819..87e0ba0 100644 --- a/translations/messages.de.yaml +++ b/translations/messages.de.yaml @@ -2,6 +2,8 @@ survey: list: login_required: "Dieser Bereich verwendet den normalen Contao-Mitgliederlogin. Bitte binden Sie das Modul auf einer geschützten Seite oder mit einer separaten Login-Seite ein." create: "Neue Umfrage anlegen" + duplicate: "Duplizieren" + duplicate_confirm: "Sind Sie sicher, dass Sie die Umfrage \"%title%\" mit allen Fragen duplizieren wollen?" delete: "Löschen" delete_confirm: "Umfrage wirklich löschen? Damit werden auch alle Fragen, Antworten und Ergebnisse endgültig gelöscht. Alternativ können Sie die Umfrage auch einfach vorübergehend deaktivieren." delete_confirm_second: "Wirklich sicher?" diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index fe7bc3c..779e0d6 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -2,6 +2,8 @@ survey: list: login_required: "This area uses the regular Contao member login. Please place the module on a protected page or provide a separate login page." create: "Create new survey" + duplicate: "Duplicate" + duplicate_confirm: "Are you sure you want to duplicate the survey \"%title%\" with all questions?" delete: "Delete" delete_confirm: "Delete this survey? This will permanently remove all questions, answers, and results as well. Alternatively, you can simply deactivate the survey temporarily." delete_confirm_second: "Are you really sure?"