Skip to content

Commit b661eec

Browse files
committed
feat: introduce Section as a new question type
Signed-off-by: Kostiantyn Miakshyn <molodchick@gmail.com>
1 parent 8b0dbca commit b661eec

13 files changed

Lines changed: 351 additions & 26 deletions

File tree

docs/DataStructure.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,7 @@ Currently supported Question-Types are:
238238
| `linearscale` | A linear or Likert scale question where you choose an option that best fits your opinion |
239239
| `color` | A color answer, hex string representation (e. g. `#123456`) |
240240
| `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). |
241+
| `section` | A structural element to group questions into sections. It cannot be answered and has no options. |
241242

242243
## Extra Settings
243244

lib/Constants.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ class Constants {
8686
];
8787

8888
/**
89-
* !! Keep in sync with src/models/AnswerTypes.js !!
89+
* !! Keep in sync with src/models/AnswerTypes.ts !!
9090
*/
9191

9292
// Available AnswerTypes
@@ -101,6 +101,7 @@ class Constants {
101101
public const ANSWER_TYPE_MULTIPLE = 'multiple';
102102
public const ANSWER_TYPE_MULTIPLEUNIQUE = 'multiple_unique';
103103
public const ANSWER_TYPE_RANKING = 'ranking';
104+
public const ANSWER_TYPE_SECTION = 'section';
104105
public const ANSWER_TYPE_SHORT = 'short';
105106
public const ANSWER_TYPE_TIME = 'time';
106107

@@ -121,6 +122,7 @@ class Constants {
121122
self::ANSWER_TYPE_MULTIPLE,
122123
self::ANSWER_TYPE_MULTIPLEUNIQUE,
123124
self::ANSWER_TYPE_RANKING,
125+
self::ANSWER_TYPE_SECTION,
124126
self::ANSWER_TYPE_SHORT,
125127
self::ANSWER_TYPE_TIME,
126128
];

lib/Controller/ApiController.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1263,6 +1263,11 @@ public function getSubmissions(int $formId, ?string $query = null, ?int $limit =
12631263
}
12641264
$questions = [];
12651265
foreach ($this->formsService->getQuestions($formId) as $question) {
1266+
// Sections are structural elements and cannot be answered
1267+
if ($question['type'] === Constants::ANSWER_TYPE_SECTION) {
1268+
continue;
1269+
}
1270+
12661271
$questions[$question['id']] = $question;
12671272
}
12681273

lib/ResponseDefinitions.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444
* questionType?: string,
4545
* }
4646
*
47-
* @psalm-type FormsQuestionType = "dropdown"|"multiple"|"multiple_unique"|"date"|"time"|"short"|"long"|"file"|"datetime"|"grid"
47+
* @psalm-type FormsQuestionType = "dropdown"|"multiple"|"multiple_unique"|"date"|"time"|"short"|"long"|"file"|"datetime"|"grid"|"section"
4848
* @psalm-type FormsQuestionGridCellType = "checkbox"|"number"|"radio"
4949
*
5050
* @psalm-type FormsQuestion = array{

lib/Service/SubmissionService.php

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,10 @@ public function getSubmissionsData(Form $form, string $fileFormat, ?File $file =
230230
// Oldest first
231231
$submissionEntities = array_reverse($submissionEntities);
232232

233-
$questions = $this->questionMapper->findByForm($form->getId());
233+
$questions = array_filter(
234+
$this->questionMapper->findByForm($form->getId()),
235+
static fn (Question $question): bool => $question->getType() !== Constants::ANSWER_TYPE_SECTION,
236+
);
234237
$defaultTimeZone = $this->config->getSystemValueString('default_timezone', 'UTC');
235238

236239
if (!$this->currentUser) {
@@ -567,6 +570,14 @@ public function validateSubmission(array $questions, array $answers, string $for
567570
$questionId = $question['id'];
568571
$questionAnswered = array_key_exists($questionId, $answers);
569572

573+
// Sections are structural elements and cannot have answers
574+
if ($question['type'] === Constants::ANSWER_TYPE_SECTION) {
575+
if ($questionAnswered && array_filter($answers[$questionId])) {
576+
throw new \InvalidArgumentException(sprintf('Section "%s" cannot have answers.', $question['text']));
577+
}
578+
continue;
579+
}
580+
570581
// Check if all required questions have an answer
571582
if ($question['isRequired']
572583
&& (!$questionAnswered

openapi.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -596,7 +596,8 @@
596596
"long",
597597
"file",
598598
"datetime",
599-
"grid"
599+
"grid",
600+
"section"
600601
]
601602
},
602603
"Share": {

src/components/Questions/Question.vue

Lines changed: 38 additions & 1 deletion
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
:modelValue="isRequired"
9597
@update:modelValue="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
:labelOutside="false"
103106
:showTrailingButton="false"
@@ -247,6 +250,11 @@ export default defineComponent({
247250
default: '',
248251
},
249252
253+
type: {
254+
type: String,
255+
default: '',
256+
},
257+
250258
contentValid: {
251259
type: Boolean,
252260
// eslint-disable-next-line vue/no-boolean-default
@@ -299,11 +307,13 @@ export default defineComponent({
299307
const buttonUp = ref<{ $el?: HTMLElement } | undefined>(undefined)
300308
const buttonDown = ref<{ $el?: HTMLElement } | undefined>(undefined)
301309
310+
const isSection = computed(() => props.type === 'section')
311+
302312
/**
303313
* Extend text with asterisk if question is required
304314
*/
305315
const computedText = computed(() => {
306-
if (props.isRequired) {
316+
if (props.isRequired && !isSection.value) {
307317
return props.text + ' *'
308318
}
309319
return props.text
@@ -424,6 +434,7 @@ export default defineComponent({
424434
titleId,
425435
descriptionId,
426436
hasDescription,
437+
isSection,
427438
hasError,
428439
hasInfo,
429440
errorId,
@@ -585,5 +596,31 @@ export default defineComponent({
585596
}
586597
}
587598
}
599+
600+
&--section {
601+
margin-block-end: 16px;
602+
position: sticky;
603+
top: 0;
604+
z-index: 2;
605+
606+
h3 {
607+
font-size: 24px !important;
608+
border-block-end: 1px solid var(--color-border);
609+
}
610+
}
611+
612+
// Limit the description to two lines while the section is stuck to the top
613+
&--section-stuck .question__header__description {
614+
// two lines at 1.5em line-height plus the output padding
615+
max-height: calc(2 * 1.5em + 12px);
616+
overflow: hidden;
617+
}
618+
}
619+
620+
// In views with a sticky top bar, sections must stick below it
621+
.app-content:not(.app-content--public) .question--section {
622+
top: calc(
623+
var(--default-clickable-area) + 2 * var(--app-navigation-padding, 0px)
624+
);
588625
}
589626
</style>
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
<!--
2+
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
- SPDX-License-Identifier: AGPL-3.0-or-later
4+
-->
5+
6+
<template>
7+
<li
8+
v-if="readOnly"
9+
ref="stickySentinel"
10+
class="question-section__sentinel"
11+
aria-hidden="true" />
12+
<Question
13+
v-bind="{ ...questionProps, ...$attrs }"
14+
ref="questionElement"
15+
:class="{ 'question--section-stuck': isStuck }"
16+
:titlePlaceholder="answerType.titlePlaceholder"
17+
:warningInvalid="answerType.warningInvalid"
18+
:errorMessage="errorMessage"
19+
v-on="commonListeners"
20+
@click="onSectionClick">
21+
<template #insert>
22+
<slot name="insert" />
23+
</template>
24+
</Question>
25+
</template>
26+
27+
<script lang="ts">
28+
import { defineComponent, onBeforeUnmount, onMounted, ref } from 'vue'
29+
import Question from './Question.vue'
30+
import {
31+
QUESTION_EMITS,
32+
QUESTION_PROPS,
33+
useQuestion,
34+
} from '../../composables/useQuestion.ts'
35+
36+
export default defineComponent({
37+
name: 'QuestionSection',
38+
39+
components: {
40+
Question,
41+
},
42+
43+
// The sentinel <li> must stay a sibling of the section, so attributes are
44+
// forwarded to the inner Question element explicitly.
45+
inheritAttrs: false,
46+
47+
props: QUESTION_PROPS,
48+
emits: QUESTION_EMITS,
49+
50+
setup(props, { emit }) {
51+
const stickySentinel = ref<HTMLElement | null>(null)
52+
const questionElement = ref<{ $el: HTMLElement } | null>(null)
53+
const question = useQuestion(props, {
54+
emit,
55+
rootElement: questionElement,
56+
})
57+
const isStuck = ref(false)
58+
let observer: IntersectionObserver | null = null
59+
60+
/**
61+
* Watch the sentinel right before the sticky section to detect when it
62+
* is stuck to the top, so the description can be limited while scrolling
63+
*/
64+
onMounted(() => {
65+
if (!props.readOnly || !stickySentinel.value) {
66+
return
67+
}
68+
const top =
69+
parseFloat(
70+
getComputedStyle(
71+
questionElement.value?.$el ?? stickySentinel.value,
72+
).top,
73+
) || 0
74+
observer = new IntersectionObserver(
75+
([entry]) => {
76+
// Once the sentinel scrolled past the sticky offset the
77+
// section is stuck to the top
78+
isStuck.value = entry.boundingClientRect.top < top
79+
},
80+
{ rootMargin: `-${top + 1}px 0px 0px 0px` },
81+
)
82+
observer.observe(stickySentinel.value)
83+
})
84+
85+
onBeforeUnmount(() => {
86+
observer?.disconnect()
87+
})
88+
89+
/**
90+
* Sections cannot be answered, they are always valid
91+
*/
92+
const validate = async (): Promise<boolean> => true
93+
94+
/**
95+
* Scrolling the sentinel into view un-sticks the section so the full
96+
* description becomes visible again
97+
*/
98+
const onSectionClick = (): void => {
99+
if (isStuck.value) {
100+
stickySentinel.value?.scrollIntoView({ behavior: 'smooth' })
101+
}
102+
}
103+
104+
return {
105+
...question,
106+
isStuck,
107+
stickySentinel,
108+
questionElement,
109+
validate,
110+
onSectionClick,
111+
}
112+
},
113+
})
114+
</script>
115+
116+
<style lang="scss" scoped>
117+
.question-section__sentinel {
118+
height: 0;
119+
margin: 0;
120+
padding: 0;
121+
list-style: none;
122+
// Land below the sticky top offset when scrolling up to the section
123+
scroll-margin-block-start: calc(
124+
var(--default-clickable-area) + 2 * var(--app-navigation-padding, 0px) +
125+
var(--default-grid-baseline, 4px)
126+
);
127+
}
128+
</style>

src/composables/useQuestion.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ interface QuestionPropsLike {
129129
isRequired: boolean
130130
readOnly: boolean
131131
name: string
132+
type: string | null
132133
maxStringLengths: Record<string, number>
133134
canMoveUp: boolean
134135
canMoveDown: boolean
@@ -145,6 +146,7 @@ interface QuestionForwardedProps {
145146
readOnly: boolean
146147
maxStringLengths: Record<string, number>
147148
name: string
149+
type: string | null
148150
canMoveUp: boolean
149151
canMoveDown: boolean
150152
}
@@ -166,6 +168,7 @@ export function useQuestion(props: QuestionPropsLike, options: UseQuestionOption
166168
readOnly: props.readOnly,
167169
maxStringLengths: props.maxStringLengths,
168170
name: props.name,
171+
type: props.type,
169172
canMoveUp: props.canMoveUp,
170173
canMoveDown: props.canMoveDown,
171174
}))

src/models/AnswerTypes.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import IconClockOutline from '@material-symbols/svg-400/outlined/schedule.svg?ra
1919
import IconTextShort from '@material-symbols/svg-400/outlined/short_text.svg?raw'
2020
import IconTextLong from '@material-symbols/svg-400/outlined/subject.svg?raw'
2121
import IconSwapVertical from '@material-symbols/svg-400/outlined/swap_vert.svg?raw'
22+
import IconViewAgenda from '@material-symbols/svg-400/outlined/view_agenda.svg?raw'
2223
import { t } from '@nextcloud/l10n'
2324
import { markRaw } from 'vue'
2425
import QuestionColor from '../components/Questions/QuestionColor.vue'
@@ -30,6 +31,7 @@ import QuestionLinearScale from '../components/Questions/QuestionLinearScale.vue
3031
import QuestionLong from '../components/Questions/QuestionLong.vue'
3132
import QuestionMultiple from '../components/Questions/QuestionMultiple.vue'
3233
import QuestionRanking from '../components/Questions/QuestionRanking.vue'
34+
import QuestionSection from '../components/Questions/QuestionSection.vue'
3335
import QuestionShort from '../components/Questions/QuestionShort.vue'
3436
import { OptionType } from './Constants.ts'
3537

@@ -296,6 +298,16 @@ const answerTypes: Record<string, AnswerTypeConfig> = {
296298
'This question needs a title and at least one answer!',
297299
),
298300
},
301+
302+
section: {
303+
component: markRaw(QuestionSection),
304+
icon: IconViewAgenda,
305+
label: t('forms', 'Section'),
306+
predefined: false,
307+
308+
titlePlaceholder: t('forms', 'Section title'),
309+
warningInvalid: t('forms', 'This section needs a title!'),
310+
},
299311
}
300312

301313
export default answerTypes

0 commit comments

Comments
 (0)