Skip to content

feat: add color question type #2748

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
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
1 change: 1 addition & 0 deletions docs/DataStructure.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ Currently supported Question-Types are:
| `time` | Showing a dropdown menu to select a time. |
| `file` | One or multiple files. It is possible to specify which mime types are allowed |
| `linearscale` | A linear or Likert scale question where you choose an option that best fits your opinion |
| `color` | A color answer, hex string representation (e. g. `#123456`) |

## Extra Settings

Expand Down
38 changes: 20 additions & 18 deletions lib/Constants.php
Original file line number Diff line number Diff line change
Expand Up @@ -66,37 +66,39 @@ class Constants {
*/

// Available AnswerTypes
public const ANSWER_TYPE_MULTIPLE = 'multiple';
public const ANSWER_TYPE_MULTIPLEUNIQUE = 'multiple_unique';
public const ANSWER_TYPE_DROPDOWN = 'dropdown';
public const ANSWER_TYPE_SHORT = 'short';
public const ANSWER_TYPE_LONG = 'long';
public const ANSWER_TYPE_COLOR = 'color';
public const ANSWER_TYPE_DATE = 'date';
public const ANSWER_TYPE_DATETIME = 'datetime';
public const ANSWER_TYPE_TIME = 'time';
public const ANSWER_TYPE_DROPDOWN = 'dropdown';
public const ANSWER_TYPE_FILE = 'file';
public const ANSWER_TYPE_LINEARSCALE = 'linearscale';
public const ANSWER_TYPE_LONG = 'long';
public const ANSWER_TYPE_MULTIPLE = 'multiple';
public const ANSWER_TYPE_MULTIPLEUNIQUE = 'multiple_unique';
public const ANSWER_TYPE_SHORT = 'short';
public const ANSWER_TYPE_TIME = 'time';

// All AnswerTypes
public const ANSWER_TYPES = [
self::ANSWER_TYPE_MULTIPLE,
self::ANSWER_TYPE_MULTIPLEUNIQUE,
self::ANSWER_TYPE_DROPDOWN,
self::ANSWER_TYPE_SHORT,
self::ANSWER_TYPE_LONG,
self::ANSWER_TYPE_COLOR,
self::ANSWER_TYPE_DATE,
self::ANSWER_TYPE_DATETIME,
self::ANSWER_TYPE_TIME,
self::ANSWER_TYPE_DROPDOWN,
self::ANSWER_TYPE_FILE,
self::ANSWER_TYPE_LINEARSCALE,
self::ANSWER_TYPE_LONG,
self::ANSWER_TYPE_MULTIPLE,
self::ANSWER_TYPE_MULTIPLEUNIQUE,
self::ANSWER_TYPE_SHORT,
self::ANSWER_TYPE_TIME,
];

// AnswerTypes, that need/have predefined Options
public const ANSWER_TYPES_PREDEFINED = [
self::ANSWER_TYPE_MULTIPLE,
self::ANSWER_TYPE_MULTIPLEUNIQUE,
self::ANSWER_TYPE_DROPDOWN,
self::ANSWER_TYPE_LINEARSCALE,
self::ANSWER_TYPE_MULTIPLE,
self::ANSWER_TYPE_MULTIPLEUNIQUE,
];

// AnswerTypes for date/time questions
Expand Down Expand Up @@ -194,10 +196,10 @@ class Constants {
* !! Keep in sync with src/mixins/ShareTypes.js !!
*/
public const SHARE_TYPES_USED = [
IShare::TYPE_USER,
IShare::TYPE_CIRCLE,
IShare::TYPE_GROUP,
IShare::TYPE_LINK,
IShare::TYPE_CIRCLE
IShare::TYPE_USER,
];

/**
Expand All @@ -214,18 +216,18 @@ class Constants {

public const PERMISSION_ALL = [
self::PERMISSION_EDIT,
self::PERMISSION_EMBED,
self::PERMISSION_RESULTS,
self::PERMISSION_RESULTS_DELETE,
self::PERMISSION_SUBMIT,
self::PERMISSION_EMBED,
];

/**
* !! Keep in sync with src/FormsEmptyContent.vue !!
* InitialStates for emptyContent to render as...
*/
public const EMPTY_NOTFOUND = 'notfound';
public const EMPTY_EXPIRED = 'expired';
public const EMPTY_NOTFOUND = 'notfound';

/**
* Constants related to extra settings for questions
Expand Down
9 changes: 9 additions & 0 deletions lib/Service/SubmissionService.php
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,15 @@ public function validateSubmission(array $questions, array $answers, string $for
throw new \InvalidArgumentException(sprintf('Invalid input for question "%s".', $question['text']));
}

// Handle color questions
if (
$question['type'] === Constants::ANSWER_TYPE_COLOR
&& $answers[$questionId][0] !== ''
&& !preg_match('/^#[a-f0-9]{6}$/i', $answers[$questionId][0])
) {
throw new \InvalidArgumentException(sprintf('Invalid color string for question "%s".', $question['text']));
}

// Handle file questions
if ($question['type'] === Constants::ANSWER_TYPE_FILE) {
$maxAllowedFilesCount = $question['extraSettings']['maxAllowedFilesCount'] ?? 0;
Expand Down
45 changes: 45 additions & 0 deletions src/components/Icons/IconPalette.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<!--
- SPDX-FileCopyrightText: 2025 Christian Hartmann <[email protected]>
- SPDX-License-Identifier: AGPL-3.0-or-later
-->

<template>
<span
:aria-hidden="!title"
:aria-label="title"
class="material-design-icon palette-icon"
role="img"
v-bind="$attrs"
@click="$emit('click', $event)">
<svg
:fill="fillColor"
class="material-design-icon__svg"
:height="size"
:width="size"
viewBox="0 -960 960 960">
<path
d="M480-96q-78.72 0-148.8-30.24-70.08-30.24-122.4-82.56-52.32-52.32-82.56-122.4Q96-401.28 96-480q0-80 30.5-149.5t84-122Q264-804 335.5-834t152.75-30q77.39 0 146.07 27Q703-810 754-763t80.5 110Q864-590 864-518q0 96-67.08 163-67.09 67-162.92 67h-67.76q-8.24 0-14.24 5t-6 12.67Q546-255 561-245q15 10 15 53 0 37-27 66.5T480-96ZM264-444q25 0 42.5-17.5T324-504q0-25-17.5-42.5T264-564q-25 0-42.5 17.5T204-504q0 25 17.5 42.5T264-444Zm120-144q25 0 42.5-17.5T444-648q0-25-17.5-42.5T384-708q-25 0-42.5 17.5T324-648q0 25 17.5 42.5T384-588Zm192 0q25 0 42.5-17.5T636-648q0-25-17.5-42.5T576-708q-25 0-42.5 17.5T516-648q0 25 17.5 42.5T576-588Zm120 144q25 0 42.5-17.5T756-504q0-25-17.5-42.5T696-564q-25 0-42.5 17.5T636-504q0 25 17.5 42.5T696-444Z" />
<title v-if="title">{{ title }}</title>
</svg>
</span>
</template>

<script>
export default {
name: 'IconPalette',
props: {
title: {
type: String,
default: '',
},
fillColor: {
type: String,
default: 'currentColor',
},
size: {
type: Number,
default: 20,
},
},
}
</script>
97 changes: 97 additions & 0 deletions src/components/Questions/QuestionColor.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
<!--
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->

<template>
<Question
v-bind="questionProps"
:title-placeholder="answerType.titlePlaceholder"
:warning-invalid="answerType.warningInvalid"
v-on="commonListeners">
<div class="question__content">
<NcColorPicker
:model-value="pickedColor"
advanced-fields
@update:model-value="onUpdatePickedColor">
<NcButton :disabled="!readOnly">{{
colorPickerPlaceholder
}}</NcButton>
</NcColorPicker>
<div :style="{ 'background-color': pickedColor }" class="color__field">
<NcButton
v-if="pickedColor !== '' && !isRequired"
class="color__field__button"
:aria-label="t('forms', 'Clear selected color')"
variant="tertiary"
@click="onUpdatePickedColor('')">
<template #icon>
<IconClose :size="20" />
</template>
</NcButton>
</div>
</div>
</Question>
</template>

<script>
import IconClose from 'vue-material-design-icons/Close.vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcColorPicker from '@nextcloud/vue/components/NcColorPicker'

import QuestionMixin from '../../mixins/QuestionMixin.js'

export default {
name: 'QuestionColor',

components: {
IconClose,
NcButton,
NcColorPicker,
},

mixins: [QuestionMixin],

data() {
return {
isLoading: false,
}
},

computed: {
colorPickerPlaceholder() {
return this.readOnly
? this.answerType.submitPlaceholder
: this.answerType.createPlaceholder
},

pickedColor() {
return this.values[0] ?? ''
},
},

methods: {
onUpdatePickedColor(color) {
this.$emit('update:values', [color])
},
},
}
</script>

<style lang="scss" scoped>
.question__content {
display: flex;
gap: var(--clickable-area-small);
}

.color__field {
width: 100px;
height: var(--default-clickable-area);
border-radius: var(--border-radius-element);

&__button {
position: relative;
margin-inline-start: calc(100% - var(--default-clickable-area));
}
}
</style>
27 changes: 27 additions & 0 deletions src/components/Results/Answer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@
</a>
</p>
</template>
<template v-else-if="questionType === 'color'">
<div class="color__result">
<div
:style="{ 'background-color': answerText }"
class="color__field" />
<NcHighlight :text="answerText" :search="highlight" />
</div>
</template>
<p v-else class="answer__text" dir="auto">
<NcHighlight :text="answerText" :search="highlight" />
</p>
Expand Down Expand Up @@ -54,6 +62,10 @@ export default {
type: String,
required: true,
},
questionType: {
type: String,
required: true,
},
highlight: {
type: String,
required: false,
Expand Down Expand Up @@ -81,5 +93,20 @@ export default {
top: 4px;
}
}

.color__field {
width: 100px;
height: var(--default-clickable-area);
border-radius: var(--border-radius-element);
position: relative;
inset-block-start: 12px;
margin-block-start: -12px;
}

.color__result {
align-items: baseline;
display: flex;
gap: calc(var(--clickable-area-small) / 2);
}
}
</style>
33 changes: 32 additions & 1 deletion src/components/Results/ResultsSummary.vue
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,26 @@
<ul v-else class="question-summary__text">
<!-- Do not wrap the following line between tags! `white-space:pre-line` respects `\n` but would produce additional empty first line -->
<!-- eslint-disable-next-line -->
<li v-for="answer in answers" :key="answer.id" dir="auto">
<li v-for="(answer, index) in answers" :key="answer.id" dir="auto">
<template v-if="answer.url">
<a :href="answer.url" target="_blank">
<IconFile :size="20" class="question-summary__text-icon" />
{{ answer.text }}
</a>
</template>
<template v-else-if="question.type === 'color'">
<div class="color__result">
<div
v-if="answer.id !== 0"
:style="{ 'background-color': answer.text }"
:class="
index === 1
? 'color__field color__field__first'
: 'color__field'
" />
{{ answer.text }}
</div>
</template>
<template v-else>
{{ answer.text }}
</template>
Expand Down Expand Up @@ -368,5 +381,23 @@ export default {
}
}
}

.color__field {
width: 100px;
height: var(--default-clickable-area);
border-radius: var(--border-radius-element);
position: relative;
inset-block-start: 12px;

&__first {
margin-block-start: -12px;
}
}

.color__result {
align-items: baseline;
display: flex;
gap: calc(var(--clickable-area-small) / 2);
}
}
</style>
6 changes: 5 additions & 1 deletion src/components/Results/Submission.vue
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@
:highlight="highlight"
:answer-text="question.squashedAnswers"
:answers="question.answers"
:question-text="question.text" />
:question-text="question.text"
:question-type="question.type" />
</div>
</template>

Expand Down Expand Up @@ -120,6 +121,7 @@ export default {
answeredQuestionsArray.push({
id: question.id,
text: question.text,
type: question.type,
answers: answers.map((answer) => {
return {
id: answer.id,
Expand All @@ -138,6 +140,7 @@ export default {
answeredQuestionsArray.push({
id: question.id,
text: question.text,
type: question.type,
squashedAnswers,
})
} else {
Expand All @@ -148,6 +151,7 @@ export default {
answeredQuestionsArray.push({
id: question.id,
text: question.text,
type: question.type,
squashedAnswers,
})
}
Expand Down
Loading
Loading