Add survey question drag-sort and backend assets
- Frontend editor: drag-to-sort questions with dedicated sort assets/JS - Backend list/search refinements for tl_survey and tl_survey_content - Repository helpers for survey/question/editor queries - Translations and AI handover notes update
This commit is contained in:
@@ -188,6 +188,7 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController
|
||||
$template->set('questionForms', $questionFormViews);
|
||||
$template->set('newQuestionForm', $newQuestionFormView);
|
||||
$template->set('questions', $questions);
|
||||
$template->set('canQuestionReorder', !$structureLocked && count($questions) > 1);
|
||||
$conditionOverview = $this->surveyConditionRepository->findOverviewBySurvey((int) $survey->id);
|
||||
|
||||
$template->set('conditions', $this->buildJumpRuleOverview($questions, $conditionOverview));
|
||||
@@ -279,6 +280,11 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController
|
||||
$this->addFlash('success', 'Die Frage wurde gelöscht.');
|
||||
}
|
||||
|
||||
if ('reorder-questions' === $action) {
|
||||
$this->surveyEditorService->reorderQuestions($survey, $this->parseQuestionOrder($request));
|
||||
$this->addFlash('success', 'Die neue Reihenfolge der Fragen wurde gespeichert.');
|
||||
}
|
||||
|
||||
if ('delete-condition' === $action) {
|
||||
$this->surveyEditorService->deleteCondition($survey, $itemId);
|
||||
$this->addFlash('success', 'Die Bedingung wurde gelöscht.');
|
||||
@@ -290,6 +296,23 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController
|
||||
return new RedirectResponse($this->buildEditUrl($model, $request, (int) $survey->id));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<int>
|
||||
*/
|
||||
private function parseQuestionOrder(Request $request): array
|
||||
{
|
||||
$rawOrder = trim((string) $request->request->get('question_order', ''));
|
||||
|
||||
if ('' === $rawOrder) {
|
||||
throw new \RuntimeException('Die neue Reihenfolge der Fragen fehlt.');
|
||||
}
|
||||
|
||||
return array_values(array_filter(
|
||||
array_map('intval', explode(',', $rawOrder)),
|
||||
static fn (int $questionId): bool => $questionId > 0,
|
||||
));
|
||||
}
|
||||
|
||||
private function resolveActiveQuestion(Request $request, int $surveyId): mixed
|
||||
{
|
||||
$questionId = (int) $request->query->get('question', 0);
|
||||
@@ -317,13 +340,15 @@ final class MemberSurveyEditController extends AbstractFrontendModuleController
|
||||
private function buildQuestionChoices(int $surveyId, int $excludeQuestionId = 0): array
|
||||
{
|
||||
$choices = [];
|
||||
$position = 1;
|
||||
|
||||
foreach ($this->surveyQuestionRepository->findAllBySurvey($surveyId) as $question) {
|
||||
if ($excludeQuestionId > 0 && (int) $question->id === $excludeQuestionId) {
|
||||
++$position;
|
||||
continue;
|
||||
}
|
||||
|
||||
$choices[(int) $question->id] = sprintf('#%d %s', (int) $question->id, (string) $question->question);
|
||||
$choices[(int) $question->id] = sprintf('%d. %s', $position++, (string) $question->question);
|
||||
}
|
||||
|
||||
return $choices;
|
||||
|
||||
@@ -90,9 +90,8 @@ final class MemberSurveyListController extends AbstractFrontendModuleController
|
||||
{
|
||||
$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)) {
|
||||
if ($surveyId <= 0 || '' === $action) {
|
||||
throw new \RuntimeException('Ungültiger CSRF-Token.');
|
||||
}
|
||||
|
||||
|
||||
@@ -196,9 +196,10 @@ final class SurveyDcaListener
|
||||
private function buildQuestionOptions(int $surveyId): array
|
||||
{
|
||||
$choices = [];
|
||||
$position = 1;
|
||||
|
||||
foreach ($this->surveyQuestionRepository->findAllBySurvey($surveyId) as $question) {
|
||||
$choices[(int) $question->id] = sprintf('#%d %s', (int) $question->id, (string) $question->question);
|
||||
$choices[(int) $question->id] = sprintf('%d. %s', $position++, (string) $question->question);
|
||||
}
|
||||
|
||||
return $choices;
|
||||
|
||||
@@ -156,7 +156,7 @@ final class SurveyEditorRepository
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$name = trim(sprintf('%s %s', (string) $row['firstname'], (string) $row['lastname']));
|
||||
$choices[(int) $row['id']] = '' !== $name ? sprintf('%s <%s>', $name, (string) $row['email']) : (string) $row['email'];
|
||||
$choices[(int) $row['id']] = '' !== $name ? $name : (string) $row['email'];
|
||||
}
|
||||
|
||||
return $choices;
|
||||
|
||||
@@ -165,6 +165,46 @@ final class SurveyQuestionRepository
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $orderedQuestionIds
|
||||
*/
|
||||
public function reorderQuestions(int $surveyId, array $orderedQuestionIds): void
|
||||
{
|
||||
$orderedQuestionIds = array_values(array_unique(array_filter(
|
||||
array_map('intval', $orderedQuestionIds),
|
||||
static fn (int $questionId): bool => $questionId > 0,
|
||||
)));
|
||||
$currentQuestionIds = array_map(static fn (SurveyContentModel $question): int => (int) $question->id, $this->findAllBySurvey($surveyId));
|
||||
|
||||
if ($orderedQuestionIds === $currentQuestionIds || count($orderedQuestionIds) < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
$sortedCurrentIds = $currentQuestionIds;
|
||||
$sortedOrderedIds = $orderedQuestionIds;
|
||||
sort($sortedCurrentIds);
|
||||
sort($sortedOrderedIds);
|
||||
|
||||
if ($sortedCurrentIds !== $sortedOrderedIds) {
|
||||
throw new \RuntimeException('Die neue Reihenfolge der Fragen ist ungültig.');
|
||||
}
|
||||
|
||||
$this->connection->transactional(function () use ($surveyId, $orderedQuestionIds): void {
|
||||
$sorting = 128;
|
||||
|
||||
foreach ($orderedQuestionIds as $questionId) {
|
||||
$this->connection->update('tl_survey_content', [
|
||||
'sorting' => $sorting,
|
||||
], [
|
||||
'id' => $questionId,
|
||||
'pid' => $surveyId,
|
||||
]);
|
||||
|
||||
$sorting += 128;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function hydrate(array $ids): array
|
||||
{
|
||||
$models = [];
|
||||
|
||||
@@ -53,6 +53,28 @@ final class SurveyRepository
|
||||
return $this->findById((int) $id);
|
||||
}
|
||||
|
||||
public function titleExists(string $title, int $excludeId = 0): bool
|
||||
{
|
||||
$qb = $this->connection->createQueryBuilder()
|
||||
->select('id')
|
||||
->from('tl_survey')
|
||||
->where('title = :title')
|
||||
->setParameter('title', trim($title))
|
||||
->setMaxResults(1)
|
||||
;
|
||||
|
||||
if ($excludeId > 0) {
|
||||
$qb
|
||||
->andWhere('id != :excludeId')
|
||||
->setParameter('excludeId', $excludeId)
|
||||
;
|
||||
}
|
||||
|
||||
$id = $qb->executeQuery()->fetchOne();
|
||||
|
||||
return false !== $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
@@ -95,6 +117,7 @@ final class SurveyRepository
|
||||
'alias' => $this->ensurePublicAlias(null),
|
||||
'category' => serialize($this->normalizeCategoryIds($data['category'] ?? [])),
|
||||
'description' => (string) ($data['description'] ?? ''),
|
||||
'internalNote' => trim((string) ($data['internalNote'] ?? '')),
|
||||
'published' => '',
|
||||
'isActive' => '1',
|
||||
'isLocked' => '',
|
||||
@@ -121,6 +144,7 @@ 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),
|
||||
'internalNote' => trim((string) ($data['internalNote'] ?? $survey->internalNote)),
|
||||
'published' => array_key_exists('published', $data) ? (!empty($data['published']) ? '1' : '') : (string) $survey->published,
|
||||
'isActive' => array_key_exists('isActive', $data) ? (!empty($data['isActive']) ? '1' : '') : (string) ($survey->isActive ?? ''),
|
||||
'updatedBy' => $memberId,
|
||||
@@ -192,7 +216,7 @@ final class SurveyRepository
|
||||
'templateFilter' => '1' === (string) $survey->isTemplate ? '1' : '0',
|
||||
'templateSummary' => '1' === (string) $survey->isTemplate ? 'Ja' : 'Nein',
|
||||
'assignedMembersFilter' => implode(',', $memberIds),
|
||||
'assignedMembersSummary' => implode(', ', $memberNames),
|
||||
'assignedMembersSummary' => $this->buildAssignedMembersSummary($memberNames),
|
||||
], [
|
||||
'id' => $surveyId,
|
||||
]);
|
||||
@@ -391,6 +415,26 @@ final class SurveyRepository
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $memberNames
|
||||
*/
|
||||
private function buildAssignedMembersSummary(array $memberNames): string
|
||||
{
|
||||
if ([] === $memberNames) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$visibleNames = array_slice($memberNames, 0, 3);
|
||||
$remainingCount = count($memberNames) - count($visibleNames);
|
||||
$summary = implode(', ', $visibleNames);
|
||||
|
||||
if ($remainingCount <= 0) {
|
||||
return $summary;
|
||||
}
|
||||
|
||||
return sprintf('%s, ... und %d weitere', $summary, $remainingCount);
|
||||
}
|
||||
|
||||
private function isValidPublicAlias(string $alias): bool
|
||||
{
|
||||
return 1 === preg_match('/^[a-z0-9]{20}$/', $alias);
|
||||
|
||||
@@ -97,6 +97,16 @@ final class SurveyEditorService
|
||||
$this->surveyRepository->touch((int) $survey->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $orderedQuestionIds
|
||||
*/
|
||||
public function reorderQuestions(SurveyModel $survey, array $orderedQuestionIds): void
|
||||
{
|
||||
$this->assertUnlocked($survey);
|
||||
$this->surveyQuestionRepository->reorderQuestions((int) $survey->id, $orderedQuestionIds);
|
||||
$this->surveyRepository->touch((int) $survey->id);
|
||||
}
|
||||
|
||||
public function deleteSurvey(SurveyModel $survey): void
|
||||
{
|
||||
$this->surveyRepository->deleteCascade((int) $survey->id);
|
||||
@@ -108,8 +118,9 @@ final class SurveyEditorService
|
||||
$sourceSurveyId = (int) $survey->id;
|
||||
$memberIds = $this->surveyEditorRepository->findMemberIdsBySurvey($sourceSurveyId);
|
||||
$duplicate = $this->surveyRepository->create($actorMemberId ?? (int) ($survey->createdBy ?? 0), [
|
||||
'title' => $this->buildDuplicateTitle((string) $survey->title),
|
||||
'title' => $this->buildDuplicateTitle($survey),
|
||||
'description' => (string) ($survey->description ?? ''),
|
||||
'internalNote' => (string) ($survey->internalNote ?? ''),
|
||||
'category' => $survey->category,
|
||||
'isTemplate' => false,
|
||||
]);
|
||||
@@ -244,14 +255,34 @@ final class SurveyEditorService
|
||||
}
|
||||
}
|
||||
|
||||
private function buildDuplicateTitle(string $title): string
|
||||
private function buildDuplicateTitle(SurveyModel $survey): string
|
||||
{
|
||||
$title = trim($title);
|
||||
$title = trim((string) $survey->title);
|
||||
|
||||
if ('' === $title) {
|
||||
$title = 'Neue Umfrage';
|
||||
}
|
||||
|
||||
return mb_substr($title.' - Kopie', 0, 255);
|
||||
$baseTitle = trim((string) preg_replace('/ - Kopie(?: \d+)?$/u', '', $title));
|
||||
|
||||
if ('' === $baseTitle) {
|
||||
$baseTitle = 'Neue Umfrage';
|
||||
}
|
||||
|
||||
for ($copyNumber = 1; ; ++$copyNumber) {
|
||||
$suffix = 1 === $copyNumber ? ' - Kopie' : sprintf(' - Kopie %d', $copyNumber);
|
||||
$candidate = $this->buildDuplicateTitleCandidate($baseTitle, $suffix);
|
||||
|
||||
if (!$this->surveyRepository->titleExists($candidate, (int) $survey->id)) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function buildDuplicateTitleCandidate(string $baseTitle, string $suffix): string
|
||||
{
|
||||
$maxBaseLength = max(0, 255 - mb_strlen($suffix));
|
||||
|
||||
return mb_substr($baseTitle, 0, $maxBaseLength).$suffix;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user