diff --git a/lib/Constants.php b/lib/Constants.php index 3cb470193..4b2e7910e 100644 --- a/lib/Constants.php +++ b/lib/Constants.php @@ -76,6 +76,7 @@ class Constants { public const ANSWER_TYPE_LONG = 'long'; public const ANSWER_TYPE_MULTIPLE = 'multiple'; public const ANSWER_TYPE_MULTIPLEUNIQUE = 'multiple_unique'; + public const ANSWER_TYPE_SECTION = 'section'; public const ANSWER_TYPE_SHORT = 'short'; public const ANSWER_TYPE_TIME = 'time'; @@ -95,6 +96,7 @@ class Constants { self::ANSWER_TYPE_LONG, self::ANSWER_TYPE_MULTIPLE, self::ANSWER_TYPE_MULTIPLEUNIQUE, + 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 789abf5f5..32b2e126c 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -1187,10 +1187,13 @@ public function getSubmissions(int $formId, ?string $query = null, ?int $limit = } $questions = []; foreach ($this->formsService->getQuestions($formId) as $question) { + if ($question['type'] === Constants::ANSWER_TYPE_SECTION) { + continue; + } + $questions[$question['id']] = $question; } - // Append Display Names $submissions = array_map(function (array $submission) use ($questions) { if (!empty($submission['answers'])) { diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index af44f96cb..78bcab057 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -43,8 +43,8 @@ * questionType?: string, * } * - * @psalm-type FormsQuestionType = "dropdown"|"multiple"|"multiple_unique"|"date"|"time"|"short"|"long"|"file"|"datetime"|"grid" - * @psalm-type FormsQuestionGridCellType = "checkbox"|"number"|"radio" + * @psalm-type FormsQuestionType = "dropdown"|"multiple"|"multiple_unique"|"date"|"time"|"short"|"long"|"file"|"datetime"|"grid"|"section" + * @psalm-type FormsQuestionGridCellType = "checkbox"|"number"|"radio"|"section" * * @psalm-type FormsQuestion = array{ * id: int, diff --git a/lib/Service/SubmissionService.php b/lib/Service/SubmissionService.php index 6f3d54162..9e61e1208 100644 --- a/lib/Service/SubmissionService.php +++ b/lib/Service/SubmissionService.php @@ -233,6 +233,11 @@ public function getSubmissionsData(Form $form, string $fileFormat, ?File $file = $submissionEntities = array_reverse($submissionEntities); $questions = $this->questionMapper->findByForm($form->getId()); + + $questions = array_filter($questions, function (Question $question) { + return $question->getType() !== Constants::ANSWER_TYPE_SECTION; + }); + $defaultTimeZone = $this->config->getSystemValueString('default_timezone', 'UTC'); if (!$this->currentUser) { diff --git a/openapi.json b/openapi.json index 4a5e7a3fd..2b22d4b3a 100644 --- a/openapi.json +++ b/openapi.json @@ -548,7 +548,8 @@ "long", "file", "datetime", - "grid" + "grid", + "section" ] }, "Share": { diff --git a/src/components/Questions/Question.vue b/src/components/Questions/Question.vue index e62a361aa..dff2070d7 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 @@ diff --git a/src/components/Questions/QuestionSection.vue b/src/components/Questions/QuestionSection.vue new file mode 100644 index 000000000..bee2aa646 --- /dev/null +++ b/src/components/Questions/QuestionSection.vue @@ -0,0 +1,25 @@ + + + + + + + + diff --git a/src/models/AnswerTypes.js b/src/models/AnswerTypes.js index 107803592..7cf9381ee 100644 --- a/src/models/AnswerTypes.js +++ b/src/models/AnswerTypes.js @@ -3,11 +3,22 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import QuestionColor from '../components/Questions/QuestionColor.vue' +import QuestionDate from '../components/Questions/QuestionDate.vue' +import QuestionDropdown from '../components/Questions/QuestionDropdown.vue' +import QuestionFile from '../components/Questions/QuestionFile.vue' +import QuestionLinearScale from '../components/Questions/QuestionLinearScale.vue' +import QuestionLong from '../components/Questions/QuestionLong.vue' +import QuestionMultiple from '../components/Questions/QuestionMultiple.vue' +import QuestionSection from '../components/Questions/QuestionSection.vue' +import QuestionShort from '../components/Questions/QuestionShort.vue' + import IconArrowDownDropCircleOutline from 'vue-material-design-icons/ArrowDownDropCircleOutline.vue' import IconCalendar from 'vue-material-design-icons/CalendarOutline.vue' import IconCheckboxOutline from 'vue-material-design-icons/CheckboxOutline.vue' import IconClockOutline from 'vue-material-design-icons/ClockOutline.vue' import IconFile from 'vue-material-design-icons/FileOutline.vue' +import IconFormatSection from 'vue-material-design-icons/FormatSection.vue' import IconGrid from 'vue-material-design-icons/Grid.vue' import IconNumeric from 'vue-material-design-icons/Numeric.vue' import IconRadioboxMarked from 'vue-material-design-icons/RadioboxMarked.vue' @@ -263,4 +274,14 @@ export default { submitPlaceholder: t('forms', 'Pick a color'), warningInvalid: t('forms', 'This question needs a title!'), }, + + section: { + component: QuestionSection, + icon: IconFormatSection, + label: t('forms', 'Section'), + predefined: false, + + titlePlaceholder: t('forms', 'Section title'), + warningInvalid: t('forms', 'This section needs a title!'), + }, } diff --git a/src/views/Create.vue b/src/views/Create.vue index 537b74787..380f35ea8 100644 --- a/src/views/Create.vue +++ b/src/views/Create.vue @@ -138,6 +138,7 @@ :answer-type="answerTypes[question.type]" :index="index + 1" :max-string-lengths="maxStringLengths" + :type="question.type" v-bind.sync="form.questions[index]" @clone="cloneQuestion(question)" @delete="deleteQuestion(question.id)" diff --git a/src/views/Submit.vue b/src/views/Submit.vue index 5b52df938..734b0c006 100644 --- a/src/views/Submit.vue +++ b/src/views/Submit.vue @@ -105,22 +105,41 @@ - - onUpdate(question, values)" /> - + + + + + + onUpdate(question, values) + " /> + + + 0) { + groups.push(currentGroup) + } + + // Start new group with section + currentGroup = { + section: question, + displayIndex: questionIndex, + questions: [], + } + } else { + // Add question to current group + currentGroup.questions.push({ + ...question, + displayIndex: questionIndex, + }) + } + questionIndex++ + } + + // Add the last group if it has content + if (currentGroup.section || currentGroup.questions.length > 0) { + groups.push(currentGroup) + } + + return groups + }, + validQuestionsIds() { return new Set(this.validQuestions.map((question) => question.id)) }, diff --git a/tests/Unit/Controller/ApiControllerTest.php b/tests/Unit/Controller/ApiControllerTest.php index c7ad3fdbb..02c78627f 100644 --- a/tests/Unit/Controller/ApiControllerTest.php +++ b/tests/Unit/Controller/ApiControllerTest.php @@ -223,7 +223,7 @@ public function dataGetSubmissions() { 'submissions' => [ ['userId' => 'anon-user-1'] ], - 'questions' => [['id' => 1, 'name' => 'questions']], + 'questions' => [['id' => 1, 'name' => 'questions', 'type' => Constants::ANSWER_TYPE_SHORT]], 'expected' => [ 'submissions' => [ [ @@ -235,6 +235,7 @@ public function dataGetSubmissions() { [ 'id' => 1, 'name' => 'questions', + 'type' => Constants::ANSWER_TYPE_SHORT, 'extraSettings' => new \stdClass(), ], ], @@ -245,7 +246,7 @@ public function dataGetSubmissions() { 'submissions' => [ ['userId' => 'jdoe'] ], - 'questions' => [['id' => 1, 'name' => 'questions']], + 'questions' => [['id' => 1, 'name' => 'questions', 'type' => Constants::ANSWER_TYPE_SHORT]], 'expected' => [ 'submissions' => [ [ @@ -257,6 +258,7 @@ public function dataGetSubmissions() { [ 'id' => 1, 'name' => 'questions', + 'type' => Constants::ANSWER_TYPE_SHORT, 'extraSettings' => new \stdClass(), ], ],