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
2 changes: 2 additions & 0 deletions lib/Constants.php
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,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_SECTION = 'section';
public const ANSWER_TYPE_SHORT = 'short';
public const ANSWER_TYPE_TIME = 'time';

Expand All @@ -89,6 +90,7 @@ class Constants {
self::ANSWER_TYPE_LONG,
self::ANSWER_TYPE_MULTIPLE,
self::ANSWER_TYPE_MULTIPLEUNIQUE,
self::ANSWER_TYPE_SECTION,
self::ANSWER_TYPE_SHORT,
self::ANSWER_TYPE_TIME,
];
Expand Down
5 changes: 4 additions & 1 deletion lib/Controller/ApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -1174,10 +1174,13 @@ public function getSubmissions(int $formId, ?string $query = null, ?int $limit =
}
$questions = [];
foreach ($this->formsService->getQuestions($formId) as $question) {
if ($question['type'] === Constants::ANSWER_TYPE_SECTION) {
continue;
}

$questions[$question['id']] = $question;
}


// Append Display Names
$submissions = array_map(function (array $submission) use ($questions) {
if (!empty($submission['answers'])) {
Expand Down
2 changes: 1 addition & 1 deletion lib/ResponseDefinitions.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
* validationType?: string
* }
*
* @psalm-type FormsQuestionType = "dropdown"|"multiple"|"multiple_unique"|"date"|"time"|"short"|"long"|"file"|"datetime"
* @psalm-type FormsQuestionType = "dropdown"|"multiple"|"multiple_unique"|"date"|"time"|"short"|"long"|"file"|"datetime"|"section"
*
* @psalm-type FormsQuestion = array{
* id: int,
Expand Down
5 changes: 5 additions & 0 deletions lib/Service/SubmissionService.php
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,11 @@ public function getSubmissionsData(Form $form, string $fileFormat, ?File $file =
$submissionEntities = array_reverse($submissionEntities);

$questions = $this->questionMapper->findByForm($form->getId());

$questions = array_filter($questions, function (Question $question) {
return $question->getType() !== Constants::ANSWER_TYPE_SECTION;
});

$defaultTimeZone = $this->config->getSystemValueString('default_timezone', 'UTC');

if (!$this->currentUser) {
Expand Down
3 changes: 2 additions & 1 deletion openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -536,7 +536,8 @@
"short",
"long",
"file",
"datetime"
"datetime",
"section"
]
},
"Share": {
Expand Down
29 changes: 29 additions & 0 deletions src/components/Questions/Question.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
:class="{
question: true,
'question--editable': !readOnly,
'question--section': readOnly && isSection,
}"
:aria-label="t('forms', 'Question number {index}', { index })">
<!-- Drag handle -->
Expand Down Expand Up @@ -91,13 +92,15 @@
</IconOverlay>
</template>
<NcActionCheckbox
v-if="!isSection"
:checked="isRequired"
@update:checked="onRequiredChange">
<!-- TRANSLATORS Making this question necessary to be answered when submitting to a form -->
{{ t('forms', 'Required') }}
</NcActionCheckbox>
<slot name="actions" />
<NcActionInput
v-if="!isSection"
:label="t('forms', 'Technical name of the question')"
:label-outside="false"
:show-trailing-button="false"
Expand Down Expand Up @@ -247,6 +250,10 @@ export default {
type: Boolean,
default: false,
},
type: {
type: String,
default: '',
},
},

computed: {
Expand Down Expand Up @@ -286,6 +293,10 @@ export default {
hasDescription() {
return this.description !== ''
},

isSection() {
return this.type === 'section'
},
},
// Ensure description is sized correctly on initial render
mounted() {
Expand Down Expand Up @@ -494,4 +505,22 @@ export default {
}
}
}

.question--section {
margin-block-end: 16px;
position: sticky;
top: 0;
background: var(--color-main-background);
z-index: 2;

h3 {
font-size: 24px !important;
border-bottom: 1px solid;
}

.question__header__description {
max-height: calc(var(--default-font-size) * 8);
overflow-y: auto;
}
}
</style>
25 changes: 25 additions & 0 deletions src/components/Questions/QuestionSection.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<!--
- 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"
v-on="commonListeners">
</Question>
</template>

<script>
import QuestionMixin from '../../mixins/QuestionMixin.js'
import Question from './Question.vue'
export default {
name: 'QuestionSection',
components: {
Question,
},
mixins: [QuestionMixin],
}
</script>
12 changes: 12 additions & 0 deletions src/models/AnswerTypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ import QuestionFile from '../components/Questions/QuestionFile.vue'
import QuestionLinearScale from '../components/Questions/QuestionLinearScale.vue'
import QuestionLong from '../components/Questions/QuestionLong.vue'
import QuestionMultiple from '../components/Questions/QuestionMultiple.vue'
import QuestionSection from '../components/Questions/QuestionSection.vue'
import QuestionShort from '../components/Questions/QuestionShort.vue'

import IconArrowDownDropCircleOutline from 'vue-material-design-icons/ArrowDownDropCircleOutline.vue'
import IconCalendar from 'vue-material-design-icons/CalendarOutline.vue'
import IconCheckboxOutline from 'vue-material-design-icons/CheckboxOutline.vue'
import IconClockOutline from 'vue-material-design-icons/ClockOutline.vue'
import IconFile from 'vue-material-design-icons/FileOutline.vue'
import IconFormatSection from 'vue-material-design-icons/FormatSection.vue'
import IconLinearScale from '../components/Icons/IconLinearScale.vue'
import IconPalette from '../components/Icons/IconPalette.vue'
import IconRadioboxMarked from 'vue-material-design-icons/RadioboxMarked.vue'
Expand Down Expand Up @@ -213,4 +215,14 @@ export default {
submitPlaceholder: t('forms', 'Pick a color'),
warningInvalid: t('forms', 'This question needs a title!'),
},

section: {
component: QuestionSection,
icon: IconFormatSection,
label: t('forms', 'Section'),
predefined: false,

titlePlaceholder: t('forms', 'Section title'),
warningInvalid: t('forms', 'This section needs a title!'),
},
}
1 change: 1 addition & 0 deletions src/views/Create.vue
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@
:answer-type="answerTypes[question.type]"
:index="index + 1"
:max-string-lengths="maxStringLengths"
:type="question.type"
v-bind.sync="form.questions[index]"
@clone="cloneQuestion(question)"
@delete="deleteQuestion(question.id)"
Expand Down
91 changes: 75 additions & 16 deletions src/views/Submit.vue
Original file line number Diff line number Diff line change
Expand Up @@ -104,22 +104,41 @@

<!-- Questions list -->
<form v-else ref="form" @submit.prevent="onSubmit">
<ul>
<Questions
:is="answerTypes[question.type].component"
v-for="(question, index) in validQuestions"
ref="questions"
:key="question.id"
read-only
:answer-type="answerTypes[question.type]"
:index="index + 1"
:max-string-lengths="maxStringLengths"
:values="answers[question.id]"
v-bind="question"
@keydown.enter="onKeydownEnter"
@keydown.ctrl.enter="onKeydownCtrlEnter"
@update:values="(values) => onUpdate(question, values)" />
</ul>
<template v-for="(group, groupIndex) in groupedQuestions">
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is necessary for the correct operation of postion:sticky when there are several sections.
And also for the future, to add display by separate pages.

<ul :key="`group-${groupIndex}`">
<Questions
v-if="group.section"
:is="answerTypes[group.section.type].component"

Check warning on line 111 in src/views/Submit.vue

View workflow job for this annotation

GitHub Actions / NPM lint

Attribute ":is" should go before "v-if"
ref="questions"
:key="group.section.id"
read-only
:answer-type="answerTypes[group.section.type]"
:index="group.displayIndex"
:max-string-lengths="maxStringLengths"
:type="group.section.type"
v-bind="group.section" />

<template v-if="group.questions.length > 0">
<Questions
:is="answerTypes[question.type].component"
v-for="question in group.questions"
ref="questions"
:key="question.id"
read-only
:answer-type="answerTypes[question.type]"
:index="question.displayIndex"
:max-string-lengths="maxStringLengths"
:type="question.type"
:values="answers[question.id]"
v-bind="question"
@keydown.enter="onKeydownEnter"
@keydown.ctrl.enter="onKeydownCtrlEnter"
@update:values="
(values) => onUpdate(question, values)
" />
</template>
</ul>
</template>
<div class="form-buttons">
<NcButton
alignment="center-reverse"
Expand Down Expand Up @@ -334,6 +353,46 @@
})
},

