From e52688bf4f5f4c5b7375c1a58c1d6d73910c21e6 Mon Sep 17 00:00:00 2001 From: global-prog Date: Wed, 9 Sep 2026 04:12:45 +0300 Subject: [PATCH] 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,