From aa2ecbaddf213f707365422af37b00f1382ac3d3 Mon Sep 17 00:00:00 2001 From: paul bochtler <65470117+datapumpernickel@users.noreply.github.com> Date: Sun, 29 Mar 2026 01:39:38 +0100 Subject: [PATCH 1/5] feat: add ranking question type Adds a new 'ranking' question type that allows respondents to drag-and-drop predefined options into their preferred order. Based on refactor/vue3 branch, using vue-draggable-plus. Signed-off-by: paul bochtler <65470117+datapumpernickel@users.noreply.github.com> --- lib/Constants.php | 7 + lib/Controller/ApiController.php | 16 ++ lib/Service/FormsService.php | 3 + lib/Service/SubmissionService.php | 36 ++- src/components/Questions/QuestionRanking.vue | 261 +++++++++++++++++++ src/components/Results/ResultsSummary.vue | 97 ++++++- src/components/Results/Submission.vue | 23 ++ src/models/AnswerTypes.js | 18 ++ tests/Unit/Service/SubmissionServiceTest.php | 96 ++++++- 9 files changed, 554 insertions(+), 3 deletions(-) create mode 100644 src/components/Questions/QuestionRanking.vue diff --git a/lib/Constants.php b/lib/Constants.php index 3cb470193..2ea586a3d 100644 --- a/lib/Constants.php +++ b/lib/Constants.php @@ -76,6 +76,7 @@ class Constants { public const ANSWER_TYPE_LONG = 'long'; public const ANSWER_TYPE_MULTIPLE = 'multiple'; public const ANSWER_TYPE_MULTIPLEUNIQUE = 'multiple_unique'; + public const ANSWER_TYPE_RANKING = 'ranking'; public const ANSWER_TYPE_SHORT = 'short'; public const ANSWER_TYPE_TIME = 'time'; @@ -95,6 +96,7 @@ class Constants { self::ANSWER_TYPE_LONG, self::ANSWER_TYPE_MULTIPLE, self::ANSWER_TYPE_MULTIPLEUNIQUE, + self::ANSWER_TYPE_RANKING, self::ANSWER_TYPE_SHORT, self::ANSWER_TYPE_TIME, ]; @@ -105,6 +107,7 @@ class Constants { self::ANSWER_TYPE_LINEARSCALE, self::ANSWER_TYPE_MULTIPLE, self::ANSWER_TYPE_MULTIPLEUNIQUE, + self::ANSWER_TYPE_RANKING, ]; // AnswerTypes for date/time questions @@ -191,6 +194,10 @@ class Constants { 'rows' => ['array'], ]; + public const EXTRA_SETTINGS_RANKING = [ + 'shuffleOptions' => ['boolean'], + ]; + public const EXTRA_SETTINGS_GRID_QUESTION_TYPE = [ self::ANSWER_GRID_TYPE_CHECKBOX, self::ANSWER_GRID_TYPE_NUMBER, diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php index 0cbe6a73e..4fe8c4032 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -1736,6 +1736,22 @@ private function storeAnswersForQuestion(Form $form, $submissionId, array $quest return; } + if ($question['type'] === Constants::ANSWER_TYPE_RANKING) { + if (!$answerArray) { + return; + } + + $answerEntity = new Answer(); + $answerEntity->setSubmissionId($submissionId); + $answerEntity->setQuestionId($question['id']); + + $answerText = json_encode($answerArray); + $answerEntity->setText($answerText); + $this->answerMapper->insert($answerEntity); + + return; + } + foreach ($answerArray as $answer) { $answerEntity = new Answer(); $answerEntity->setSubmissionId($submissionId); diff --git a/lib/Service/FormsService.php b/lib/Service/FormsService.php index f5b421c3a..5f2006ae5 100644 --- a/lib/Service/FormsService.php +++ b/lib/Service/FormsService.php @@ -810,6 +810,9 @@ public function areExtraSettingsValid(array $extraSettings, string $questionType case Constants::ANSWER_TYPE_GRID: $allowed = Constants::EXTRA_SETTINGS_GRID; break; + case Constants::ANSWER_TYPE_RANKING: + $allowed = Constants::EXTRA_SETTINGS_RANKING; + break; case Constants::ANSWER_TYPE_TIME: $allowed = Constants::EXTRA_SETTINGS_TIME; break; diff --git a/lib/Service/SubmissionService.php b/lib/Service/SubmissionService.php index b80c96ece..b2ec5d803 100644 --- a/lib/Service/SubmissionService.php +++ b/lib/Service/SubmissionService.php @@ -252,6 +252,8 @@ public function getSubmissionsData(Form $form, string $fileFormat, ?File $file = $gridRowsPerQuestionId = []; /** @var array> $gridColumnsPerQuestionId */ $gridColumnsPerQuestionId = []; + /** @var array> $rankingOptionsPerQuestionId */ + $rankingOptionsPerQuestionId = []; $optionPerOptionId = []; foreach ($questions as $question) { @@ -280,6 +282,15 @@ public function getSubmissionsData(Form $form, string $fileFormat, ?File $file = } } } + } elseif ($question->getType() === Constants::ANSWER_TYPE_RANKING) { + $options = $this->optionMapper->findByQuestion($question->getId()); + foreach ($options as $option) { + $optionPerOptionId[$option->getId()] = $option; + $rankingOptionsPerQuestionId[$question->getId()][] = $option->getId(); + } + foreach ($rankingOptionsPerQuestionId[$question->getId()] as $optionId) { + $header[] = $question->getText() . ' (' . $optionPerOptionId[$optionId]->getText() . ')'; + } } else { $header[] = $question->getText(); } @@ -311,7 +322,7 @@ public function getSubmissionsData(Form $form, string $fileFormat, ?File $file = // Answers, make sure we keep the question order $answers = array_reduce($this->answerMapper->findBySubmission($submission->getId()), - function (array $carry, Answer $answer) use ($questionPerQuestionId, $gridRowsPerQuestionId, $gridColumnsPerQuestionId, $optionPerOptionId) { + function (array $carry, Answer $answer) use ($questionPerQuestionId, $gridRowsPerQuestionId, $gridColumnsPerQuestionId, $rankingOptionsPerQuestionId, $optionPerOptionId) { $questionId = $answer->getQuestionId(); $questionType = isset($questionPerQuestionId[$questionId]) ? $questionPerQuestionId[$questionId]->getType() : null; @@ -354,6 +365,15 @@ function (array $carry, Answer $answer) use ($questionPerQuestionId, $gridRowsPe } } $carry[$questionId] = ['columns' => $columns]; + } elseif ($questionType === Constants::ANSWER_TYPE_RANKING) { + $rankedIds = json_decode($answer->getText(), true); + // Build map: optionId -> rank position (1-based) + $rankByOptionId = array_flip($rankedIds); + $columns = []; + foreach ($rankingOptionsPerQuestionId[$questionId] as $optionId) { + $columns[] = isset($rankByOptionId[$optionId]) ? $rankByOptionId[$optionId] + 1 : ''; + } + $carry[$questionId] = ['columns' => $columns]; } else { if (array_key_exists($questionId, $carry)) { $carry[$questionId] .= '; ' . $answer->getText(); @@ -510,6 +530,7 @@ public function validateSubmission(array $questions, array $answers, string $for } elseif ($answersCount > 1 && $question['type'] !== Constants::ANSWER_TYPE_FILE && $question['type'] !== Constants::ANSWER_TYPE_GRID + && $question['type'] !== Constants::ANSWER_TYPE_RANKING && !($question['type'] === Constants::ANSWER_TYPE_DATE && isset($question['extraSettings']['dateRange']) || $question['type'] === Constants::ANSWER_TYPE_TIME && isset($question['extraSettings']['timeRange']))) { // Check if non-multiple questions have not more than one answer @@ -561,6 +582,19 @@ public function validateSubmission(array $questions, array $answers, string $for throw new \InvalidArgumentException(sprintf('Invalid input for question "%s".', $question['text'])); } + // Handle ranking questions: answers must be a permutation of all option IDs + if ($question['type'] === Constants::ANSWER_TYPE_RANKING) { + $optionIds = array_map('intval', array_column($question['options'] ?? [], 'id')); + $rankedIds = array_map('intval', $answers[$questionId]); + $sortedRanked = $rankedIds; + $sortedOptions = $optionIds; + sort($sortedRanked); + sort($sortedOptions); + if ($sortedRanked !== $sortedOptions) { + throw new \InvalidArgumentException(sprintf('Ranking for question "%s" must include all options exactly once.', $question['text'])); + } + } + // Handle color questions if ( $question['type'] === Constants::ANSWER_TYPE_COLOR diff --git a/src/components/Questions/QuestionRanking.vue b/src/components/Questions/QuestionRanking.vue new file mode 100644 index 000000000..fc8611fad --- /dev/null +++ b/src/components/Questions/QuestionRanking.vue @@ -0,0 +1,261 @@ + + + + + + + diff --git a/src/components/Results/ResultsSummary.vue b/src/components/Results/ResultsSummary.vue index 404cf869c..cee6e4f29 100644 --- a/src/components/Results/ResultsSummary.vue +++ b/src/components/Results/ResultsSummary.vue @@ -12,9 +12,40 @@ {{ questionTypeLabel }}

+ +
+

+ {{ t('forms', 'Ranked by Borda count: each 1st place receives {n} points, 2nd place {n1} points, and so on. Higher score means more preferred.', { n: question.options.length, n1: question.options.length - 1 }) }} +

+
    +
  1. + + +
  2. +
+
+