From 30e54c4efea83584ad9bb4c34499ff141b5a366e Mon Sep 17 00:00:00 2001 From: ailkiv Date: Mon, 8 Sep 2025 06:20:58 +0000 Subject: [PATCH] feat: introduce Section as a new question type Signed-off-by: ailkiv --- lib/Constants.php | 2 + lib/Controller/ApiController.php | 5 +- lib/ResponseDefinitions.php | 2 +- lib/Service/SubmissionService.php | 5 ++ openapi.json | 3 +- src/components/Questions/Question.vue | 29 +++++++ src/components/Questions/QuestionSection.vue | 25 ++++++ src/models/AnswerTypes.js | 12 +++ src/views/Create.vue | 1 + src/views/Submit.vue | 91 ++++++++++++++++---- tests/Unit/Controller/ApiControllerTest.php | 6 +- 11 files changed, 160 insertions(+), 21 deletions(-) create mode 100644 src/components/Questions/QuestionSection.vue diff --git a/lib/Constants.php b/lib/Constants.php index 7b09bda65..45e8ed1ec 100644 --- a/lib/Constants.php +++ b/lib/Constants.php @@ -75,6 +75,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'; @@ -89,6 +90,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 6b1282c69..050516003 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -1174,10 +1174,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 f4833aa50..c936eeee4 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -42,7 +42,7 @@ * validationType?: string * } * - * @psalm-type FormsQuestionType = "dropdown"|"multiple"|"multiple_unique"|"date"|"time"|"short"|"long"|"file"|"datetime" + * @psalm-type FormsQuestionType = "dropdown"|"multiple"|"multiple_unique"|"date"|"time"|"short"|"long"|"file"|"datetime"|"section" * * @psalm-type FormsQuestion = array{ * id: int, diff --git a/lib/Service/SubmissionService.php b/lib/Service/SubmissionService.php index 7d2b2be7d..434ed7e41 100644 --- a/lib/Service/SubmissionService.php +++ b/lib/Service/SubmissionService.php @@ -229,6 +229,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 772186baa..fbe46794c 100644 --- a/openapi.json +++ b/openapi.json @@ -536,7 +536,8 @@ "short", "long", "file", - "datetime" + "datetime", + "section" ] }, "Share": { diff --git a/src/components/Questions/Question.vue b/src/components/Questions/Question.vue index 45a47688d..5b8c50db8 100644 --- a/src/components/Questions/Question.vue +++ b/src/components/Questions/Question.vue @@ -8,6 +8,7 @@ :class="{ question: true, '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 df8cf7464..5fa7580cf 100644 --- a/src/models/AnswerTypes.js +++ b/src/models/AnswerTypes.js @@ -10,6 +10,7 @@ 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' @@ -17,6 +18,7 @@ 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 IconLinearScale from '../components/Icons/IconLinearScale.vue' import IconPalette from '../components/Icons/IconPalette.vue' import IconRadioboxMarked from 'vue-material-design-icons/RadioboxMarked.vue' @@ -213,4 +215,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 8019e1496..1de62035e 100644 --- a/src/views/Create.vue +++ b/src/views/Create.vue @@ -137,6 +137,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 e0f29cd97..d443405df 100644 --- a/src/views/Submit.vue +++ b/src/views/Submit.vue @@ -104,22 +104,41 @@
-
    - -
+
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 e1b1d5b00..5852ed4d6 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(), ], ],