Skip to content

Commit cc8765a

Browse files
committed
feat: Add 'input_type' and 'max_input_length' options to OpenQuestion.
1 parent 860e4b0 commit cc8765a

File tree

11 files changed

+197
-16
lines changed

11 files changed

+197
-16
lines changed

ddm/core/static/ddm_core/vue/css/vue_questionnaire.css

+1-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

ddm/core/static/ddm_core/vue/js/chunk-vendors.js

+1-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

ddm/core/static/ddm_core/vue/js/vue_questionnaire.js

+1-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

ddm/core/static/ddm_core/vue/js/vue_questionnaire.js.map

+1-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Generated by Django 4.2.16 on 2025-02-15 11:44
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('ddm_questionnaire', '0007_alter_questionitem_label_alt'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='openquestion',
15+
name='input_type',
16+
field=models.CharField(choices=[('text', 'Any text'), ('numbers', 'Numbers only'), ('email', 'Email address')], default='text', help_text='Select the type of input allowed.', max_length=20, verbose_name='Input type'),
17+
),
18+
migrations.AddField(
19+
model_name='openquestion',
20+
name='max_input_length',
21+
field=models.IntegerField(blank=True, default=None, help_text="Participants' input cannot exceed this length. If empty, no input length restriction is enforced.", null=True, verbose_name='Maximum input length'),
22+
),
23+
migrations.AlterField(
24+
model_name='openquestion',
25+
name='display',
26+
field=models.CharField(choices=[('small', 'Small'), ('large', 'Large')], default='small', help_text='"Small" displays a one-line textfield, "Large" a multiline textfield as input.', max_length=20),
27+
),
28+
]

ddm/questionnaire/models.py

+27-1
Original file line numberDiff line numberDiff line change
@@ -316,14 +316,40 @@ class DisplayOptions(models.TextChoices):
316316
max_length=20,
317317
blank=False,
318318
choices=DisplayOptions.choices,
319-
default=DisplayOptions.LARGE,
319+
default=DisplayOptions.SMALL,
320320
help_text='"Small" displays a one-line textfield, "Large" a multiline '
321321
'textfield as input.'
322322
)
323323

324+
class InputTypes(models.TextChoices):
325+
TEXT = 'text', 'Any text'
326+
NUMBER = 'numbers', 'Numbers only'
327+
EMAIL = 'email', 'Email address'
328+
input_type = models.CharField(
329+
max_length=20,
330+
blank=False,
331+
choices=InputTypes.choices,
332+
default=InputTypes.TEXT,
333+
verbose_name='Input type',
334+
help_text='Select the type of input allowed.'
335+
)
336+
337+
max_input_length = models.IntegerField(
338+
verbose_name='Maximum input length',
339+
help_text=(
340+
"Participants' input cannot exceed this length. If empty, "
341+
"no input length restriction is enforced."
342+
),
343+
blank=True,
344+
null=True,
345+
default=None
346+
)
347+
324348
def create_config(self):
325349
config = super().create_config()
326350
config['options']['display'] = self.display
351+
config['options']['input_type'] = self.input_type
352+
config['options']['max_input_length'] = self.max_input_length
327353
return config
328354

329355
def validate_response(self, response):

ddm/questionnaire/views.py

+14-5
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@
1212
from ddm.projects.models import DonationProject
1313
from ddm.questionnaire.models import (
1414
QuestionBase, QuestionType, SingleChoiceQuestion, MultiChoiceQuestion,
15-
OpenQuestion, MatrixQuestion, SemanticDifferential, Transition, QuestionItem,
16-
ScalePoint
15+
OpenQuestion, MatrixQuestion, SemanticDifferential, Transition,
16+
QuestionItem, ScalePoint
1717
)
1818

1919

@@ -49,7 +49,8 @@ def get_all_questions(self):
4949
return project.questionbase_set.all()
5050

5151
def get_queryset(self):
52-
return super().get_queryset().filter(project__url_id=self.kwargs['project_url_id'])
52+
return super().get_queryset().filter(
53+
project__url_id=self.kwargs['project_url_id'])
5354

