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 @@
+
+
+
+
+
+
+ {{ t('forms', 'Shuffle options') }}
+
+
+
+
+
+ {{ t('forms', 'Add multiple options') }}
+
+
+
+
+
+
+
+
+ {{ t('forms', 'Tap to rank') }}
+
+
+
+
+
+
+ {{ t('forms', 'Tap items above to rank them') }}
+
+
+
+
+ {{ t('forms', 'Your ranking') }}
+
+
+
+
+
+
{{ index + 1 }}.
+
{{ option.text }}
+
+
+
+
+
+
+
+
+
+ {{ t('forms', 'Move option up') }}
+
+
+
+
+
+ {{ t('forms', 'Move option down') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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,
+ },
+ )
+ }}
+
+
+ -
+
+
+
+
+
+
-