diff --git a/lib/Constants.php b/lib/Constants.php
index 67d4d5596..7316c62eb 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,15 @@ class Constants {
'rows' => ['array'],
];
+ /**
+ * How many icons a rating question offers, and which icon to draw.
+ * ratingIcon is one of 'star' (default), 'heart' or 'thumb'.
+ */
+ public const EXTRA_SETTINGS_RATING = [
+ 'maxRating' => ['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..80fde5550 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 => [],
diff --git a/lib/Service/SubmissionService.php b/lib/Service/SubmissionService.php
index 340022111..4b52bd32c 100644
--- a/lib/Service/SubmissionService.php
+++ b/lib/Service/SubmissionService.php
@@ -633,6 +633,19 @@ 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 is validated on its own rather than as a
+ // predefined-option type: the answer is the number of icons chosen.
+ if ($question['type'] === Constants::ANSWER_TYPE_RATING) {
+ $maxRating = $question['extraSettings']['maxRating'] ?? 5;
+ foreach ($answers[$questionId] as $answer) {
+ if (!ctype_digit((string)$answer)
+ || (int)$answer < 1
+ || (int)$answer > $maxRating) {
+ throw new \InvalidArgumentException(sprintf('The answer for question "%s" must be a whole number between 1 and %d.', $question['text'], $maxRating));
+ }
+ }
+ }
+
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'] ?? []);
diff --git a/src/components/Questions/QuestionRating.vue b/src/components/Questions/QuestionRating.vue
new file mode 100644
index 000000000..49d5b5286
--- /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,