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 @@
+
+
+
+
+
+
+ {{ t('forms', 'Shuffle options') }}
+
+
+
+
+
+ {{ t('forms', 'Add multiple options') }}
+
+
+
+
+
+
+
+ {{ index + 1 }}.
+
+
+
+ {{ option.text }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 }) }}
+
+
+
+
+
+ {{ option.bordaTotal }}
+
+
+ ({{ t('forms', 'avg. rank {average}', { average: option.avgRank }) }}):
+
+
+ {{ option.text }}
+
+
+
+
+
+
+
@@ -296,6 +327,64 @@ export default {
return questionOptionsStats
},
+ /**
+ * Borda count ranking statistics
+ */
+ rankingStats() {
+ const n = this.question.options.length
+ const stats = {}
+
+ for (const opt of this.question.options) {
+ stats[opt.id] = {
+ id: opt.id,
+ text: opt.text,
+ bordaTotal: 0,
+ rankSum: 0,
+ count: 0,
+ }
+ }
+
+ for (const submission of this.submissions) {
+ const answer = submission.answers.find(
+ (a) => a.questionId === this.question.id,
+ )
+ if (!answer) continue
+ const ranked = JSON.parse(answer.text)
+ ranked.forEach((optionId, index) => {
+ if (stats[optionId]) {
+ stats[optionId].bordaTotal += n - index
+ stats[optionId].rankSum += index + 1
+ stats[optionId].count++
+ }
+ })
+ }
+
+ const result = Object.values(stats)
+ .map((s) => ({
+ ...s,
+ avgRank:
+ s.count > 0
+ ? (s.rankSum / s.count).toFixed(1)
+ : '-',
+ }))
+ .sort((a, b) => b.bordaTotal - a.bordaTotal)
+
+ // Mark best (highest Borda score)
+ if (result.length > 0 && result[0].bordaTotal > 0) {
+ const best = result[0].bordaTotal
+ result.forEach((o) => {
+ o.best = o.bordaTotal === best
+ })
+ }
+
+ return result
+ },
+
+ maxBordaScore() {
+ const n = this.question.options.length
+ return n * this.submissions.length
+ },
+
gridColumns() {
return this.question.options.filter(
(option) => option.optionType === OptionType.Column,
@@ -537,6 +626,12 @@ export default {
cursor: default;
}
+ .question-summary__ranking-description {
+ color: var(--color-text-maxcontrast);
+ font-style: italic;
+ margin-block-end: 8px;
+ }
+
.question-summary__statistic-text--best {
font-weight: bold;
}
diff --git a/src/components/Results/Submission.vue b/src/components/Results/Submission.vue
index 523619e86..739f71edb 100644
--- a/src/components/Results/Submission.vue
+++ b/src/components/Results/Submission.vue
@@ -218,6 +218,29 @@ export default {
.map((answer) => answer.text)
.join(' - ')
+ answeredQuestionsArray.push({
+ id: question.id,
+ text: question.text,
+ type: question.type,
+ squashedAnswers,
+ })
+ } else if (question.type === 'ranking') {
+ const optionsPerId = {}
+ question.options.forEach((option) => {
+ optionsPerId[option.id] = option
+ })
+ const rankedIds = answers[0]?.text
+ ? JSON.parse(answers[0].text)
+ : []
+ const squashedAnswers = rankedIds
+ .map((id, index) => {
+ const option = optionsPerId[id]
+ return option
+ ? `${index + 1}. ${option.text}`
+ : `${index + 1}. ?`
+ })
+ .join('\n')
+
answeredQuestionsArray.push({
id: question.id,
text: question.text,
diff --git a/src/models/AnswerTypes.js b/src/models/AnswerTypes.js
index b3b157d47..92c147812 100644
--- a/src/models/AnswerTypes.js
+++ b/src/models/AnswerTypes.js
@@ -12,6 +12,7 @@ import IconFile from 'vue-material-design-icons/FileOutline.vue'
import IconGrid from 'vue-material-design-icons/Grid.vue'
import IconNumeric from 'vue-material-design-icons/Numeric.vue'
import IconRadioboxMarked from 'vue-material-design-icons/RadioboxMarked.vue'
+import IconReorderHorizontal from 'vue-material-design-icons/ReorderHorizontal.vue'
import IconTextLong from 'vue-material-design-icons/TextLong.vue'
import IconTextShort from 'vue-material-design-icons/TextShort.vue'
import IconLinearScale from '../components/Icons/IconLinearScale.vue'
@@ -24,6 +25,7 @@ import QuestionGrid from '../components/Questions/QuestionGrid.vue'
import QuestionLinearScale from '../components/Questions/QuestionLinearScale.vue'
import QuestionLong from '../components/Questions/QuestionLong.vue'
import QuestionMultiple from '../components/Questions/QuestionMultiple.vue'
+import QuestionRanking from '../components/Questions/QuestionRanking.vue'
import QuestionShort from '../components/Questions/QuestionShort.vue'
import { OptionType } from './Constants.ts'
@@ -264,4 +266,20 @@ export default {
submitPlaceholder: t('forms', 'Pick a color'),
warningInvalid: t('forms', 'This question needs a title!'),
},
+
+ ranking: {
+ component: markRaw(QuestionRanking),
+ icon: markRaw(IconReorderHorizontal),
+ label: t('forms', 'Ranking'),
+ predefined: true,
+ validate: (question) => question.options.length > 0,
+
+ titlePlaceholder: t('forms', 'Ranking question title'),
+ createPlaceholder: t('forms', 'People can rank options'),
+ submitPlaceholder: t('forms', 'Drag to rank'),
+ warningInvalid: t(
+ 'forms',
+ 'This question needs a title and at least one answer!',
+ ),
+ },
}
diff --git a/tests/Unit/Service/SubmissionServiceTest.php b/tests/Unit/Service/SubmissionServiceTest.php
index a5f92bcb2..5735b65ba 100644
--- a/tests/Unit/Service/SubmissionServiceTest.php
+++ b/tests/Unit/Service/SubmissionServiceTest.php
@@ -573,6 +573,32 @@ public function dataGetSubmissionsData() {
"","Anonymous user","1973-11-29T22:33:09+01:00"
'
],
+ 'ranking-submission' => [
+ // Questions
+ [
+ ['id' => 1, 'type' => 'ranking', 'text' => 'Rank these', 'options' => [
+ ['id' => 10, 'text' => 'Option A'],
+ ['id' => 11, 'text' => 'Option B'],
+ ['id' => 12, 'text' => 'Option C'],
+ ]]
+ ],
+ // Array of Submissions incl. Answers
+ [
+ [
+ 'id' => 1,
+ 'userId' => 'user1',
+ 'timestamp' => 123456789,
+ 'answers' => [
+ ['questionId' => 1, 'text' => '[11,10,12]'],
+ ]
+ ],
+ ],
+ // Expected CSV-Result: one column per option with rank position
+ '
+ "User ID","User display name","Timestamp","Rank these (Option A)","Rank these (Option B)","Rank these (Option C)"
+ "user1","User 1","1973-11-29T22:33:09+01:00","2","1","3"
+ '
+ ],
];
}
/**
@@ -1121,6 +1147,11 @@ public function dataValidateSubmission() {
// time range
['id' => 18, 'type' => 'time', 'text' => 'q1', 'isRequired' => true, 'extraSettings' => ['timeRange' => true]],
['id' => 19, 'type' => 'color', 'isRequired' => false],
+ ['id' => 20, 'type' => 'ranking', 'text' => 'Rank these', 'isRequired' => false, 'options' => [
+ ['id' => 30],
+ ['id' => 31],
+ ['id' => 32]
+ ]],
],
// Answers
[
@@ -1144,10 +1175,73 @@ public function dataValidateSubmission() {
// valid time range
'18' => ['12:33', '12:34'],
'19' => ['#FF0000'],
+ '20' => [31, 30, 32],
],
// Expected Result
null,
- ]
+ ],
+ 'valid-ranking-submission' => [
+ // Questions
+ [
+ ['id' => 1, 'type' => 'ranking', 'text' => 'Rank these', 'isRequired' => false, 'options' => [
+ ['id' => 10],
+ ['id' => 11],
+ ['id' => 12]
+ ]]
+ ],
+ // Answers
+ [
+ '1' => [12, 10, 11]
+ ],
+ // Expected Result
+ null,
+ ],
+ 'invalid-ranking-missing-option' => [
+ // Questions
+ [
+ ['id' => 1, 'type' => 'ranking', 'text' => 'Rank these', 'isRequired' => false, 'options' => [
+ ['id' => 10],
+ ['id' => 11],
+ ['id' => 12]
+ ]]
+ ],
+ // Answers
+ [
+ '1' => [10, 11]
+ ],
+ // Expected Result
+ 'Ranking for question "Rank these" must include all options exactly once.',
+ ],
+ 'invalid-ranking-unknown-option' => [
+ // Questions
+ [
+ ['id' => 1, 'type' => 'ranking', 'text' => 'Rank these', 'isRequired' => false, 'options' => [
+ ['id' => 10],
+ ['id' => 11]
+ ]]
+ ],
+ // Answers
+ [
+ '1' => [10, 99]
+ ],
+ // Expected Result – caught by generic predefined-options check before ranking check
+ 'Answer "99" for question "Rank these" is not a valid option.',
+ ],
+ 'invalid-ranking-duplicate-option' => [
+ // Questions
+ [
+ ['id' => 1, 'type' => 'ranking', 'text' => 'Rank these', 'isRequired' => false, 'options' => [
+ ['id' => 10],
+ ['id' => 11]
+ ]]
+ ],
+ // Answers
+ [
+ '1' => [10, 10]
+ ],
+ // Expected Result
+ 'Ranking for question "Rank these" must include all options exactly once.',
+ ],
];
}
From e60d459f000354ee37b5388236c64fcf3d0d3b06 Mon Sep 17 00:00:00 2001
From: paul bochtler <65470117+datapumpernickel@users.noreply.github.com>
Date: Sun, 29 Mar 2026 11:39:28 +0200
Subject: [PATCH 2/5] fix linting and static check
Signed-off-by: paul bochtler <65470117+datapumpernickel@users.noreply.github.com>
---
lib/Service/SubmissionService.php | 5 +-
src/components/Questions/QuestionRanking.vue | 1 +
src/components/Results/ResultsSummary.vue | 62 +++++++++++---------
3 files changed, 37 insertions(+), 31 deletions(-)
diff --git a/lib/Service/SubmissionService.php b/lib/Service/SubmissionService.php
index b2ec5d803..9fa969dd9 100644
--- a/lib/Service/SubmissionService.php
+++ b/lib/Service/SubmissionService.php
@@ -367,11 +367,10 @@ 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 : '';
+ $position = array_search($optionId, $rankedIds);
+ $columns[] = $position !== false ? $position + 1 : '';
}
$carry[$questionId] = ['columns' => $columns];
} else {
diff --git a/src/components/Questions/QuestionRanking.vue b/src/components/Questions/QuestionRanking.vue
index fc8611fad..eda4aae89 100644
--- a/src/components/Questions/QuestionRanking.vue
+++ b/src/components/Questions/QuestionRanking.vue
@@ -157,6 +157,7 @@ export default {
get() {
return this.sortOptionsOfType(this.options, OptionType.Choice)
},
+
set(value) {
this.updateOptionsOrder(value, OptionType.Choice)
},
diff --git a/src/components/Results/ResultsSummary.vue b/src/components/Results/ResultsSummary.vue
index cee6e4f29..a7237bc58 100644
--- a/src/components/Results/ResultsSummary.vue
+++ b/src/components/Results/ResultsSummary.vue
@@ -13,33 +13,42 @@
-
+
- {{ 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 }) }}
+ {{
+ 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,
+ },
+ )
+ }}
-
-
-
- {{ option.bordaTotal }}
-
-
- ({{ t('forms', 'avg. rank {average}', { average: option.avgRank }) }}):
-
-
- {{ option.text }}
-
-
-
-
+
+
+
+ {{ option.bordaTotal }}
+
+
+ ({{
+ t('forms', 'avg. rank {average}', {
+ average: option.avgRank,
+ })
+ }}):
+
+
+ {{ option.text }}
+
+
+
+
@@ -362,10 +371,7 @@ export default {
const result = Object.values(stats)
.map((s) => ({
...s,
- avgRank:
- s.count > 0
- ? (s.rankSum / s.count).toFixed(1)
- : '-',
+ avgRank: s.count > 0 ? (s.rankSum / s.count).toFixed(1) : '-',
}))
.sort((a, b) => b.bordaTotal - a.bordaTotal)
From 26ff771421af5ba1dd6fc22abcf410fc3fc22abb Mon Sep 17 00:00:00 2001
From: paul bochtler <65470117+datapumpernickel@users.noreply.github.com>
Date: Sun, 29 Mar 2026 17:01:55 +0200
Subject: [PATCH 3/5] fix: return default ranking when unchanged, disable
required
Signed-off-by: paul bochtler <65470117+datapumpernickel@users.noreply.github.com>
---
src/components/Questions/Question.vue | 8 +++++++-
src/components/Questions/QuestionRanking.vue | 10 ++++++++++
2 files changed, 17 insertions(+), 1 deletion(-)
diff --git a/src/components/Questions/Question.vue b/src/components/Questions/Question.vue
index f25b9acae..2b4054b52 100644
--- a/src/components/Questions/Question.vue
+++ b/src/components/Questions/Question.vue
@@ -82,7 +82,7 @@
forceMenu
placement="bottom-end"
class="question__header__title__menu">
-
+
@@ -91,6 +91,7 @@
@@ -241,6 +242,11 @@ export default {
default: '',
},
+ allowRequired: {
+ type: Boolean,
+ default: true,
+ },
+
contentValid: {
type: Boolean,
default: true,
diff --git a/src/components/Questions/QuestionRanking.vue b/src/components/Questions/QuestionRanking.vue
index eda4aae89..3b820a420 100644
--- a/src/components/Questions/QuestionRanking.vue
+++ b/src/components/Questions/QuestionRanking.vue
@@ -10,6 +10,7 @@
:warningInvalid="answerType.warningInvalid"
:contentValid="contentValid"
:shiftDragHandle="shiftDragHandle"
+ :allowRequired="false"
v-on="commonListeners">
0) {
+ this.$emit(
+ 'update:values',
+ this.rankedOptions.map((o) => o.id),
+ )
+ }
},
/**
From 5b029867fd0455f9770bacc92c8f8c9c153f9949 Mon Sep 17 00:00:00 2001
From: paul bochtler <65470117+datapumpernickel@users.noreply.github.com>
Date: Sun, 29 Mar 2026 17:40:36 +0200
Subject: [PATCH 4/5] change to tap-and-drag logic for possibility to leave
blank - add unittest for blank answer
Signed-off-by: paul bochtler <65470117+datapumpernickel@users.noreply.github.com>
---
src/components/Questions/Question.vue | 8 +-
src/components/Questions/QuestionRanking.vue | 146 +++++++++++++++++--
tests/Unit/Service/SubmissionServiceTest.php | 51 +++++++
3 files changed, 182 insertions(+), 23 deletions(-)
diff --git a/src/components/Questions/Question.vue b/src/components/Questions/Question.vue
index 2b4054b52..f25b9acae 100644
--- a/src/components/Questions/Question.vue
+++ b/src/components/Questions/Question.vue
@@ -82,7 +82,7 @@
forceMenu
placement="bottom-end"
class="question__header__title__menu">
-
+
@@ -91,7 +91,6 @@
@@ -242,11 +241,6 @@ export default {
default: '',
},
- allowRequired: {
- type: Boolean,
- default: true,
- },
-
contentValid: {
type: Boolean,
default: true,
diff --git a/src/components/Questions/QuestionRanking.vue b/src/components/Questions/QuestionRanking.vue
index 3b820a420..197677b25 100644
--- a/src/components/Questions/QuestionRanking.vue
+++ b/src/components/Questions/QuestionRanking.vue
@@ -10,7 +10,6 @@
:warningInvalid="answerType.warningInvalid"
:contentValid="contentValid"
:shiftDragHandle="shiftDragHandle"
- :allowRequired="false"
v-on="commonListeners">
-
+
+
+
+
+ {{ t('forms', 'Tap to rank') }}
+
+
+ {{ option.text }}
+
+
+
+
+
+ {{ t('forms', 'Tap items above to rank them') }}
+
+
+
+
+ {{ t('forms', 'Your ranking') }}
+
+
+
{{ option.text }}
+
+
+
+
+
@@ -107,7 +139,9 @@
import { VueDraggable as Draggable } from 'vue-draggable-plus'
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import NcActionCheckbox from '@nextcloud/vue/components/NcActionCheckbox'
+import NcButton from '@nextcloud/vue/components/NcButton'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
+import IconClose from 'vue-material-design-icons/Close.vue'
import IconContentPaste from 'vue-material-design-icons/ContentPaste.vue'
import IconDragHorizontalVariant from 'vue-material-design-icons/DragHorizontalVariant.vue'
import OptionInputDialog from '../OptionInputDialog.vue'
@@ -123,10 +157,12 @@ export default {
components: {
AnswerInput,
Draggable,
+ IconClose,
IconContentPaste,
IconDragHorizontalVariant,
NcActionButton,
NcActionCheckbox,
+ NcButton,
NcLoadingIcon,
OptionInputDialog,
Question,
@@ -141,6 +177,7 @@ export default {
isLoading: false,
isOptionDialogShown: false,
rankedOptions: [],
+ unrankedOptions: [],
OptionType,
}
},
@@ -176,7 +213,7 @@ export default {
methods: {
/**
- * Initialize ranked options from existing values or default order
+ * Initialize ranked/unranked options from existing values or default order
*/
initRankedOptions() {
const sorted = this.sortOptionsOfType(this.options, OptionType.Choice)
@@ -187,28 +224,64 @@ export default {
this.rankedOptions = this.values
.map((id) => byId[parseInt(id)])
.filter(Boolean)
+ this.unrankedOptions = sorted.filter(
+ (o) => !this.rankedOptions.some((r) => r.id === o.id),
+ )
+ } else if (this.readOnly) {
+ // Submit mode: start with all options unranked
+ this.rankedOptions = []
+ this.unrankedOptions = [...sorted]
} else {
+ // Edit mode: show all options in default order
this.rankedOptions = [...sorted]
+ this.unrankedOptions = []
}
+ },
- // In submit mode, emit the initial ranking so the answer is always
- // recorded – even when the user agrees with the default order.
- if (this.readOnly && this.rankedOptions.length > 0) {
- this.$emit(
- 'update:values',
- this.rankedOptions.map((o) => o.id),
- )
- }
+ /**
+ * Move an option from the unranked pool to the ranked list
+ *
+ * @param {object} option The option to rank
+ */
+ rankOption(option) {
+ this.unrankedOptions = this.unrankedOptions.filter(
+ (o) => o.id !== option.id,
+ )
+ this.rankedOptions.push(option)
+ this.emitValues()
},
/**
- * Emit the new ranking after a drag ends
+ * Move an option from the ranked list back to the unranked pool
+ *
+ * @param {object} option The option to unrank
+ */
+ unrankOption(option) {
+ this.rankedOptions = this.rankedOptions.filter((o) => o.id !== option.id)
+ this.unrankedOptions.push(option)
+ this.emitValues()
+ },
+
+ /**
+ * Emit the current ranking after a drag reorder
*/
onRankingEnd() {
- this.$emit(
- 'update:values',
- this.rankedOptions.map((o) => o.id),
- )
+ this.emitValues()
+ },
+
+ /**
+ * Emit the current values based on ranking state
+ */
+ emitValues() {
+ if (this.rankedOptions.length === 0) {
+ // Nothing ranked — emit empty to signal unanswered
+ this.$emit('update:values', [])
+ } else {
+ this.$emit(
+ 'update:values',
+ this.rankedOptions.map((o) => o.id),
+ )
+ }
},
},
}
@@ -221,6 +294,47 @@ export default {
gap: var(--default-grid-baseline);
}
+.ranking-unranked {
+ margin-block-end: 12px;
+
+ &__label {
+ font-weight: bold;
+ color: var(--color-text-maxcontrast);
+ margin-block-end: 8px;
+ }
+
+ &__item {
+ display: inline-block;
+ padding: 8px 16px;
+ margin: 0 8px 8px 0;
+ background-color: var(--color-background-dark);
+ border: 1px solid var(--color-border);
+ border-radius: var(--border-radius-large);
+ cursor: pointer;
+ font-size: inherit;
+ color: var(--color-main-text);
+ transition: background-color var(--animation-quick);
+
+ &:hover,
+ &:focus-visible {
+ background-color: var(--color-background-hover);
+ border-color: var(--color-primary-element);
+ }
+ }
+}
+
+.ranking-empty {
+ color: var(--color-text-maxcontrast);
+ font-style: italic;
+ padding: 12px 0;
+}
+
+.ranking-ranked__label {
+ font-weight: bold;
+ color: var(--color-text-maxcontrast);
+ margin-block-end: 4px;
+}
+
.ranking-item {
display: flex;
align-items: center;
diff --git a/tests/Unit/Service/SubmissionServiceTest.php b/tests/Unit/Service/SubmissionServiceTest.php
index 5735b65ba..78cf9c34d 100644
--- a/tests/Unit/Service/SubmissionServiceTest.php
+++ b/tests/Unit/Service/SubmissionServiceTest.php
@@ -599,6 +599,29 @@ public function dataGetSubmissionsData() {
"user1","User 1","1973-11-29T22:33:09+01:00","2","1","3"
'
],
+ 'ranking-unanswered' => [
+ // Questions
+ [
+ ['id' => 1, 'type' => 'ranking', 'text' => 'Rank these', 'options' => [
+ ['id' => 10, 'text' => 'Option A'],
+ ['id' => 11, 'text' => 'Option B'],
+ ]]
+ ],
+ // Submission with no ranking answer
+ [
+ [
+ 'id' => 1,
+ 'userId' => 'user1',
+ 'timestamp' => 123456789,
+ 'answers' => [],
+ ],
+ ],
+ // Expected CSV-Result: columns exist but values are empty
+ '
+ "User ID","User display name","Timestamp","Rank these (Option A)","Rank these (Option B)"
+ "user1","User 1","1973-11-29T22:33:09+01:00","",""
+ '
+ ],
];
}
/**
@@ -1242,6 +1265,34 @@ public function dataValidateSubmission() {
// Expected Result
'Ranking for question "Rank these" must include all options exactly once.',
],
+ 'valid-ranking-not-required-unanswered' => [
+ // Questions
+ [
+ ['id' => 1, 'type' => 'ranking', 'text' => 'Rank these', 'isRequired' => false, 'options' => [
+ ['id' => 10],
+ ['id' => 11],
+ ['id' => 12]
+ ]]
+ ],
+ // Answers – user did not rank anything
+ [],
+ // Expected Result – no error
+ null,
+ ],
+ 'invalid-ranking-required-unanswered' => [
+ // Questions
+ [
+ ['id' => 1, 'type' => 'ranking', 'text' => 'Rank these', 'isRequired' => true, 'options' => [
+ ['id' => 10],
+ ['id' => 11],
+ ['id' => 12]
+ ]]
+ ],
+ // Answers – user did not rank anything
+ [],
+ // Expected Result – required question must be answered
+ 'Question "Rank these" is required.',
+ ],
];
}
From 08800d30ac10dbbe11392625120800a27b3761de Mon Sep 17 00:00:00 2001
From: paul bochtler <65470117+datapumpernickel@users.noreply.github.com>
Date: Sun, 29 Mar 2026 23:54:19 +0200
Subject: [PATCH 5/5] fix: realign drag layout to be consistent with create
view and include keyboard menu
Signed-off-by: paul bochtler <65470117+datapumpernickel@users.noreply.github.com>
---
src/components/Questions/QuestionRanking.vue | 106 +++++++++++++++----
1 file changed, 87 insertions(+), 19 deletions(-)
diff --git a/src/components/Questions/QuestionRanking.vue b/src/components/Questions/QuestionRanking.vue
index 197677b25..817223993 100644
--- a/src/components/Questions/QuestionRanking.vue
+++ b/src/components/Questions/QuestionRanking.vue
@@ -69,18 +69,43 @@
class="ranking-item"
role="listitem">
{{ index + 1 }}.
-
-
-
{{ option.text }}
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+ {{ t('forms', 'Move option up') }}
+
+
+
+
+
+ {{ t('forms', 'Move option down') }}
+
+
+
+
+
+
+
+
@@ -139,11 +164,14 @@
import { VueDraggable as Draggable } from 'vue-draggable-plus'
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import NcActionCheckbox from '@nextcloud/vue/components/NcActionCheckbox'
+import NcActions from '@nextcloud/vue/components/NcActions'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
+import IconArrowDown from 'vue-material-design-icons/ArrowDown.vue'
+import IconArrowUp from 'vue-material-design-icons/ArrowUp.vue'
import IconClose from 'vue-material-design-icons/Close.vue'
import IconContentPaste from 'vue-material-design-icons/ContentPaste.vue'
-import IconDragHorizontalVariant from 'vue-material-design-icons/DragHorizontalVariant.vue'
+import IconDragIndicator from '../Icons/IconDragIndicator.vue'
import OptionInputDialog from '../OptionInputDialog.vue'
import AnswerInput from './AnswerInput.vue'
import Question from './Question.vue'
@@ -157,11 +185,14 @@ export default {
components: {
AnswerInput,
Draggable,
+ IconArrowDown,
+ IconArrowUp,
IconClose,
IconContentPaste,
- IconDragHorizontalVariant,
+ IconDragIndicator,
NcActionButton,
NcActionCheckbox,
+ NcActions,
NcButton,
NcLoadingIcon,
OptionInputDialog,
@@ -262,6 +293,32 @@ export default {
this.emitValues()
},
+ /**
+ * Move the ranked option at index up by one position
+ *
+ * @param {number} index Current index
+ */
+ onMoveUp(index) {
+ if (index <= 0) return
+ const items = [...this.rankedOptions]
+ ;[items[index - 1], items[index]] = [items[index], items[index - 1]]
+ this.rankedOptions = items
+ this.emitValues()
+ },
+
+ /**
+ * Move the ranked option at index down by one position
+ *
+ * @param {number} index Current index
+ */
+ onMoveDown(index) {
+ if (index >= this.rankedOptions.length - 1) return
+ const items = [...this.rankedOptions]
+ ;[items[index], items[index + 1]] = [items[index + 1], items[index]]
+ this.rankedOptions = items
+ this.emitValues()
+ },
+
/**
* Emit the current ranking after a drag reorder
*/
@@ -353,19 +410,30 @@ export default {
color: var(--color-text-maxcontrast);
}
- &__drag-handle {
+ &__text {
+ flex: 1;
+ }
+
+ &__actions {
display: flex;
- align-items: center;
+ gap: var(--default-grid-baseline);
+ margin-inline-start: auto;
+ }
+
+ &__drag-handle {
+ color: var(--color-text-maxcontrast);
cursor: grab;
+ &:hover,
+ &:focus,
+ &:focus-within {
+ color: var(--color-main-text);
+ }
+
&:active {
cursor: grabbing;
}
}
-
- &__text {
- flex: 1;
- }
}
.options-list-transition-move,