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,