Skip to content
Draft
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
1 change: 1 addition & 0 deletions docs/DataStructure.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ Currently supported Question-Types are:
| `linearscale` | A linear or Likert scale question where you choose an option that best fits your opinion |
| `color` | A color answer, hex string representation (e. g. `#123456`) |
| `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). |
| `section` | A structural element to group questions into sections. It cannot be answered and has no options. |

## Extra Settings

Expand Down
4 changes: 3 additions & 1 deletion lib/Constants.php
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ class Constants {
];

/**
* !! Keep in sync with src/models/AnswerTypes.js !!
* !! Keep in sync with src/models/AnswerTypes.ts !!
*/

// Available AnswerTypes
Expand All @@ -101,6 +101,7 @@ class Constants {
public const ANSWER_TYPE_MULTIPLE = 'multiple';
public const ANSWER_TYPE_MULTIPLEUNIQUE = 'multiple_unique';
public const ANSWER_TYPE_RANKING = 'ranking';
public const ANSWER_TYPE_SECTION = 'section';
public const ANSWER_TYPE_SHORT = 'short';
public const ANSWER_TYPE_TIME = 'time';

Expand All @@ -121,6 +122,7 @@ class Constants {
self::ANSWER_TYPE_MULTIPLE,
self::ANSWER_TYPE_MULTIPLEUNIQUE,
self::ANSWER_TYPE_RANKING,
self::ANSWER_TYPE_SECTION,
self::ANSWER_TYPE_SHORT,
self::ANSWER_TYPE_TIME,
];
Expand Down
5 changes: 5 additions & 0 deletions lib/Controller/ApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -1263,6 +1263,11 @@ public function getSubmissions(int $formId, ?string $query = null, ?int $limit =
}
$questions = [];
foreach ($this->formsService->getQuestions($formId) as $question) {
// Sections are structural elements and cannot be answered
if ($question['type'] === Constants::ANSWER_TYPE_SECTION) {
continue;
}

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

Expand Down
2 changes: 1 addition & 1 deletion lib/ResponseDefinitions.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
* questionType?: string,
* }
*
* @psalm-type FormsQuestionType = "dropdown"|"multiple"|"multiple_unique"|"date"|"time"|"short"|"long"|"file"|"datetime"|"grid"
* @psalm-type FormsQuestionType = "dropdown"|"multiple"|"multiple_unique"|"date"|"time"|"short"|"long"|"file"|"datetime"|"grid"|"section"
* @psalm-type FormsQuestionGridCellType = "checkbox"|"number"|"radio"
*
* @psalm-type FormsQuestion = array{
Expand Down
13 changes: 12 additions & 1 deletion lib/Service/SubmissionService.php
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,10 @@ public function getSubmissionsData(Form $form, string $fileFormat, ?File $file =
// Oldest first
$submissionEntities = array_reverse($submissionEntities);

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

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

// Sections are structural elements and cannot have answers
if ($question['type'] === Constants::ANSWER_TYPE_SECTION) {
if ($questionAnswered && array_filter($answers[$questionId])) {
throw new \InvalidArgumentException(sprintf('Section "%s" cannot have answers.', $question['text']));
}
continue;
}

// Check if all required questions have an answer
if ($question['isRequired']
&& (!$questionAnswered
Expand Down
3 changes: 2 additions & 1 deletion openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -596,7 +596,8 @@
"long",
"file",
"datetime",
"grid"
"grid",
"section"
]
},
"Share": {
Expand Down
39 changes: 38 additions & 1 deletion src/components/Questions/Question.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
class="question"
:class="{
'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"
:modelValue="isRequired"
@update:modelValue="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')"
:labelOutside="false"
:showTrailingButton="false"
Expand Down Expand Up @@ -247,6 +250,11 @@ export default defineComponent({
default: '',
},

type: {
type: String,
default: '',
},

contentValid: {
type: Boolean,
// eslint-disable-next-line vue/no-boolean-default
Expand Down Expand Up @@ -299,11 +307,13 @@ export default defineComponent({
const buttonUp = ref<{ $el?: HTMLElement } | undefined>(undefined)
const buttonDown = ref<{ $el?: HTMLElement } | undefined>(undefined)

const isSection = computed(() => props.type === 'section')

/**
* Extend text with asterisk if question is required
*/
const computedText = computed(() => {
if (props.isRequired) {
if (props.isRequired && !isSection.value) {
return props.text + ' *'
}
return props.text
Expand Down Expand Up @@ -424,6 +434,7 @@ export default defineComponent({
titleId,
descriptionId,
hasDescription,
isSection,
hasError,
hasInfo,
errorId,
Expand Down Expand Up @@ -585,5 +596,31 @@ export default defineComponent({
}
}
}

&--section {
margin-block-end: 16px;
position: sticky;
top: 0;
z-index: 2;

h3 {
font-size: 24px !important;
border-block-end: 1px solid var(--color-border);
}
}

// Limit the description to two lines while the section is stuck to the top
&--section-stuck .question__header__description {
// two lines at 1.5em line-height plus the output padding
max-height: calc(2 * 1.5em + 12px);
overflow: hidden;
}
}

// In views with a sticky top bar, sections must stick below it
.app-content:not(.app-content--public) .question--section {
top: calc(
var(--default-clickable-area) + 2 * var(--app-navigation-padding, 0px)
);
}
</style>
128 changes: 128 additions & 0 deletions src/components/Questions/QuestionSection.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
<!--
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->

<template>
<li
v-if="readOnly"
ref="stickySentinel"
class="question-section__sentinel"
aria-hidden="true" />
<Question
v-bind="{ ...questionProps, ...$attrs }"
ref="questionElement"
:class="{ 'question--section-stuck': isStuck }"
:titlePlaceholder="answerType.titlePlaceholder"
:warningInvalid="answerType.warningInvalid"
:errorMessage="errorMessage"
v-on="commonListeners"
@click="onSectionClick">
<template #insert>
<slot name="insert" />
</template>
</Question>
</template>

<script lang="ts">
import { defineComponent, onBeforeUnmount, onMounted, ref } from 'vue'
import Question from './Question.vue'
import {
QUESTION_EMITS,
QUESTION_PROPS,
useQuestion,
} from '../../composables/useQuestion.ts'

export default defineComponent({
name: 'QuestionSection',

components: {
Question,
},

// The sentinel <li> must stay a sibling of the section, so attributes are
// forwarded to the inner Question element explicitly.
inheritAttrs: false,

props: QUESTION_PROPS,
emits: QUESTION_EMITS,

setup(props, { emit }) {
const stickySentinel = ref<HTMLElement | null>(null)
const questionElement = ref<{ $el: HTMLElement } | null>(null)
const question = useQuestion(props, {
emit,
rootElement: questionElement,
})
const isStuck = ref(false)
let observer: IntersectionObserver | null = null

/**
* Watch the sentinel right before the sticky section to detect when it
* is stuck to the top, so the description can be limited while scrolling
*/
onMounted(() => {
if (!props.readOnly || !stickySentinel.value) {
return
}
const top =
parseFloat(
getComputedStyle(
questionElement.value?.$el ?? stickySentinel.value,
).top,
) || 0
observer = new IntersectionObserver(
([entry]) => {
// Once the sentinel scrolled past the sticky offset the
// section is stuck to the top
isStuck.value = entry.boundingClientRect.top < top
},
{ rootMargin: `-${top + 1}px 0px 0px 0px` },
)
observer.observe(stickySentinel.value)
})

onBeforeUnmount(() => {
observer?.disconnect()
})

/**
* Sections cannot be answered, they are always valid
*/
const validate = async (): Promise<boolean> => true

/**
* Scrolling the sentinel into view un-sticks the section so the full
* description becomes visible again
*/
const onSectionClick = (): void => {
if (isStuck.value) {
stickySentinel.value?.scrollIntoView({ behavior: 'smooth' })
}
}

return {
...question,
isStuck,
stickySentinel,
questionElement,
validate,
onSectionClick,
}
},
})
</script>

<style lang="scss" scoped>
.question-section__sentinel {
height: 0;
margin: 0;
padding: 0;
list-style: none;
// Land below the sticky top offset when scrolling up to the section
scroll-margin-block-start: calc(
var(--default-clickable-area) + 2 * var(--app-navigation-padding, 0px) +
var(--default-grid-baseline, 4px)
);
}
</style>
3 changes: 3 additions & 0 deletions src/composables/useQuestion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ interface QuestionPropsLike {
isRequired: boolean
readOnly: boolean
name: string
type: string | null
maxStringLengths: Record<string, number>
canMoveUp: boolean
canMoveDown: boolean
Expand All @@ -145,6 +146,7 @@ interface QuestionForwardedProps {
readOnly: boolean
maxStringLengths: Record<string, number>
name: string
type: string | null
canMoveUp: boolean
canMoveDown: boolean
}
Expand All @@ -166,6 +168,7 @@ export function useQuestion(props: QuestionPropsLike, options: UseQuestionOption
readOnly: props.readOnly,
maxStringLengths: props.maxStringLengths,
name: props.name,
type: props.type,
canMoveUp: props.canMoveUp,
canMoveDown: props.canMoveDown,
}))
Expand Down
12 changes: 12 additions & 0 deletions src/models/AnswerTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import IconClockOutline from '@material-symbols/svg-400/outlined/schedule.svg?ra
import IconTextShort from '@material-symbols/svg-400/outlined/short_text.svg?raw'
import IconTextLong from '@material-symbols/svg-400/outlined/subject.svg?raw'
import IconSwapVertical from '@material-symbols/svg-400/outlined/swap_vert.svg?raw'
import IconViewAgenda from '@material-symbols/svg-400/outlined/view_agenda.svg?raw'
import { t } from '@nextcloud/l10n'
import { markRaw } from 'vue'
import QuestionColor from '../components/Questions/QuestionColor.vue'
Expand All @@ -30,6 +31,7 @@ import QuestionLinearScale from '../components/Questions/QuestionLinearScale.vue
import QuestionLong from '../components/Questions/QuestionLong.vue'
import QuestionMultiple from '../components/Questions/QuestionMultiple.vue'
import QuestionRanking from '../components/Questions/QuestionRanking.vue'
import QuestionSection from '../components/Questions/QuestionSection.vue'
import QuestionShort from '../components/Questions/QuestionShort.vue'
import { OptionType } from './Constants.ts'

Expand Down Expand Up @@ -296,6 +298,16 @@ const answerTypes: Record<string, AnswerTypeConfig> = {
'This question needs a title and at least one answer!',
),
},

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

titlePlaceholder: t('forms', 'Section title'),
warningInvalid: t('forms', 'This section needs a title!'),
},
}

export default answerTypes
Loading
Loading