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:
Jürgen Mummert
2026-06-14 09:13:07 +02:00
parent 79c4402aee
commit 1b16675ae9
23 changed files with 928 additions and 40 deletions
@@ -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.');
}
+2 -1
View File
@@ -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;
+1 -1
View File
@@ -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 = [];
+45 -1
View File
@@ -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);
+35 -4
View File
@@ -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;
}
}