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 @@ + + + + + + + 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 [

AltStyle によって変換されたページ (->オリジナル) /