Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
18b2571
feat: add ranking question type
datapumpernickel Mar 29, 2026
6ac67f3
fix linting and static check
datapumpernickel Mar 29, 2026
107a6d1
fix: return default ranking when unchanged, disable required
datapumpernickel Mar 29, 2026
bde8f38
change to tap-and-drag logic for possibility to leave blank - add uni…
datapumpernickel Mar 29, 2026
8c1fc71
fix: realign drag layout to be consistent with create view and includ…
datapumpernickel Mar 29, 2026
82ce218
fix: apply review suggestions
datapumpernickel Apr 3, 2026
8397eb3
fix: redesign ranking submit layout to two columns
datapumpernickel Apr 3, 2026
3b728b0
fix: revert to single column layout including review suggestions to k…
datapumpernickel Apr 3, 2026
e1ae66b
fix: phpunit test with sort()
datapumpernickel Apr 5, 2026
6a180a6
fix: linting fail because of additional tab
datapumpernickel Apr 5, 2026
34daae8
remove tap to rank hint and implement draggable ranking
datapumpernickel Apr 15, 2026
311d281
move handlebar to front
datapumpernickel Apr 17, 2026
5da01fb
fix: remove radio button and put drag-icon in edit mode
datapumpernickel Apr 17, 2026
3ad8cd2
implement tertiary hover styling on ranked options
datapumpernickel Apr 23, 2026
1cba4a2
fix(ranking): restore local state, clear form sync and submit validation
datapumpernickel May 12, 2026
2105397
test(ranking): add e2e coverage for restore, clear form and required …
datapumpernickel May 12, 2026
67b0009
fix(ranking): harmonize validation error wording
datapumpernickel May 12, 2026
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
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