From d1975dcd7dfd7745ad21bf2d5ff1197862bb2483 Mon Sep 17 00:00:00 2001 From: Christian Hartmann Date: Fri, 28 Feb 2025 20:41:15 +0100 Subject: [PATCH] feat: add linear scale answer type and associated settings Signed-off-by: Christian Hartmann --- docs/DataStructure.md | 5 + lib/Constants.php | 12 +- lib/Controller/ApiController.php | 2 +- lib/ResponseDefinitions.php | 4 + lib/Service/FormsService.php | 11 + lib/Service/SubmissionService.php | 13 +- openapi.json | 29 ++ src/components/Icons/IconLinearScale.vue | 45 +++ .../Questions/QuestionLinearScale.vue | 368 ++++++++++++++++++ src/components/Results/ResultsSummary.vue | 79 +++- src/models/AnswerTypes.js | 23 +- tests/Unit/Service/FormsServiceTest.php | 31 +- tests/Unit/Service/SubmissionServiceTest.php | 18 +- 13 files changed, 616 insertions(+), 24 deletions(-) create mode 100644 src/components/Icons/IconLinearScale.vue create mode 100644 src/components/Questions/QuestionLinearScale.vue diff --git a/docs/DataStructure.md b/docs/DataStructure.md index b9ee87e6b..6c9e6221e 100644 --- a/docs/DataStructure.md +++ b/docs/DataStructure.md @@ -210,6 +210,7 @@ Currently supported Question-Types are: | _`datetime`_ | _deprecated: No longer available for new questions. Showing a dropdown calendar to select a date **and** a time._ | | `time` | Showing a dropdown menu to select a time. | | `file` | One or multiple files. It is possible to specify which mime types are allowed | +| `linearscale` | A linear or Likert scale question where you choose an option that best fits your opinion | ## Extra Settings @@ -230,3 +231,7 @@ Optional extra settings for some [Question Types](#question-types) | `dateMax` | `date` | Integer | - | Maximum allowed date to be chosen (as Unix timestamp) | | `dateMin` | `date` | Integer | - | Minimum allowed date to be chosen (as Unix timestamp) | | `dateRange` | `date` | Boolean | `true/false` | The date picker should query a date range | +| `optionsLowest` | `linearscale` | Integer | `0, 1` | Set the lowest value of the scale, default: `1` | +| `optionsHighest` | `linearscale` | Integer | `2, 3, 4, 5, 6, 7, 8, 9, 10` | Set the highest value of the scale, default: `5` | +| `optionsLabelLowest` | `linearscale` | string | - | Set the label of the lowest value, default: `'Strongly disagree'` | +| `optionsLabelHighest` | `linearscale` | string | - | Set the label of the highest value, default: `'Strongly agree'` | diff --git a/lib/Constants.php b/lib/Constants.php index cdee36930..2ee8cdfc9 100644 --- a/lib/Constants.php +++ b/lib/Constants.php @@ -75,6 +75,7 @@ class Constants { public const ANSWER_TYPE_DATETIME = 'datetime'; public const ANSWER_TYPE_TIME = 'time'; public const ANSWER_TYPE_FILE = 'file'; + public const ANSWER_TYPE_LINEARSCALE = 'linearscale'; // All AnswerTypes public const ANSWER_TYPES = [ @@ -87,13 +88,15 @@ class Constants { self::ANSWER_TYPE_DATETIME, self::ANSWER_TYPE_TIME, self::ANSWER_TYPE_FILE, + self::ANSWER_TYPE_LINEARSCALE, ]; // AnswerTypes, that need/have predefined Options public const ANSWER_TYPES_PREDEFINED = [ self::ANSWER_TYPE_MULTIPLE, self::ANSWER_TYPE_MULTIPLEUNIQUE, - self::ANSWER_TYPE_DROPDOWN + self::ANSWER_TYPE_DROPDOWN, + self::ANSWER_TYPE_LINEARSCALE, ]; // AnswerTypes for date/time questions @@ -161,6 +164,13 @@ class Constants { 'x-office/spreadsheet', ]; + public const EXTRA_SETTINGS_LINEARSCALE = [ + 'optionsLowest' => ['integer', 'NULL'], + 'optionsHighest' => ['integer', 'NULL'], + 'optionsLabelLowest' => ['string', 'NULL'], + 'optionsLabelHighest' => ['string', 'NULL'], + ]; + public const FILENAME_INVALID_CHARS = [ "\n", '/', diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php index ea30608d6..15d9e9d50 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -1538,7 +1538,7 @@ private function storeAnswersForQuestion(Form $form, $submissionId, array $quest $answerText = ''; $uploadedFile = null; // Are we using answer ids as values - if (in_array($question['type'], Constants::ANSWER_TYPES_PREDEFINED)) { + if (in_array($question['type'], Constants::ANSWER_TYPES_PREDEFINED) && $question['type'] !== Constants::ANSWER_TYPE_LINEARSCALE) { // Search corresponding option, skip processing if not found $optionIndex = array_search($answer, array_column($question['options'], 'id')); if ($optionIndex !== false) { diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index c87c0d274..b3faf1426 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -28,8 +28,12 @@ * dateRange?: bool, * maxAllowedFilesCount?: int, * maxFileSize?: int, + * optionsHighest?: 2|3|4|5|6|7|8|9|10, + * optionsLabelHighest?: string, + * optionsLabelLowest?: string, * optionsLimitMax?: int, * optionsLimitMin?: int, + * optionsLowest?: 0|1, * shuffleOptions?: bool, * validationRegex?: string, * validationType?: string diff --git a/lib/Service/FormsService.php b/lib/Service/FormsService.php index db0a547b5..4aa353a2c 100644 --- a/lib/Service/FormsService.php +++ b/lib/Service/FormsService.php @@ -632,6 +632,9 @@ public function areExtraSettingsValid(array $extraSettings, string $questionType case Constants::ANSWER_TYPE_DATE: $allowed = Constants::EXTRA_SETTINGS_DATE; break; + case Constants::ANSWER_TYPE_LINEARSCALE: + $allowed = Constants::EXTRA_SETTINGS_LINEARSCALE; + break; default: $allowed = []; } @@ -708,6 +711,14 @@ public function areExtraSettingsValid(array $extraSettings, string $questionType return false; } } + + // Special handling of linear scale validation + } elseif ($questionType === Constants::ANSWER_TYPE_LINEARSCALE) { + // Ensure limits are sane + if (isset($extraSettings['optionsLowest']) && ($extraSettings['optionsLowest'] < 0 || $extraSettings['optionsLowest'] > 1) || + isset($extraSettings['optionsHighest']) && ($extraSettings['optionsHighest'] < 2 || $extraSettings['optionsHighest'] > 10)) { + return false; + } } return true; } diff --git a/lib/Service/SubmissionService.php b/lib/Service/SubmissionService.php index baea59590..61235f3ba 100644 --- a/lib/Service/SubmissionService.php +++ b/lib/Service/SubmissionService.php @@ -365,7 +365,7 @@ public function validateSubmission(array $questions, array $answers, string $for continue; } - // Check number of answers + // Check number of answers for multiple answers $answersCount = count($answers[$questionId]); if ($question['type'] === Constants::ANSWER_TYPE_MULTIPLE) { $minOptions = $question['extraSettings']['optionsLimitMin'] ?? -1; @@ -399,8 +399,16 @@ public function validateSubmission(array $questions, array $answers, string $for // Check if all answers are within the possible options if (in_array($question['type'], Constants::ANSWER_TYPES_PREDEFINED) && empty($question['extraSettings']['allowOtherAnswer'])) { foreach ($answers[$questionId] as $answer) { + // Handle linear scale questions + if ($question['type'] === Constants::ANSWER_TYPE_LINEARSCALE) { + $optionsLowest = $question['extraSettings']['optionsLowest'] ?? 1; + $optionsHighest = $question['extraSettings']['optionsHighest'] ?? 5; + if (!ctype_digit($answer) || intval($answer) < $optionsLowest || intval($answer) > $optionsHighest) { + throw new \InvalidArgumentException(sprintf('The answer for question "%s" must be an integer between %d and %d.', $question['text'], $optionsLowest, $optionsHighest)); + } + } // Search corresponding option, return false if non-existent - if (!in_array($answer, array_column($question['options'], 'id'))) { + elseif (!in_array($answer, array_column($question['options'], 'id'))) { throw new \InvalidArgumentException(sprintf('Answer "%s" for question "%s" is not a valid option.', $answer, $question['text'])); } } @@ -411,6 +419,7 @@ public function validateSubmission(array $questions, array $answers, string $for throw new \InvalidArgumentException(sprintf('Invalid input for question "%s".', $question['text'])); } + // Handle file questions if ($question['type'] === Constants::ANSWER_TYPE_FILE) { $maxAllowedFilesCount = $question['extraSettings']['maxAllowedFilesCount'] ?? 0; if ($maxAllowedFilesCount > 0 && count($answers[$questionId]) > $maxAllowedFilesCount) { diff --git a/openapi.json b/openapi.json index 0dc887668..f82bfc9e5 100644 --- a/openapi.json +++ b/openapi.json @@ -437,6 +437,27 @@ "type": "integer", "format": "int64" }, + "optionsHighest": { + "type": "integer", + "format": "int64", + "enum": [ + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10 + ] + }, + "optionsLabelHighest": { + "type": "string" + }, + "optionsLabelLowest": { + "type": "string" + }, "optionsLimitMax": { "type": "integer", "format": "int64" @@ -445,6 +466,14 @@ "type": "integer", "format": "int64" }, + "optionsLowest": { + "type": "integer", + "format": "int64", + "enum": [ + 0, + 1 + ] + }, "shuffleOptions": { "type": "boolean" }, diff --git a/src/components/Icons/IconLinearScale.vue b/src/components/Icons/IconLinearScale.vue new file mode 100644 index 000000000..b721a43f2 --- /dev/null +++ b/src/components/Icons/IconLinearScale.vue @@ -0,0 +1,45 @@ + + + + + diff --git a/src/components/Questions/QuestionLinearScale.vue b/src/components/Questions/QuestionLinearScale.vue new file mode 100644 index 000000000..8b75d27a1 --- /dev/null +++ b/src/components/Questions/QuestionLinearScale.vue @@ -0,0 +1,368 @@ + + + + + + + diff --git a/src/components/Results/ResultsSummary.vue b/src/components/Results/ResultsSummary.vue index 1ced107e7..48abea6f8 100644 --- a/src/components/Results/ResultsSummary.vue +++ b/src/components/Results/ResultsSummary.vue @@ -9,7 +9,7 @@ {{ question.text }}

