From 044ebee351b5a97d071a8f1c37a1de16200b3c9b Mon Sep 17 00:00:00 2001 From: global-prog Date: Wed, 9 Sep 2026 04:02:55 +0300 Subject: [PATCH 1/2] 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, From 29ea98626e0199494c4f2fb63a2d6ba0f0cdbe1a Mon Sep 17 00:00:00 2001 From: global-prog Date: 2026年9月11日 17:30:52 +0300 Subject: [PATCH 2/2] refactor: build the rating on the linear scale's settings and validation A rating is a linear scale that always starts at 1 and is drawn as icons, so it should not carry settings and checks of its own: * its top end is stored as optionsHighest rather than a separate maxRating key, and optionsLowest is refused, since a rating's lowest end is always 1; * the linear scale's bounds on optionsHighest (2 to 10) now apply to it as well. The editor already offered exactly that range, but the server never enforced it; * the range check that validated linear scale answers is moved into one helper that both types call, keeping its message and defaults, so linear scale behaviour is unchanged. Adds tests for the rating's accepted settings and for its answer validation, which the first commit did not have. Signed-off-by: global-prog --- lib/Constants.php | 8 ++- lib/Service/FormsService.php | 6 +- lib/Service/SubmissionService.php | 37 ++++++---- src/components/Questions/QuestionRating.vue | 26 +++---- tests/Unit/Service/FormsServiceTest.php | 30 ++++++++ tests/Unit/Service/SubmissionServiceTest.php | 72 ++++++++++++++++++++ 6 files changed, 148 insertions(+), 31 deletions(-) diff --git a/lib/Constants.php b/lib/Constants.php index 7316c62eb..3d5159b63 100644 --- a/lib/Constants.php +++ b/lib/Constants.php @@ -222,11 +222,13 @@ class Constants { ]; /** - * How many icons a rating question offers, and which icon to draw. - * ratingIcon is one of 'star' (default), 'heart' or 'thumb'. + * 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 = [ - 'maxRating' => ['integer', 'NULL'], + 'optionsHighest' => ['integer', 'NULL'], 'ratingIcon' => ['string', 'NULL'], ]; diff --git a/lib/Service/FormsService.php b/lib/Service/FormsService.php index 80fde5550..0e1e51792 100644 --- a/lib/Service/FormsService.php +++ b/lib/Service/FormsService.php @@ -947,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 4b52bd32c..8abe0ff1c 100644 --- a/lib/Service/SubmissionService.php +++ b/lib/Service/SubmissionService.php @@ -633,16 +633,12 @@ 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. + // 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) { - $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)); - } + $this->validateScaleAnswer($question, $answer); } } @@ -653,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) { @@ -743,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 index 49d5b5286..89edb9a45 100644 --- a/src/components/Questions/QuestionRating.vue +++ b/src/components/Questions/QuestionRating.vue @@ -12,14 +12,14 @@ v-on="commonListeners">