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 @@ + + + + + + + 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, + }, + ) + }} +

+
    +
  1. + + +
  2. +
+
+