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..9fa969dd9 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,14 @@ function (array $carry, Answer $answer) use ($questionPerQuestionId, $gridRowsPe } } $carry[$questionId] = ['columns' => $columns]; + } elseif ($questionType === Constants::ANSWER_TYPE_RANKING) { + $rankedIds = json_decode($answer->getText(), true); + $columns = []; + foreach ($rankingOptionsPerQuestionId[$questionId] as $optionId) { + $position = array_search($optionId, $rankedIds); + $columns[] = $position !== false ? $position + 1 : ''; + } + $carry[$questionId] = ['columns' => $columns]; } else { if (array_key_exists($questionId, $carry)) { $carry[$questionId] .= '; ' . $answer->getText(); @@ -510,6 +529,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 +581,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..817223993 --- /dev/null +++ b/src/components/Questions/QuestionRanking.vue @@ -0,0 +1,454 @@ + + + + + + + diff --git a/src/components/Results/ResultsSummary.vue b/src/components/Results/ResultsSummary.vue index 404cf869c..a7237bc58 100644 --- a/src/components/Results/ResultsSummary.vue +++ b/src/components/Results/ResultsSummary.vue @@ -12,9 +12,49 @@ {{ 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. +
+
+