diff --git a/lib/Constants.php b/lib/Constants.php
index 67d4d5596..3d5159b63 100644
--- a/lib/Constants.php
+++ b/lib/Constants.php
@@ -101,6 +101,7 @@ class Constants {
public const ANSWER_TYPE_MULTIPLE = 'multiple';
public const ANSWER_TYPE_MULTIPLEUNIQUE = 'multiple_unique';
public const ANSWER_TYPE_RANKING = 'ranking';
+ public const ANSWER_TYPE_RATING = 'rating';
public const ANSWER_TYPE_SHORT = 'short';
public const ANSWER_TYPE_TIME = 'time';
@@ -121,6 +122,7 @@ class Constants {
self::ANSWER_TYPE_MULTIPLE,
self::ANSWER_TYPE_MULTIPLEUNIQUE,
self::ANSWER_TYPE_RANKING,
+ self::ANSWER_TYPE_RATING,
self::ANSWER_TYPE_SHORT,
self::ANSWER_TYPE_TIME,
];
@@ -219,6 +221,17 @@ class Constants {
'rows' => ['array'],
];
+ /**
+ * A rating is a linear scale that always starts at 1 and is drawn as icons, so it
+ * shares the linear scale's key for its top end (and that key's bounds) rather than
+ * having one of its own. optionsLowest is deliberately absent: a rating's lowest end
+ * is always 1. ratingIcon is one of 'star' (default), 'heart' or 'thumb'.
+ */
+ public const EXTRA_SETTINGS_RATING = [
+ 'optionsHighest' => ['integer', 'NULL'],
+ 'ratingIcon' => ['string', 'NULL'],
+ ];
+
public const EXTRA_SETTINGS_RANKING = [
'shuffleOptions' => ['boolean'],
];
diff --git a/lib/Service/FormsService.php b/lib/Service/FormsService.php
index c7a73a630..0e1e51792 100644
--- a/lib/Service/FormsService.php
+++ b/lib/Service/FormsService.php
@@ -841,6 +841,7 @@ public function areExtraSettingsValid(array $extraSettings, string $questionType
Constants::ANSWER_TYPE_DATE => Constants::EXTRA_SETTINGS_DATE,
Constants::ANSWER_TYPE_GRID => Constants::EXTRA_SETTINGS_GRID,
Constants::ANSWER_TYPE_RANKING => Constants::EXTRA_SETTINGS_RANKING,
+ Constants::ANSWER_TYPE_RATING => Constants::EXTRA_SETTINGS_RATING,
Constants::ANSWER_TYPE_TIME => Constants::EXTRA_SETTINGS_TIME,
Constants::ANSWER_TYPE_LINEARSCALE => Constants::EXTRA_SETTINGS_LINEARSCALE,
default => [],
@@ -946,8 +947,10 @@ public function areExtraSettingsValid(array $extraSettings, string $questionType
}
// Special handling of linear scale validation
- } elseif ($questionType === Constants::ANSWER_TYPE_LINEARSCALE) {
- // Ensure limits are sane
+ } elseif ($questionType === Constants::ANSWER_TYPE_LINEARSCALE
+ || $questionType === Constants::ANSWER_TYPE_RATING) {
+ // Ensure limits are sane. A rating cannot set optionsLowest at all, so for it
+ // only the top end is checked.
if (isset($extraSettings['optionsLowest']) && ($extraSettings['optionsLowest'] < 0 || $extraSettings['optionsLowest'] > 1)
|| isset($extraSettings['optionsHighest']) && ($extraSettings['optionsHighest'] < 2 || $extraSettings['optionsHighest'] > 10)) {
return false;
diff --git a/lib/Service/SubmissionService.php b/lib/Service/SubmissionService.php
index 340022111..8abe0ff1c 100644
--- a/lib/Service/SubmissionService.php
+++ b/lib/Service/SubmissionService.php
@@ -633,6 +633,15 @@ public function validateSubmission(array $questions, array $answers, string $for
}
// Check if all answers are within the possible options
+ // A rating carries no options, so it cannot go through the predefined-option
+ // branch below, but its answer is a point on a scale exactly as a linear scale's
+ // is, so it is held to the same rule.
+ if ($question['type'] === Constants::ANSWER_TYPE_RATING) {
+ foreach ($answers[$questionId] as $answer) {
+ $this->validateScaleAnswer($question, $answer);
+ }
+ }
+
if (in_array($question['type'], Constants::ANSWER_TYPES_PREDEFINED) && empty($question['extraSettings']['allowOtherAnswer'])) {
// Normalize option IDs once for consistent comparison (DB may return ints, request may send strings)
$optionIds = $this->normalizeOptionIds($question['options'] ?? []);
@@ -640,11 +649,7 @@ public function validateSubmission(array $questions, array $answers, string $for
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((string)$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));
- }
+ $this->validateScaleAnswer($question, $answer);
}
// Check if all grid rows, columns and values match the configured grid subtype
elseif ($question['type'] === Constants::ANSWER_TYPE_GRID) {
@@ -730,6 +735,25 @@ public function validateSubmission(array $questions, array $answers, string $for
}
}
+ /**
+ * Check one answer to a question answered on a numbered scale.
+ *
+ * Shared by the linear scale and the rating, which differ only in how the scale is
+ * drawn. The bounds and their defaults are the linear scale's; a rating does not
+ * accept optionsLowest, so its lowest end always falls back to 1.
+ *
+ * @param array $question the question being answered
+ * @param mixed $answer one submitted value
+ * @throws \InvalidArgumentException if the answer is not a whole number within range
+ */
+ private function validateScaleAnswer(array $question, mixed $answer): void {
+ $optionsLowest = $question['extraSettings']['optionsLowest'] ?? 1;
+ $optionsHighest = $question['extraSettings']['optionsHighest'] ?? 5;
+ if (!ctype_digit((string)$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));
+ }
+ }
+
/**
* Validate correct date/time formats
* @param array $answers Array with date from answer
diff --git a/src/components/Questions/QuestionRating.vue b/src/components/Questions/QuestionRating.vue
new file mode 100644
index 000000000..89edb9a45
--- /dev/null
+++ b/src/components/Questions/QuestionRating.vue
@@ -0,0 +1,272 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ icon.label }}
+
+
+
+
+
+
+
+
+
+
diff --git a/src/models/AnswerTypes.ts b/src/models/AnswerTypes.ts
index e2bc47b9e..6beab4bb8 100644
--- a/src/models/AnswerTypes.ts
+++ b/src/models/AnswerTypes.ts
@@ -17,6 +17,7 @@ import IconPalette from '@material-symbols/svg-400/outlined/palette.svg?raw'
import IconRadioboxMarked from '@material-symbols/svg-400/outlined/radio_button_checked.svg?raw'
import IconClockOutline from '@material-symbols/svg-400/outlined/schedule.svg?raw'
import IconTextShort from '@material-symbols/svg-400/outlined/short_text.svg?raw'
+import IconStar from '@material-symbols/svg-400/outlined/star.svg?raw'
import IconTextLong from '@material-symbols/svg-400/outlined/subject.svg?raw'
import IconSwapVertical from '@material-symbols/svg-400/outlined/swap_vert.svg?raw'
import { t } from '@nextcloud/l10n'
@@ -30,6 +31,7 @@ import QuestionLinearScale from '../components/Questions/QuestionLinearScale.vue
import QuestionLong from '../components/Questions/QuestionLong.vue'
import QuestionMultiple from '../components/Questions/QuestionMultiple.vue'
import QuestionRanking from '../components/Questions/QuestionRanking.vue'
+import QuestionRating from '../components/Questions/QuestionRating.vue'
import QuestionShort from '../components/Questions/QuestionShort.vue'
import { OptionType } from './Constants.ts'
@@ -267,6 +269,16 @@ const answerTypes: Record = {
warningInvalid: t('forms', 'This question needs a title!'),
},
+ rating: {
+ component: markRaw(QuestionRating),
+ icon: IconStar,
+ label: t('forms', 'Rating'),
+ predefined: false,
+
+ titlePlaceholder: t('forms', 'Rating question title'),
+ warningInvalid: t('forms', 'This question needs a title!'),
+ },
+
color: {
component: markRaw(QuestionColor),
icon: IconPalette,
diff --git a/tests/Unit/Service/FormsServiceTest.php b/tests/Unit/Service/FormsServiceTest.php
index 881031c05..60255fda7 100644
--- a/tests/Unit/Service/FormsServiceTest.php
+++ b/tests/Unit/Service/FormsServiceTest.php
@@ -1392,6 +1392,36 @@ public function testAreExtraSettingsValid(array $extraSettings, string $question
public static function dataAreExtraSettingsValid() {
return [
+ 'valid-rating-settings' => [
+ 'extraSettings' => [
+ 'optionsHighest' => 10,
+ 'ratingIcon' => 'heart',
+ ],
+ 'questionType' => Constants::ANSWER_TYPE_RATING,
+ 'expected' => true
+ ],
+ 'rating-top-above-scale-limit' => [
+ 'extraSettings' => [
+ 'optionsHighest' => 11,
+ ],
+ 'questionType' => Constants::ANSWER_TYPE_RATING,
+ 'expected' => false
+ ],
+ 'rating-top-below-scale-limit' => [
+ 'extraSettings' => [
+ 'optionsHighest' => 1,
+ ],
+ 'questionType' => Constants::ANSWER_TYPE_RATING,
+ 'expected' => false
+ ],
+ 'rating-has-no-lowest-end' => [
+ // A rating always starts at 1, so the linear scale's lower bound is refused.
+ 'extraSettings' => [
+ 'optionsLowest' => 0,
+ ],
+ 'questionType' => Constants::ANSWER_TYPE_RATING,
+ 'expected' => false
+ ],
'empty-extra-settings' => [
'extraSettings' => [],
'questionType' => Constants::ANSWER_TYPE_LONG,
diff --git a/tests/Unit/Service/SubmissionServiceTest.php b/tests/Unit/Service/SubmissionServiceTest.php
index b6e7fcc16..8e955dc57 100644
--- a/tests/Unit/Service/SubmissionServiceTest.php
+++ b/tests/Unit/Service/SubmissionServiceTest.php
@@ -805,6 +805,78 @@ private function setUpCsvTest(array $questions, array $submissions, string $csvT
// Data for validation of Submissions
public static function dataValidateSubmission() {
return [
+ 'rating-within-configured-top' => [
+ // Questions
+ [
+ ['id' => 1, 'type' => 'rating', 'text' => 'r', 'isRequired' => false, 'extraSettings' => ['optionsHighest' => 10]],
+ ],
+ // Answers
+ [
+ '1' => ['7'],
+ ],
+ // Expected Result
+ null,
+ ],
+ 'rating-at-default-top' => [
+ // Questions
+ [
+ ['id' => 1, 'type' => 'rating', 'text' => 'r', 'isRequired' => false],
+ ],
+ // Answers
+ [
+ '1' => ['5'],
+ ],
+ // Expected Result
+ null,
+ ],
+ 'rating-above-default-top' => [
+ // Questions
+ [
+ ['id' => 1, 'type' => 'rating', 'text' => 'r', 'isRequired' => false],
+ ],
+ // Answers
+ [
+ '1' => ['6'],
+ ],
+ // Expected Result
+ 'The answer for question "r" must be an integer between 1 and 5.',
+ ],
+ 'rating-above-configured-top' => [
+ // Questions
+ [
+ ['id' => 1, 'type' => 'rating', 'text' => 'r', 'isRequired' => false, 'extraSettings' => ['optionsHighest' => 3]],
+ ],
+ // Answers
+ [
+ '1' => ['4'],
+ ],
+ // Expected Result
+ 'The answer for question "r" must be an integer between 1 and 3.',
+ ],
+ 'rating-zero' => [
+ // Questions
+ [
+ ['id' => 1, 'type' => 'rating', 'text' => 'r', 'isRequired' => false],
+ ],
+ // Answers
+ [
+ '1' => ['0'],
+ ],
+ // Expected Result
+ 'The answer for question "r" must be an integer between 1 and 5.',
+ ],
+ 'rating-not-a-number' => [
+ // Questions
+ [
+ ['id' => 1, 'type' => 'rating', 'text' => 'r', 'isRequired' => false],
+ ],
+ // Answers
+ [
+ '1' => ['three'],
+ ],
+ // Expected Result
+ 'The answer for question "r" must be an integer between 1 and 5.',
+ ],
'required-not-answered' => [
// Questions
[