Skip to content
Closed
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
7 changes: 7 additions & 0 deletions lib/Constants.php
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,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 @@ -95,6 +96,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 @@ -105,6 +107,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 @@ -191,6 +194,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 @@ -1720,7 +1720,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 @@ -810,6 +810,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
34 changes: 34 additions & 0 deletions lib/Service/SubmissionService.php
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,12 @@ 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;
}
$header[] = $question->getText();
} else {
$header[] = $question->getText();
}
Expand Down Expand Up @@ -354,6 +360,20 @@ function (array $carry, Answer $answer) use ($questionPerQuestionId, $gridRowsPe
}
}
$carry[$questionId] = ['columns' => $columns];
} elseif ($questionType === Constants::ANSWER_TYPE_RANKING) {
$rankedIds = json_decode($answer->getText(), true);
if (is_array($rankedIds)) {
$rankedTexts = [];
foreach ($rankedIds as $optionId) {
$optionId = (int)$optionId;
if (isset($optionPerOptionId[$optionId])) {
$rankedTexts[] = $optionPerOptionId[$optionId]->getText();
}
}
$carry[$questionId] = implode(', ', $rankedTexts);
} else {
$carry[$questionId] = $answer->getText();
}
} else {
if (array_key_exists($questionId, $carry)) {
$carry[$questionId] .= '; ' . $answer->getText();
Expand Down Expand Up @@ -510,6 +530,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 +582,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]);
$sortedRanked = $rankedIds;
$sortedOptions = $optionIds;
sort($sortedRanked);
sort($sortedOptions);
if ($sortedRanked !== $sortedOptions) {
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
258 changes: 258 additions & 0 deletions src/components/Questions/QuestionRanking.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
<!--
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->

<template>
<Question
v-bind="questionProps"
:title-placeholder="answerType.titlePlaceholder"
:warning-invalid="answerType.warningInvalid"
:content-valid="contentValid"
:shift-drag-handle="shiftDragHandle"
v-on="commonListeners">
<template #actions>
<NcActionCheckbox
:model-value="extraSettings?.shuffleOptions"
@update:model-value="onShuffleOptionsChange">
{{ t('forms', 'Shuffle options') }}
</NcActionCheckbox>
<NcActionButton close-after-click @click="isOptionDialogShown = true">
<template #icon>
<IconContentPaste :size="20" />
</template>
{{ t('forms', 'Add multiple options') }}
</NcActionButton>
</template>

<!-- SUBMIT MODE: drag-to-rank -->
<Draggable
v-if="readOnly"
:list="rankedOptions"
class="question__content question-ranking"
:animation="200"
handle=".ranking-item__drag-handle"
tag="ol"
role="list"
:aria-labelledby="titleId"
:aria-describedby="description ? descriptionId : undefined"
@end="onRankingEnd">
<li
v-for="(option, index) in rankedOptions"
:key="option.id"
class="ranking-item"
role="listitem">
<span class="ranking-item__position">{{ index + 1 }}</span>
<IconDragVertical

Check failure on line 46 in src/components/Questions/QuestionRanking.vue

View workflow job for this annotation

GitHub Actions / NPM lint

Replace `⏎↹↹↹↹↹class="ranking-item__drag-handle"⏎↹↹↹↹↹` with `·class="ranking-item__drag-handle"·`
class="ranking-item__drag-handle"
:size="20" />
<span class="ranking-item__text">{{ option.text }}</span>
</li>
</Draggable>

<!-- EDIT MODE: standard option editor -->
<template v-else>
<div v-if="isLoading">
<NcLoadingIcon :size="64" />
</div>
<Draggable
v-else
v-model="choices"
class="question__content"
:animation="200"
direction="vertical"
handle=".option__drag-handle"
invert-swap
tag="ul"
@change="saveOptionsOrder('choice')"
@start="isDragging = true"
@end="isDragging = false">
<TransitionGroup
:name="
isDragging
? 'no-external-transition-on-drag'
: 'options-list-transition'
">
<AnswerInput
v-for="(answer, index) in choices"
:key="answer.local ? 'option-local' : answer.id"
ref="input"

Check warning on line 79 in src/components/Questions/QuestionRanking.vue

View workflow job for this annotation

GitHub Actions / NPM lint

'input' is defined as ref, but never used
:answer="answer"
:form-id="formId"
:index="index"
:is-unique="true"
:max-index="options.length - 1"
:max-option-length="maxStringLengths.optionText"
option-type="choice"
@create-answer="onCreateAnswer"
@update:answer="updateAnswer"
@delete="deleteOption"
@focus-next="focusNextInput"
@move-up="onOptionMoveUp(index, 'choice')"
@move-down="onOptionMoveDown(index, 'choice')"
@tabbed-out="checkValidOption('choice')" />
</TransitionGroup>
</Draggable>
</template>

<!-- Add multiple options modal -->
<OptionInputDialog
:open.sync="isOptionDialogShown"
@multiple-answers="handleMultipleOptions" />
</Question>
</template>

<script>
import Draggable from 'vuedraggable'
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import NcActionCheckbox from '@nextcloud/vue/components/NcActionCheckbox'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import IconContentPaste from 'vue-material-design-icons/ContentPaste.vue'
import IconDragVertical from 'vue-material-design-icons/DragVertical.vue'
import OptionInputDialog from '../OptionInputDialog.vue'
import AnswerInput from './AnswerInput.vue'
import Question from './Question.vue'
import QuestionMixin from '../../mixins/QuestionMixin.js'
import QuestionMultipleMixin from '../../mixins/QuestionMultipleMixin.ts'
import { OptionType } from '../../models/Constants.ts'

export default {
name: 'QuestionRanking',

components: {
AnswerInput,
Draggable,
IconContentPaste,
IconDragVertical,
NcActionButton,
NcActionCheckbox,
NcLoadingIcon,
OptionInputDialog,
Question,
},

mixins: [QuestionMixin, QuestionMultipleMixin],
emits: ['update:values'],

data() {
return {
isDragging: false,
isLoading: false,
isOptionDialogShown: false,
rankedOptions: [],
rankedInitialized: false,
}
},

computed: {
shiftDragHandle() {
return !this.readOnly && this.options.length !== 0 && !this.isLastEmpty
},

choices: {
get() {
return this.sortOptionsOfType(this.options, OptionType.Choice)
},

set(value) {
this.updateOptionsOrder(value, OptionType.Choice)
},
},
},

watch: {
options: {
immediate: true,
handler() {
// Only initialize once in submit mode to avoid resetting after drag
if (this.readOnly && this.rankedInitialized) {
return
}
this.initRankedOptions()
},
},
},

mounted() {
// If in submit mode and no values set yet, emit the initial order
if (this.readOnly && (!this.values || this.values.length === 0)) {
this.$emit('update:values', this.rankedOptions.map((o) => o.id))

Check failure on line 179 in src/components/Questions/QuestionRanking.vue

View workflow job for this annotation

GitHub Actions / NPM lint

Replace `'update:values',·this.rankedOptions.map((o)·=>·o.id)` with `⏎↹↹↹↹'update:values',⏎↹↹↹↹this.rankedOptions.map((o)·=>·o.id),⏎↹↹↹`
}
},

methods: {
/**
* Initialize rankedOptions from existing values or default option order.
*/
initRankedOptions() {
const opts = this.sortOptionsOfType(this.options, OptionType.Choice)

Check failure on line 188 in src/components/Questions/QuestionRanking.vue

View workflow job for this annotation

GitHub Actions / NPM lint

Replace `this.options,·OptionType.Choice)` with `⏎↹↹↹↹this.options,⏎↹↹↹↹OptionType.Choice,`
.filter((o) => !o.local)

Check failure on line 189 in src/components/Questions/QuestionRanking.vue

View workflow job for this annotation

GitHub Actions / NPM lint

Replace `↹` with `)`

if (this.values && Array.isArray(this.values) && this.values.length > 0) {

Check failure on line 191 in src/components/Questions/QuestionRanking.vue

View workflow job for this annotation

GitHub Actions / NPM lint

Replace `this.values·&&·Array.isArray(this.values)·&&·this.values.length·>·0` with `⏎↹↹↹↹this.values⏎↹↹↹↹&&·Array.isArray(this.values)⏎↹↹↹↹&&·this.values.length·>·0⏎↹↹↹`
const ordered = []
for (const id of this.values) {
const opt = opts.find((o) => o.id === parseInt(id))
if (opt) ordered.push(opt)
}
for (const opt of opts) {
if (!ordered.find((o) => o.id === opt.id)) {
ordered.push(opt)
}
}
this.rankedOptions = ordered
} else {
this.rankedOptions = opts
}
this.rankedInitialized = true
},

/**
* Called after drag ends — emit the new ranking order to the parent.
*/
onRankingEnd() {
this.$emit('update:values', this.rankedOptions.map((o) => o.id))

Check failure on line 213 in src/components/Questions/QuestionRanking.vue

View workflow job for this annotation

GitHub Actions / NPM lint

Replace `'update:values',·this.rankedOptions.map((o)·=>·o.id)` with `⏎↹↹↹↹'update:values',⏎↹↹↹↹this.rankedOptions.map((o)·=>·o.id),⏎↹↹↹`
},
},
}
</script>

<style lang="scss" scoped>
.question-ranking {
list-style: none;
padding: 0;
}

.ranking-item {
display: flex;
align-items: center;
padding: 12px 16px;
margin-bottom: 4px;
background: var(--color-background-dark);
border-radius: var(--border-radius-large);
user-select: none;
min-height: 44px;

&__position {
font-weight: bold;
margin-inline-end: 12px;
min-width: 20px;
text-align: center;
color: var(--color-primary-element);
}

&__drag-handle {
margin-inline-end: 8px;
color: var(--color-text-maxcontrast);
cursor: grab;
padding: 8px;

&:active {
cursor: grabbing;
}
}

&__text {
flex: 1;
}
}
</style>
Loading
Loading