Skip to content

Commit 4de93a3

Browse files
authored
Merge pull request #3262 from datapumpernickel/feature/ranking-question-type-vue3
feat: ranking question type
2 parents 8271912 + 0284b20 commit 4de93a3

15 files changed

Lines changed: 1142 additions & 49 deletions

File tree

docs/DataStructure.md

Lines changed: 39 additions & 38 deletions
Large diffs are not rendered by default.

lib/Constants.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ class Constants {
9494
public const ANSWER_TYPE_LONG = 'long';
9595
public const ANSWER_TYPE_MULTIPLE = 'multiple';
9696
public const ANSWER_TYPE_MULTIPLEUNIQUE = 'multiple_unique';
97+
public const ANSWER_TYPE_RANKING = 'ranking';
9798
public const ANSWER_TYPE_SHORT = 'short';
9899
public const ANSWER_TYPE_TIME = 'time';
99100

@@ -113,6 +114,7 @@ class Constants {
113114
self::ANSWER_TYPE_LONG,
114115
self::ANSWER_TYPE_MULTIPLE,
115116
self::ANSWER_TYPE_MULTIPLEUNIQUE,
117+
self::ANSWER_TYPE_RANKING,
116118
self::ANSWER_TYPE_SHORT,
117119
self::ANSWER_TYPE_TIME,
118120
];
@@ -124,6 +126,7 @@ class Constants {
124126
self::ANSWER_TYPE_GRID,
125127
self::ANSWER_TYPE_MULTIPLE,
126128
self::ANSWER_TYPE_MULTIPLEUNIQUE,
129+
self::ANSWER_TYPE_RANKING,
127130
];
128131

129132
// AnswerTypes for date/time questions
@@ -210,6 +213,10 @@ class Constants {
210213
'rows' => ['array'],
211214
];
212215

216+
public const EXTRA_SETTINGS_RANKING = [
217+
'shuffleOptions' => ['boolean'],
218+
];
219+
213220
public const EXTRA_SETTINGS_GRID_QUESTION_TYPE = [
214221
self::ANSWER_GRID_TYPE_CHECKBOX,
215222
self::ANSWER_GRID_TYPE_NUMBER,

lib/Controller/ApiController.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1775,7 +1775,7 @@ public function uploadFiles(int $formId, int $questionId, string $shareHash = ''
17751775
* @param string[]|array<array{uploadedFileId: string, uploadedFileName: string}> $answerArray
17761776
*/
17771777
private function storeAnswersForQuestion(Form $form, $submissionId, array $question, array $answerArray): void {
1778-
if ($question['type'] === Constants::ANSWER_TYPE_GRID) {
1778+
if ($question['type'] === Constants::ANSWER_TYPE_GRID || $question['type'] === Constants::ANSWER_TYPE_RANKING) {
17791779
if (!$answerArray) {
17801780
return;
17811781
}

lib/Service/FormsService.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -818,6 +818,9 @@ public function areExtraSettingsValid(array $extraSettings, string $questionType
818818
case Constants::ANSWER_TYPE_GRID:
819819
$allowed = Constants::EXTRA_SETTINGS_GRID;
820820
break;
821+
case Constants::ANSWER_TYPE_RANKING:
822+
$allowed = Constants::EXTRA_SETTINGS_RANKING;
823+
break;
821824
case Constants::ANSWER_TYPE_TIME:
822825
$allowed = Constants::EXTRA_SETTINGS_TIME;
823826
break;

lib/Service/SubmissionService.php

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,8 @@ public function getSubmissionsData(Form $form, string $fileFormat, ?File $file =
252252
$gridRowsPerQuestionId = [];
253253
/** @var array<int, array<int, string>> $gridColumnsPerQuestionId */
254254
$gridColumnsPerQuestionId = [];
255+
/** @var array<int, list<int>> $rankingOptionsPerQuestionId */
256+
$rankingOptionsPerQuestionId = [];
255257

256258
$optionPerOptionId = [];
257259
foreach ($questions as $question) {
@@ -280,6 +282,15 @@ public function getSubmissionsData(Form $form, string $fileFormat, ?File $file =
280282
}
281283
}
282284
}
285+
} elseif ($question->getType() === Constants::ANSWER_TYPE_RANKING) {
286+
$options = $this->optionMapper->findByQuestion($question->getId());
287+
foreach ($options as $option) {
288+
$optionPerOptionId[$option->getId()] = $option;
289+
$rankingOptionsPerQuestionId[$question->getId()][] = $option->getId();
290+
}
291+
foreach ($rankingOptionsPerQuestionId[$question->getId()] as $optionId) {
292+
$header[] = $question->getText() . ' (' . $optionPerOptionId[$optionId]->getText() . ')';
293+
}
283294
} else {
284295
$header[] = $question->getText();
285296
}
@@ -311,7 +322,7 @@ public function getSubmissionsData(Form $form, string $fileFormat, ?File $file =
311322

312323
// Answers, make sure we keep the question order
313324
$answers = array_reduce($this->answerMapper->findBySubmission($submission->getId()),
314-
function (array $carry, Answer $answer) use ($questionPerQuestionId, $gridRowsPerQuestionId, $gridColumnsPerQuestionId, $optionPerOptionId) {
325+
function (array $carry, Answer $answer) use ($questionPerQuestionId, $gridRowsPerQuestionId, $gridColumnsPerQuestionId, $rankingOptionsPerQuestionId, $optionPerOptionId) {
315326
$questionId = $answer->getQuestionId();
316327
$questionType = isset($questionPerQuestionId[$questionId]) ? $questionPerQuestionId[$questionId]->getType() : null;
317328

@@ -354,6 +365,14 @@ function (array $carry, Answer $answer) use ($questionPerQuestionId, $gridRowsPe
354365
}
355366
}
356367
$carry[$questionId] = ['columns' => $columns];
368+
} elseif ($questionType === Constants::ANSWER_TYPE_RANKING) {
369+
$rankedIds = json_decode($answer->getText(), true);
370+
$columns = [];
371+
foreach ($rankingOptionsPerQuestionId[$questionId] as $optionId) {
372+
$position = array_search($optionId, $rankedIds);
373+
$columns[] = $position !== false ? $position + 1 : '';
374+
}
375+
$carry[$questionId] = ['columns' => $columns];
357376
} else {
358377
if (array_key_exists($questionId, $carry)) {
359378
$carry[$questionId] .= '; ' . $answer->getText();
@@ -510,6 +529,7 @@ public function validateSubmission(array $questions, array $answers, string $for
510529
} elseif ($answersCount > 1
511530
&& $question['type'] !== Constants::ANSWER_TYPE_FILE
512531
&& $question['type'] !== Constants::ANSWER_TYPE_GRID
532+
&& $question['type'] !== Constants::ANSWER_TYPE_RANKING
513533
&& !($question['type'] === Constants::ANSWER_TYPE_DATE && isset($question['extraSettings']['dateRange'])
514534
|| $question['type'] === Constants::ANSWER_TYPE_TIME && isset($question['extraSettings']['timeRange']))) {
515535
// Check if non-multiple questions have not more than one answer
@@ -561,6 +581,19 @@ public function validateSubmission(array $questions, array $answers, string $for
561581
throw new \InvalidArgumentException(sprintf('Invalid input for question "%s".', $question['text']));
562582
}
563583

584+
// Handle ranking questions: answers must be a permutation of all option IDs
585+
if ($question['type'] === Constants::ANSWER_TYPE_RANKING) {
586+
$optionIds = array_map('intval', array_column($question['options'] ?? [], 'id'));
587+
$rankedIds = array_map('intval', $answers[$questionId]);
588+
589+
sort($optionIds);
590+
sort($rankedIds);
591+
592+
if ($rankedIds !== $optionIds) {
593+
throw new \InvalidArgumentException(sprintf('Ranking for question "%s" must include all options exactly once.', $question['text']));
594+
}
595+
}
596+
564597
// Handle color questions
565598
if (
566599
$question['type'] === Constants::ANSWER_TYPE_COLOR
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { expect, mergeTests } from '@playwright/test'
7+
import { test as formTest } from '../support/fixtures/form.ts'
8+
import { test as appNavigationTest } from '../support/fixtures/navigation.ts'
9+
import { test as randomUserTest } from '../support/fixtures/random-user.ts'
10+
import { test as submitTest } from '../support/fixtures/submit.ts'
11+
import { test as topBarTest } from '../support/fixtures/topBar.ts'
12+
import { QuestionType } from '../support/sections/QuestionType.ts'
13+
import { FormsView } from '../support/sections/TopBarSection.ts'
14+
15+
const test = mergeTests(
16+
randomUserTest,
17+
appNavigationTest,
18+
formTest,
19+
topBarTest,
20+
submitTest,
21+
)
22+
23+
test.describe('Ranking question', () => {
24+
test.beforeEach(async ({ page, appNavigation, form }) => {
25+
await page.goto('apps/forms')
26+
await page.waitForURL(/apps\/forms\/?$/)
27+
await appNavigation.clickNewForm()
28+
await form.fillTitle('Ranking test form')
29+
30+
await form.addQuestion(QuestionType.Ranking)
31+
const questions = await form.getQuestions()
32+
await questions[0].fillTitle('Rank snacks')
33+
await questions[0].addAnswer('Pretzels')
34+
await questions[0].addAnswer('Popcorn')
35+
await questions[0].addAnswer('Nuts')
36+
})
37+
38+
test('Restores unsubmitted ranking from local storage on reload', async ({
39+
topBar,
40+
submitView,
41+
page,
42+
}) => {
43+
await topBar.toggleView(FormsView.View)
44+
45+
await submitView.rankOption('Rank snacks', 'Pretzels')
46+
await submitView.rankOption('Rank snacks', 'Popcorn')
47+
48+
await page.reload()
49+
50+
const question = submitView.getQuestion('Rank snacks')
51+
await expect(
52+
question.getByRole('button', { name: 'Remove from ranking' }),
53+
).toHaveCount(2)
54+
})
55+
56+
test('Clear form resets ranked options', async ({ topBar, submitView }) => {
57+
await topBar.toggleView(FormsView.View)
58+
59+
await submitView.rankOption('Rank snacks', 'Pretzels')
60+
await submitView.rankOption('Rank snacks', 'Popcorn')
61+
await submitView.clearForm()
62+
63+
const question = submitView.getQuestion('Rank snacks')
64+
await expect(
65+
question.getByRole('button', { name: 'Remove from ranking' }),
66+
).toHaveCount(0)
67+
await expect(
68+
question.getByRole('button', { name: 'Pretzels' }),
69+
).toBeVisible()
70+
await expect(question.getByRole('button', { name: 'Popcorn' })).toBeVisible()
71+
})
72+
73+
test('Required ranking blocks submit until all options are ranked', async ({
74+
topBar,
75+
submitView,
76+
form,
77+
}) => {
78+
const questions = await form.getQuestions()
79+
await questions[0].toggleRequired()
80+
81+
await topBar.toggleView(FormsView.View)
82+
83+
await submitView.submitButton.click()
84+
await expect(submitView.successMessage).not.toBeVisible()
85+
86+
await submitView.rankOption('Rank snacks', 'Pretzels')
87+
await submitView.submitButton.click()
88+
await expect(submitView.successMessage).not.toBeVisible()
89+
90+
await submitView.rankOption('Rank snacks', 'Popcorn')
91+
await submitView.rankOption('Rank snacks', 'Nuts')
92+
await submitView.submit()
93+
await expect(submitView.successMessage).toBeVisible()
94+
})
95+
96+
test('Partial ranking submission is blocked by required validation', async ({
97+
topBar,
98+
submitView,
99+
}) => {
100+
await topBar.toggleView(FormsView.View)
101+
102+
// Rank only 2 out of 3 items
103+
await submitView.rankOption('Rank snacks', 'Pretzels')
104+
await submitView.rankOption('Rank snacks', 'Popcorn')
105+
106+
// Try to submit — should fail
107+
await submitView.submitButton.click()
108+
109+
// Verify error prevents submission (success message hidden)
110+
await expect(submitView.successMessage).not.toBeVisible()
111+
})
112+
113+
test('Complete ranking submission succeeds after partial attempt', async ({
114+
topBar,
115+
submitView,
116+
}) => {
117+
await topBar.toggleView(FormsView.View)
118+
119+
// Rank first 2 items
120+
await submitView.rankOption('Rank snacks', 'Pretzels')
121+
await submitView.rankOption('Rank snacks', 'Popcorn')
122+
123+
// Submit attempt fails (partial ranking)
124+
await submitView.submitButton.click()
125+
await expect(submitView.successMessage).not.toBeVisible()
126+
127+
// Complete the ranking
128+
await submitView.rankOption('Rank snacks', 'Nuts')
129+
130+
// Now submit should succeed
131+
await submitView.submit()
132+
await expect(submitView.successMessage).toBeVisible()
133+
})
134+
135+
test('Multiple ranking questions maintain separate drag contexts', async ({
136+
form,
137+
topBar,
138+
submitView,
139+
}) => {
140+
// Add a second ranking question
141+
await form.addQuestion(QuestionType.Ranking)
142+
const questions = await form.getQuestions()
143+
await questions[1].fillTitle('Rank preferences')
144+
await questions[1].addAnswer('Option X')
145+
await questions[1].addAnswer('Option Y')
146+
await questions[1].addAnswer('Option Z')
147+
148+
await topBar.toggleView(FormsView.View)
149+
150+
// Rank first question completely
151+
await submitView.rankOption('Rank snacks', 'Pretzels')
152+
await submitView.rankOption('Rank snacks', 'Popcorn')
153+
await submitView.rankOption('Rank snacks', 'Nuts')
154+
155+
// Rank second question partially
156+
await submitView.rankOption('Rank preferences', 'Option X')
157+
await submitView.rankOption('Rank preferences', 'Option Z')
158+
159+
// Verify both rankings are correct
160+
const q1 = submitView.getQuestion('Rank snacks')
161+
const q2 = submitView.getQuestion('Rank preferences')
162+
163+
await expect(
164+
q1.getByRole('button', { name: 'Remove from ranking' }),
165+
).toHaveCount(3)
166+
await expect(
167+
q2.getByRole('button', { name: 'Remove from ranking' }),
168+
).toHaveCount(2)
169+
170+
// Submit should require q2 to be complete
171+
await submitView.submitButton.click()
172+
await expect(submitView.successMessage).not.toBeVisible()
173+
174+
// Complete q2
175+
await submitView.rankOption('Rank preferences', 'Option Y')
176+
await submitView.submit()
177+
await expect(submitView.successMessage).toBeVisible()
178+
})
179+
})

playwright/support/sections/QuestionType.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export enum QuestionType {
1111
File = 'File',
1212
LinearScale = 'Linear scale',
1313
LongAnswer = 'Long text',
14+
Ranking = 'Ranking',
1415
RadioButtons = 'Radio buttons',
1516
ShortAnswer = 'Short answer',
1617
}

playwright/support/sections/SubmitSection.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@
66
import type { Locator, Page, Response } from '@playwright/test'
77

88
export class SubmitSection {
9+
public readonly clearFormButton: Locator
910
public readonly submitButton: Locator
1011
public readonly successMessage: Locator
1112

1213
constructor(public readonly page: Page) {
14+
this.clearFormButton = this.page.getByRole('button', { name: 'Clear form' })
1315
this.submitButton = this.page.getByRole('button', { name: 'Submit' })
1416
this.successMessage = this.page.getByText(
1517
'Thank you for completing the form!',
@@ -99,6 +101,29 @@ export class SubmitSection {
99101
await this.page.getByRole('option', { name: optionName }).click()
100102
}
101103

104+
/**
105+
* Rank an option by clicking it in the unranked pool.
106+
*
107+
* @param questionName the title of the question
108+
* @param optionName the option text to move into ranked list
109+
*/
110+
public async rankOption(
111+
questionName: string | RegExp,
112+
optionName: string | RegExp,
113+
): Promise<void> {
114+
const question = this.getQuestion(questionName)
115+
await question.getByRole('button', { name: optionName }).click()
116+
}
117+
118+
/**
119+
* Click clear form and confirm the dialog.
120+
*/
121+
public async clearForm(): Promise<void> {
122+
await this.clearFormButton.click()
123+
const dialog = this.page.getByRole('dialog', { name: 'Clear form' })
124+
await dialog.getByRole('button', { name: 'Clear' }).click()
125+
}
126+
102127
/** Click submit and wait for the API response. */
103128
public async submit(): Promise<Response> {
104129
const response = this.page.waitForResponse(

src/components/Questions/AnswerInput.vue

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,11 @@ export default {
139139
default: false,
140140
},
141141
142+
isRanking: {
143+
type: Boolean,
144+
default: false,
145+
},
146+
142147
maxIndex: {
143148
type: Number,
144149
required: true,
@@ -260,6 +265,10 @@ export default {
260265
return IconTableRow
261266
}
262267
268+
if (this.isRanking) {
269+
return IconDragIndicator
270+
}
271+
263272
return this.isUnique ? IconRadioboxBlank : IconCheckboxBlankOutline
264273
},
265274
},
@@ -542,8 +551,7 @@ export default {
542551
height: 100%;
543552
}
544553
545-
.option__drag-handle,
546-
.drag-indicator-icon {
554+
.option__drag-handle {
547555
color: var(--color-text-maxcontrast);
548556
cursor: grab;
549557
margin-block: auto;

0 commit comments

Comments
 (0)