Skip to content

Commit ba3baf7

Browse files
AIlkivChartman123
authored andcommitted
feat: introduce Section as a new question type
Signed-off-by: ailkiv <a.ilkiv.ye@gmail.com> Signed-off-by: Christian Hartmann <chris-hartmann@gmx.de>
1 parent a9c9ddf commit ba3baf7

File tree

11 files changed

+163
-21
lines changed

11 files changed

+163
-21
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: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
* questionType?: string,
4444
* }
4545
*
46-
* @psalm-type FormsQuestionType = "dropdown"|"multiple"|"multiple_unique"|"date"|"time"|"short"|"long"|"file"|"datetime"|"grid"
46+
* @psalm-type FormsQuestionType = "dropdown"|"multiple"|"multiple_unique"|"date"|"time"|"short"|"long"|"file"|"datetime"|"grid"|"section"
4747
* @psalm-type FormsQuestionGridCellType = "checkbox"|"number"|"radio"
4848
*
4949
* @psalm-type FormsQuestion = array{

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: 30 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,11 @@ export default {
259262
type: Boolean,
260263
default: false,
261264
},
265+
266+
type: {
267+
type: String,
268+
default: '',
269+
},
262270
},
263271
264272
emits: [
@@ -309,6 +317,10 @@ export default {
309317
hasDescription() {
310318
return this.description !== ''
311319
},
320+
321+
isSection() {
322+
return this.type === 'section'
323+
},
312324
},
313325
314326
// Ensure description is sized correctly on initial render
@@ -520,4 +532,22 @@ export default {
520532
}
521533
}
522534
}
535+
536+
.question--section {
537+
margin-block-end: 16px;
538+
position: sticky;
539+
top: 0;
540+
background: var(--color-main-background);
541+
z-index: 2;
542+
543+
h3 {
544+
font-size: 24px !important;
545+
border-bottom: 1px solid;
546+
}
547+
548+
.question__header__description {
549+
max-height: calc(var(--default-font-size) * 8);
550+
overflow-y: auto;
551+
}
552+
}
523553
</style>
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
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 Question from './Question.vue'
17+
import QuestionMixin from '../../mixins/QuestionMixin.js'
18+
19+
export default {
20+
name: 'QuestionSection',
21+
components: {
22+
Question,
23+
},
24+
25+
mixins: [QuestionMixin],
26+
}
27+
</script>

src/models/AnswerTypes.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import IconCalendar from 'vue-material-design-icons/CalendarOutline.vue'
88
import IconCheckboxOutline from 'vue-material-design-icons/CheckboxOutline.vue'
99
import IconClockOutline from 'vue-material-design-icons/ClockOutline.vue'
1010
import IconFile from 'vue-material-design-icons/FileOutline.vue'
11+
import IconFormatSection from 'vue-material-design-icons/FormatSection.vue'
1112
import IconGrid from 'vue-material-design-icons/Grid.vue'
1213
import IconNumeric from 'vue-material-design-icons/Numeric.vue'
1314
import IconRadioboxMarked from 'vue-material-design-icons/RadioboxMarked.vue'
@@ -23,6 +24,7 @@ import QuestionGrid from '../components/Questions/QuestionGrid.vue'
2324
import QuestionLinearScale from '../components/Questions/QuestionLinearScale.vue'
2425
import QuestionLong from '../components/Questions/QuestionLong.vue'
2526
import QuestionMultiple from '../components/Questions/QuestionMultiple.vue'
27+
import QuestionSection from '../components/Questions/QuestionSection.vue'
2628
import QuestionShort from '../components/Questions/QuestionShort.vue'
2729
import { OptionType } from './Constants.ts'
2830

@@ -263,4 +265,14 @@ export default {
263265
submitPlaceholder: t('forms', 'Pick a color'),
264266
warningInvalid: t('forms', 'This question needs a title!'),
265267
},
268+
269+
section: {
270+
component: QuestionSection,
271+
icon: IconFormatSection,
272+
label: t('forms', 'Section'),
273+
predefined: false,
274+
275+
titlePlaceholder: t('forms', 'Section title'),
276+
warningInvalid: t('forms', 'This section needs a title!'),
277+
},
266278
}

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)