From 0284b208b3dd66fb7d319b5d5db9c5e18bead05a 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] feat: add ranking question type
Adds a new 'ranking' question type that allows respondents to
drag-and-drop predefined options into their preferred order.
Signed-off-by: paul bochtler <65470117+datapumpernickel@users.noreply.github.com>
Signed-off-by: Christian Hartmann
---
docs/DataStructure.md | 77 +--
lib/Constants.php | 7 +
lib/Controller/ApiController.php | 2 +-
lib/Service/FormsService.php | 3 +
lib/Service/SubmissionService.php | 35 +-
playwright/e2e/ranking-question.spec.ts | 179 ++++++
playwright/support/sections/QuestionType.ts | 1 +
playwright/support/sections/SubmitSection.ts | 25 +
src/components/Questions/AnswerInput.vue | 12 +-
src/components/Questions/QuestionRanking.vue | 538 +++++++++++++++++++
src/components/Results/ResultsSummary.vue | 103 +++-
src/components/Results/Submission.vue | 23 +
src/models/AnswerTypes.js | 18 +
src/views/Submit.vue | 21 +-
tests/Unit/Service/SubmissionServiceTest.php | 147 ++++-
15 files changed, 1142 insertions(+), 49 deletions(-)
create mode 100644 playwright/e2e/ranking-question.spec.ts
create mode 100644 src/components/Questions/QuestionRanking.vue
diff --git a/docs/DataStructure.md b/docs/DataStructure.md
index ed02cf97a..72ce23c47 100644
--- a/docs/DataStructure.md
+++ b/docs/DataStructure.md
@@ -224,49 +224,50 @@ Defines some extended options of sharing / access
Currently supported Question-Types are:
-| Type-ID | Description |
-| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
-| `multiple` | Typically known as 'Checkboxes'. Using pre-defined options, the user can select one or multiple from. Needs at least one option available. |
-| `multiple_unique` | Typically known as 'Radio Buttons'. Using pre-defined options, the user can select exactly one from. Needs at least one option available. |
-| `dropdown` | Similar to `multiple_unique`, but rendered as dropdown field. |
-| `short` | A short text answer. Single text line |
-| `long` | A long text answer. Multi-line supported |
-| `date` | Showing a dropdown calendar to select a date. |
-| _`datetime`_ | _deprecated: No longer available for new questions. Showing a dropdown calendar to select a date **and** a time._ |
-| `time` | Showing a dropdown menu to select a time. |
-| `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`) |
+| Type-ID | Description |
+| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `multiple` | Typically known as 'Checkboxes'. Using pre-defined options, the user can select one or multiple from. Needs at least one option available. |
+| `multiple_unique` | Typically known as 'Radio Buttons'. Using pre-defined options, the user can select exactly one from. Needs at least one option available. |
+| `dropdown` | Similar to `multiple_unique`, but rendered as dropdown field. |
+| `short` | A short text answer. Single text line |
+| `long` | A long text answer. Multi-line supported |
+| `date` | Showing a dropdown calendar to select a date. |
+| _`datetime`_ | _deprecated: No longer available for new questions. Showing a dropdown calendar to select a date **and** a time._ |
+| `time` | Showing a dropdown menu to select a time. |
+| `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
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 |
-| `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 |
-| `validationRegex` | `short` | string | regular expression | if `validationType` is 'regex' this defines the regular expression to apply |
-| `allowedFileTypes` | `file` | Array of strings | `'image', 'x-office/document'` | Allowed file types for file upload |
-| `allowedFileExtensions` | `file` | Array of strings | `'jpg', 'png'` | Allowed file extensions for file upload |
-| `maxAllowedFilesCount` | `file` | Integer | - | Maximum number of files that can be uploaded, 0 means no limit |
-| `maxFileSize` | `file` | Integer | - | Maximum file size in bytes, 0 means no limit |
-| `dateMax` | `date` | Integer | - | Maximum allowed date to be chosen (as Unix timestamp) |
-| `dateMin` | `date` | Integer | - | Minimum allowed date to be chosen (as Unix timestamp) |
-| `dateRange` | `date` | Boolean | `true/false` | The date picker should query a date range |
-| `timeMax` | `time` | string | - | Maximum allowed time to be chosen (as `HH:mm` string) |
-| `timeMin` | `time` | string | - | Minimum allowed time to be chosen (as `HH:mm` string) |
-| `timeRange` | `time` | Boolean | `true/false` | The time picker should query a time range |
-| `optionsLowest` | `linearscale` | Integer | `0, 1` | Set the lowest value of the scale, default: `1` |
-| `optionsHighest` | `linearscale` | Integer | `2, 3, 4, 5, 6, 7, 8, 9, 10` | Set the highest value of the scale, default: `5` |
-| `optionsLabelLowest` | `linearscale` | string | - | Set the label of the lowest value, default: `'Strongly disagree'` |
-| `optionsLabelHighest` | `linearscale` | string | - | Set the label of the highest value, default: `'Strongly agree'` |
-| `columns` | `grid` | Array | - | Array of column identifiers / labels for grid questions |
-| `rows` | `grid` | Array | - | Array of row identifiers / labels for grid questions |
-| `questionType` | `grid` | String | `checkbox`, `number`, `radio` | Type of cell for grid questions (checkbox, numeric input, or radio) |
+| 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, 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 |
+| `validationRegex` | `short` | string | regular expression | if `validationType` is 'regex' this defines the regular expression to apply |
+| `allowedFileTypes` | `file` | Array of strings | `'image', 'x-office/document'` | Allowed file types for file upload |
+| `allowedFileExtensions` | `file` | Array of strings | `'jpg', 'png'` | Allowed file extensions for file upload |
+| `maxAllowedFilesCount` | `file` | Integer | - | Maximum number of files that can be uploaded, 0 means no limit |
+| `maxFileSize` | `file` | Integer | - | Maximum file size in bytes, 0 means no limit |
+| `dateMax` | `date` | Integer | - | Maximum allowed date to be chosen (as Unix timestamp) |
+| `dateMin` | `date` | Integer | - | Minimum allowed date to be chosen (as Unix timestamp) |
+| `dateRange` | `date` | Boolean | `true/false` | The date picker should query a date range |
+| `timeMax` | `time` | string | - | Maximum allowed time to be chosen (as `HH:mm` string) |
+| `timeMin` | `time` | string | - | Minimum allowed time to be chosen (as `HH:mm` string) |
+| `timeRange` | `time` | Boolean | `true/false` | The time picker should query a time range |
+| `optionsLowest` | `linearscale` | Integer | `0, 1` | Set the lowest value of the scale, default: `1` |
+| `optionsHighest` | `linearscale` | Integer | `2, 3, 4, 5, 6, 7, 8, 9, 10` | Set the highest value of the scale, default: `5` |
+| `optionsLabelLowest` | `linearscale` | string | - | Set the label of the lowest value, default: `'Strongly disagree'` |
+| `optionsLabelHighest` | `linearscale` | string | - | Set the label of the highest value, default: `'Strongly agree'` |
+| `columns` | `grid` | Array | - | Array of column identifiers / labels for grid questions |
+| `rows` | `grid` | Array | - | Array of row identifiers / labels for grid questions |
+| `questionType` | `grid` | String | `checkbox`, `number`, `radio` | Type of cell for grid questions (checkbox, numeric input, or radio) |
### Option Types
diff --git a/lib/Constants.php b/lib/Constants.php
index ed65a40a6..b2cd0138b 100644
--- a/lib/Constants.php
+++ b/lib/Constants.php
@@ -94,6 +94,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';
@@ -113,6 +114,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,
];
@@ -124,6 +126,7 @@ class Constants {
self::ANSWER_TYPE_GRID,
self::ANSWER_TYPE_MULTIPLE,
self::ANSWER_TYPE_MULTIPLEUNIQUE,
+ self::ANSWER_TYPE_RANKING,
];
// AnswerTypes for date/time questions
@@ -210,6 +213,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 f4a0445e7..1335659b6 100644
--- a/lib/Service/FormsService.php
+++ b/lib/Service/FormsService.php
@@ -818,6 +818,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..ebe37334b
--- /dev/null
+++ b/playwright/e2e/ranking-question.spec.ts
@@ -0,0 +1,179 @@
+/**
+ * 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()
+ })
+
+ test('Partial ranking submission is blocked by required validation', async ({
+ topBar,
+ submitView,
+ }) => {
+ await topBar.toggleView(FormsView.View)
+
+ // Rank only 2 out of 3 items
+ await submitView.rankOption('Rank snacks', 'Pretzels')
+ await submitView.rankOption('Rank snacks', 'Popcorn')
+
+ // Try to submit — should fail
+ await submitView.submitButton.click()
+
+ // Verify error prevents submission (success message hidden)
+ await expect(submitView.successMessage).not.toBeVisible()
+ })
+
+ test('Complete ranking submission succeeds after partial attempt', async ({
+ topBar,
+ submitView,
+ }) => {
+ await topBar.toggleView(FormsView.View)
+
+ // Rank first 2 items
+ await submitView.rankOption('Rank snacks', 'Pretzels')
+ await submitView.rankOption('Rank snacks', 'Popcorn')
+
+ // Submit attempt fails (partial ranking)
+ await submitView.submitButton.click()
+ await expect(submitView.successMessage).not.toBeVisible()
+
+ // Complete the ranking
+ await submitView.rankOption('Rank snacks', 'Nuts')
+
+ // Now submit should succeed
+ await submitView.submit()
+ await expect(submitView.successMessage).toBeVisible()
+ })
+
+ test('Multiple ranking questions maintain separate drag contexts', async ({
+ form,
+ topBar,
+ submitView,
+ }) => {
+ // Add a second ranking question
+ await form.addQuestion(QuestionType.Ranking)
+ const questions = await form.getQuestions()
+ await questions[1].fillTitle('Rank preferences')
+ await questions[1].addAnswer('Option X')
+ await questions[1].addAnswer('Option Y')
+ await questions[1].addAnswer('Option Z')
+
+ await topBar.toggleView(FormsView.View)
+
+ // Rank first question completely
+ await submitView.rankOption('Rank snacks', 'Pretzels')
+ await submitView.rankOption('Rank snacks', 'Popcorn')
+ await submitView.rankOption('Rank snacks', 'Nuts')
+
+ // Rank second question partially
+ await submitView.rankOption('Rank preferences', 'Option X')
+ await submitView.rankOption('Rank preferences', 'Option Z')
+
+ // Verify both rankings are correct
+ const q1 = submitView.getQuestion('Rank snacks')
+ const q2 = submitView.getQuestion('Rank preferences')
+
+ await expect(
+ q1.getByRole('button', { name: 'Remove from ranking' }),
+ ).toHaveCount(3)
+ await expect(
+ q2.getByRole('button', { name: 'Remove from ranking' }),
+ ).toHaveCount(2)
+
+ // Submit should require q2 to be complete
+ await submitView.submitButton.click()
+ await expect(submitView.successMessage).not.toBeVisible()
+
+ // Complete q2
+ await submitView.rankOption('Rank preferences', 'Option Y')
+ 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 03949b8a7..811ff90ac 100644
--- a/src/components/Questions/AnswerInput.vue
+++ b/src/components/Questions/AnswerInput.vue
@@ -139,6 +139,11 @@ export default {
default: false,
},
+ isRanking: {
+ type: Boolean,
+ default: false,
+ },
+
maxIndex: {
type: Number,
required: true,
@@ -260,6 +265,10 @@ export default {
return IconTableRow
}
+ if (this.isRanking) {
+ return IconDragIndicator
+ }
+
return this.isUnique ? IconRadioboxBlank : IconCheckboxBlankOutline
},
},
@@ -542,8 +551,7 @@ export default {
height: 100%;
}
- .option__drag-handle,
- .drag-indicator-icon {
+ .option__drag-handle {
color: var(--color-text-maxcontrast);
cursor: grab;
margin-block: auto;
diff --git a/src/components/Questions/QuestionRanking.vue b/src/components/Questions/QuestionRanking.vue
new file mode 100644
index 000000000..ebebbb078
--- /dev/null
+++ b/src/components/Questions/QuestionRanking.vue
@@ -0,0 +1,538 @@
+
+
+
+
+
+
+ {{ 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 4af61e322..e91340ee1 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,
+ },
+ )
+ }}
+
+
+ -
+
+
+
+
+
+
-