Changing forced response items to requested response

561 views
Skip to first unread message

Dallas Novakowski

unread,
Jun 3, 2021, 3:05:29 PM6/3/21
to oTree help & discussion
Currently, the error message for blank responses prevents participants from moving onto the next page. This is useful for necessary parts of my study (e.g., consent, key DVs), but is otherwise inconsistent with my consent form - participants have the right to skip other answers sections or items they don't want to complete.

What I'm looking for is the means to change both the message and behaviour of the "this field is required" error, so that participants are requested (but not forced) to provide a response.  (equivalent to https://www.qualtrics.com/support/survey-platform/survey-module/editing-questions/validation/#RequestResponse)

My first thought is defining an error_message function, and combining  with the blank=True argument for each page (setting field_error_message for each item (as in https://groups.google.com/g/otree/c/nbYsFFFEkpA/m/ZeCgvEjhAwAJ) would be very repetitive for my purposes). I'm quite a novice to otree and programming more generally, any guidance would be much appreciated.

Thanks


Message has been deleted
Message has been deleted

Chris @ oTree

unread,
Jun 4, 2021, 12:09:07 AM6/4/21
to oTree help & discussion
You can do this with some simple JavaScript. I just added an example to otree-snippets. The app is called are_you_sure:

DallasN

unread,
Jun 4, 2021, 1:59:41 PM6/4/21
to oTree help & discussion
Thanks for this, Chris, as well as all of the support you've offered through the forum, docs, and platform.

I'll work on modifying are_you_sure to my purposes. My target behaviour is:

1st form submission: in-form, field-by-field warnings
2nd form submission: next page (could be mediated by the warning window, or straight to the next page - not sure yet)

The purpose of field-by-field warnings is to maximize response rate. Otherwise, many participants will just default to hitting OK at the window. At its extreme, I'm hoping to have the warning appear under each individual field in a table as in the following function:

<tbody>
{{ for field in form }}
   
<tr>
        <td>
{{ field.label }} {{formfield_errors field.name}}</td>
       
{{ for choice in field }}
           
<td>{{ choice }} </td>
       
{{ endfor }}
   
</tr>
{{ endfor }}

Thanks again for the help.

DallasN

unread,
Jul 23, 2021, 8:00:51 PM7/23/21
to oTree help & discussion
For anyone interested in this problem - I still haven't found an elegant and generalizable solution (and don't yet know how to integrate it with Django-template loops for a radio-table), but the building blocks are here, working both for radio widgets and integer entry:

__init__.py

class Player(BasePlayer):
age = models.IntegerField(label='What is your age?', min=13, max=125, isValid=True,)
gender = models.StringField(
choices=[['Male', 'Male'], ['Female', 'Female'], ['Other', 'Other']],
label='What is your gender?',
widget=widgets.RadioSelect,
)
education = models.StringField(
choices = ['Less than high school degree', "High school graduate (high school diploma or equivalent including GED)",
"Some college but no degree", "Associate degree in college (2-year)", "Bachelor's degree in college (4-year)",
"Master's degree", "Doctoral degree", "Professional degree (JD, MD)"],
label = 'What is the highest level of school you have completed or the highest degree you have received?',
widget = widgets.RadioSelect,
blank=True
)
# PAGES
class Demographics(Page):
form_model = 'player'
form_fields = ['age', 'gender', 'education', "income"]

@staticmethod
def js_vars(player): # highlights variables/fields that do not need to be filled (but that we'll be displaying a one-time warning message if they're left blank)
return dict(optional_fields = Demographics.form_fields[2:4])
pass

Demographics.html
{{ block content }}


<body>

<p>{{ form.age.label }}<br><br>
{{ form.age }}
{{ formfield_errors 'age' }}</p>

<p>{{ form.gender.label }}
{{ form.gender }}
{{ formfield_errors 'gender' }}</p>

<p>{{ form.education.label }}
{{ form.education }}
{{ formfield_errors 'education' }}
<span id="education_message" style="color:#990000"></span></p>

<p>{{ form.income.label }}
{{ form.income }}
{{ formfield_errors 'income' }}
<span id="income_message" style="color:#990000"></span></p>

<span id="error_message"></span>

</body>


<button type="button" class="btn btn-primary" onclick="Validate()">Next</button>


<script>
let optional_fields = js_vars.optional_fields;
let times_submitted = 0;

function Validate() {
// let error_messages = []
var items = []
let isValid = []
let messages = []

times_submitted++

if (times_submitted >= 2) {
form.submit()
} else {

for (i = 0; i < optional_fields.length; i++) {
isValid[i] = false
messages[i] = "Please fill out this field"

//Reference the Group of questions.
// error_messages.push(optional_fields[i]);
items.push(Array.from(document.getElementsByName(optional_fields[i])));

// branches for radio (div) vs. text input (input, probably) items
if (document.getElementById(optional_fields[i]).tagName == "DIV") {
//Loop and verify whether at-least one RadioButton is checked.
for (var j = 0; j < items[i].length; j++) {
if (items[i][j].checked) {
// if at least one radio is checked for the question, return true
isValid[i] = true;
messages[i] = "";
break;
} else {
scroll(0,0)
}
}
} else { // tests valid entry for non-div (i.e., text entry) items
if (document.getElementById(optional_fields[i]).value) {
isValid[i] = true;
messages[i] = "";
} else {
scroll(0,0)
}
}
}
}
error_message.innerHTML = isValid
education_message.innerHTML = messages[0]
income_message.innerHTML = messages[1]
}
</script>



{{ endblock }}
Message has been deleted

DallasN

unread,
Jul 24, 2021, 6:29:24 PM7/24/21
to oTree help & discussion
Likewise, here's a messy solution to bringing response request messages in a radio table.

init.py
def make_iu(label):
return models.IntegerField(
choices=[[1,"Not at all characteristic of me"],[2, "A little characteristic of me"],
[3, "Somewhat characteristic of me"],[4, "Very characteristic of me"],
[5, "Entirely characteristic of me"]],
label=label,
widget=widgets.RadioSelectHorizontal,
blank=True
)


class Player(BasePlayer):
iu_1 = make_iu("Unforeseen events upset me greatly")
iu_2 = make_iu("It frustrates me not having all the information I need.")
pass


# PAGES
class IU(Page):
form_model = 'player'
form_fields = ['iu_1', 'iu_2']

@staticmethod
def js_vars(player): # highlights variables/fields that do not need to be filled (but that we'll be displaying a one-time warning message if they're left blank)
return dict(optional_fields = IU.form_fields)
pass
pass

.html
{{ block title }}Attitudes Towards Uncertainty{{ endblock }}
{{ block content }}


<p>
Please select the number that best corresponds to how much you agree with each item
</p>

<span id="stuff" style="color:#990000"></span>

<div class="tableFixHead">
<table class="tableFixHead">
<thead ><tr>
<th style="width: 40%;"></th>
<th style="width: 12%;">Not at all characteristic of me</th>
<th style="width: 12%;">A little characteristic of me </th>
<th style="width: 12%;">Somewhat characteristic of me </th>
<th style="width: 12%;">Very characteristic of me </th>
<th style="width: 12%;">Entirely characteristic of me </th>

</tr></thead>
<tbody>

<tr>
<td>{{ form.iu_1.label }} {{ formfield_errors 'iu_1' }} <br> <span id="iu_1_message" style="color:#990000"></span></td>
{{ for choice in form.iu_1 }}

<td>{{ choice }}</td>
{{ endfor }}
</tr>


<tr>
<td>{{ form.iu_2.label }} {{ formfield_errors 'iu_2' }} <br> <span id="iu_2_message" style="color:#990000"></span></td>
{{ for choice in form.iu_2 }}

<td>{{ choice }}</td>
{{ endfor }}
</tr>

</tbody>
</table>
</div>



<button type="button" class="btn btn-primary" onclick="Validate()">Next</button>


<script>
let optional_fields = js_vars.optional_fields;
let times_submitted = 0;



function Validate() {
let items = []

let isValid = []
let messages = []

times_submitted++

for (i = 0; i < optional_fields.length; i++) {
isValid[i] = false
messages[i] = "Please fill out this field"

//Reference the Group of questions.
items.push(Array.from(document.getElementsByName(optional_fields[i])));

// //Loop and verify whether at-least one RadioButton is checked.
for (var j = 0; j < items[i].length; j++) {
if (items[i][j].checked) {
// if at least one radio is checked for the question, return true
isValid[i] = true;
messages[i] = "";
break;
}
}
}

// create summary items and flow after all the looping

let all_checked = isValid.every(x => x); //flags whether all questions are checked

if (all_checked == true){ // branch submission or kicking participants to top of screen, depending on whether everything is checked and whether they have already tried submitting
form.submit()
} else {

if (times_submitted >= 2) {
form.submit()
} else {
scroll(0, 0)
}


}


iu_1_message.innerHTML = messages[0]
iu_2_message.innerHTML = messages[1]
stuff.innerHTML = all_checked
// iu_3_message.innerHTML = messages[2]
// iu_4_message.innerHTML = messages[3]
}
</script>



<style>
table, th, td {
border: 1px solid grey;
border-collapse: collapse;
margin: 1em 0;
text-align: center;
vertical-align: middle;
}

th {
position: -webkit-sticky;
position: sticky;
top: 0;
z-index: 2;
vertical-align: bottom;
background-color: #666;
color: #fff;
}

th:not([scope=row]) { /*gives sticky first column?? https://adrianroselli.com/2020/01/fixed-table-headers.html*/
position: -webkit-sticky;
position: sticky;
top: 0;
z-index: 2;
}
th[scope=row] {
background: linear-gradient(90deg, transparent 0%, transparent calc(100% - .05em), #d6d6d6 calc(100% - .05em), #d6d6d6 100%);
}

td { /*formats basic data, with padding and centered alignments*/
padding: 0.25em 0.5em 0.25em 1em;
text-align: center;
vertical-align: middle;
}

tr:nth-child(even) { /*adds alternating coloring for cells */
background-color: rgba(0, 0, 0, 0.05);
}
tr:nth-child(odd) {
background-color: rgba(255, 255, 255, 0.05);
}
</style>

{{ endblock }}

Chris @ oTree

unread,
Jul 25, 2021, 12:53:51 AM7/25/21
to oTree help & discussion
Actually this is the same mechanics as the the comprehension_test app here: https://www.otreehub.com/projects/otree-snippets/
The only thing you need to change is that rather than comparing the answer to a solution, you check whether it is blank or not:

errors = {f: 'Wrong' for f in solutions if not values[f]}

DallasN

unread,
Oct 13, 2021, 2:18:24 PM10/13/21
to oTree help & discussion
For anyone coming across this in the future - Chris' solution is great, and can be implemented like so:

class Player(BasePlayer):
submit_missing = models.IntegerField(initial=0)

class (Page):
@staticmethod
def error_message(player: Player, values):
errors = {f: 'Please fill in this field' for f in values if not values[f]}
if errors:
player.submit_missing += 1
if player.submit_missing < 2:
return errors

@staticmethod # if using method for multiple pages
def before_next_page(player, timeout_happened):
player.submit_missing = 0

One of my particular cases had some optional and some required fields on the same page, with some radio and some integer input items. My javascript solution is below:

@staticmethod
def js_vars(player): # highlights variables/fields that do or do not need to be filled
return dict(optional_fields = Demographics.form_fields[2:4],
required_fields = Demographics.form_fields[0:2])


let required_fields = js_vars.required_fields;
let optional_fields = js_vars.optional_fields;
let times_submitted = 0;

function Validate() {
var items = []
let reqitems = []
let isValid = []
let reqValid = []
let messages = []
let req_messages = []

let checker = arr => arr.every(v => v === true) // this function checks whether all elements in an array are true

//each time the next button is pressed, times submitted increments
times_submitted++

// required variable error flagging
for (i = 0; i < required_fields.length; i++) {
reqValid[i] = false
req_messages[i] = "Please fill out this field"

reqitems.push(Array.from(document.getElementsByName(required_fields[i]))) //Reference the Group of optional questions.

// branches for radio (div) vs. text input (input, probably) items
if (document.getElementById("id_" + required_fields[i]).tagName == "DIV"){
for (var j = 0; j < reqitems[i].length; j++) {
if (reqitems[i][j].checked) {
// if at least one radio is checked for the question, return true and change item's message to blank
reqValid[i] = true;
req_messages[i] = "";
break;
} else {
scroll(0, 0)
}
}
} else {
if (document.getElementById("id_" + required_fields[i]).value) {
reqValid[i] = true;
req_messages[i] = "";
} else {
scroll(0,0)
}
}
}
// export messages to html
age_message.innerHTML = req_messages[0]
gender_message.innerHTML = req_messages[1]

//for optional variables
if (times_submitted >= 2) {
if(checker(reqValid) == true) {
form.submit() //optional submission - if next is hit twice or more, submit
}
} else { // if next not hit more than twice...
for (i = 0; i < optional_fields.length; i++) {
isValid[i] = false
messages[i] = "Please fill out this field"

items.push(Array.from(document.getElementsByName(optional_fields[i]))) //Reference the Group of optional questions.

// branches for radio (div) vs. text input (input, probably) items
if (document.getElementById("id_" + optional_fields[i]).tagName == "DIV"){

for (var j = 0; j < items[i].length; j++) {
if (items[i][j].checked) {
// if at least one radio is checked for the question, return true and change item's message to blank
isValid[i] = true;
messages[i] = "";
break;
} else {
scroll(0, 0)
}
}
} else {
if (document.getElementById("id_" + optional_fields[i]).value) {

isValid[i] = true;
messages[i] = "";
} else {
scroll(0,0)
}
}
}
if(checker(reqValid) == true && checker(isValid) == true) {
form.submit() //optional submission - if next is hit twice or more, submit
}
education_message.innerHTML = messages[0]
income_message.innerHTML = messages[1]
}
}
Reply all
Reply to author
Forward
0 new messages