/**
* Group questions by sections
* Each section contains its questions and the section itself
*/
groupedQuestions() {
const groups = []
let currentGroup = { section: null, questions: [] }
let questionIndex = 1

for (const question of this.validQuestions) {
if (question.type === 'section') {
// Save current group if it has content
if (currentGroup.section || currentGroup.questions.length > 0) {
groups.push(currentGroup)
}

// Start new group with section
currentGroup = {
section: question,
displayIndex: questionIndex,
questions: [],
}
} else {
// Add question to current group
currentGroup.questions.push({
...question,
displayIndex: questionIndex,
})
}
questionIndex++
}

// Add the last group if it has content
if (currentGroup.section || currentGroup.questions.length > 0) {
groups.push(currentGroup)
}

return groups
},

validQuestionsIds() {
return new Set(this.validQuestions.map((question) => question.id))
},
Expand Down
6 changes: 4 additions & 2 deletions tests/Unit/Controller/ApiControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ public function dataGetSubmissions() {
'submissions' => [
['userId' => 'anon-user-1']
],
'questions' => [['id' => 1, 'name' => 'questions']],
'questions' => [['id' => 1, 'name' => 'questions', 'type' => Constants::ANSWER_TYPE_SHORT]],
'expected' => [
'submissions' => [
[
Expand All @@ -235,6 +235,7 @@ public function dataGetSubmissions() {
[
'id' => 1,
'name' => 'questions',
'type' => Constants::ANSWER_TYPE_SHORT,
'extraSettings' => new \stdClass(),
],
],
Expand All @@ -245,7 +246,7 @@ public function dataGetSubmissions() {
'submissions' => [
['userId' => 'jdoe']
],
'questions' => [['id' => 1, 'name' => 'questions']],
'questions' => [['id' => 1, 'name' => 'questions', 'type' => Constants::ANSWER_TYPE_SHORT]],
'expected' => [
'submissions' => [
[
Expand All @@ -257,6 +258,7 @@ public function dataGetSubmissions() {
[
'id' => 1,
'name' => 'questions',
'type' => Constants::ANSWER_TYPE_SHORT,
'extraSettings' => new \stdClass(),
],
],
Expand Down
Loading