5455

5556
class QuestionFormMixin(ProjectMixin):
@@ -65,13 +66,21 @@ class QuestionFormMixin(ProjectMixin):
6566
'transition': Transition
6667
}
6768

68-
SHARED_FIELDS = ['name', 'blueprint', 'page', 'index', 'variable_name', 'text', 'required']
69+
SHARED_FIELDS = [
70+
'name',
71+
'blueprint',
72+
'page',
73+
'index',
74+
'variable_name',
75+
'text',
76+
'required'
77+
]
6978
QUESTION_FIELDS = {
7079
'single_choice': SHARED_FIELDS + ['randomize_items'],
7180
'multi_choice': SHARED_FIELDS + ['randomize_items'],
7281
'matrix': SHARED_FIELDS + ['randomize_items'],
7382
'semantic_diff': SHARED_FIELDS + ['randomize_items'],
74-
'open': SHARED_FIELDS + ['display'],
83+
'open': SHARED_FIELDS + ['input_type', 'max_input_length', 'display'],
7584
'transition': SHARED_FIELDS
7685
}
7786

docs/modules/researchers/pages/project_configuration/questionnaire/questionnaire_configuration.adoc

+23
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,29 @@ Randomize items:: Enable or disable randomization of *all* items.
8181

8282
== Specific Settings
8383

84+
=== Open Question
85+
86+
Input type:: Define whether to apply restrictions for the input to
87+
this field. Can be 'text' to allow any kind of text, 'number' to
88+
only allow numerical characters, or 'email' to only allow valid
89+
email addresses. Default is 'text'.
90+
91+
Maximum input length:: Restricts the input to a certain number of characters.
92+
If this option is left empty, no input length restriction is enforced.
93+
94+
Display:: Define whether to show a 'small' one-line input field or
95+
a larger multi-line text-box. Defaults to "small" and only
96+
applies when the chosen input type is "Text".
97+
98+
[NOTE]
99+
====
100+
The `input type` and `maximum input length` options are only "softly" enforced
101+
in the participant's browser by hinting at and highlighting non-compliant input.
102+
This means that these restrictions are not enforced on the server-side if
103+
participants choose to ignore the input hint.
104+
====
105+
106+
84107
=== Question Items
85108

86109
For Single Choice Questions, Multiple Choice Questions, Matrix Questions and Semantic Differentials,

frontend/QuestionnaireApp/src/components/QuestionOpen.vue

