diff --git a/addons/survey/controllers/main.py b/addons/survey/controllers/main.py index 1c07cb9a715..dfa0a49687f 100644 --- a/addons/survey/controllers/main.py +++ b/addons/survey/controllers/main.py @@ -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) diff --git a/addons/survey/models/survey_question.py b/addons/survey/models/survey_question.py index 2ef1631c8ce..1e130d749c7 100644 --- a/addons/survey/models/survey_question.py +++ b/addons/survey/models/survey_question.py @@ -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 diff --git a/addons/survey/models/survey_survey.py b/addons/survey/models/survey_survey.py index f589629ee5d..781e66b6e98 100644 --- a/addons/survey/models/survey_survey.py +++ b/addons/survey/models/survey_survey.py @@ -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 # ------------------------------------------------------------ diff --git a/addons/survey/models/survey_user.py b/addons/survey/models/survey_user.py index af5d745145c..d6ea0b1fed8 100644 --- a/addons/survey/models/survey_user.py +++ b/addons/survey/models/survey_user.py @@ -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' diff --git a/addons/survey/static/src/js/survey_form.js b/addons/survey/static/src/js/survey_form.js index 21bad10c7fd..34e429eacee 100644 --- a/addons/survey/static/src/js/survey_form.js +++ b/addons/survey/static/src/js/survey_form.js @@ -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 // ------------------------------------------------------------------------- diff --git a/addons/survey/static/src/scss/survey_views.scss b/addons/survey/static/src/scss/survey_views.scss index f81f5a660aa..fcfc0ffc9a2 100644 --- a/addons/survey/static/src/scss/survey_views.scss +++ b/addons/survey/static/src/scss/survey_views.scss @@ -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); +} diff --git a/addons/survey/views/survey_question_views.xml b/addons/survey/views/survey_question_views.xml index e273f0b1f83..64336d5743b 100644 --- a/addons/survey/views/survey_question_views.xml +++ b/addons/survey/views/survey_question_views.xml @@ -157,9 +157,19 @@ - - - + + + + + + + + + + @@ -190,6 +200,9 @@ + + - or press Enter
@@ -196,7 +198,7 @@
-