diff --git a/docs/DataStructure.md b/docs/DataStructure.md
index cdd2f8477..e244b7b19 100644
--- a/docs/DataStructure.md
+++ b/docs/DataStructure.md
@@ -235,6 +235,7 @@ Currently supported Question-Types are:
| `file` | One or multiple files. It is possible to specify which mime types are allowed |
| `linearscale` | A linear or Likert scale question where you choose an option that best fits your opinion |
| `color` | A color answer, hex string representation (e. g. `#123456`) |
+| `ranking` | Using pre-defined options, the user ranks them from most to least preferred. Needs at least one option available. Answers are stored in ranked order (one answer row per option). |
## Extra Settings
@@ -243,7 +244,7 @@ Optional extra settings for some [Question Types](#question-types)
| Extra Setting | Question Type | Type | Values | Description |
| ----------------------- | ------------------------------------- | ---------------- | ------------------------------------------- | --------------------------------------------------------------------------- |
| `allowOtherAnswer` | `multiple, multiple_unique` | Boolean | `true/false` | Allows the user to specify a custom answer |
-| `shuffleOptions` | `dropdown, multiple, multiple_unique` | Boolean | `true/false` | The list of options should be shuffled |
+| `shuffleOptions` | `dropdown, multiple, multiple_unique, ranking` | Boolean | `true/false` | The list of options should be shuffled |
| `optionsLimitMax` | `multiple` | Integer | - | Maximum number of options that can be selected |
| `optionsLimitMin` | `multiple` | Integer | - | Minimum number of options that must be selected |
| `validationType` | `short` | string | `null, 'phone', 'email', 'regex', 'number'` | Custom validation for checking a submission |
diff --git a/lib/Constants.php b/lib/Constants.php
index fa0c347db..d28a3d7b9 100644
--- a/lib/Constants.php
+++ b/lib/Constants.php
@@ -82,6 +82,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';
@@ -101,6 +102,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,
];
@@ -111,6 +113,7 @@ class Constants {
self::ANSWER_TYPE_LINEARSCALE,
self::ANSWER_TYPE_MULTIPLE,
self::ANSWER_TYPE_MULTIPLEUNIQUE,
+ self::ANSWER_TYPE_RANKING,
];
// AnswerTypes for date/time questions
@@ -197,6 +200,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 e98d5c188..042ccd309 100644
--- a/lib/Controller/ApiController.php
+++ b/lib/Controller/ApiController.php
@@ -1775,7 +1775,7 @@ public function uploadFiles(int $formId, int $questionId, string $shareHash = ''
* @param string[]|array $answerArray
*/
private function storeAnswersForQuestion(Form $form, $submissionId, array $question, array $answerArray): void {
- if ($question['type'] === Constants::ANSWER_TYPE_GRID) {
+ if ($question['type'] === Constants::ANSWER_TYPE_GRID || $question['type'] === Constants::ANSWER_TYPE_RANKING) {
if (!$answerArray) {
return;
}
diff --git a/lib/Service/FormsService.php b/lib/Service/FormsService.php
index d0e93f5b8..23a37471f 100644
--- a/lib/Service/FormsService.php
+++ b/lib/Service/FormsService.php
@@ -813,6 +813,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..5aa0b8b28 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]);
+
+ sort($optionIds);
+ sort($rankedIds);
+
+ if ($rankedIds !== $optionIds) {
+ 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/playwright/e2e/ranking-question.spec.ts b/playwright/e2e/ranking-question.spec.ts
new file mode 100644
index 000000000..acf0852e0
--- /dev/null
+++ b/playwright/e2e/ranking-question.spec.ts
@@ -0,0 +1,95 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { expect, mergeTests } from '@playwright/test'
+import { test as formTest } from '../support/fixtures/form.ts'
+import { test as appNavigationTest } from '../support/fixtures/navigation.ts'
+import { test as randomUserTest } from '../support/fixtures/random-user.ts'
+import { test as submitTest } from '../support/fixtures/submit.ts'
+import { test as topBarTest } from '../support/fixtures/topBar.ts'
+import { QuestionType } from '../support/sections/QuestionType.ts'
+import { FormsView } from '../support/sections/TopBarSection.ts'
+
+const test = mergeTests(
+ randomUserTest,
+ appNavigationTest,
+ formTest,
+ topBarTest,
+ submitTest,
+)
+
+test.describe('Ranking question', () => {
+ test.beforeEach(async ({ page, appNavigation, form }) => {
+ await page.goto('apps/forms')
+ await page.waitForURL(/apps\/forms\/?$/)
+ await appNavigation.clickNewForm()
+ await form.fillTitle('Ranking test form')
+
+ await form.addQuestion(QuestionType.Ranking)
+ const questions = await form.getQuestions()
+ await questions[0].fillTitle('Rank snacks')
+ await questions[0].addAnswer('Pretzels')
+ await questions[0].addAnswer('Popcorn')
+ await questions[0].addAnswer('Nuts')
+ })
+
+ test('Restores unsubmitted ranking from local storage on reload', async ({
+ topBar,
+ submitView,
+ page,
+ }) => {
+ await topBar.toggleView(FormsView.View)
+
+ await submitView.rankOption('Rank snacks', 'Pretzels')
+ await submitView.rankOption('Rank snacks', 'Popcorn')
+
+ await page.reload()
+
+ const question = submitView.getQuestion('Rank snacks')
+ await expect(
+ question.getByRole('button', { name: 'Remove from ranking' }),
+ ).toHaveCount(2)
+ })
+
+ test('Clear form resets ranked options', async ({ topBar, submitView }) => {
+ await topBar.toggleView(FormsView.View)
+
+ await submitView.rankOption('Rank snacks', 'Pretzels')
+ await submitView.rankOption('Rank snacks', 'Popcorn')
+ await submitView.clearForm()
+
+ const question = submitView.getQuestion('Rank snacks')
+ await expect(
+ question.getByRole('button', { name: 'Remove from ranking' }),
+ ).toHaveCount(0)
+ await expect(
+ question.getByRole('button', { name: 'Pretzels' }),
+ ).toBeVisible()
+ await expect(question.getByRole('button', { name: 'Popcorn' })).toBeVisible()
+ })
+
+ test('Required ranking blocks submit until all options are ranked', async ({
+ topBar,
+ submitView,
+ form,
+ }) => {
+ const questions = await form.getQuestions()
+ await questions[0].toggleRequired()
+
+ await topBar.toggleView(FormsView.View)
+
+ await submitView.submitButton.click()
+ await expect(submitView.successMessage).not.toBeVisible()
+
+ await submitView.rankOption('Rank snacks', 'Pretzels')
+ await submitView.submitButton.click()
+ await expect(submitView.successMessage).not.toBeVisible()
+
+ await submitView.rankOption('Rank snacks', 'Popcorn')
+ await submitView.rankOption('Rank snacks', 'Nuts')
+ await submitView.submit()
+ await expect(submitView.successMessage).toBeVisible()
+ })
+})
diff --git a/playwright/support/sections/QuestionType.ts b/playwright/support/sections/QuestionType.ts
index 208b981cf..cb9cecad2 100644
--- a/playwright/support/sections/QuestionType.ts
+++ b/playwright/support/sections/QuestionType.ts
@@ -11,6 +11,7 @@ export enum QuestionType {
File = 'File',
LinearScale = 'Linear scale',
LongAnswer = 'Long text',
+ Ranking = 'Ranking',
RadioButtons = 'Radio buttons',
ShortAnswer = 'Short answer',
}
diff --git a/playwright/support/sections/SubmitSection.ts b/playwright/support/sections/SubmitSection.ts
index f4c10042b..a23e0e097 100644
--- a/playwright/support/sections/SubmitSection.ts
+++ b/playwright/support/sections/SubmitSection.ts
@@ -6,10 +6,12 @@
import type { Locator, Page, Response } from '@playwright/test'
export class SubmitSection {
+ public readonly clearFormButton: Locator
public readonly submitButton: Locator
public readonly successMessage: Locator
constructor(public readonly page: Page) {
+ this.clearFormButton = this.page.getByRole('button', { name: 'Clear form' })
this.submitButton = this.page.getByRole('button', { name: 'Submit' })
this.successMessage = this.page.getByText(
'Thank you for completing the form!',
@@ -99,6 +101,29 @@ export class SubmitSection {
await this.page.getByRole('option', { name: optionName }).click()
}
+ /**
+ * Rank an option by clicking it in the unranked pool.
+ *
+ * @param questionName the title of the question
+ * @param optionName the option text to move into ranked list
+ */
+ public async rankOption(
+ questionName: string | RegExp,
+ optionName: string | RegExp,
+ ): Promise {
+ const question = this.getQuestion(questionName)
+ await question.getByRole('button', { name: optionName }).click()
+ }
+
+ /**
+ * Click clear form and confirm the dialog.
+ */
+ public async clearForm(): Promise {
+ await this.clearFormButton.click()
+ const dialog = this.page.getByRole('dialog', { name: 'Clear form' })
+ await dialog.getByRole('button', { name: 'Clear' }).click()
+ }
+
/** Click submit and wait for the API response. */
public async submit(): Promise {
const response = this.page.waitForResponse(
diff --git a/src/components/Questions/AnswerInput.vue b/src/components/Questions/AnswerInput.vue
index a40b36dea..4a2ce804f 100644
--- a/src/components/Questions/AnswerInput.vue
+++ b/src/components/Questions/AnswerInput.vue
@@ -8,6 +8,7 @@
+
+
+
+
+
+ {{ t('forms', 'Shuffle options') }}
+
+
+
+
+
+ {{ t('forms', 'Add multiple options') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('forms', 'All options ranked') }}
+
+
+
+
+
+
+ {{ t('forms', 'Your ranking') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('forms', 'Move option up') }}
+
+
+
+
+
+ {{ t('forms', 'Move option down') }}
+
+
+ {{ index + 1 }}.
+ {{ option.text }}
+
+
+
+
+
+
+
+
+ {{ t('forms', 'Tap options above to rank them') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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,
+ },
+ )
+ }}
+
+
+ -
+
+
+
+
+
+
-