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
95 changes: 95 additions & 0 deletions playwright/e2e/ranking-question.spec.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
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
13 changes: 11 additions & 2 deletions src/components/Questions/AnswerInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
<component
:is="pseudoIcon"
v-if="!isDropdown"
:size="24"
class="question__item__pseudoInput" />
<input
ref="input"
Expand Down Expand Up @@ -145,6 +146,11 @@ export default {
default: false,
},

isRanking: {
type: Boolean,
default: false,
},

maxIndex: {
type: Number,
required: true,
Expand Down Expand Up @@ -256,6 +262,10 @@ export default {
return IconTableRow
}

if (this.isRanking) {
return IconDragIndicator
}

return this.isUnique ? IconRadioboxBlank : IconCheckboxBlankOutline
},
},
Expand Down Expand Up @@ -538,8 +548,7 @@ export default {
height: 100%;
}

.option__drag-handle,
.drag-indicator-icon {
.option__drag-handle {
color: var(--color-text-maxcontrast);
cursor: grab;
margin-block: auto;
Expand Down
Loading
Loading