diff --git a/docs/DataStructure.md b/docs/DataStructure.md index 72ce23c47..f18f39a9f 100644 --- a/docs/DataStructure.md +++ b/docs/DataStructure.md @@ -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 diff --git a/lib/Constants.php b/lib/Constants.php index 67d4d5596..4fab7b3c5 100644 --- a/lib/Constants.php +++ b/lib/Constants.php @@ -86,7 +86,7 @@ class Constants { ]; /** - * !! Keep in sync with src/models/AnswerTypes.js !! + * !! Keep in sync with src/models/AnswerTypes.ts !! */ // Available AnswerTypes @@ -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'; @@ -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, ]; diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php index ebc411fd1..b38ff1301 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -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; } diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 2eed57e39..14e8bbde3 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -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{ diff --git a/lib/Service/SubmissionService.php b/lib/Service/SubmissionService.php index 340022111..2fcab7b49 100644 --- a/lib/Service/SubmissionService.php +++ b/lib/Service/SubmissionService.php @@ -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) { @@ -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 diff --git a/openapi.json b/openapi.json index f9eb1da32..605535981 100644 --- a/openapi.json +++ b/openapi.json @@ -596,7 +596,8 @@ "long", "file", "datetime", - "grid" + "grid", + "section" ] }, "Share": { diff --git a/src/components/Questions/Question.vue b/src/components/Questions/Question.vue index ebd9f8a7c..5e92cb062 100644 --- a/src/components/Questions/Question.vue +++ b/src/components/Questions/Question.vue @@ -8,6 +8,7 @@ class="question" :class="{ 'question--editable': !readOnly, + 'question--section': readOnly && isSection, }" :aria-label="t('forms', 'Question number {index}', { index })"> @@ -91,6 +92,7 @@ @@ -98,6 +100,7 @@ (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 @@ -424,6 +434,7 @@ export default defineComponent({ titleId, descriptionId, hasDescription, + isSection, hasError, hasInfo, errorId, @@ -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) + ); } diff --git a/src/components/Questions/QuestionSection.vue b/src/components/Questions/QuestionSection.vue new file mode 100644 index 000000000..3f5c5c3e7 --- /dev/null +++ b/src/components/Questions/QuestionSection.vue @@ -0,0 +1,128 @@ + + + + + + + + + + + + + + diff --git a/src/composables/useQuestion.ts b/src/composables/useQuestion.ts index c7a140bf1..d77795e6b 100644 --- a/src/composables/useQuestion.ts +++ b/src/composables/useQuestion.ts @@ -129,6 +129,7 @@ interface QuestionPropsLike { isRequired: boolean readOnly: boolean name: string + type: string | null maxStringLengths: Record canMoveUp: boolean canMoveDown: boolean @@ -145,6 +146,7 @@ interface QuestionForwardedProps { readOnly: boolean maxStringLengths: Record name: string + type: string | null canMoveUp: boolean canMoveDown: boolean } @@ -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, })) diff --git a/src/models/AnswerTypes.ts b/src/models/AnswerTypes.ts index e2bc47b9e..3759523de 100644 --- a/src/models/AnswerTypes.ts +++ b/src/models/AnswerTypes.ts @@ -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' @@ -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' @@ -296,6 +298,16 @@ const answerTypes: Record = { '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 diff --git a/src/views/Submit.vue b/src/views/Submit.vue index 6b038e2d5..347c27859 100644 --- a/src/views/Submit.vue +++ b/src/views/Submit.vue @@ -125,26 +125,43 @@ - + - - onUpdate(question, values) - " /> - + + + + + onUpdate(item.question, values) + " /> + + Promise } +interface IndexedQuestion { + question: SubmitQuestion + displayIndex: number +} + +interface QuestionGroup { + section: IndexedQuestion | null + questions: IndexedQuestion[] +} + interface DialogButton { label: string icon: string @@ -403,6 +430,42 @@ export default defineComponent({ }) as SubmitQuestion[] }) + /** + * Group questions by sections. + * Each section contains its questions and the section itself. + * This is needed for position:sticky to work when there are several + * sections and allows to display groups on separate pages later on. + */ + const groupedQuestions = computed(() => { + const groups: QuestionGroup[] = [] + let currentGroup: QuestionGroup = { section: null, questions: [] } + let questionIndex = 1 + + for (const question of validQuestions.value) { + if (question.type === 'section') { + if (currentGroup.section || currentGroup.questions.length > 0) { + groups.push(currentGroup) + } + currentGroup = { + section: { question, displayIndex: questionIndex }, + questions: [], + } + } else { + currentGroup.questions.push({ + question, + displayIndex: questionIndex, + }) + } + questionIndex++ + } + + if (currentGroup.section || currentGroup.questions.length > 0) { + groups.push(currentGroup) + } + + return groups + }) + const validQuestionsIds = computed>(() => { return new Set(validQuestions.value.map((question) => question.id)) }) @@ -1065,6 +1128,7 @@ export default defineComponent({ confirmLeaveFormButtons, expirationMessage, formElement, + groupedQuestions, hasAnswers, infoMessage, isArchived, diff --git a/tests/Unit/Controller/ApiControllerTest.php b/tests/Unit/Controller/ApiControllerTest.php index dbd0952cd..99c81503d 100644 --- a/tests/Unit/Controller/ApiControllerTest.php +++ b/tests/Unit/Controller/ApiControllerTest.php @@ -207,7 +207,7 @@ public static function dataGetSubmissions() { 'submissions' => [ ['userId' => 'anon-user-1'] ], - 'questions' => [['id' => 1, 'name' => 'questions']], + 'questions' => [['id' => 1, 'name' => 'questions', 'type' => Constants::ANSWER_TYPE_SHORT]], 'expected' => [ 'submissions' => [ [ @@ -219,6 +219,7 @@ public static function dataGetSubmissions() { [ 'id' => 1, 'name' => 'questions', + 'type' => Constants::ANSWER_TYPE_SHORT, 'extraSettings' => new \stdClass(), ], ], @@ -229,7 +230,7 @@ public static function dataGetSubmissions() { 'submissions' => [ ['userId' => 'jdoe'] ], - 'questions' => [['id' => 1, 'name' => 'questions']], + 'questions' => [['id' => 1, 'name' => 'questions', 'type' => Constants::ANSWER_TYPE_SHORT]], 'expected' => [ 'submissions' => [ [ @@ -241,6 +242,33 @@ public static function dataGetSubmissions() { [ 'id' => 1, 'name' => 'questions', + 'type' => Constants::ANSWER_TYPE_SHORT, + 'extraSettings' => new \stdClass(), + ], + ], + 'filteredSubmissionsCount' => 1, + ] + ], + 'sections are filtered out' => [ + 'submissions' => [ + ['userId' => 'jdoe'] + ], + 'questions' => [ + ['id' => 1, 'name' => 'questions', 'type' => Constants::ANSWER_TYPE_SHORT], + ['id' => 2, 'name' => 'section', 'type' => Constants::ANSWER_TYPE_SECTION], + ], + 'expected' => [ + 'submissions' => [ + [ + 'userId' => 'jdoe', + 'userDisplayName' => 'jdoe', + ] + ], + 'questions' => [ + [ + 'id' => 1, + 'name' => 'questions', + 'type' => Constants::ANSWER_TYPE_SHORT, 'extraSettings' => new \stdClass(), ], ], diff --git a/tests/Unit/Service/SubmissionServiceTest.php b/tests/Unit/Service/SubmissionServiceTest.php index b6e7fcc16..cf56687ca 100644 --- a/tests/Unit/Service/SubmissionServiceTest.php +++ b/tests/Unit/Service/SubmissionServiceTest.php @@ -1358,6 +1358,41 @@ public static function dataValidateSubmission() { // Expected Result – required question must be answered 'Question "Rank these" is required.', ], + 'invalid-section-with-answer' => [ + // Questions + [ + ['id' => 1, 'type' => 'section', 'text' => 'My section', 'isRequired' => false] + ], + // Answers – sections cannot have answers + [ + '1' => ['some answer'] + ], + // Expected Result + 'Section "My section" cannot have answers.', + ], + 'valid-section-without-answer' => [ + // Questions + [ + ['id' => 1, 'type' => 'section', 'text' => 'My section', 'isRequired' => false], + ['id' => 2, 'type' => 'short', 'text' => 'A question', 'isRequired' => false] + ], + // Answers + [ + '2' => ['an answer'] + ], + // Expected Result – no error + null, + ], + 'valid-section-required-unanswered' => [ + // Questions – a required section still cannot be answered and must not block submission + [ + ['id' => 1, 'type' => 'section', 'text' => 'My section', 'isRequired' => true] + ], + // Answers + [], + // Expected Result – no error + null, + ], ]; }