From 044ebee351b5a97d071a8f1c37a1de16200b3c9b Mon Sep 17 00:00:00 2001 From: global-prog Date: Wed, 9 Sep 2026 04:02:55 +0300 Subject: [PATCH] feat: rating question type Implements #356: a compact star rating, with hearts and thumbs as alternatives. A linear scale already covers 1..N, but as a row of radio buttons. A rating is the control people expect for "how would you rate this", takes far less width, and reads at a glance in the results. Built from real radio inputs rather than clickable icons, so it stays keyboard navigable and every option is announced with the value it selects. The hit area is a full clickable-area square while the icon itself stays small, since an icon-sized target is awkward to hit on a phone. The hover animation is dropped under prefers-reduced-motion. Configurable from 2 to 10 icons, defaulting to 5. The answer is stored as the plain number, so results and CSV export need no special handling. Validated server-side as a whole number within the configured maximum rather than trusting the client. Rating carries no options, so it is checked on its own rather than as a predefined-option type. No schema change. FormsQuestionType is left alone, matching how linearscale, ranking and color are already handled, so openapi.json is unaffected. Signed-off-by: global-prog --- lib/Constants.php | 11 + lib/Service/FormsService.php | 1 + lib/Service/SubmissionService.php | 13 + src/components/Questions/QuestionRating.vue | 272 ++++++++++++++++++++ src/models/AnswerTypes.ts | 12 + 5 files changed, 309 insertions(+) create mode 100644 src/components/Questions/QuestionRating.vue 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 @@ + + + + + + + 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,