Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/DataStructure.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 |
Expand Down
7 changes: 7 additions & 0 deletions lib/Constants.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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,
];
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion lib/Controller/ApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -1775,7 +1775,7 @@ public function uploadFiles(int $formId, int $questionId, string $shareHash = ''
* @param string[]|array<array{uploadedFileId: string, uploadedFileName: string}> $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;
}
Expand Down
3 changes: 3 additions & 0 deletions lib/Service/FormsService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
35 changes: 34 additions & 1 deletion lib/Service/SubmissionService.php
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,8 @@ public function getSubmissionsData(Form $form, string $fileFormat, ?File $file =
$gridRowsPerQuestionId = [];
/** @var array<int, array<int, string>> $gridColumnsPerQuestionId */
$gridColumnsPerQuestionId = [];
/** @var array<int, list<int>> $rankingOptionsPerQuestionId */
$rankingOptionsPerQuestionId = [];

$optionPerOptionId = [];
foreach ($questions as $question) {
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
179 changes: 179 additions & 0 deletions playwright/e2e/ranking-question.spec.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
1 change: 1 addition & 0 deletions playwright/support/sections/QuestionType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export enum QuestionType {
File = 'File',
LinearScale = 'Linear scale',
LongAnswer = 'Long text',
Ranking = 'Ranking',
RadioButtons = 'Radio buttons',
ShortAnswer = 'Short answer',
}
25 changes: 25 additions & 0 deletions playwright/support/sections/SubmitSection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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!',
Expand Down Expand Up @@ -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<void> {
const question = this.getQuestion(questionName)
await question.getByRole('button', { name: optionName }).click()
}

/**
* Click clear form and confirm the dialog.
*/
public async clearForm(): Promise<void> {
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<Response> {
const response = this.page.waitForResponse(
Expand Down
Loading
Loading