Skip to content

Commit 7ebfff9

Browse files
feat: add ranking question type
Adds a new 'ranking' question type that allows respondents to drag-and-drop predefined options into their preferred order. Based on refactor/vue3 branch, using vue-draggable-plus. Signed-off-by: paul bochtler <65470117+datapumpernickel@users.noreply.github.com> fix linting and static check Signed-off-by: paul bochtler <65470117+datapumpernickel@users.noreply.github.com> fix: return default ranking when unchanged, disable required Signed-off-by: paul bochtler <65470117+datapumpernickel@users.noreply.github.com> change to tap-and-drag logic for possibility to leave blank - add unittest for blank answer Signed-off-by: paul bochtler <65470117+datapumpernickel@users.noreply.github.com> fix: realign drag layout to be consistent with create view and include keyboard menu Signed-off-by: paul bochtler <65470117+datapumpernickel@users.noreply.github.com> fix: apply review suggestions - merge ranking into grid condition in ApiController - swap icon to IconSwapVertical - fix copyright year to 2026 - use v-else for ranked list header - simplify sort comparison in validation Signed-off-by: paul bochtler <65470117+datapumpernickel@users.noreply.github.com> fix: redesign ranking submit layout to two columns Signed-off-by: paul bochtler <65470117+datapumpernickel@users.noreply.github.com> fix: revert to single column layout including review suggestions to keep unranked options area visible Signed-off-by: paul bochtler <65470117+datapumpernickel@users.noreply.github.com> fix: phpunit test with sort() Signed-off-by: paul bochtler <65470117+datapumpernickel@users.noreply.github.com> fix: linting fail because of additional tab Signed-off-by: paul bochtler <65470117+datapumpernickel@users.noreply.github.com> remove tap to rank hint and implement draggable ranking Signed-off-by: paul bochtler <65470117+datapumpernickel@users.noreply.github.com> move handlebar to front Signed-off-by: paul bochtler <65470117+datapumpernickel@users.noreply.github.com> fix: remove radio button and put drag-icon in edit mode Signed-off-by: paul bochtler <65470117+datapumpernickel@users.noreply.github.com> implement tertiary hover styling on ranked options Signed-off-by: paul bochtler <65470117+datapumpernickel@users.noreply.github.com> fix(ranking): restore local state, clear form sync and submit validation Signed-off-by: paul bochtler <65470117+datapumpernickel@users.noreply.github.com> test(ranking): add e2e coverage for restore, clear form and required behavior Signed-off-by: paul bochtler <65470117+datapumpernickel@users.noreply.github.com> fix(ranking): harmonize validation error wording Signed-off-by: paul bochtler <65470117+datapumpernickel@users.noreply.github.com> fix(ranking): use unique drag groups and v-if for empty states, add ranking question type to docs/DataStructure.md Signed-off-by: paul bochtler <65470117+datapumpernickel@users.noreply.github.com>
1 parent ad3db12 commit 7ebfff9

15 files changed

Lines changed: 1011 additions & 11 deletions

File tree

