diff --git a/lib/Constants.php b/lib/Constants.php index 67d4d5596..4c334860d 100644 --- a/lib/Constants.php +++ b/lib/Constants.php @@ -100,9 +100,11 @@ class Constants { public const ANSWER_TYPE_LONG = 'long'; public const ANSWER_TYPE_MULTIPLE = 'multiple'; public const ANSWER_TYPE_MULTIPLEUNIQUE = 'multiple_unique'; + public const ANSWER_TYPE_IMAGE = 'image'; public const ANSWER_TYPE_RANKING = 'ranking'; public const ANSWER_TYPE_SHORT = 'short'; public const ANSWER_TYPE_TIME = 'time'; + public const ANSWER_TYPE_VIDEO = 'video'; public const ANSWER_GRID_TYPE_CHECKBOX = 'checkbox'; public const ANSWER_GRID_TYPE_NUMBER = 'number'; @@ -120,9 +122,11 @@ class Constants { self::ANSWER_TYPE_LONG, self::ANSWER_TYPE_MULTIPLE, self::ANSWER_TYPE_MULTIPLEUNIQUE, + self::ANSWER_TYPE_IMAGE, self::ANSWER_TYPE_RANKING, self::ANSWER_TYPE_SHORT, self::ANSWER_TYPE_TIME, + self::ANSWER_TYPE_VIDEO, ]; // AnswerTypes, that need/have predefined Options @@ -219,6 +223,23 @@ class Constants { 'rows' => ['array'], ]; + /** + * Display-only blocks carry no answer; they only reference something to show. + */ + public const EXTRA_SETTINGS_MEDIA = [ + 'url' => ['string', 'NULL'], + 'alt' => ['string', 'NULL'], + ]; + + /** + * Question types that are shown but never answered, so they are skipped when + * validating a submission and left out of exports. + */ + public const ANSWER_TYPES_DISPLAY_ONLY = [ + self::ANSWER_TYPE_IMAGE, + self::ANSWER_TYPE_VIDEO, + ]; + public const EXTRA_SETTINGS_RANKING = [ 'shuffleOptions' => ['boolean'], ]; diff --git a/lib/Service/FormsService.php b/lib/Service/FormsService.php index c7a73a630..d9d3db1c7 100644 --- a/lib/Service/FormsService.php +++ b/lib/Service/FormsService.php @@ -840,6 +840,8 @@ public function areExtraSettingsValid(array $extraSettings, string $questionType Constants::ANSWER_TYPE_FILE => Constants::EXTRA_SETTINGS_FILE, Constants::ANSWER_TYPE_DATE => Constants::EXTRA_SETTINGS_DATE, Constants::ANSWER_TYPE_GRID => Constants::EXTRA_SETTINGS_GRID, + Constants::ANSWER_TYPE_IMAGE => Constants::EXTRA_SETTINGS_MEDIA, + Constants::ANSWER_TYPE_VIDEO => Constants::EXTRA_SETTINGS_MEDIA, Constants::ANSWER_TYPE_RANKING => Constants::EXTRA_SETTINGS_RANKING, Constants::ANSWER_TYPE_TIME => Constants::EXTRA_SETTINGS_TIME, Constants::ANSWER_TYPE_LINEARSCALE => Constants::EXTRA_SETTINGS_LINEARSCALE, diff --git a/lib/Service/SubmissionService.php b/lib/Service/SubmissionService.php index 340022111..11c746917 100644 --- a/lib/Service/SubmissionService.php +++ b/lib/Service/SubmissionService.php @@ -231,6 +231,16 @@ public function getSubmissionsData(Form $form, string $fileFormat, ?File $file = $submissionEntities = array_reverse($submissionEntities); $questions = $this->questionMapper->findByForm($form->getId()); + // Display-only blocks hold no answers; leaving them in would add an empty column + // per block to every export. + $questions = array_values(array_filter( + $questions, + static fn ($question): bool => !in_array( + $question->getType(), + Constants::ANSWER_TYPES_DISPLAY_ONLY, + true, + ), + )); $defaultTimeZone = $this->config->getSystemValueString('default_timezone', 'UTC'); if (!$this->currentUser) { @@ -567,6 +577,17 @@ public function validateSubmission(array $questions, array $answers, string $for $questionId = $question['id']; $questionAnswered = array_key_exists($questionId, $answers); + // Display-only blocks take no answer. An absent answer is therefore expected and + // must not count as an unanswered mandatory question -- but a present one is + // refused outright rather than skipped, since nothing else would stop it being + // stored against a block that has nowhere to show it. + if (in_array($question['type'], Constants::ANSWER_TYPES_DISPLAY_ONLY, true)) { + if ($questionAnswered) { + throw new \InvalidArgumentException(sprintf('Question "%s" does not take an answer.', $question['text'])); + } + continue; + } + // Check if all required questions have an answer if ($question['isRequired'] && (!$questionAnswered diff --git a/src/components/Questions/QuestionMedia.vue b/src/components/Questions/QuestionMedia.vue new file mode 100644 index 000000000..f5d04c8b8 --- /dev/null +++ b/src/components/Questions/QuestionMedia.vue @@ -0,0 +1,168 @@ + + + + + + + diff --git a/src/models/AnswerTypes.ts b/src/models/AnswerTypes.ts index e2bc47b9e..5fc228dfd 100644 --- a/src/models/AnswerTypes.ts +++ b/src/models/AnswerTypes.ts @@ -12,11 +12,13 @@ import IconCalendar from '@material-symbols/svg-400/outlined/calendar_today.svg? import IconCheckboxOutline from '@material-symbols/svg-400/outlined/check_box.svg?raw' import IconFile from '@material-symbols/svg-400/outlined/draft.svg?raw' import IconGrid from '@material-symbols/svg-400/outlined/grid_view.svg?raw' +import IconImage from '@material-symbols/svg-400/outlined/image.svg?raw' import IconLinearScale from '@material-symbols/svg-400/outlined/linear_scale.svg?raw' 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 IconVideo from '@material-symbols/svg-400/outlined/smart_display.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' @@ -28,6 +30,7 @@ import QuestionFile from '../components/Questions/QuestionFile.vue' import QuestionGrid from '../components/Questions/QuestionGrid.vue' import QuestionLinearScale from '../components/Questions/QuestionLinearScale.vue' import QuestionLong from '../components/Questions/QuestionLong.vue' +import QuestionMedia from '../components/Questions/QuestionMedia.vue' import QuestionMultiple from '../components/Questions/QuestionMultiple.vue' import QuestionRanking from '../components/Questions/QuestionRanking.vue' import QuestionShort from '../components/Questions/QuestionShort.vue' @@ -55,6 +58,8 @@ export interface AnswerTypeConfig { warningInvalid: string unique?: boolean subtypes?: Record + /** Which media a display-only block shows. */ + mediaKind?: 'image' | 'video' pickerType?: string storageFormat?: string momentFormat?: string @@ -267,6 +272,28 @@ const answerTypes: Record = { warningInvalid: t('forms', 'This question needs a title!'), }, + image: { + component: markRaw(QuestionMedia), + icon: IconImage, + label: t('forms', 'Image'), + predefined: false, + mediaKind: 'image', + + titlePlaceholder: t('forms', 'Image caption'), + warningInvalid: t('forms', 'This block needs a caption!'), + }, + + video: { + component: markRaw(QuestionMedia), + icon: IconVideo, + label: t('forms', 'Video'), + predefined: false, + mediaKind: 'video', + + titlePlaceholder: t('forms', 'Video caption'), + warningInvalid: t('forms', 'This block needs a caption!'), + }, + color: { component: markRaw(QuestionColor), icon: IconPalette, diff --git a/tests/Unit/Service/FormsServiceTest.php b/tests/Unit/Service/FormsServiceTest.php index 881031c05..14bd3196b 100644 --- a/tests/Unit/Service/FormsServiceTest.php +++ b/tests/Unit/Service/FormsServiceTest.php @@ -1392,6 +1392,37 @@ public function testAreExtraSettingsValid(array $extraSettings, string $question public static function dataAreExtraSettingsValid() { return [ + 'valid-image-settings' => [ + 'extraSettings' => [ + 'url' => 'https://example.com/picture.png', + 'alt' => 'A picture', + ], + 'questionType' => Constants::ANSWER_TYPE_IMAGE, + 'expected' => true + ], + 'valid-video-settings' => [ + 'extraSettings' => [ + 'url' => 'https://example.com/clip', + ], + 'questionType' => Constants::ANSWER_TYPE_VIDEO, + 'expected' => true + ], + 'invalid-image-key' => [ + // A block has no answer, so the answer-shaping settings of other types + // must not be accepted on it. + 'extraSettings' => [ + 'shuffleOptions' => true, + ], + 'questionType' => Constants::ANSWER_TYPE_IMAGE, + 'expected' => false + ], + 'invalid-video-type' => [ + 'extraSettings' => [ + 'url' => ['not', 'a', 'string'], + ], + 'questionType' => Constants::ANSWER_TYPE_VIDEO, + '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..332e04f09 100644 --- a/tests/Unit/Service/SubmissionServiceTest.php +++ b/tests/Unit/Service/SubmissionServiceTest.php @@ -805,6 +805,54 @@ private function setUpCsvTest(array $questions, array $submissions, string $csvT // Data for validation of Submissions public static function dataValidateSubmission() { return [ + 'display-only-block-not-answered' => [ + // Questions + [ + ['id' => 1, 'type' => 'image', 'text' => 'picture', 'isRequired' => false], + ['id' => 2, 'type' => 'short', 'text' => 'q2', 'isRequired' => true], + ], + // Answers + [ + '2' => ['answer'], + ], + // Expected Result + null, + ], + 'display-only-block-marked-required' => [ + // Questions -- a block can never be answered, so a stray required flag must + // not make the whole form impossible to submit. + [ + ['id' => 1, 'type' => 'video', 'text' => 'clip', 'isRequired' => true], + ], + // Answers + [], + // Expected Result + null, + ], + 'display-only-image-answered' => [ + // Questions + [ + ['id' => 1, 'type' => 'image', 'text' => 'picture', 'isRequired' => false], + ], + // Answers + [ + '1' => ['anything'], + ], + // Expected Result + 'Question "picture" does not take an answer.', + ], + 'display-only-video-answered' => [ + // Questions + [ + ['id' => 1, 'type' => 'video', 'text' => 'clip', 'isRequired' => false], + ], + // Answers + [ + '1' => ['anything'], + ], + // Expected Result + 'Question "clip" does not take an answer.', + ], 'required-not-answered' => [ // Questions [