- {{ answerTypes[question.type].label }} + {{ questionTypeLabel }}

@@ -86,14 +86,62 @@ export default { }, computed: { + questionTypeLabel() { + const label = this.answerTypes[this.question.type].label + const labelLowest = + this.question.extraSettings?.optionsLabelLowest + ?? t('forms', 'Strongly disagree') + const labelHighest = + this.question.extraSettings?.optionsLabelHighest + ?? t('forms', 'Strongly agree') + const optionsLowest = + this.question.extraSettings?.optionsLowest?.toString() ?? '1' + const optionsHighest = + this.question.extraSettings?.optionsHighest?.toString() ?? '5' + + if (labelLowest === '' && labelHighest === '') { + return label + } + + const descriptionParts = [] + if (labelLowest !== '') { + descriptionParts.push(`${optionsLowest}: ${labelLowest}`) + } + if (labelHighest !== '') { + descriptionParts.push(`${optionsHighest}: ${labelHighest}`) + } + + const description = ` (${descriptionParts.join(', ')})` + return label + description + }, + // For countable questions like multiple choice and checkboxes questionOptions() { // Build list of question options - const questionOptionsStats = this.question.options.map((option) => ({ - ...option, - count: 0, - percentage: 0, - })) + let questionOptionsStats + if (this.question.type !== 'linearscale') { + questionOptionsStats = this.question.options.map((option) => ({ + ...option, + count: 0, + percentage: 0, + })) + } else { + questionOptionsStats = Array.from( + { + length: + (this.question.extraSettings?.optionsHighest ?? 5) + - (this.question.extraSettings?.optionsLowest ?? 1) + + 1, + }, + (_, i) => ({ + text: ( + i + (this.question.extraSettings?.optionsLowest ?? 1) + ).toString(), + count: 0, + percentage: 0, + }), + ) + } // Also record 'Other' if (this.question.extraSettings?.allowOtherAnswer) { @@ -144,18 +192,25 @@ export default { }) // Sort options by response count - questionOptionsStats.sort((object1, object2) => { - return object2.count - object1.count - }) + if (this.question.type !== 'linearscale') { + questionOptionsStats.sort((object1, object2) => { + return object2.count - object1.count + }) + } else { + // for linear scale questions move the "No response" element to the end + questionOptionsStats.push(questionOptionsStats.shift()) + } questionOptionsStats.forEach((questionOptionsStat) => { // Fill percentage values questionOptionsStat.percentage = Math.round( (100 * questionOptionsStat.count) / this.submissions.length, ) - // Mark all best results. First one is best for sure due to sorting - questionOptionsStat.best = - questionOptionsStat.count === questionOptionsStats[0].count + // Mark all best results + const maxCount = Math.max( + ...questionOptionsStats.map((option) => option.count), + ) + questionOptionsStat.best = questionOptionsStat.count === maxCount }) return questionOptionsStats diff --git a/src/models/AnswerTypes.js b/src/models/AnswerTypes.js index 34b94e44f..b34f00cf8 100644 --- a/src/models/AnswerTypes.js +++ b/src/models/AnswerTypes.js @@ -6,18 +6,20 @@ 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 QuestionShort from '../components/Questions/QuestionShort.vue' -import IconCheckboxOutline from 'vue-material-design-icons/CheckboxOutline.vue' -import IconRadioboxMarked from 'vue-material-design-icons/RadioboxMarked.vue' import IconArrowDownDropCircleOutline from 'vue-material-design-icons/ArrowDownDropCircleOutline.vue' -import IconTextShort from 'vue-material-design-icons/TextShort.vue' -import IconTextLong from 'vue-material-design-icons/TextLong.vue' -import IconFile from 'vue-material-design-icons/File.vue' import IconCalendar from 'vue-material-design-icons/Calendar.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/File.vue' +import IconLinearScale from '../components/Icons/IconLinearScale.vue' +import IconRadioboxMarked from 'vue-material-design-icons/RadioboxMarked.vue' +import IconTextLong from 'vue-material-design-icons/TextLong.vue' +import IconTextShort from 'vue-material-design-icons/TextShort.vue' /** * @typedef {object} AnswerTypes @@ -29,6 +31,7 @@ import IconClockOutline from 'vue-material-design-icons/ClockOutline.vue' * @property {string} date Date Answer * @property {string} datetime Date and Time Answer * @property {string} time Time Answer + * @property {string} linearscale Linear Scale Answer */ export default { /** @@ -183,4 +186,14 @@ export default { storageFormat: 'HH:mm', momentFormat: 'LT', }, + + linearscale: { + component: QuestionLinearScale, + icon: IconLinearScale, + label: t('forms', 'Linear scale'), + predefined: true, + + titlePlaceholder: t('forms', 'Linear scale question title'), + warningInvalid: t('forms', 'This question needs a title!'), + }, } diff --git a/tests/Unit/Service/FormsServiceTest.php b/tests/Unit/Service/FormsServiceTest.php index b0ac64845..336c8a4e7 100644 --- a/tests/Unit/Service/FormsServiceTest.php +++ b/tests/Unit/Service/FormsServiceTest.php @@ -1411,7 +1411,36 @@ public function dataAreExtraSettingsValid() { ], 'questionType' => Constants::ANSWER_TYPE_DROPDOWN, 'expected' => false - ] + ], + 'valid-linearscale-settings' => [ + 'extraSettings' => [ + 'optionsLowest' => 0, + 'optionsHighest' => 5, + 'optionsLabelLowest' => 'disagree', + 'optionsLabelHighest' => 'agree' + ], + 'questionType' => Constants::ANSWER_TYPE_LINEARSCALE, + 'expected' => true + ], + 'invalid-linearscale-settings' => [ + 'extraSettings' => [ + 'optionsLowest' => 1, + 'optionsHighest' => 10, + 'optionsLabelLowest' => 'disagree', + 'optionsLabelHighest' => 'agree', + 'someInvalidOption' => true + ], + 'questionType' => Constants::ANSWER_TYPE_LINEARSCALE, + 'expected' => false + ], + 'outofrange-linearscale-settings' => [ + 'extraSettings' => [ + 'optionsLowest' => 3, + 'optionsHighest' => 11, + ], + 'questionType' => Constants::ANSWER_TYPE_LINEARSCALE, + 'expected' => false + ], ]; } diff --git a/tests/Unit/Service/SubmissionServiceTest.php b/tests/Unit/Service/SubmissionServiceTest.php index 1b8475277..82e7d3913 100644 --- a/tests/Unit/Service/SubmissionServiceTest.php +++ b/tests/Unit/Service/SubmissionServiceTest.php @@ -875,6 +875,18 @@ public function dataValidateSubmission() { // Expected Result 'Question "q1" can only have one answer.', ], + 'invalid-linearcale-question' => [ + // Questions + [ + ['id' => 1, 'type' => 'linearscale', 'text' => 'q1', 'isRequired' => false, 'extraSettings' => ['optionsLowest' => 0]] + ], + // Answers + [ + '1' => ['6'] + ], + // Expected Result + 'The answer for question "q1" must be an integer between 0 and 5.', + ], 'full-good-submission' => [ // Questions [ @@ -904,7 +916,8 @@ public function dataValidateSubmission() { ['id' => 11, 'type' => 'short', 'isRequired' => false, 'extraSettings' => ['validationType' => 'number']], ['id' => 12, 'type' => 'short', 'isRequired' => false, 'extraSettings' => ['validationType' => 'phone']], ['id' => 13, 'type' => 'short', 'isRequired' => false, 'extraSettings' => ['validationType' => 'regex', 'validationRegex' => '/[a-z]{3}[0-9]{3}/']], - ['id' => 16, 'type' => 'date', 'isRequired' => false, 'extraSettings' => [ + ['id' => 14, 'type' => 'linearscale', 'isRequired' => false, 'extraSettings' => ['optionsLowest' => 0]], + ['id' => 15, 'type' => 'date', 'isRequired' => false, 'extraSettings' => [ 'dateMin' => 1742860800, 'dateMax' => 1743033600] ], @@ -924,7 +937,8 @@ public function dataValidateSubmission() { '11' => ['100.45'], '12' => ['+49 711 25 24 28 90'], '13' => ['abc123'], - '16' => ['2025-03-26'] + '14' => ['3'], + '15' => ['2025-03-26'], ], // Expected Result null,