Skip to content

Commit e00ae3b

Browse files
committed
feat: add linear scale answer type and associated settings
Signed-off-by: Christian Hartmann <chris-hartmann@gmx.de>
1 parent 0693330 commit e00ae3b

13 files changed

Lines changed: 616 additions & 24 deletions

File tree

docs/DataStructure.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,7 @@ Currently supported Question-Types are:
210210
| _`datetime`_ | _deprecated: No longer available for new questions. Showing a dropdown calendar to select a date **and** a time._ |
211211
| `time` | Showing a dropdown menu to select a time. |
212212
| `file` | One or multiple files. It is possible to specify which mime types are allowed |
213+
| `linearscale` | A linear or Likert scale question where you choose an option that best fits your opinion |
213214

214215
## Extra Settings
215216

@@ -230,3 +231,7 @@ Optional extra settings for some [Question Types](#question-types)
230231
| `dateMax` | `date` | Integer | - | Maximum allowed date to be chosen (as Unix timestamp) |
231232
| `dateMin` | `date` | Integer | - | Minimum allowed date to be chosen (as Unix timestamp) |
232233
| `dateRange` | `date` | Boolean | `true/false` | The date picker should query a date range |
234+
| `optionsLowest` | `linearscale` | Integer | `0, 1` | Set the lowest value of the scale, default: `1` |
235+
| `optionsHighest` | `linearscale` | Integer | `2, 3, 4, 5, 6, 7, 8, 9, 10` | Set the highest value of the scale, default: `5` |
236+
| `optionsLabelLowest` | `linearscale` | string | - | Set the label of the lowest value, default: `'Strongly disagree'` |
237+
| `optionsLabelHighest` | `linearscale` | string | - | Set the label of the highest value, default: `'Strongly agree'` |

lib/Constants.php

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ class Constants {
7575
public const ANSWER_TYPE_DATETIME = 'datetime';
7676
public const ANSWER_TYPE_TIME = 'time';
7777
public const ANSWER_TYPE_FILE = 'file';
78+
public const ANSWER_TYPE_LINEARSCALE = 'linearscale';
7879

7980
// All AnswerTypes
8081
public const ANSWER_TYPES = [
@@ -87,13 +88,15 @@ class Constants {
8788
self::ANSWER_TYPE_DATETIME,
8889
self::ANSWER_TYPE_TIME,
8990
self::ANSWER_TYPE_FILE,
91+
self::ANSWER_TYPE_LINEARSCALE,
9092
];
9193

9294
// AnswerTypes, that need/have predefined Options
9395
public const ANSWER_TYPES_PREDEFINED = [
9496
self::ANSWER_TYPE_MULTIPLE,
9597
self::ANSWER_TYPE_MULTIPLEUNIQUE,
96-
self::ANSWER_TYPE_DROPDOWN
98+
self::ANSWER_TYPE_DROPDOWN,
99+
self::ANSWER_TYPE_LINEARSCALE,
97100
];
98101

99102
// AnswerTypes for date/time questions
@@ -161,6 +164,13 @@ class Constants {
161164
'x-office/spreadsheet',
162165
];
163166

167+
public const EXTRA_SETTINGS_LINEARSCALE = [
168+
'optionsLowest' => ['integer'],
169+
'optionsHighest' => ['integer'],
170+
'optionsLabelLowest' => ['string'],
171+
'optionsLabelHighest' => ['string'],
172+
];
173+
164174
public const FILENAME_INVALID_CHARS = [
165175
"\n",
166176
'/',

lib/Controller/ApiController.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1538,7 +1538,7 @@ private function storeAnswersForQuestion(Form $form, $submissionId, array $quest
15381538
$answerText = '';
15391539
$uploadedFile = null;
15401540
// Are we using answer ids as values
1541-
if (in_array($question['type'], Constants::ANSWER_TYPES_PREDEFINED)) {
1541+
if (in_array($question['type'], Constants::ANSWER_TYPES_PREDEFINED) && $question['type'] !== Constants::ANSWER_TYPE_LINEARSCALE) {
15421542
// Search corresponding option, skip processing if not found
15431543
$optionIndex = array_search($answer, array_column($question['options'], 'id'));
15441544
if ($optionIndex !== false) {

lib/ResponseDefinitions.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,12 @@
2828
* dateRange?: bool,
2929
* maxAllowedFilesCount?: int,
3030
* maxFileSize?: int,
31+
* optionsHighest?: 2|3|4|5|6|7|8|9|10,
32+
* optionsLabelHighest?: string,
33+
* optionsLabelLowest?: string,
3134
* optionsLimitMax?: int,
3235
* optionsLimitMin?: int,
36+
* optionsLowest?: 0|1,
3337
* shuffleOptions?: bool,
3438
* validationRegex?: string,
3539
* validationType?: string

lib/Service/FormsService.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -632,6 +632,9 @@ public function areExtraSettingsValid(array $extraSettings, string $questionType
632632
case Constants::ANSWER_TYPE_DATE:
633633
$allowed = Constants::EXTRA_SETTINGS_DATE;
634634
break;
635+
case Constants::ANSWER_TYPE_LINEARSCALE:
636+
$allowed = Constants::EXTRA_SETTINGS_LINEARSCALE;
637+
break;
635638
default:
636639
$allowed = [];
637640
}
@@ -708,6 +711,14 @@ public function areExtraSettingsValid(array $extraSettings, string $questionType
708711
return false;
709712
}
710713
}
714+
715+
// Special handling of linear scale validation
716+
} elseif ($questionType === Constants::ANSWER_TYPE_LINEARSCALE) {
717+
// Ensure limits are sane
718+
if (isset($extraSettings['optionsLowest']) && ($extraSettings['optionsLowest'] < 0 || $extraSettings['optionsLowest'] > 1) ||
719+
isset($extraSettings['optionsHighest']) && ($extraSettings['optionsHighest'] < 2 || $extraSettings['optionsHighest'] > 10)) {
720+
return false;
721+
}
711722
}
712723
return true;
713724
}

lib/Service/SubmissionService.php

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -365,7 +365,7 @@ public function validateSubmission(array $questions, array $answers, string $for
365365
continue;
366366
}
367367

368-
// Check number of answers
368+
// Check number of answers for multiple answers
369369
$answersCount = count($answers[$questionId]);
370370
if ($question['type'] === Constants::ANSWER_TYPE_MULTIPLE) {
371371
$minOptions = $question['extraSettings']['optionsLimitMin'] ?? -1;
@@ -399,8 +399,16 @@ public function validateSubmission(array $questions, array $answers, string $for
399399
// Check if all answers are within the possible options
400400
if (in_array($question['type'], Constants::ANSWER_TYPES_PREDEFINED) && empty($question['extraSettings']['allowOtherAnswer'])) {
401401
foreach ($answers[$questionId] as $answer) {
402+
// Handle linear scale questions
403+
if ($question['type'] === Constants::ANSWER_TYPE_LINEARSCALE) {
404+
$optionsLowest = $question['extraSettings']['optionsLowest'] ?? 1;
405+
$optionsHighest = $question['extraSettings']['optionsHighest'] ?? 5;
406+
if (!ctype_digit($answer) || intval($answer) < $optionsLowest || intval($answer) > $optionsHighest) {
407+
throw new \InvalidArgumentException(sprintf('The answer for question "%s" must be an integer between %d and %d.', $question['text'], $optionsLowest, $optionsHighest));
408+
}
409+
}
402410
// Search corresponding option, return false if non-existent
403-
if (!in_array($answer, array_column($question['options'], 'id'))) {
411+
elseif (!in_array($answer, array_column($question['options'], 'id'))) {
404412
throw new \InvalidArgumentException(sprintf('Answer "%s" for question "%s" is not a valid option.', $answer, $question['text']));
405413
}
406414
}
@@ -411,6 +419,7 @@ public function validateSubmission(array $questions, array $answers, string $for
411419
throw new \InvalidArgumentException(sprintf('Invalid input for question "%s".', $question['text']));
412420
}
413421

422+
// Handle file questions
414423
if ($question['type'] === Constants::ANSWER_TYPE_FILE) {
415424
$maxAllowedFilesCount = $question['extraSettings']['maxAllowedFilesCount'] ?? 0;
416425
if ($maxAllowedFilesCount > 0 && count($answers[$questionId]) > $maxAllowedFilesCount) {

openapi.json

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,27 @@
437437
"type": "integer",
438438
"format": "int64"
439439
},
440+
"optionsHighest": {
441+
"type": "integer",
442+
"format": "int64",
443+
"enum": [
444+
2,
445+
3,
446+
4,
447+
5,
448+
6,
449+
7,
450+
8,
451+
9,
452+
10
453+
]
454+
},
455+
"optionsLabelHighest": {
456+
"type": "string"
457+
},
458+
"optionsLabelLowest": {
459+
"type": "string"
460+
},
440461
"optionsLimitMax": {
441462
"type": "integer",
442463
"format": "int64"
@@ -445,6 +466,14 @@
445466
"type": "integer",
446467
"format": "int64"
447468
},
469+
"optionsLowest": {
470+
"type": "integer",
471+
"format": "int64",
472+
"enum": [
473+
0,
474+
1
475+
]
476+
},
448477
"shuffleOptions": {
449478
"type": "boolean"
450479
},
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<!--
2+
- SPDX-FileCopyrightText: 2025 Christian Hartmann <chris-hartmann@gmx.de>
3+
- SPDX-License-Identifier: AGPL-3.0-or-later
4+
-->
5+
6+
<template>
7+
<span
8+
:aria-hidden="!title"
9+
:aria-label="title"
10+
class="material-design-icon linear-scale-icon"
11+
role="img"
12+
v-bind="$attrs"
13+
@click="$emit('click', $event)">
14+
<svg
15+
:fill="fillColor"
16+
class="material-design-icon__svg"
17+
:height="size"
18+
:width="size"
19+
viewBox="0 -960 960 960">
20+
<path
21+
d="M672-288q-71 0-123.38-44.36Q496.24-376.73 482.9-444H281q-11 26-35 43t-54 17q-40.32 0-68.16-27.77Q96-439.55 96-479.77 96-520 123.84-548q27.84-28 68.16-28 30 0 54 17t35.46 43H484q13.21-67.28 65.1-111.64Q601-672 672-672q79.68 0 135.84 56.23 56.16 56.22 56.16 136Q864-400 807.84-344 751.68-288 672-288Zm0-72q50 0 85-35t35-85q0-50-35-85t-85-35q-50 0-85 35t-35 85q0 50 35 85t85 35Z" />
22+
<title v-if="title">{{ title }}</title>
23+
</svg>
24+
</span>
25+
</template>
26+
27+
<script>
28+
export default {
29+
name: 'IconLinearScale',
30+
props: {
31+
title: {
32+
type: String,
33+
default: '',
34+
},
35+
fillColor: {
36+
type: String,
37+
default: 'currentColor',
38+
},
39+
size: {
40+
type: Number,
41+
default: 20,
42+
},
43+
},
44+
}
45+
</script>

0 commit comments

Comments
 (0)