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 @@
+
+
+
+
+
+ {{
+ isImage
+ ? t('forms', 'No image address set yet.')
+ : t('forms', 'No video address set yet.')
+ }}
+
+
+
+
+
+
+ {{
+ t(
+ 'forms',
+ 'This address is on another site. Loading it tells that site the IP address of everyone who opens the form.',
+ )
+ }}
+
+
+
+
+
+
+
+
+
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
[