From e52688bf4f5f4c5b7375c1a58c1d6d73910c21e6 Mon Sep 17 00:00:00 2001 From: global-prog Date: Wed, 9 Sep 2026 04:12:45 +0300 Subject: [PATCH 1/2] feat: image and video blocks Adds two display-only blocks so a form can show a picture or point at a video between its questions, rather than only text. They are modelled as ordinary question types that happen to carry no answer, which keeps them in the existing ordering and drag-and-drop with no new concepts. A shared ANSWER_TYPES_DISPLAY_ONLY list marks them so they are skipped when validating a submission and filtered out of exports - without that, each block would add an empty column to every CSV. Video is deliberately NOT embedded in an iframe. Embedding makes every respondent's browser contact the video host on page load, disclosing their IP address and usually setting cookies, which would quietly undermine a form marked as anonymous. It renders as a link the respondent chooses to follow. Images do render inline, since that is the point of an image block, but with referrerpolicy="no-referrer" so the form's address is not passed on, and the editor warns when the address is not on this instance. No schema change: the address and description live in the existing extraSettings. 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 | 21 +++ lib/Service/FormsService.php | 2 + lib/Service/SubmissionService.php | 16 ++ src/components/Questions/QuestionMedia.vue | 168 +++++++++++++++++++++ src/models/AnswerTypes.ts | 27 ++++ 5 files changed, 234 insertions(+) create mode 100644 src/components/Questions/QuestionMedia.vue 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..4d3ffa234 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,12 @@ public function validateSubmission(array $questions, array $answers, string $for $questionId = $question['id']; $questionAnswered = array_key_exists($questionId, $answers); + // Display-only blocks are never answered, so they must not be treated as an + // unanswered mandatory question. + if (in_array($question['type'], Constants::ANSWER_TYPES_DISPLAY_ONLY, true)) { + 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, From 8843b6c1b70193492b95fa943c3b785bca6ea252 Mon Sep 17 00:00:00 2001 From: global-prog Date: Fri, 11 Sep 2026 17:27:35 +0300 Subject: [PATCH 2/2] fix: refuse an answer submitted to an image or video block A display-only block was skipped during validation whether or not an answer came with it, so a hand-made request could store an answer against a block that has nowhere to show it. An absent answer is still expected -- and still must not count as a missing required one -- but a present answer is now refused. The submit view never sends one for these blocks, since they emit no value, so this changes nothing for a form filled in normally. Adds tests for both halves, and for the image and video settings accepted by areExtraSettingsValid, which were not exercised before. Signed-off-by: global-prog --- lib/Service/SubmissionService.php | 9 +++- tests/Unit/Service/FormsServiceTest.php | 31 +++++++++++++ tests/Unit/Service/SubmissionServiceTest.php | 48 ++++++++++++++++++++ 3 files changed, 86 insertions(+), 2 deletions(-) diff --git a/lib/Service/SubmissionService.php b/lib/Service/SubmissionService.php index 4d3ffa234..11c746917 100644 --- a/lib/Service/SubmissionService.php +++ b/lib/Service/SubmissionService.php @@ -577,9 +577,14 @@ public function validateSubmission(array $questions, array $answers, string $for $questionId = $question['id']; $questionAnswered = array_key_exists($questionId, $answers); - // Display-only blocks are never answered, so they must not be treated as an - // unanswered mandatory question. + // 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; } 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 [