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,