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 @@
+
+
+
+
- {{ 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,