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

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