Skip to content

Commit 1b29dc6

Browse files
AIlkivChartman123
authored andcommitted
feat: introduce Section as a new question type
Signed-off-by: ailkiv <a.ilkiv.ye@gmail.com>
1 parent a9c9ddf commit 1b29dc6

File tree

11 files changed

+170
-22
lines changed

11 files changed

+170
-22
lines changed

lib/Constants.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ class Constants {
7676
public const ANSWER_TYPE_LONG = 'long';
7777
public const ANSWER_TYPE_MULTIPLE = 'multiple';
7878
public const ANSWER_TYPE_MULTIPLEUNIQUE = 'multiple_unique';
79+
public const ANSWER_TYPE_SECTION = 'section';
7980
public const ANSWER_TYPE_SHORT = 'short';
8081
public const ANSWER_TYPE_TIME = 'time';
8182

@@ -95,6 +96,7 @@ class Constants {
9596
self::ANSWER_TYPE_LONG,
9697
self::ANSWER_TYPE_MULTIPLE,
9798
self::ANSWER_TYPE_MULTIPLEUNIQUE,
99+
self::ANSWER_TYPE_SECTION,
98100
self::ANSWER_TYPE_SHORT,
99101
self::ANSWER_TYPE_TIME,
100102
];

lib/Controller/ApiController.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1187,10 +1187,13 @@ public function getSubmissions(int $formId, ?string $query = null, ?int $limit =
11871187
}
11881188
$questions = [];
11891189
foreach ($this->formsService->getQuestions($formId) as $question) {
1190+
if ($question['type'] === Constants::ANSWER_TYPE_SECTION) {
1191+
continue;
1192+
}
1193+
11901194
$questions[$question['id']] = $question;
11911195
}
11921196

1193-
11941197
// Append Display Names
11951198
$submissions = array_map(function (array $submission) use ($questions) {
11961199
if (!empty($submission['answers'])) {

lib/ResponseDefinitions.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,8 @@
4343
* questionType?: string,
4444
* }
4545
*
46-
* @psalm-type FormsQuestionType = "dropdown"|"multiple"|"multiple_unique"|"date"|"time"|"short"|"long"|"file"|"datetime"|"grid"
47-
* @psalm-type FormsQuestionGridCellType = "checkbox"|"number"|"radio"
46+
* @psalm-type FormsQuestionType = "dropdown"|"multiple"|"multiple_unique"|"date"|"time"|"short"|"long"|"file"|"datetime"|"grid"|"section"
47+
* @psalm-type FormsQuestionGridCellType = "checkbox"|"number"|"radio"|"section"
4848
*
4949
* @psalm-type FormsQuestion = array{
5050
* id: int,

lib/Service/SubmissionService.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,11 @@ public function getSubmissionsData(Form $form, string $fileFormat, ?File $file =
233233
$submissionEntities = array_reverse($submissionEntities);
234234

235235
$questions = $this->questionMapper->findByForm($form->getId());
236+
237+
$questions = array_filter($questions, function (Question $question) {
238+
return $question->getType() !== Constants::ANSWER_TYPE_SECTION;
239+
});
240+
236241
$defaultTimeZone = $this->config->getSystemValueString('default_timezone', 'UTC');
237242

238243
if (!$this->currentUser) {

openapi.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -548,7 +548,8 @@
548548
"long",
549549
"file",
550550
"datetime",
551-
"grid"
551+
"grid",
552+
"section"
552553
]
553554
},
554555
"Share": {

src/components/Questions/Question.vue

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
class="question"
99
:class="{
1010
'question--editable': !readOnly,
11+
'question--section': readOnly && isSection,
1112
}"
1213
:aria-label="t('forms', 'Question number {index}', { index })">
1314
<!-- Drag handle -->
@@ -91,13 +92,15 @@
9192
</IconOverlay>
9293
</template>
9394
<NcActionCheckbox
95+
v-if="!isSection"
9496
:model-value="isRequired"
9597
@update:model-value="onRequiredChange">
9698
<!-- TRANSLATORS Making this question necessary to be answered when submitting to a form -->
9799
{{ t('forms', 'Required') }}
98100
</NcActionCheckbox>
99101
<slot name="actions" />
100102
<NcActionInput
103+
v-if="!isSection"
101104
:label="t('forms', 'Technical name of the question')"
102105
:label-outside="false"
103106
:show-trailing-button="false"
@@ -259,6 +262,10 @@ export default {
259262
type: Boolean,
260263
default: false,
261264
},
265+
type: {
266+
type: String,
267+
default: '',
268+
},
262269
},
263270
264271
emits: [
@@ -309,6 +316,10 @@ export default {
309316
hasDescription() {
310317
return this.description !== ''
311318
},
319+
320+
isSection() {
321+
return this.type === 'section'
322+
},
312323
},
313324
314325
// Ensure description is sized correctly on initial render
@@ -520,4 +531,22 @@ export default {
520531
}
521532
}
522533
}
534+
535+
.question--section {
536+
margin-block-end: 16px;
537+
position: sticky;
538+
top: 0;
539+
background: var(--color-main-background);
540+
z-index: 2;
541+
542+
h3 {
543+
font-size: 24px !important;
544+
border-bottom: 1px solid;
545+
}
546+
547+
.question__header__description {
548+
max-height: calc(var(--default-font-size) * 8);
549+
overflow-y: auto;
550+
}
551+
}
523552
</style>
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<!--
2+
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
- SPDX-License-Identifier: AGPL-3.0-or-later
4+
-->
5+
6+
<template>
7+
<Question
8+
v-bind="questionProps"
9+
:title-placeholder="answerType.titlePlaceholder"
10+
:warning-invalid="answerType.warningInvalid"
11+
v-on="commonListeners">
12+
</Question>
13+
</template>
14+
15+
<script>
16+
import QuestionMixin from '../../mixins/QuestionMixin.js'
17+
import Question from './Question.vue'
18+
export default {
19+
name: 'QuestionSection',
20+
components: {
21+
Question,
22+
},
23+
mixins: [QuestionMixin],
24+
}
25+
</script>

src/models/AnswerTypes.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,22 @@
33
* SPDX-License-Identifier: AGPL-3.0-or-later
44
*/
55

6+
import QuestionColor from '../components/Questions/QuestionColor.vue'
7+
import QuestionDate from '../components/Questions/QuestionDate.vue'
8+
import QuestionDropdown from '../components/Questions/QuestionDropdown.vue'
9+
import QuestionFile from '../components/Questions/QuestionFile.vue'
10+
import QuestionLinearScale from '../components/Questions/QuestionLinearScale.vue'
11+
import QuestionLong from '../components/Questions/QuestionLong.vue'
12+
import QuestionMultiple from '../components/Questions/QuestionMultiple.vue'
13+
import QuestionSection from '../components/Questions/QuestionSection.vue'
14+
import QuestionShort from '../components/Questions/QuestionShort.vue'
15+
616
import IconArrowDownDropCircleOutline from 'vue-material-design-icons/ArrowDownDropCircleOutline.vue'
717
import IconCalendar from 'vue-material-design-icons/CalendarOutline.vue'
818
import IconCheckboxOutline from 'vue-material-design-icons/CheckboxOutline.vue'
919
import IconClockOutline from 'vue-material-design-icons/ClockOutline.vue'
1020
import IconFile from 'vue-material-design-icons/FileOutline.vue'
21+
import IconFormatSection from 'vue-material-design-icons/FormatSection.vue'
1122
import IconGrid from 'vue-material-design-icons/Grid.vue'
1223
import IconNumeric from 'vue-material-design-icons/Numeric.vue'
1324
import IconRadioboxMarked from 'vue-material-design-icons/RadioboxMarked.vue'
@@ -263,4 +274,14 @@ export default {
263274
submitPlaceholder: t('forms', 'Pick a color'),
264275
warningInvalid: t('forms', 'This question needs a title!'),
265276
},
277+
278+
section: {
279+
component: QuestionSection,
280+
icon: IconFormatSection,
281+
label: t('forms', 'Section'),
282+
predefined: false,
283+
284+
titlePlaceholder: t('forms', 'Section title'),
285+
warningInvalid: t('forms', 'This section needs a title!'),
286+
},
266287
}

src/views/Create.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@
138138
:answer-type="answerTypes[question.type]"
139139
:index="index + 1"
140140
:max-string-lengths="maxStringLengths"
141+
:type="question.type"
141142
v-bind.sync="form.questions[index]"
142143
@clone="cloneQuestion(question)"
143144
@delete="deleteQuestion(question.id)"

src/views/Submit.vue

Lines changed: 75 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -105,22 +105,41 @@
105105

106106
<!-- Questions list -->
107107
<form v-else ref="form" @submit.prevent="onSubmit">
108-
<ul>
109-
<component
110-
:is="answerTypes[question.type].component"
111-
v-for="(question, index) in validQuestions"
112-
ref="questions"
113-
:key="question.id"
114-
read-only
115-
:answer-type="answerTypes[question.type]"
116-
:index="index + 1"
117-
:max-string-lengths="maxStringLengths"
118-
:values="answers[question.id]"
119-
v-bind="question"
120-
@keydown.enter="onKeydownEnter"
121-
@keydown.ctrl.enter="onKeydownCtrlEnter"
122-
@update:values="(values) => onUpdate(question, values)" />
123-
</ul>
108+
<template v-for="(group, groupIndex) in groupedQuestions">
109+
<ul :key="`group-${groupIndex}`">
110+
<Questions
111+
v-if="group.section"
112+
:is="answerTypes[group.section.type].component"
113+
ref="questions"
114+
:key="group.section.id"
115+
read-only
116+
:answer-type="answerTypes[group.section.type]"
117+
:index="group.displayIndex"
118+
:max-string-lengths="maxStringLengths"
119+
:type="group.section.type"
120+
v-bind="group.section" />
121+
122+
<template v-if="group.questions.length > 0">
123+
<component
124+
:is="answerTypes[question.type].component"
125+
v-for="question in group.questions"
126+
ref="questions"
127+
:key="question.id"
128+
read-only
129+
:answer-type="answerTypes[question.type]"
130+
:index="question.displayIndex"
131+
:max-string-lengths="maxStringLengths"
132+
:type="question.type"
133+
:values="answers[question.id]"
134+
v-bind="question"
135+
@keydown.enter="onKeydownEnter"
136+
@keydown.ctrl.enter="onKeydownCtrlEnter"
137+
@update:values="
138+
(values) => onUpdate(question, values)
139+
" />
140+
</template>
141+
</ul>
142+
</template>
124143
<div class="form-buttons">
125144
<NcButton
126145
alignment="center-reverse"
@@ -334,6 +353,46 @@ export default {
334353
})
335354
},
336355
356+
/**
357+
* Group questions by sections
358+
* Each section contains its questions and the section itself
359+
*/
360+
groupedQuestions() {
361+
const groups = []
362+
let currentGroup = { section: null, questions: [] }
363+
let questionIndex = 1
364+
365+
for (const question of this.validQuestions) {
366+
if (question.type === 'section') {
367+
// Save current group if it has content
368+
if (currentGroup.section || currentGroup.questions.length > 0) {
369+
groups.push(currentGroup)
370+
}
371+
372+
// Start new group with section
373+
currentGroup = {
374+
section: question,
375+
displayIndex: questionIndex,
376+
questions: [],
377+
}
378+
} else {
379+
// Add question to current group
380+
currentGroup.questions.push({
381+
...question,
382+
displayIndex: questionIndex,
383+
})
384+
}
385+
questionIndex++
386+
}
387+
388+
// Add the last group if it has content
389+
if (currentGroup.section || currentGroup.questions.length > 0) {
390+
groups.push(currentGroup)
391+
}
392+
393+
return groups
394+
},
395+
337396
validQuestionsIds() {
338397
return new Set(this.validQuestions.map((question) => question.id))
339398
},

0 commit comments

Comments
 (0)