+90-4
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,39 @@
22
<div>
33
<div v-html="text"></div>
44
<div :id="'answer-' + qid" class="question-response-body">
5-
<input v-if="options.display == 'small'" type="text" :name="qid" @change="responseChanged($event)">
6-
<textarea class="open-question-textarea" v-if="options.display == 'large'" type="text" :name="qid" @change="responseChanged($event)"></textarea>
5+
<template v-if="options.input_type === 'text'">
6+
<input v-if="options.display === 'small'"
7+
type="text"
8+
:name="qid"
9+
:maxlength="getMaxLength"
10+
@change="responseChanged($event)">
11+
<textarea v-if="options.display === 'large'"
12+
class="open-question-textarea"
13+
type="text"
14+
:name="qid"
15+
:maxlength="getMaxLength"
16+
@change="responseChanged($event)"></textarea>
17+
</template>
18+
19+
<template v-else-if="options.input_type === 'numbers'">
20+
<input type="text"
21+
v-only-digits
22+
:name="qid"
23+
:maxlength="getMaxLength"
24+
@change="responseChanged($event)">
25+
<p class="input-hint">{{ $t('hint-number-input') }}</p>
26+
</template>
27+
28+
<template v-else-if="options.input_type === 'email'">
29+
<input type="email"
30+
v-valid-email
31+
:name="qid"
32+
:maxlength="getMaxLength"
33+
@change="responseChanged($event)">
34+
<p class="input-hint hint-invalid-input pb-0 mb-0">{{ $t('hint-invalid-email') }}</p>
35+
<p class="input-hint">{{ $t('hint-email-input') }}</p>
36+
</template>
37+
738
</div>
839
</div>
940
</template>
@@ -18,18 +49,73 @@ export default {
1849
response: '-99'
1950
}
2051
},
52+
computed: {
53+
getMaxLength() {
54+
return this.options.max_input_length !== null ? this.options.max_input_length : undefined;
55+
}
56+
},
2157
created() {
2258
this.$emit('responseChanged', {id: this.qid, response: this.response, question: this.text, items: null});
2359
},
2460
methods: {
2561
responseChanged(event) {
26-
this.response = event.target.value;
62+
if (event.target.value === '' || event.target.value === null) {
63+
this.response = '-99'
64+
} else {
65+
this.response = event.target.value;
66+
}
2767
this.$emit('responseChanged', {id: this.qid, response: this.response, question: this.text, items: null});
2868
}
69+
},
70+
directives: {
71+
onlyDigits: {
72+
mounted(el) {
73+
el.addEventListener("input", function () {
74+
el.value = el.value.replace(/\D/g, ""); // Remove non-numeric characters
75+
});
76+
}
77+
},
78+
validEmail: {
79+
mounted(el) {
80+
el.addEventListener("blur", function () {
81+
const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
82+
const hint = el.nextElementSibling;
83+
if (!emailRegex.test(el.value)) {
84+
el.classList.add("invalid-email");
85+
if (hint && hint.classList.contains("hint-invalid-input")) {
86+
hint.style.display = "block";
87+
}
88+
} else {
89+
el.classList.remove("invalid-email");
90+
if (hint && hint.classList.contains("hint-invalid-input")) {
91+
hint.style.display = "none";
92+
}
93+
}
94+
});
95+
96+
el.addEventListener("focus", function () {
97+
const hint = el.nextElementSibling;
98+
if (hint && hint.classList.contains("hint-invalid-input")) {
99+
hint.style.display = "none";
100+
}
101+
});
102+
}
103+
}
29104
}
30105
}
31106
</script>
32107

33108
<style scoped>
34-
109+
.input-hint {
110+
font-size: 0.8rem;
111+
color: grey;
112+
}
113+
.invalid-email {
114+
border: 2px solid red !important;
115+
border-radius: 3px;
116+
}
117+
.hint-invalid-input {
118+
color: red;
119+
display: none;
120+
}
35121
</style>
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
{
22
"en": {
33
"next-btn-label": "Next",
4-
"required-but-missing-hint": "Please answer this question."
4+
"required-but-missing-hint": "Please answer this question.",
5+
"hint-number-input": "Only numbers are allowed.",
6+
"hint-email-input": "Enter email address in the format name{'@'}domain.com.",
7+
"hint-invalid-email": "This is not a valid email pattern."
58
},
69
"de": {
710
"next-btn-label": "Weiter",
8-
"required-but-missing-hint": "Bitte beantworten Sie diese Frage."
11+
"required-but-missing-hint": "Bitte beantworten Sie diese Frage.",
12+
"hint-number-input": "Es sind nur Zahlen erlaubt.",
13+
"hint-email-input": "E-Mail-Adresse im Format name{'@'}domain.de eingeben.",
14+
"hint-invalid-email": "Das scheint keine gültige E-Mail-Adresse zu sein."
915
}
1016
}

test_project/settings.py

+3
Original file line numberDiff line numberDiff line change
@@ -138,3 +138,6 @@
138138
CKEDITOR_5_FILE_UPLOAD_PERMISSION = 'authenticated'
139139
CKEDITOR_5_ALLOW_ALL_FILE_TYPES = True
140140
CKEDITOR_5_UPLOAD_FILE_TYPES = ['jpeg', 'pdf', 'png', 'mp4']
141+
142+
# TODO: Delete again - only temporary
143+
# MIDDLEWARE += ['csp.middleware.CSPMiddleware']

0 commit comments

Comments
 (0)