docs/DataStructure.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,7 @@ Currently supported Question-Types are:
235235
| `file` | One or multiple files. It is possible to specify which mime types are allowed |
236236
| `linearscale` | A linear or Likert scale question where you choose an option that best fits your opinion |
237237
| `color` | A color answer, hex string representation (e. g. `#123456`) |
238+
| `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). |
238239

239240
## Extra Settings
240241

@@ -243,7 +244,7 @@ Optional extra settings for some [Question Types](#question-types)
243244
| Extra Setting | Question Type | Type | Values | Description |
244245
| ----------------------- | ------------------------------------- | ---------------- | ------------------------------------------- | --------------------------------------------------------------------------- |
245246
| `allowOtherAnswer` | `multiple, multiple_unique` | Boolean | `true/false` | Allows the user to specify a custom answer |
246-
| `shuffleOptions` | `dropdown, multiple, multiple_unique` | Boolean | `true/false` | The list of options should be shuffled |
247+
| `shuffleOptions` | `dropdown, multiple, multiple_unique, ranking` | Boolean | `true/false` | The list of options should be shuffled |
247248
| `optionsLimitMax` | `multiple` | Integer | - | Maximum number of options that can be selected |
248249
| `optionsLimitMin` | `multiple` | Integer | - | Minimum number of options that must be selected |
249250
| `validationType` | `short` | string | `null, 'phone', 'email', 'regex', 'number'` | Custom validation for checking a submission |

lib/Constants.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ class Constants {
8282
public const ANSWER_TYPE_LONG = 'long';
8383
public const ANSWER_TYPE_MULTIPLE = 'multiple';
8484
public const ANSWER_TYPE_MULTIPLEUNIQUE = 'multiple_unique';
85+
public const ANSWER_TYPE_RANKING = 'ranking';
8586
public const ANSWER_TYPE_SHORT = 'short';
8687
public const ANSWER_TYPE_TIME = 'time';
8788

@@ -101,6 +102,7 @@ class Constants {
101102
self::ANSWER_TYPE_LONG,
102103
self::ANSWER_TYPE_MULTIPLE,
103104
self::ANSWER_TYPE_MULTIPLEUNIQUE,
105+
self::ANSWER_TYPE_RANKING,
104106
self::ANSWER_TYPE_SHORT,
105107
self::ANSWER_TYPE_TIME,
106108
];
@@ -111,6 +113,7 @@ class Constants {
111113
self::ANSWER_TYPE_LINEARSCALE,
112114
self::ANSWER_TYPE_MULTIPLE,
113115
self::ANSWER_TYPE_MULTIPLEUNIQUE,
116+
self::ANSWER_TYPE_RANKING,
114117
];
115118

116119
// AnswerTypes for date/time questions
@@ -197,6 +200,10 @@ class Constants {
197200
'rows' => ['array'],
198201
];
199202

203+
public const EXTRA_SETTINGS_RANKING = [
204+
'shuffleOptions' => ['boolean'],
205+
];
206+
200207
public const EXTRA_SETTINGS_GRID_QUESTION_TYPE = [
201208
self::ANSWER_GRID_TYPE_CHECKBOX,
202209
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
@@ -813,6 +813,9 @@ public function areExtraSettingsValid(array $extraSettings, string $questionType
813813
case Constants::ANSWER_TYPE_GRID:
814814
$allowed = Constants::EXTRA_SETTINGS_GRID;
815815
break;
816+
case Constants::ANSWER_TYPE_RANKING:
817+
$allowed = Constants::EXTRA_SETTINGS_RANKING;
818+
break;
816819
case Constants::ANSWER_TYPE_TIME:
817820
$allowed = Constants::EXTRA_SETTINGS_TIME;
818821
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: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
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+
})

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: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
<component
99
:is="pseudoIcon"
1010
v-if="!isDropdown"
11+
:size="24"
1112
class="question__item__pseudoInput" />
1213
<input
1314
ref="input"
@@ -145,6 +146,11 @@ export default {
145146
default: false,
146147
},
147148
149+
isRanking: {
150+
type: Boolean,
151+
default: false,
152+
},
153+
148154
maxIndex: {
149155
type: Number,
150156
required: true,
@@ -256,6 +262,10 @@ export default {
256262
return IconTableRow
257263
}
258264
265+
if (this.isRanking) {
266+
return IconDragIndicator
267+
}
268+
259269
return this.isUnique ? IconRadioboxBlank : IconCheckboxBlankOutline
260270
},
261271
},
@@ -538,8 +548,7 @@ export default {
538548
height: 100%;
539549
}
540550
541-
.option__drag-handle,
542-
.drag-indicator-icon {
551+
.option__drag-handle {
543552
color: var(--color-text-maxcontrast);
544553
cursor: grab;
545554
margin-block: auto;

0 commit comments

Comments
 (0)