[IMP] survey: add conditional display option on questions

Purpose
=======

This commit adds a new survey mode : conditional questions. A conditional
question is a question that will be displayed to the user only if the user
selects a specific answer from another previous question i.e. question B is
displayed if user answers 'Choice 1' at question A.

Specifications
==============

There are 2 different ways to handle conditional questions.

  * For one_page and page_per_section survey layout: conditional management is
    done mainly at frontend side. The idea is to be able to load all the
    questions of the section, hide the conditional questions and display them
    dynamically on answer check.
    E.g.: If user checks answer 'Choice 1' from question A, question B is
    displayed immediately.
    If question B is on section 2 and survey layout is set to section per page,
    question B will be displayed immediately on next page template load.
    Indeed, on each page load, server sends all the selected answers and the
    template displays the conditional question depending on the already
    elected answers;

  * For page_per_question survey layout: conditional management is done mainly
    at backend side. The idea is to find on each page the next question to
    display depending on:

    * navigation direction (next - previous)
    * already selected answers and conditional question dependance
    * survey questions sequence

	E.g.: If questions survey are Question A, B and C and question B must be
	displayed if answer 'Choice 1' from question A is selected.
	At Question A, if user select 'Choice 1' and goes next: display B
	At Question B, no matter the answer, if user goes next: display C
	At Question B, no matter the answer, if user goes prev: display A
	At Question A, if user select 'Choice 2' and goes next: display C
	At Question C, no matter the answer, if user goes next: End survey
	At Question C, if user has selected 'Choice 1' at question A and goes prev -> B
	At Question C, if user has selected 'Choice 2' at question A and goes prev -> A

In both modes answers on questions that should not have been displayed to the
user depending on the previous selected answers are cleared.
E.g. : User checks 'choice 1' from question A, then complete question B.
Then user unchecks 'choice 1' -> answer on question B is cleared.

Ignore conditional configuration :

  * during survey session, to avoid having different users answering different
    questions on the same session. That would be confusing for users to see
    questions on presenter screen that they didn't have the opportunity to
    answer;
  * if survey questions selection is random, because it will not display
    conditional questions if they are not kept by randomization. To avoid
    confusion, conditional configuration is simply ignored, not unset, so the
	user can still reactivate it without having to reconfigure everything.

Remove questions that should not have been displayed to the user (depending of
the made choices) in scoring computation and print layout.
--> Inactives questions are removed from predefined questions when user finish
the survey.
E.g.: If the user selects first path on 2, he will never see second path.
If the user answers correctly to all question of first path, he should have
100%, instead of 50%, even if questions of path 2 are mandatory and scored.

Link
====

