Skip to content

Commit 285761c

Browse files
committed
feat: add linear scale answer type and associated settings
Signed-off-by: Christian Hartmann <chris-hartmann@gmx.de>
1 parent aea0490 commit 285761c

7 files changed

Lines changed: 349 additions & 6 deletions

File tree

lib/Constants.php

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ class Constants {
7575
public const ANSWER_TYPE_DATETIME = 'datetime';
7676
public const ANSWER_TYPE_TIME = 'time';
7777
public const ANSWER_TYPE_FILE = 'file';
78+
public const ANSWER_TYPE_LINEARSCALE = 'linearscale';
7879

7980
// All AnswerTypes
8081
public const ANSWER_TYPES = [
@@ -87,13 +88,15 @@ class Constants {
8788
self::ANSWER_TYPE_DATETIME,
8889
self::ANSWER_TYPE_TIME,
8990
self::ANSWER_TYPE_FILE,
91+
self::ANSWER_TYPE_LINEARSCALE,
9092
];
9193

9294
// AnswerTypes, that need/have predefined Options
9395
public const ANSWER_TYPES_PREDEFINED = [
9496
self::ANSWER_TYPE_MULTIPLE,
9597
self::ANSWER_TYPE_MULTIPLEUNIQUE,
96-
self::ANSWER_TYPE_DROPDOWN
98+
self::ANSWER_TYPE_DROPDOWN,
99+
self::ANSWER_TYPE_LINEARSCALE
97100
];
98101

99102
// AnswerTypes for date/time questions
@@ -155,6 +158,13 @@ class Constants {
155158
'x-office/spreadsheet',
156159
];
157160

161+
public const EXTRA_SETTINGS_LINEARSCALE = [
162+
'optionsLowest' => ['integer'],
163+
'optionsHighest' => ['integer'],
164+
'optionsLabelLowest' => ['string'],
165+
'optionsLabelHighest' => ['string'],
166+
];
167+
158168
public const FILENAME_INVALID_CHARS = [
159169
"\n",
160170
'/',

lib/ResponseDefinitions.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,12 @@
2525
* allowedFileTypes?: list<string>,
2626
* maxAllowedFilesCount?: int,
2727
* maxFileSize?: int,
28+
* optionsEnd?: int,
29+
* optionsLabelBest?: string,
30+
* optionsLabelWorst?: string,
2831
* optionsLimitMax?: int,
2932
* optionsLimitMin?: int,
33+
* optionsStart?: int,
3034
* shuffleOptions?: bool,
3135
* validationRegex?: string,
3236
* validationType?: string

lib/Service/FormsService.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -629,6 +629,9 @@ public function areExtraSettingsValid(array $extraSettings, string $questionType
629629
case Constants::ANSWER_TYPE_FILE:
630630
$allowed = Constants::EXTRA_SETTINGS_FILE;
631631
break;
632+
case Constants::ANSWER_TYPE_LINEARSCALE:
633+
$allowed = Constants::EXTRA_SETTINGS_LINEARSCALE;
634+
break;
632635
default:
633636
$allowed = [];
634637
}

openapi.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,16 @@
426426
"type": "integer",
427427
"format": "int64"
428428
},
429+
"optionsEnd": {
430+
"type": "integer",
431+
"format": "int64"
432+
},
433+
"optionsLabelBest": {
434+
"type": "string"
435+
},
436+
"optionsLabelWorst": {
437+
"type": "string"
438+
},
429439
"optionsLimitMax": {
430440
"type": "integer",
431441
"format": "int64"
@@ -434,6 +444,10 @@
434444
"type": "integer",
435445
"format": "int64"
436446
},
447+
"optionsStart": {
448+
"type": "integer",
449+
"format": "int64"
450+
},
437451
"shuffleOptions": {
438452
"type": "boolean"
439453
},
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<!--
2+
- SPDX-FileCopyrightText: 2025 Christian Hartmann <chris-hartmann@gmx.de>
3+
- SPDX-License-Identifier: AGPL-3.0-or-later
4+
-->
5+
6+
<template>
7+
<span
8+
:aria-hidden="!title"
9+
:aria-label="title"
10+
class="material-design-icon drag-indicator-icon"
11+
role="img"
12+
v-bind="$attrs"
13+
@click="$emit('click', $event)">
14+
<svg
15+
:fill="fillColor"
16+
class="material-design-icon__svg"
17+
:height="size"
18+
:width="size"
19+
viewBox="0 -960 960 960">
20+
<path
21+
d="M672-288q-71 0-123.38-44.36Q496.24-376.73 482.9-444H281q-11 26-35 43t-54 17q-40.32 0-68.16-27.77Q96-439.55 96-479.77 96-520 123.84-548q27.84-28 68.16-28 30 0 54 17t35.46 43H484q13.21-67.28 65.1-111.64Q601-672 672-672q79.68 0 135.84 56.23 56.16 56.22 56.16 136Q864-400 807.84-344 751.68-288 672-288Zm0-72q50 0 85-35t35-85q0-50-35-85t-85-35q-50 0-85 35t-35 85q0 50 35 85t85 35Z" />
22+
<title v-if="title">{{ title }}</title>
23+
</svg>
24+
</span>
25+
</template>
26+
27+
<script>
28+
export default {
29+
name: 'IconLinearScale',
30+
props: {
31+
title: {
32+
type: String,
33+
default: '',
34+
},
35+
fillColor: {
36+
type: String,
37+
default: 'currentColor',
38+
},
39+
size: {
40+
type: Number,
41+
default: 20,
42+
},
43+
},
44+
}
45+
</script>
Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
<!--
2+
- SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
3+
- SPDX-License-Identifier: AGPL-3.0-or-later
4+
-->
5+
6+
<template>
7+
<Question
8+
v-bind="questionProps"
9+
:title-placeholder="answerType.titlePlaceholder"
10+
:warning-invalid="answerType.warningInvalid"
11+
v-on="commonListeners">
12+
<template #actions>
13+
<NcActionInput
14+
v-model="optionsLowest"
15+
type="multiselect"
16+
:clearable="false"
17+
:label="t('forms', 'Lowest value')"
18+
label-outside
19+
:options="[0, 1]"
20+
required
21+
@update:modelValue="onExtraSettingsChange({optionsLowest})">
22+
<template #icon>
23+
<IconPencil :size="20" />
24+
</template>
25+
</NcActionInput>
26+
<NcActionInput
27+
v-model="optionsHighest"
28+
type="multiselect"
29+
:clearable="false"
30+
:label="t('forms', 'Highest value')"
31+
label-outside
32+
:options="[2, 3, 4, 5, 6, 7, 8, 9, 10]"
33+
required
34+
@update:modelValue="onExtraSettingsChange({optionsHighest})">
35+
<template #icon>
36+
<IconPencil :size="20" />
37+
</template>
38+
</NcActionInput>
39+
</template>
40+
41+
<div :class="readOnly ? 'question__content' : 'question__content question__content__edit'">
42+
<NcTextArea
43+
v-if="!readOnly"
44+
ref="lowest"
45+
v-model="optionsLabelLowest"
46+
class="label-input-field"
47+
:label="t('forms', 'Label (optional)')"
48+
:aria-label="t('forms', 'Label for lowest value')"
49+
resize="none"
50+
@input="onLabelChange($event, 'optionsLabelLowest')"
51+
@blur="onBlur('lowest')">
52+
</NcTextArea>
53+
<div v-else class="label-lowest">{{ optionsLabelLowest }}</div>
54+
<div class="question__content__options">
55+
<NcCheckboxRadioSwitch
56+
v-for="option in scaleOptions"
57+
:key="option"
58+
:disabled="!readOnly"
59+
:checked="questionValues"
60+
:value="option.toString()"
61+
:name="`${id}-answer`"
62+
type="radio"
63+
:required="checkRequired(option)"
64+
@update:checked="onChange"
65+
@keydown.enter.exact.prevent="onKeydownEnter">
66+
{{ option }}
67+
</NcCheckboxRadioSwitch>
68+
</div>
69+
<NcTextArea
70+
v-if="!readOnly"
71+
ref="highest"
72+
v-model="optionsLabelHighest"
73+
class="label-input-field"
74+
:label="t('forms', 'Label (optional)')"
75+
:aria-label="t('forms', 'Label for highest value')"
76+
resize="none"
77+
@input="onLabelChange($event, 'optionsLabelHighest')"
78+
@blur="onBlur('highest')">
79+
</NcTextArea>
80+
<div v-else class="label-highest">{{ optionsLabelHighest }}</div>
81+
</div>
82+
</Question>
83+
</template>
84+
85+
<script>
86+
import { t } from '@nextcloud/l10n'
87+
import NcActionInput from '@nextcloud/vue/components/NcActionInput'
88+
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
89+
import NcTextArea from '@nextcloud/vue/components/NcTextArea'
90+
91+
import IconPencil from 'vue-material-design-icons/Pencil.vue'
92+
93+
import QuestionMixin from '../../mixins/QuestionMixin.js'
94+
95+
export default {
96+
name: 'QuestionLinearScale',
97+
98+
components: {
99+
IconPencil,
100+
NcActionInput,
101+
NcCheckboxRadioSwitch,
102+
NcTextArea,
103+
},
104+
105+
mixins: [QuestionMixin],
106+
107+
data() {
108+
return {
109+
isLoading: false,
110+
111+
optionsLowest: 1,
112+
optionsHighest: 5,
113+
optionsLabelLowest: t('forms', 'Strongly disagree'),
114+
optionsLabelHighest: t('forms', 'Strongly agree'),
115+
}
116+
},
117+
118+
computed: {
119+
scaleOptions() {
120+
return Array.from(
121+
{ length: this.optionsHighest - this.optionsLowest + 1 },
122+
(_, i) => i + this.optionsLowest,
123+
)
124+
},
125+
126+
isUnique() {
127+
return this.answerType.unique === true
128+
},
129+
130+
questionValues() {
131+
return this.isUnique ? this.values?.[0] : this.values
132+
},
133+
},
134+
135+
methods: {
136+
onChange(value) {
137+
this.$emit('update:values', this.isUnique ? [value].flat() : value)
138+
},
139+
140+
/**
141+
* Is the provided answer required ?
142+
* This is needed for checkboxes as html5
143+
* doesn't allow to require at least ONE checked.
144+
* So we require the one that are checked or all
145+
* if none are checked yet.
146+
*
147+
* @return {boolean}
148+
*/
149+
checkRequired() {
150+
// false, if question not required
151+
if (!this.isRequired) {
152+
return false
153+
}
154+
155+
// true for Radiobuttons
156+
if (this.isUnique) {
157+
return true
158+
}
159+
160+
// For checkboxes, only required if no other is checked
161+
return false
162+
},
163+
164+
/**
165+
* Handles the change event for a label input.
166+
*
167+
* @param {Event} event - The event object from the input change.
168+
* @param {string} label - The label that is being changed.
169+
*/
170+
onLabelChange({ target }, label) {
171+
this.resizeLabel(label)
172+
this.onExtraSettingsChange(label)
173+
},
174+
175+
/**
176+
* Resizes the given label to fit within the specified constraints.
177+
*
178+
* @param {string} label - The label text that needs to be resized.
179+
*/
180+
resizeLabel(label) {
181+
let textarea
182+
// next tick ensures that the textarea is attached to DOM
183+
if (label === 'lowest') {
184+
textarea = this.$refs.lowest.$refs.input
185+
} else if (label === 'highest') {
186+
textarea = this.$refs.highest.$refs.input
187+
}
188+
this.$nextTick(() => {
189+
if (textarea) {
190+
textarea.style.cssText = 'height: 0'
191+
// include 2px border
192+
textarea.style.cssText = `height: ${textarea.scrollHeight + 4}px; resize: none;`
193+
}
194+
})
195+
},
196+
197+
/**
198+
* Handles the blur event for a label input.
199+
*
200+
* @param {string} label - The label that is being blurred.
201+
*/
202+
onBlur(label) {
203+
if (label === 'lowest') {
204+
this.optionsLabelLowest = this.optionsLabelLowest.trim()
205+
} else if (label === 'highest') {
206+
this.optionsLabelHighest = this.optionsLabelHighest.trim()
207+
}
208+
this.resizeLabel(label)
209+
},
210+
},
211+
}
212+
</script>
213+
214+
<style lang="scss" scoped>
215+
.question__content {
216+
display: flex;
217+
padding-block-start: var(--clickable-area-small);
218+
219+
&__options {
220+
width: calc(100% - 240px);
221+
display: flex;
222+
flex-direction: row;
223+
justify-content: space-evenly;
224+
}
225+
226+
&__edit {
227+
margin-inline-start: -12px;
228+
}
229+
230+
:deep(.checkbox-content__text) {
231+
position: absolute;
232+
margin-block-start: calc(-1 * var(--clickable-area-large));
233+
margin-inline-start: 8px;
234+
}
235+
236+
.label-input-field {
237+
width: 120px;
238+
align-self: center;
239+
min-height: fit-content;
240+
}
241+
242+
.label-lowest {
243+
width: 120px;
244+
align-self: center;
245+
text-align: start;
246+
}
247+
248+
.label-highest {
249+
width: 120px;
250+
align-self: center;
251+
text-align: end;
252+
}
253+
}
254+
</style>

0 commit comments

Comments
 (0)