Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions lib/Constants.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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
Expand Down Expand Up @@ -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'],
];
Expand Down
2 changes: 2 additions & 0 deletions lib/Service/FormsService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
16 changes: 16 additions & 0 deletions lib/Service/SubmissionService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
168 changes: 168 additions & 0 deletions src/components/Questions/QuestionMedia.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->

<template>
<Question
v-bind="questionProps"
:titlePlaceholder="answerType.titlePlaceholder"
:warningInvalid="answerType.warningInvalid"
v-on="commonListeners">
<div class="question-media">
<img
v-if="isImage && url"
:src="url"
:alt="alt"
class="question-media__image"
referrerpolicy="no-referrer"
loading="lazy" />

<a
v-else-if="!isImage && url"
:href="url"
class="question-media__link"
target="_blank"
rel="noopener noreferrer external">
{{ alt || url }}
</a>

<p v-else class="question-media__empty">
{{
isImage
? t('forms', 'No image address set yet.')
: t('forms', 'No video address set yet.')
}}
</p>

<template v-if="!readOnly">
<NcTextField
:label="t('forms', 'Address')"
placeholder="https://"
:modelValue="url"
@update:modelValue="onUrlChange" />
<NcTextField
:label="
isImage
? t('forms', 'Description for screen readers')
: t('forms', 'Link text')
"
:modelValue="alt"
@update:modelValue="onAltChange" />
<NcNoteCard v-if="isExternal" type="warning">
{{
t(
'forms',
'This address is on another site. Loading it tells that site the IP address of everyone who opens the form.',
)
}}
</NcNoteCard>
</template>
</div>
</Question>
</template>

<script lang="ts">
import { t } from '@nextcloud/l10n'
import { computed, defineComponent } from 'vue'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import Question from './Question.vue'
import {
QUESTION_EMITS,
QUESTION_PROPS,
useQuestion,
} from '../../composables/useQuestion.ts'

export default defineComponent({
name: 'QuestionMedia',

components: {
NcNoteCard,
NcTextField,
Question,
},

props: QUESTION_PROPS,
emits: QUESTION_EMITS,

setup(props, { emit }) {
const question = useQuestion(props, { emit })

const extraSettings = computed(
() => (props.extraSettings as Record<string, unknown> | undefined) ?? {},
)

const isImage = computed(
() =>
(props.answerType as { mediaKind?: string })?.mediaKind !== 'video',
)

const url = computed<string>(() => (extraSettings.value.url as string) || '')
const alt = computed<string>(() => (extraSettings.value.alt as string) || '')

/**
* Whether the address points somewhere other than this instance, which is worth
* warning about because loading it discloses the respondent to that host.
*/
const isExternal = computed<boolean>(() => {
if (!url.value) {
return false
}
try {
return (
new URL(url.value, window.location.origin).origin
!== window.location.origin
)
} catch {
// An address that cannot be parsed is not yet worth warning about.
return false
}
})

/**
* @param value the new address
*/
function onUrlChange(value: string): void {
question.onExtraSettingsChange({ url: value || null })
}

/**
* @param value the new description or link text
*/
function onAltChange(value: string): void {
question.onExtraSettingsChange({ alt: value || null })
}

return {
...question,
alt,
isExternal,
isImage,
onAltChange,
onUrlChange,
t,
url,
}
},
})
</script>

<style lang="scss" scoped>
.question-media {
display: flex;
flex-direction: column;
gap: 8px;

&__image {
border-radius: var(--border-radius);
max-height: 400px;
max-width: 100%;
object-fit: contain;
}

&__empty {
color: var(--color-text-maxcontrast);
}
}
</style>
27 changes: 27 additions & 0 deletions src/models/AnswerTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -55,6 +58,8 @@ export interface AnswerTypeConfig {
warningInvalid: string
unique?: boolean
subtypes?: Record<string, AnswerTypeSubtype>
/** Which media a display-only block shows. */
mediaKind?: 'image' | 'video'
pickerType?: string
storageFormat?: string
momentFormat?: string
Expand Down Expand Up @@ -267,6 +272,28 @@ const answerTypes: Record<string, AnswerTypeConfig> = {
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,
Expand Down