diff --git a/addons/survey/controllers/main.py b/addons/survey/controllers/main.py index e46485a0d50..a6efd4f8981 100644 --- a/addons/survey/controllers/main.py +++ b/addons/survey/controllers/main.py @@ -257,11 +257,13 @@ class Survey(http.Controller): """ This method prepares all the data needed for template rendering, in function of the survey user input state. :param post: - previous_page_id : come from the breadcrumb or the back button and force the next questions to load - to be the previous ones. """ + to be the previous ones. + - next_skipped_page : force the display of next skipped question or page if any.""" data = { 'is_html_empty': is_html_empty, 'survey': survey_sudo, 'answer': answer_sudo, + 'skipped_questions': answer_sudo._get_skipped_questions(), 'breadcrumb_pages': [{ 'id': page.id, 'title': page.title, @@ -306,17 +308,23 @@ class Survey(http.Controller): return data if answer_sudo.state == 'in_progress': + next_page_or_question = None if answer_sudo.is_session_answer: next_page_or_question = survey_sudo.session_question_id else: - 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 'next_skipped_page' in post: + next_page_or_question = answer_sudo._get_next_skipped_page_or_question() + if not 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 next_page_or_question: - data.update({ - 'survey_last': survey_sudo._is_last_page_or_question(answer_sudo, next_page_or_question) - }) + if answer_sudo.survey_first_submitted: + survey_last = answer_sudo._is_last_skipped_page_or_question(next_page_or_question) + else: + survey_last = survey_sudo._is_last_page_or_question(answer_sudo, next_page_or_question) + data.update({'survey_last': survey_last}) if answer_sudo.is_session_answer and next_page_or_question.is_time_limited: data.update({ @@ -377,6 +385,7 @@ class Survey(http.Controller): background_image_url = survey_data['page'].background_image_url return { + 'has_skipped_questions': any(answer_sudo._get_skipped_questions()), 'survey_content': survey_content, 'survey_progress': survey_progress, 'survey_navigation': request.env['ir.qweb']._render('survey.survey_navigation', survey_data), @@ -534,16 +543,31 @@ class Survey(http.Controller): answer_sudo._mark_done() elif 'previous_page_id' in post: # when going back, save the last displayed to reload the survey where the user left it. - answer_sudo.write({'last_displayed_page_id': post['previous_page_id']}) + answer_sudo.last_displayed_page_id = post['previous_page_id'] # Go back to specific page using the breadcrumb. Lines are saved and survey continues return self._prepare_question_html(survey_sudo, answer_sudo, **post) + elif 'next_skipped_page_or_question' in post: + answer_sudo.last_displayed_page_id = page_or_question_id + return self._prepare_question_html(survey_sudo, answer_sudo, next_skipped_page=True) else: if not answer_sudo.is_session_answer: - next_page = survey_sudo._get_next_page_or_question(answer_sudo, page_or_question_id) + page_or_question = request.env['survey.question'].sudo().browse(page_or_question_id) + if answer_sudo.survey_first_submitted and answer_sudo._is_last_skipped_page_or_question(page_or_question): + next_page = request.env['survey.question'] + else: + next_page = survey_sudo._get_next_page_or_question(answer_sudo, page_or_question_id) if not next_page: - answer_sudo._mark_done() + if survey_sudo.users_can_go_back and answer_sudo.user_input_line_ids.filtered( + lambda a: a.skipped and a.question_id.constr_mandatory): + answer_sudo.write({ + 'last_displayed_page_id': page_or_question_id, + 'survey_first_submitted': True, + }) + return self._prepare_question_html(survey_sudo, answer_sudo, next_skipped_page=True) + else: + answer_sudo._mark_done() - answer_sudo.write({'last_displayed_page_id': page_or_question_id}) + answer_sudo.last_displayed_page_id = page_or_question_id return self._prepare_question_html(survey_sudo, answer_sudo) diff --git a/addons/survey/models/survey_question.py b/addons/survey/models/survey_question.py index 5965557397c..de57bd88d1f 100644 --- a/addons/survey/models/survey_question.py +++ b/addons/survey/models/survey_question.py @@ -403,11 +403,11 @@ class SurveyQuestion(models.Model): if isinstance(answer, str): answer = answer.strip() # Empty answer to mandatory question - if self.constr_mandatory and not answer and self.question_type not in ['simple_choice', 'multiple_choice']: - return {self.id: self.constr_error_msg or _('This question requires an answer.')} - # because in choices question types, comment can count as answer - if answer or self.question_type in ['simple_choice', 'multiple_choice']: + if not answer and self.question_type not in ['simple_choice', 'multiple_choice']: + if self.constr_mandatory and not self.survey_id.users_can_go_back: + return {self.id: self.constr_error_msg or _('This question requires an answer.')} + else: if self.question_type == 'char_box': return self._validate_char_box(answer) elif self.question_type == 'numerical_box': @@ -473,7 +473,8 @@ class SurveyQuestion(models.Model): def _validate_choice(self, answer, comment): # Empty comment - if self.constr_mandatory \ + if not self.survey_id.users_can_go_back \ + and self.constr_mandatory \ and not answer \ and not (self.comments_allowed and self.comment_count_as_answer and comment): return {self.id: self.constr_error_msg or _('This question requires an answer.')} diff --git a/addons/survey/models/survey_user_input.py b/addons/survey/models/survey_user_input.py index f4cdc3e4595..4d310387a54 100644 --- a/addons/survey/models/survey_user_input.py +++ b/addons/survey/models/survey_user_input.py @@ -52,6 +52,7 @@ class SurveyUserInput(models.Model): scoring_percentage = fields.Float("Score (%)", compute="_compute_scoring_values", store=True, compute_sudo=True) # stored for perf reasons scoring_total = fields.Float("Total Score", compute="_compute_scoring_values", store=True, compute_sudo=True) # stored for perf reasons scoring_success = fields.Boolean('Quizz Passed', compute='_compute_scoring_success', store=True, compute_sudo=True) # stored for perf reasons + survey_first_submitted = fields.Boolean(string='Survey First Submitted') # live sessions is_session_answer = fields.Boolean('Is in a Session', help="Is that user input part of a survey session or not.") question_time_limit_reached = fields.Boolean("Question Time Limit Reached", compute='_compute_question_time_limit_reached') @@ -610,6 +611,55 @@ class SurveyUserInput(models.Model): inactive_questions = self._get_inactive_conditional_questions() return survey.question_ids - inactive_questions + def _get_next_skipped_page_or_question(self): + """Get next skipped question or page in case the option 'can_go_back' is set on the survey + It loops to the first skipped question or page if 'last_displayed_page_id' is the last + skipped question or page.""" + self.ensure_one() + skipped_mandatory_answer_ids = self.user_input_line_ids.filtered( + lambda answer: answer.skipped and answer.question_id.constr_mandatory) + + if not skipped_mandatory_answer_ids: + return self.env['survey.question'] + + page_or_question_key = 'page_id' if self.survey_id.questions_layout == 'page_per_section' else 'question_id' + page_or_question_ids = skipped_mandatory_answer_ids.mapped(page_or_question_key).sorted() + + if self.last_displayed_page_id not in page_or_question_ids\ + or self.last_displayed_page_id == page_or_question_ids[-1]: + return page_or_question_ids[0] + + current_page_index = page_or_question_ids.ids.index(self.last_displayed_page_id.id) + return page_or_question_ids[current_page_index + 1] + + def _get_skipped_questions(self): + self.ensure_one() + + return self.user_input_line_ids.filtered( + lambda answer: answer.skipped and answer.question_id.constr_mandatory).question_id + + def _is_last_skipped_page_or_question(self, page_or_question): + """In case of a submitted survey tells if the question or page is the last + skipped page or question. + + This is used to : + + - Display a Submit button if the actual question is the last skipped question. + - Avoid displaying a Submit button on the last survey question if there are + still skipped questions before. + - Avoid displaying the next page if submitting the latest skipped question. + + :param page_or_question: page if survey's layout is page_per_section, question if page_per_question. + """ + if self.survey_id.questions_layout == 'one_page': + return True + skipped = self._get_skipped_questions() + if not skipped: + return True + if self.survey_id.questions_layout == 'page_per_section': + skipped = skipped.page_id + return skipped == page_or_question + # ------------------------------------------------------------ # MESSAGING # ------------------------------------------------------------ diff --git a/addons/survey/static/src/js/survey_form.js b/addons/survey/static/src/js/survey_form.js index e72170a92fe..89d596949ed 100644 --- a/addons/survey/static/src/js/survey_form.js +++ b/addons/survey/static/src/js/survey_form.js @@ -130,8 +130,10 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend(SurveyPreloa if (keyCode === 13 || keyCode === 39) { // Enter or arrow-right: go Next event.preventDefault(); if (!this.preventEnterSubmit) { - var isFinish = this.$('button[value="finish"]').length !== 0; - this._submitForm({isFinish: isFinish}); + this._submitForm({ + isFinish: this.el.querySelectorAll('button[value="finish"]').length !== 0, + nextSkipped: this.el.querySelectorAll('button[value="next_skipped"]').length !== 0 ? keyCode === 13 : false, + }); } } else if (keyCode === 37) { // arrow-left: previous (if available) // It's easier to actually click on the button (if in the DOM) as it contains necessary @@ -172,7 +174,7 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend(SurveyPreloa .closest('.o_survey_form_choice') .find('.o_survey_comment').length !== 0; if (!questionHasComment) { - this._submitForm({}); + this._submitForm({'nextSkipped': $choiceItemGroup.data('isSkippedQuestion')}); } } } @@ -243,11 +245,13 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend(SurveyPreloa _onSubmit: function (event) { event.preventDefault(); - var options = {}; - var $target = $(event.currentTarget); - if ($target.val() === 'previous') { - options.previousPageId = $target.data('previousPageId'); - } else if ($target.val() === 'finish') { + const options = {}; + const target = event.currentTarget; + if (target.value === 'previous') { + options.previousPageId = parseInt(target.dataset['previousPageId']); + } else if (target.value === 'next_skipped') { + options.nextSkipped = true; + } else if (target.value === 'finish') { options.isFinish = true; } this._submitForm(options); @@ -365,6 +369,9 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend(SurveyPreloa if (options.previousPageId) { params.previous_page_id = options.previousPageId; } + if (options.nextSkipped) { + params.next_skipped_page_or_question = true; + } var route = "/survey/submit"; if (this.options.isStartScreen) { @@ -417,7 +424,7 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend(SurveyPreloa var fadeOutPromise = new Promise(function (resolve, reject) {resolveFadeOut = resolve;}); var selectorsToFadeout = ['.o_survey_form_content']; - if (options.isFinish) { + if (options.isFinish && !this.nextScreenResult.has_skipped_questions) { selectorsToFadeout.push('.breadcrumb', '.o_survey_timer'); deleteCookie('survey_' + self.options.surveyToken); } @@ -454,7 +461,7 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend(SurveyPreloa var self = this; var result = this.nextScreenResult; - if (!(options && options.isFinish) + if ((!(options && options.isFinish) || result.has_skipped_questions) && !this.options.sessionInProgress) { this.preventEnterSubmit = false; } @@ -489,7 +496,7 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend(SurveyPreloa this.surveyTimerWidget.destroy(); } } - if (options && options.isFinish) { + if (options && options.isFinish && !result.has_skipped_questions) { this._initResultWidget(); if (this.surveyBreadcrumbWidget) { this.$('.o_survey_breadcrumb_container').addClass('d-none'); @@ -518,6 +525,7 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend(SurveyPreloa this.$('button[type="submit"]').removeClass('disabled'); + this._scrollToFirstError(); self._focusOnFirstInput(); } else if (result && result.fields && result.error === 'validation') { this.$('.o_survey_form_content').fadeIn(0); @@ -1126,13 +1134,22 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend(SurveyPreloa var self = this; var errorKeys = Object.keys(errors || {}); errorKeys.forEach(key => { - self.$("#" + key + '>.o_survey_question_error').append($('
', {text: errors[key]})).addClass("slide_in");
+ self.$("#" + key + '>.o_survey_question_error').append($('', {text: errors[key]})).addClass("slide_in");
if (errorKeys[0] === key) {
self._scrollToError(self.$('.js_question-wrapper#' + key));
}
});
},
+ /**
+ * This method is used to scroll to error generated in the backend.
+ * (Those errors are displayed when the user skip mandatory question(s))
+ */
+ _scrollToFirstError: function() {
+ const errorElem = this.el.querySelector('.o_survey_question_error :not(:empty)');
+ errorElem?.scrollIntoView();
+ },
+
_scrollToError: function ($target) {
var scrollLocation = $target.offset().top;
var navbarHeight = $('.o_main_navbar').height();
diff --git a/addons/survey/static/tests/tours/survey_roaming_mandatory_questions.js b/addons/survey/static/tests/tours/survey_roaming_mandatory_questions.js
new file mode 100644
index 00000000000..c0f80d7736f
--- /dev/null
+++ b/addons/survey/static/tests/tours/survey_roaming_mandatory_questions.js
@@ -0,0 +1,74 @@
+/** @odoo-module **/
+
+import { registry } from '@web/core/registry';
+
+registry.category('web_tour.tours').add('test_survey_roaming_mandatory_questions', {
+ test: true,
+ url: '/survey/start/853ebb30-40f2-43bf-a95a-bbf0e367a365',
+ steps: () => [{
+ content: 'Click on Start',
+ trigger: 'button.btn:contains("Start")',
+ }, {
+ content: 'Skip question Q1',
+ trigger: 'button.btn:contains("Continue")',
+ }, {
+ content: 'Skip question Q2',
+ extra_trigger: 'div.js_question-wrapper:contains("Q2")',
+ trigger: 'button.btn:contains("Continue")',
+ }, {
+ content: 'Check if Q3 button is Submit',
+ trigger: 'button.btn:contains("Submit")',
+ isCheck: true,
+ }, {
+ content: 'Go back to Q2',
+ trigger: 'button.btn[value="previous"]',
+ }, {
+ content: 'Check if the alert box is present',
+ trigger: 'div.o_survey_question_error span',
+ isCheck: true,
+ }, {
+ content: 'Skip question Q2 again',
+ trigger: 'button.btn:contains("Continue")',
+ }, {
+ content: 'Answer Q3',
+ trigger: 'div.js_question-wrapper:contains("Q3") label:contains("Answer 1")',
+ }, {
+ content: 'Click on Submit',
+ trigger: 'button.btn:contains("Submit")',
+ }, {
+ content: 'Check if question is Q1',
+ trigger: 'div.js_question-wrapper:contains("Q1")',
+ isCheck: true,
+ }, {
+ content: 'Click on "Next Skipped" button',
+ trigger: 'button.btn:contains("Next Skipped")',
+ }, {
+ content: 'Check if question is Q2',
+ trigger: 'div.js_question-wrapper:contains("Q2")',
+ isCheck: true,
+ }, {
+ content: 'Click on "Next Skipped" button',
+ trigger: 'button.btn:contains("Next Skipped")',
+ }, {
+ content: 'Check if question is Q1 again (should loop on skipped questions)',
+ trigger: 'div.js_question-wrapper:contains("Q1")',
+ isCheck: true,
+ }, {
+ content: 'Answer Q1',
+ trigger: 'div.js_question-wrapper:contains("Q1") label:contains("Answer 2")',
+ }, {
+ content: 'Check if the visible question is the skipped question Q2',
+ trigger: 'div.js_question-wrapper:contains("Q2")',
+ isCheck: true,
+ }, {
+ content: 'Answer Q2',
+ trigger: 'div.js_question-wrapper:contains("Q2") label:contains("Answer 3")',
+ }, {
+ content: 'Click on Submit',
+ trigger: 'button.btn:contains("Submit")',
+ }, {
+ content: 'Check if the survey is done',
+ trigger: 'div.o_survey_finished h1:contains("Thank you!")',
+ isCheck: true,
+ }],
+});
diff --git a/addons/survey/tests/test_survey_ui_feedback.py b/addons/survey/tests/test_survey_ui_feedback.py
index 51c85f0d3d7..32eeffa7485 100644
--- a/addons/survey/tests/test_survey_ui_feedback.py
+++ b/addons/survey/tests/test_survey_ui_feedback.py
@@ -292,3 +292,48 @@ class TestUiFeedback(HttpCaseWithUserDemo):
def test_06_survey_prefill(self):
access_token = self.survey_feedback.access_token
self.start_tour("/survey/start/%s" % access_token, 'test_survey_prefill')
+
+ def test_07_survey_roaming_mandatory_questions(self):
+ survey_with_mandatory_questions = self.env['survey.survey'].create({
+ 'title': 'Survey With Mandatory questions',
+ 'access_token': '853ebb30-40f2-43bf-a95a-bbf0e367a365',
+ 'access_mode': 'public',
+ 'users_can_go_back': True,
+ 'questions_layout': 'page_per_question',
+ 'description': " Test survey with roaming freely option and mandatory questions
@@ -337,7 +345,10 @@