Task ID: 23509
PR #44950
This commit is contained in:
David Beguin
2020-03-06 10:29:50 +00:00
committed by Thibault Delavallée
parent fb846dc44a
commit 0675d68afb
9 changed files with 390 additions and 81 deletions
+34 -12
View File
@@ -275,6 +275,19 @@ class Survey(http.Controller):
'format_datetime': lambda dt: format_datetime(request.env, dt, dt_format=False),
'format_date': lambda date: format_date(request.env, date)
}
if survey_sudo.questions_layout != 'page_per_question':
triggering_answer_by_question, triggered_questions_by_answer, selected_answers = answer_sudo._get_conditional_values()
data.update({
'triggering_answer_by_question': {
question.id: triggering_answer_by_question[question].id for question in triggering_answer_by_question.keys()
if triggering_answer_by_question[question]
},
'triggered_questions_by_answer': {
answer.id: [
triggered_questions_by_answer[answer].ids
] for answer in triggered_questions_by_answer.keys()},
'selected_answers': selected_answers.ids
})
if not answer_sudo.is_session_answer and survey_sudo.is_time_limited and answer_sudo.start_datetime:
data.update({
@@ -287,7 +300,7 @@ class Survey(http.Controller):
# Bypass all if page_id is specified (comes from breadcrumb or previous button)
if 'previous_page_id' in post:
previous_page_or_question_id = int(post['previous_page_id'])
new_previous_id = survey_sudo._previous_page_or_question_id(answer_sudo, previous_page_or_question_id)
new_previous_id = survey_sudo._get_next_page_or_question(answer_sudo, previous_page_or_question_id, go_back=True).id
data.update({
page_or_question_key: request.env['survey.question'].sudo().browse(previous_page_or_question_id),
'previous_page_id': new_previous_id,
@@ -297,28 +310,31 @@ class Survey(http.Controller):
if answer_sudo.state == 'in_progress':
if answer_sudo.is_session_answer:
page_or_question_id, is_last = survey_sudo.session_question_id, False
next_page_or_question = survey_sudo.session_question_id
else:
page_or_question_id, is_last = survey_sudo.next_page_or_question(
next_page_or_question = survey_sudo._get_next_page_or_question(
answer_sudo,
answer_sudo.last_displayed_page_id.id if answer_sudo.last_displayed_page_id else 0)
if answer_sudo.is_session_answer and page_or_question_id.is_time_limited:
if next_page_or_question:
pages_or_questions = survey_sudo._get_pages_or_questions(answer_sudo)
if pages_or_questions.ids.index(next_page_or_question.id) == len(pages_or_questions) - 1:
data.update({'survey_last': True})
if answer_sudo.is_session_answer and next_page_or_question.is_time_limited:
data.update({
'timer_start': survey_sudo.session_question_start_time.isoformat(),
'time_limit_minutes': page_or_question_id.time_limit / 60
'time_limit_minutes': next_page_or_question.time_limit / 60
})
data.update({
page_or_question_key: page_or_question_id,
'has_answered': answer_sudo.user_input_line_ids.filtered(lambda line: line.question_id == page_or_question_id)
page_or_question_key: next_page_or_question,
'has_answered': answer_sudo.user_input_line_ids.filtered(lambda line: line.question_id == next_page_or_question)
})
if survey_sudo.questions_layout != 'one_page':
data.update({
'previous_page_id': survey_sudo._previous_page_or_question_id(answer_sudo, page_or_question_id.id)
'previous_page_id': survey_sudo._get_next_page_or_question(answer_sudo, next_page_or_question.id, go_back=True).id
})
if is_last:
data.update({'last': True})
elif answer_sudo.state == 'done' or answer_sudo.survey_time_limit_reached:
# Display success message
return self._prepare_survey_finished_values(survey_sudo, answer_sudo)
@@ -444,7 +460,10 @@ class Survey(http.Controller):
errors = {}
# Prepare answers / comment by question, validate and save answers
inactive_questions = answer_sudo._get_inactive_conditional_questions()
for question in questions:
if question in inactive_questions: # if question is inactive, skip validation and save
continue
answer, comment = self._extract_comment_from_answers(question, post.get(str(question.id)))
errors.update(question.validate_question(answer, comment))
if not errors.get(question.id):
@@ -453,6 +472,9 @@ class Survey(http.Controller):
if errors and not (answer_sudo.survey_time_limit_reached or answer_sudo.question_time_limit_reached):
return {'error': 'validation', 'fields': errors}
if not answer_sudo.is_session_answer: # Ignore conditional configurations during survey session.
answer_sudo._clear_inactive_conditional_answers()
if answer_sudo.survey_time_limit_reached or survey_sudo.questions_layout == 'one_page':
answer_sudo._mark_done()
elif 'previous_page_id' in post:
@@ -461,8 +483,8 @@ class Survey(http.Controller):
else:
vals = {'last_displayed_page_id': page_or_question_id}
if not answer_sudo.is_session_answer:
next_page, unused = request.env['survey.survey'].next_page_or_question(answer_sudo, page_or_question_id)
if next_page is None:
next_page = survey_sudo._get_next_page_or_question(answer_sudo, page_or_question_id)
if not next_page:
answer_sudo._mark_done()
answer_sudo.write(vals)
+36
View File
@@ -130,6 +130,23 @@ class SurveyQuestion(models.Model):
'survey.user_input.line', 'question_id', string='Answers',
domain=[('skipped', '=', False)], groups='survey.group_survey_user')
# Conditional display
is_conditional = fields.Boolean(
string='Conditional Display', copy=False, help="""If checked, this question will be displayed only
if the specified conditional answer have been selected in a previous question""")
triggering_question_id = fields.Many2one(
'survey.question', string="Triggering Question", copy=False, compute="_compute_triggering_question_id",
store=True, readonly=False, help="Question containing the triggering answer to display the current question.",
domain="""[('survey_id', '=', survey_id),
'&', ('question_type', 'in', ['simple_choice', 'multiple_choice']),
'|',
('sequence', '<', sequence),
'&', ('sequence', '=', sequence), ('id', '<', id)]""")
triggering_answer_id = fields.Many2one(
'survey.question.answer', string="Triggering Answer", copy=False, compute="_compute_triggering_answer_id",
store=True, readonly=False, help="Answer that will trigger the display the current question.",
domain="[('question_id', '=', triggering_question_id)]")
_sql_constraints = [
('positive_len_min', 'CHECK (validation_length_min >= 0)', 'A length must be positive!'),
('positive_len_max', 'CHECK (validation_length_max >= 0)', 'A length must be positive!'),
@@ -193,6 +210,25 @@ class SurveyQuestion(models.Model):
if question.question_type != 'char_box':
question.save_as_nickname = False
@api.depends('is_conditional')
def _compute_triggering_question_id(self):
""" Used as an 'onchange' : Reset the triggering question if user uncheck 'Conditional Display'
Avoid CacheMiss : set the value to False if the value is not set yet."""
for question in self:
if not question.is_conditional or question.triggering_question_id is None:
question.triggering_question_id = False
@api.depends('triggering_question_id')
def _compute_triggering_answer_id(self):
""" Used as an 'onchange' : Reset the triggering answer if user unset or change the triggering question
or uncheck 'Conditional Display'.
Avoid CacheMiss : set the value to False if the value is not set yet."""
for question in self:
if not question.triggering_question_id \
or question.triggering_question_id != question.triggering_answer_id.question_id\
or question.triggering_answer_id is None:
question.triggering_answer_id = False
# Validation methods
def validate_question(self, answer, comment=None):
""" Validate question, depending on question type and parameters
+74 -52
View File
@@ -83,7 +83,8 @@ class Survey(models.Model):
string="Scoring", required=True, default='no_scoring')
scoring_success_min = fields.Float('Success %', default=80.0)
# attendees context: attempts and time limitation
is_attempts_limited = fields.Boolean('Limited number of attempts', help="Check this option if you want to limit the number of attempts per user")
is_attempts_limited = fields.Boolean('Limited number of attempts', help="Check this option if you want to limit the number of attempts per user",
compute="_compute_is_attempts_limited", store=True, readonly=False)
attempts_limit = fields.Integer('Number of attempts', default=1)
is_time_limited = fields.Boolean('The survey is limited in time')
time_limit = fields.Float("Time limit (minutes)")
@@ -126,6 +127,8 @@ class Survey(models.Model):
session_show_leaderboard = fields.Boolean("Show Session Leaderboard", compute='_compute_session_show_leaderboard',
help="Whether or not we want to show the attendees leaderboard for this survey.")
session_speed_rating = fields.Boolean("Reward quick answers", help="Attendees get more points if they answer quickly")
# conditional questions management
has_conditional_questions = fields.Boolean("Contains conditional questions", compute="_compute_has_conditional_questions")
_sql_constraints = [
('access_token_unique', 'unique(access_token)', 'Access token should be unique'),
@@ -178,6 +181,14 @@ class Survey(models.Model):
survey.page_ids = survey.question_and_page_ids.filtered(lambda question: question.is_page)
survey.question_ids = survey.question_and_page_ids - survey.page_ids
@api.depends('question_and_page_ids.is_conditional', 'users_login_required', 'access_mode')
def _compute_is_attempts_limited(self):
for survey in self:
if any(question.is_conditional for question in survey.question_and_page_ids)\
or (survey.access_mode == 'public' and not survey.users_login_required)\
or survey.is_attempts_limited is None:
survey.is_attempts_limited = False
@api.depends('session_start_time', 'user_input_ids')
def _compute_session_answer_count(self):
""" We have to loop since our result is dependent of the survey.session_start_time.
@@ -221,6 +232,11 @@ class Survey(models.Model):
survey.session_show_leaderboard = survey.scoring_type != 'no_scoring' and \
any(question.save_as_nickname for question in survey.question_and_page_ids)
@api.depends('question_and_page_ids.is_conditional')
def _compute_has_conditional_questions(self):
for survey in self:
survey.has_conditional_questions = any(question.is_conditional for question in survey.question_and_page_ids)
@api.onchange('scoring_success_min')
def _onchange_scoring_success_min(self):
if self.scoring_success_min < 0 or self.scoring_success_min > 100:
@@ -232,11 +248,6 @@ class Survey(models.Model):
self.certification = False
self.is_time_limited = False
@api.onchange('users_login_required', 'access_mode')
def _onchange_access_mode(self):
if self.access_mode == 'public' and not self.users_login_required:
self.is_attempts_limited = False
@api.onchange('attempts_limit')
def _onchange_attempts_limit(self):
if self.attempts_limit <= 0:
@@ -430,63 +441,57 @@ class Survey(models.Model):
@api.model
def _get_pages_or_questions(self, user_input):
if self.questions_layout == 'one_page':
return None
return self.env['survey.question']
elif self.questions_layout == 'page_per_question' and self.questions_selection == 'random':
return list(enumerate(
user_input.predefined_question_ids
))
return user_input.predefined_question_ids
else:
return list(enumerate(
self.question_ids if self.questions_layout == 'page_per_question' else self.page_ids
))
return self.question_ids if self.questions_layout == 'page_per_question' else self.page_ids
@api.model
def _previous_page_or_question_id(self, user_input, page_or_question_id):
survey = user_input.survey_id
pages_or_questions = survey._get_pages_or_questions(user_input)
current_page_index = pages_or_questions.index(next(p for p in pages_or_questions if p[1].id == page_or_question_id))
# is first page
if current_page_index == 0:
return None
previous_page_id = pages_or_questions[current_page_index - 1][1].id
return previous_page_id
@api.model
def next_page_or_question(self, user_input, page_or_question_id):
""" The next page to display to the user, knowing that page_id is the id
of the last displayed page.
If page_id == 0, it will always return the first page of the survey.
If all the pages have been displayed, it will return None
.. note::
It is assumed here that a careful user will not try to set go_back
to True if she knows that the page to display is the first one!
(doing this will probably cause a giant worm to eat her house)
def _get_next_page_or_question(self, user_input, page_or_question_id, go_back=False):
"""
This methods gets the next question or page to display.
In case the survey contains conditional question, when navigating to next or previous questions,
in page_per_question layout only, the next or previous question to display depends on the selected answers
and the questions hierarchy. This methods returns the next question index to display among the survey_questions.
:param user_input: user's answers
:param page_or_question_id: current page or question id
:param go_back: must be True to get the previous question
:return: next or previous question.id
"""
survey = user_input.survey_id
pages_or_questions = survey._get_pages_or_questions(user_input)
if not pages_or_questions:
return (None, False)
Question = self.env['survey.question']
# First page
if page_or_question_id == 0:
return (pages_or_questions[0][1], len(pages_or_questions) == 1)
# Get Next
if not go_back:
if not pages_or_questions:
return Question
# First page
if page_or_question_id == 0:
return pages_or_questions[0]
current_page_index = pages_or_questions.index(next(p for p in pages_or_questions if p[1].id == page_or_question_id))
current_page_index = pages_or_questions.ids.index(next(p.id for p in pages_or_questions if p.id == page_or_question_id))
# All the pages have been displayed
if current_page_index == len(pages_or_questions) - 1:
return (None, False)
# Get previous and we are on first page OR Get Next and we are on last page
if (go_back and current_page_index == 0) or current_page_index == len(pages_or_questions) - 1:
return Question
# Conditional Questions Management
triggering_answer_by_question, triggered_questions_by_answer, selected_answers = user_input._get_conditional_values()
if survey.has_conditional_questions and survey.questions_layout == 'page_per_question' and triggered_questions_by_answer:
potential_next_questions = pages_or_questions[0:current_page_index] if go_back \
else pages_or_questions[current_page_index+1:]
for question in sorted(potential_next_questions, key=lambda q: q.id, reverse=go_back):
triggering_answer = triggering_answer_by_question.get(question)
if not triggering_answer or triggering_answer in selected_answers:
# If next question found
return question
else:
show_last = current_page_index == len(pages_or_questions) - 2
return (pages_or_questions[current_page_index + 1][1], show_last)
return pages_or_questions[current_page_index + (1 if not go_back else -1)]
return Question
def _get_survey_questions(self, answer=None, page_id=None, question_id=None):
""" Returns a tuple containing: the survey question and the passed question_id / page_id
@@ -530,6 +535,23 @@ class Survey(models.Model):
questions = questions & answer.predefined_question_ids
return questions, page_or_question_id
# ------------------------------------------------------------
# CONDITIONAL QUESTIONS MANAGEMENT
# ------------------------------------------------------------
def _get_conditional_maps(self):
triggering_answer_by_question = {}
triggered_questions_by_answer = {}
for question in self.question_ids:
triggering_answer_by_question[question] = question.is_conditional and question.triggering_answer_id
if question.is_conditional:
if question.triggering_answer_id in triggered_questions_by_answer:
triggered_questions_by_answer[question.triggering_answer_id] |= question
else:
triggered_questions_by_answer[question.triggering_answer_id] = question
return triggering_answer_by_question, triggered_questions_by_answer
# ------------------------------------------------------------
# SESSIONS MANAGEMENT
# ------------------------------------------------------------
+88 -1
View File
@@ -55,7 +55,7 @@ class SurveyUserInput(models.Model):
('unique_token', 'UNIQUE (access_token)', 'An access token must be unique!'),
]
@api.depends('user_input_line_ids.answer_score', 'user_input_line_ids.question_id')
@api.depends('user_input_line_ids.answer_score', 'user_input_line_ids.question_id', 'predefined_question_ids')
def _compute_scoring_values(self):
for user_input in self:
total_possible_score = sum([
@@ -209,6 +209,9 @@ class SurveyUserInput(models.Model):
if user_input.survey_id.certification_give_badge:
badge_ids.append(user_input.survey_id.certification_badge_id.id)
# Update predefined_question_id to remove inactive questions
user_input.predefined_question_ids -= user_input._get_inactive_conditional_questions()
if badge_ids:
challenges = Challenge.search([('reward_id', 'in', badge_ids)])
if challenges:
@@ -359,6 +362,90 @@ class SurveyUserInput(models.Model):
{'text': _("Unanswered"), 'count': res[user_input]['skipped']}
] for user_input in self]
# ------------------------------------------------------------
# Conditional Questions Management
# ------------------------------------------------------------
def _get_conditional_values(self):
""" For survey containing conditional questions, we need a triggered_questions_by_answer map that contains
{key: answer, value: the question that the answer triggers, if selected},
The idea is to be able to verify, on every answer check, if this answer is triggering the display
of another question.
If answer is not in the conditional map:
- nothing happens.
If the answer is in the conditional map:
- If we are in ONE PAGE survey : (handled at CLIENT side)
-> display immediately the depending question
- If we are in PAGE PER SECTION : (handled at CLIENT side)
- If related question is on the same page :
-> display immediately the depending question
- If the related question is not on the same page :
-> keep the answers in memory and check at next page load if the depending question is in there and
display it, if so.
- If we are in PAGE PER QUESTION : (handled at SERVER side)
-> During submit, determine which is the next question to display getting the next question
that is the next in sequence and that is either not triggered by another question's answer, or that
is triggered by an already selected answer.
To do all this, we need to return:
- list of all selected answers: [answer_id1, answer_id2, ...] (for survey reloading, otherwise, this list is
updated at client side)
- triggered_questions_by_answer: dict -> for a given answer, list of questions triggered by this answer;
Used mainly for dynamic show/hide behaviour at client side
- triggering_answer_by_question: dict -> for a given question, the answer that triggers it
Used mainly to ease template rendering
"""
triggering_answer_by_question, triggered_questions_by_answer = {}, {}
# Ignore conditional configuration if randomised questions selection
if self.survey_id.questions_selection != 'random':
triggering_answer_by_question, triggered_questions_by_answer = self.survey_id._get_conditional_maps()
selected_answers = self._get_selected_suggested_answers()
return triggering_answer_by_question, triggered_questions_by_answer, selected_answers
def _get_selected_suggested_answers(self):
"""
For now, only simple and multiple choices question type are handled by the conditional questions feature.
Mapping all the suggested answers selected by the user will also include answers from matrix question type,
Those ones won't be used.
Maybe someday, conditional questions feature will be extended to work with matrix question.
:return: all the suggested answer selected by the user.
"""
return self.mapped('user_input_line_ids.suggested_answer_id')
def _clear_inactive_conditional_answers(self):
"""
Clean eventual answers on conditional questions that should not have been displayed to user.
This method is used mainly for page per question survey, a similar method does the same treatment
at client side for the other survey layouts.
E.g.: if depending answer was uncheck after answering conditional question, we need to clear answers
of that conditional question, for two reasons:
- ensure correct scoring
- if the selected answer triggers another question later in the survey, if the answer is not cleared,
a question that should not be displayed to the user will be.
TODO DBE: Maybe this can be the only cleaning method, even for section_per_page or one_page where
conditional questions are, for now, cleared in JS directly. But this can be annoying if user typed a long
answer, changed his mind unchecking depending answer and changed again his mind by rechecking the depending
answer -> For now, the long answer will be lost. If we use this as the master cleaning method,
long answer will be cleared only during submit.
"""
inactive_questions = self._get_inactive_conditional_questions()
# delete user.input.line on question that should not be answered.
answers_to_delete = self.user_input_line_ids.filtered(lambda answer: answer.question_id in inactive_questions)
answers_to_delete.unlink()
def _get_inactive_conditional_questions(self):
triggering_answer_by_question, triggered_questions_by_answer, selected_answers = self._get_conditional_values()
# get questions that should not be answered
inactive_questions = self.env['survey.question']
for answer in triggered_questions_by_answer.keys():
if answer not in selected_answers:
for question in triggered_questions_by_answer[answer]:
inactive_questions |= question
return inactive_questions
class SurveyUserInputLine(models.Model):
_name = 'survey.user_input.line'
+115 -5
View File
@@ -34,6 +34,7 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({
return this._super.apply(this, arguments).then(function () {
self.options = self.$target.find('form').data();
self.readonly = self.options.readonly;
self.selectedAnswers = self.options.selectedAnswers;
// Init fields
if (!self.options.isStartScreen && !self.readonly) {
self._initTimer();
@@ -105,6 +106,7 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({
* @param {Event} event
*/
_onChangeChoiceItem: function (event) {
var self = this;
var $target = $(event.currentTarget);
var $choiceItemGroup = $target.closest('.o_survey_form_choice');
var $otherItem = $choiceItemGroup.find('.o_survey_js_form_other_comment');
@@ -126,8 +128,42 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({
$matrixBtn.closest('tr').find('td').removeClass('o_survey_selected');
$matrixBtn.addClass('o_survey_selected');
} else {
$choiceItemGroup.find('label').removeClass('o_survey_selected');
$target.closest('label').addClass('o_survey_selected');
var previouslySelectedAnswer = $choiceItemGroup.find('label.o_survey_selected');
previouslySelectedAnswer.removeClass('o_survey_selected');
var newlySelectedAnswer = $target.closest('label');
if (newlySelectedAnswer.find('input').val() !== previouslySelectedAnswer.find('input').val()) {
newlySelectedAnswer.addClass('o_survey_selected');
}
// Conditional display
if (this.options.questionsLayout !== 'page_per_question') {
var treatedQuestionIds = []; // Needed to avoid show (1st 'if') then immediately hide (2nd 'if') question during conditional propagation cascade
if (Object.keys(this.options.triggeredQuestionsByAnswer).includes(previouslySelectedAnswer.find('input').val())) {
// Hide and clear depending question
this.options.triggeredQuestionsByAnswer[previouslySelectedAnswer.find('input').val()].forEach(function (questionId) {
var dependingQuestion = $('.js_question-wrapper#' + questionId);
dependingQuestion.addClass('d-none');
self._clearQuestionInputs(dependingQuestion);
treatedQuestionIds.push(questionId);
});
// Remove answer from selected answer
self.selectedAnswers.splice(self.selectedAnswers.indexOf(parseInt($target.val())), 1);
}
if (Object.keys(this.options.triggeredQuestionsByAnswer).includes($target.val())) {
// Display depending question
this.options.triggeredQuestionsByAnswer[$target.val()].forEach(function (questionId) {
if (!treatedQuestionIds.includes(questionId)) {
var dependingQuestion = $('.js_question-wrapper#' + questionId);
dependingQuestion.removeClass('d-none');
}
});
// Add answer to selected answer
this.selectedAnswers.push(parseInt($target.val()));
}
}
}
} else { // $target.attr('type') === 'checkbox'
if ($matrixBtn.length > 0) {
@@ -135,6 +171,25 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({
} else {
var $label = $target.closest('label');
$label.toggleClass('o_survey_selected', !$label.hasClass('o_survey_selected'));
// Conditional display
if (this.options.questionsLayout !== 'page_per_question' && Object.keys(this.options.triggeredQuestionsByAnswer).includes($target.val())) {
var isInputSelected = $label.hasClass('o_survey_selected');
// Hide and clear or display depending question
this.options.triggeredQuestionsByAnswer[$target.val()].forEach(function (questionId) {
var dependingQuestion = $('.js_question-wrapper#' + questionId);
dependingQuestion.toggleClass('d-none', !isInputSelected);
if (!isInputSelected) {
self._clearQuestionInputs(dependingQuestion);
}
});
// Add/remove answer to/from selected answer
if (!isInputSelected) {
self.selectedAnswers.splice(self.selectedAnswers.indexOf(parseInt($target.val())), 1);
} else {
self.selectedAnswers.push(parseInt($target.val()));
}
}
}
}
},
@@ -342,6 +397,12 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({
if (result && !result.error) {
this.$(".o_survey_form_content").empty();
this.$(".o_survey_form_content").html(result);
// Hide timer if end screen (if page_per_question in case of conditional questions)
if (self.options.questionsLayout === 'page_per_question' && this.$('.o_survey_finished').length > 0) {
options.isFinish = true;
}
this.$('div.o_survey_form_date').each(function () {
self._initDateTimePicker($(this));
});
@@ -408,16 +469,24 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({
data[key] = value;
});
var inactiveQuestionIds = this.options.sessionInProgress ? [] : this._getInactiveConditionalQuestionIds();
$form.find('[data-question-type]').each(function () {
var $input = $(this);
var $questionWrapper = $input.closest(".js_question-wrapper");
var questionId = $questionWrapper.attr('id');
// If question is inactive, skip validation.
if (inactiveQuestionIds.includes(parseInt(questionId))) {
return;
}
var questionRequired = $questionWrapper.data('required');
var constrErrorMsg = $questionWrapper.data('constrErrorMsg');
var validationErrorMsg = $questionWrapper.data('validationErrorMsg');
var questionId = $questionWrapper.attr('id');
var questionRequired = $questionWrapper.data('required');
switch ($input.data('questionType')) {
case 'char_box':
if (questionRequired && !$input.val()) {
if (questionRequired && !questionInactive && !$input.val()) {
errors[questionId] = constrErrorMsg;
} else if ($input.val() && $input.attr('type') === 'email' && !self._validateEmail($input.val())) {
errors[questionId] = validationEmailMsg;
@@ -835,6 +904,47 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({
}
},
// CONDITIONAL QUESTIONS MANAGEMENT TOOLS
// -------------------------------------------------------------------------
/**
* Clear / Un-select all the input from the given question
* + propagate conditional hierarchy by triggering change on choice inputs.
*
* @private
*/
_clearQuestionInputs: function (question) {
question.find('input').each(function () {
if ($(this).attr('type') === 'text' || $(this).attr('type') === 'number') {
$(this).val('');
} else if ($(this).prop('checked')) {
$(this).prop('checked', false).change();
}
});
question.find('textarea').val('');
},
/**
* Get questions that are not supposed to be answered by the user.
* Those are the ones triggered by answers that the user did not selected.
*
* @private
*/
_getInactiveConditionalQuestionIds: function () {
var self = this;
var inactiveQuestionIds = [];
if (this.options.triggeredQuestionsByAnswer) {
Object.keys(this.options.triggeredQuestionsByAnswer).forEach(function (answerId) {
if (!self.selectedAnswers.includes(parseInt(answerId))) {
self.options.triggeredQuestionsByAnswer[answerId].forEach(function (questionId) {
inactiveQuestionIds.push(questionId);
});
}
});
}
return inactiveQuestionIds;
},
// ERRORS TOOLS
// -------------------------------------------------------------------------
@@ -20,3 +20,7 @@ table.o_section_list_view tr.o_data_row.o_is_section {
border-top: 1px solid #BBB;
border-bottom: 1px solid #BBB;
}
.icon_rotates {
transform: rotate(180deg);
}
+17 -3
View File
@@ -157,9 +157,19 @@
<field name="matrix_subtype" attrs="{'invisible':[('question_type','not in',['matrix'])],'required':[('question_type','=','matrix')]}"/>
</group>
</group>
<group string="Display" attrs="{'invisible':[('question_type','not in',['simple_choice', 'multiple_choice'])]}">
<field name="column_nb" string="Number of columns"/>
<field name="allow_value_image"/>
<group>
<group string="Display" attrs="{'invisible':[('question_type','not in',['simple_choice', 'multiple_choice'])]}">
<field name="column_nb" string="Number of columns"/>
<field name="allow_value_image"/>
</group>
<group string="Conditional Display" attrs="{'invisible': [('questions_selection', '=', 'random')]}">
<field name="is_conditional"/>
<field name="triggering_question_id" options="{'no_open': True, 'no_create': True}"
attrs="{'invisible': [('is_conditional','=', False)], 'required': [('is_conditional','=', True)]}"/>
<field name="triggering_answer_id" options="{'no_open': True, 'no_create': True}"
attrs="{'invisible': ['|', ('is_conditional','=', False), ('triggering_question_id','=', False)],
'required': [('is_conditional','=', True)]}"/>
</group>
</group>
<group string="Allow Comments" attrs="{'invisible':[('question_type','not in',['simple_choice','multiple_choice', 'matrix'])]}">
<field name='comments_allowed' />
@@ -190,6 +200,9 @@
<field name="title"/>
<field name="survey_id"/>
<field name="question_type"/>
<field name="triggering_question_id" invisible="1"/>
<button icon="fa-code-fork" attrs="{'invisible': [('triggering_question_id', '=', False)]}"
title="This question depends on another question's answer." class="icon_rotates"/>
</tree>
</field>
</record>
@@ -203,6 +216,7 @@
<field name="question_type" string="Type"/>
<group expand="1" string="Group By">
<filter name="group_by_type" string="Type" domain="[]" context="{'group_by':'question_type'}"/>
<filter name="group_by_survey" string="Survey" domain="[]" context="{'group_by':'survey_id'}"/>
</group>
</search>
</field>
+8 -2
View File
@@ -57,6 +57,7 @@
<group>
<group>
<field name="active" invisible="1"/>
<field name="has_conditional_questions" invisible="1"/>
</group>
</group>
<notebook>
@@ -68,7 +69,11 @@
<field name="question_type" />
<field name="is_page" invisible="1"/>
<field name="questions_selection" invisible="1"/>
<field name="survey_id" invisible="1"/>
<field name="triggering_question_id" invisible="1"/>
<field name="random_questions_count" attrs="{'column_invisible': [('parent.questions_selection', '=', 'all')], 'invisible': [('is_page', '=', False)]}" />
<button icon="fa-code-fork" attrs="{'invisible': [('triggering_question_id', '=', False)]}"
title="This question depends on another question's answer." class="icon_rotates"/>
<control>
<create name="add_section_control" string="Add a section" context="{'default_is_page': True, 'default_questions_selection': 'all'}"/>
<create name="add_question_control" string="Add a question"/>
@@ -96,8 +101,9 @@
<group string="Candidates" name="candidates">
<field name="access_mode"/>
<field name="users_login_required"/>
<label for="is_attempts_limited" string="Attempts Limit" attrs="{'invisible': [('access_mode', '=', 'public'), ('users_login_required', '=', False)]}"/>
<div attrs="{'invisible': [('access_mode', '=', 'public'), ('users_login_required', '=', False)]}">
<label for="is_attempts_limited" string="Attempts Limit"
attrs="{'invisible': ['|', ('has_conditional_questions', '=', True), '&amp;', ('access_mode', '=', 'public'), ('users_login_required', '=', False)]}"/>
<div attrs="{'invisible': ['|', ('has_conditional_questions', '=', True), '&amp;', ('access_mode', '=', 'public'), ('users_login_required', '=', False)]}">
<field name="is_attempts_limited" nolabel="1"/>
<field name="attempts_limit" attrs="{'invisible': [('is_attempts_limited', '=', False)]}" nolabel="1" class="oe_inline" /> <span attrs="{'invisible': [('is_attempts_limited', '=', False)]}"> attempts</span>
</div>
+14 -6
View File
@@ -58,7 +58,7 @@
</div>
</div>
<div t-att-class="'o_survey_breadcrumb_container mt8' + (' d-none ' if answer.state != 'in_progress' else '')"
t-if="survey.questions_layout == 'page_per_section' and answer.state != 'done'"
t-if="not survey.has_conditional_questions and survey.questions_layout == 'page_per_section' and answer.state != 'done'"
t-att-data-can-go-back="survey.users_can_go_back"
t-att-data-pages="json.dumps(breadcrumb_pages)" />
</div>
@@ -74,7 +74,9 @@
t-att-data-is-start-screen="answer.state == 'new'"
t-att-data-readonly="survey_form_readonly"
t-att-data-has-answered="bool(has_answered)"
t-att-data-questions-layout="survey.questions_layout">
t-att-data-questions-layout="survey.questions_layout"
t-att-data-triggered-questions-by-answer="json.dumps(triggered_questions_by_answer)"
t-att-data-selected-answers="json.dumps(selected_answers)">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<input type="hidden" name="token" t-att-value="answer.access_token" />
<div class="o_survey_error alert alert-danger d-none" role="alert">
@@ -164,7 +166,7 @@
<div class="offset-3 col-6 text-center mt16 mb128">
<button t-if="survey.users_can_go_back and page != survey.page_ids[0]" type="submit" class="btn btn-secondary"
name="button_submit" value="previous" t-att-data-previous-page-id="previous_page_id">Back</button>
<button type="submit" t-att-value="'next' if not last else 'finish'" class="btn btn-primary">
<button type="submit" t-att-value="'next' if not survey_last else 'finish'" class="btn btn-primary">
Continue</button><span class="font-weight-bold ml-2 d-none d-md-inline"> or press Enter</span>
</div>
<div class="col-3">
@@ -196,7 +198,7 @@
<div class="offset-3 col-6 text-center mt16 mb128">
<button t-if="not answer.is_session_answer and survey.users_can_go_back and question != answer.predefined_question_ids[0]" type="submit" class="btn btn-secondary"
name="button_submit" value="previous" t-att-data-previous-page-id="previous_page_id">Back</button>
<button type="submit" t-att-value="'next' if not last else 'finish'" class="btn btn-primary">
<button type="submit" t-att-value="'next' if not survey_last else 'finish'" class="btn btn-primary">
<t t-if="answer.is_session_answer">
Submit
</t>
@@ -274,11 +276,17 @@
<!-- Question widgets -->
<template id="question_container" name="Survey: question container">
<t t-set="display_question"
t-value="survey.questions_layout == 'page_per_question'
or (survey.questions_layout == 'one_page' and not question.is_conditional)
or (survey.questions_layout == 'page_per_section' and (not question.is_conditional or triggering_answer_by_question[question.id] in selected_answers))"/>
<t t-set="answer_lines" t-value="answer.user_input_line_ids.filtered(lambda line: line.question_id == question)"/>
<!--Use Key selection if number of choices is < 26 to keep Z for other choice if any-->
<t t-set="letters" t-value="'ABCDEFGHIJKLMNOPQRSTUVWXYZ'"/>
<t t-set="useKeySelection" t-value="len(question.suggested_answer_ids) &lt; len(letters) and survey.questions_layout == 'page_per_question'"/>
<div class="js_question-wrapper pb-4" t-att-id="question.id" t-att-data-required="question.constr_mandatory"
<div t-att-class="'js_question-wrapper pb-4 %s' % ('d-none' if not display_question else '')"
t-att-id="question.id" t-att-data-required="question.constr_mandatory"
t-att-data-constr-error-msg="question.constr_error_msg"
t-att-data-validation-error-msg="question.validation_error_msg">
<h4>
@@ -544,7 +552,7 @@
</template>
<template id="survey_progression" name="Survey: Progression">
<t t-if="len(page_ids) > 1">
<t t-if="len(page_ids) > 1 and not survey.has_conditional_questions">
<t t-set="percentage" t-value="round(100*(page_number/len(page_ids)))"/>
<t t-if="survey.progression_mode == 'percent'">
<span class="o_survey_progress_percent" t-esc="percentage"/> % completed