From b1e32976a6297daf2fd5200230c45fe823d26f15 Mon Sep 17 00:00:00 2001 From: David Beguin Date: Thu, 4 Apr 2019 11:56:40 +0000 Subject: [PATCH] [REF] survey : make survey form a widget Purpose of this commit is - to make the survey js controller a widget, to be more 'odoo-standard'. - to lessen number of RPCs and clean old code for prefilling and validation of surveys. Clean Prefill : Prefill can be done directly in the template as the template has already all the needed values (in answer object). Clean Validation : Validation is done at server side and is independant from prefilling values. Dates: Dates are now formatted directly in the template, at rendering, using a format_date fonction pointer called in the template. Don't use widget in review mode: Remove o_survey_form class from review template as only dates were processed in the widget for review template. Breadcrumb : Remove button previous, prev=prev and go_back mechanism : The previous page is handled by the breadcrump Remove redirect url mechanism. Breadcrumb now saves the answers when going to a previous page. Move o_survey_form class to a higher div to englobe breadcrumb in the widget and ease his handling. Remove locale load as already done in session.js#load_modules This commit modifies the route type of survey submit to work in json. The js survey form controller calls now manually the route via rpc. Task ID : 1930132 PR #32419 --- addons/survey/controllers/main.py | 239 +++++---------- addons/survey/models/survey_survey.py | 83 +++-- addons/survey/static/src/js/survey.js | 244 --------------- addons/survey/static/src/js/survey_form.js | 322 ++++++++++++++++++++ addons/survey/static/src/js/survey_timer.js | 2 +- addons/survey/tests/common.py | 4 +- addons/survey/views/assets.xml | 4 +- addons/survey/views/survey_templates.xml | 185 ++++++----- 8 files changed, 568 insertions(+), 515 deletions(-) delete mode 100644 addons/survey/static/src/js/survey.js create mode 100644 addons/survey/static/src/js/survey_form.js diff --git a/addons/survey/controllers/main.py b/addons/survey/controllers/main.py index 63a463e95a9..3b63cd3dd38 100644 --- a/addons/survey/controllers/main.py +++ b/addons/survey/controllers/main.py @@ -13,7 +13,7 @@ from odoo import fields, http, _ from odoo.addons.base.models.ir_ui_view import keep_query from odoo.exceptions import UserError from odoo.http import request, content_disposition -from odoo.tools import ustr +from odoo.tools import ustr, format_datetime, format_date _logger = logging.getLogger(__name__) @@ -223,219 +223,125 @@ class Survey(http.Controller): else: return request.redirect('/survey/fill/%s/%s' % (survey_sudo.access_token, answer_sudo.token)) - # Survey direct link to a specific page - @http.route('/survey/page///', - type='http', auth='public', website=True) - def survey_change_page(self, survey_token, answer_token, page_id, **post): - """ Method called when the user switches from one page to another using the breadcrumbs links - in the survey layout. - TODO: Right now, the answers that are not submitted are LOST when changing from one page to another - using this method. - - The survey "submit" mechanism needs to be refactored entirely to make this more user-friendly.""" - # Controls if the survey can be displayed - access_data = self._get_access_data(survey_token, answer_token, ensure_token=False) - if access_data['validity_code'] is not True: - return self._redirect_with_error(access_data, access_data['validity_code']) - - survey_sudo, answer_sudo = access_data['survey_sudo'], access_data['answer_sudo'] - - return request.render('survey.survey', { - 'survey': survey_sudo, - 'page': request.env['survey.question'].sudo().browse(page_id), - 'answer': answer_sudo - }) - @http.route('/survey/fill//', type='http', auth='public', website=True) - def survey_display_page(self, survey_token, answer_token, prev=None, **post): + def survey_display_page(self, survey_token, answer_token, **post): access_data = self._get_access_data(survey_token, answer_token, ensure_token=True) if access_data['validity_code'] is not True: return self._redirect_with_error(access_data, access_data['validity_code']) survey_sudo, answer_sudo = access_data['survey_sudo'], access_data['answer_sudo'] + data = { + 'format_datetime': lambda dt: format_datetime(request.env, dt, dt_format=False), + 'format_date': lambda date: format_date(request.env, date) + } + + page_or_question_key = 'question' if survey_sudo.questions_layout == 'page_per_question' else 'page' + + # 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) + data.update({ + 'survey': survey_sudo, + page_or_question_key: request.env['survey.question'].sudo().browse(previous_page_or_question_id), + 'answer': answer_sudo, + 'previous_page_id': new_previous_id + }) + return request.render('survey.survey', data) + if survey_sudo.is_time_limited and not answer_sudo.start_datetime: # init start date when user starts filling in the survey answer_sudo.write({ 'start_datetime': fields.Datetime.now() }) - page_or_question_key = 'question' if survey_sudo.questions_layout == 'page_per_question' else 'page' # Select the right page if answer_sudo.state == 'new': # First page - page_or_question_id, last = survey_sudo.next_page_or_question(answer_sudo, 0, go_back=False) - data = { + page_or_question_id, is_last = survey_sudo.next_page_or_question(answer_sudo, 0) + data.update({ 'survey': survey_sudo, page_or_question_key: page_or_question_id, - 'answer': answer_sudo - } - if last: + 'answer': answer_sudo, + }) + if is_last: data.update({'last': True}) return request.render('survey.survey', data) elif answer_sudo.state == 'done': # Display success message return request.render('survey.sfinished', self._prepare_survey_finished_values(survey_sudo, answer_sudo)) elif answer_sudo.state == 'skip': - flag = (True if prev and prev == 'prev' else False) - page_or_question_id, last = survey_sudo.next_page_or_question(answer_sudo, answer_sudo.last_displayed_page_id.id, go_back=flag) + page_or_question_id, is_last = survey_sudo.next_page_or_question(answer_sudo, answer_sudo.last_displayed_page_id.id) + previous_id = survey_sudo._previous_page_or_question_id(answer_sudo, page_or_question_id.id) - #special case if you click "previous" from the last page, then leave the survey, then reopen it from the URL, avoid crash - if not page_or_question_id: - page_or_question_id, last = survey_sudo.next_page_or_question(answer_sudo, answer_sudo.last_displayed_page_id.id, go_back=True) - - data = { + data.update({ 'survey': survey_sudo, page_or_question_key: page_or_question_id, - 'answer': answer_sudo - } - if last: + 'answer': answer_sudo, + 'previous_page_id': previous_id + }) + if is_last: data.update({'last': True}) return request.render('survey.survey', data) else: return request.render("survey.403", {'survey': survey_sudo}) - @http.route('/survey/prefill//', type='http', auth='public', website=True) - def survey_get_answers(self, survey_token, answer_token, page_or_question_id=None, **post): - """ TDE NOTE: original comment: # AJAX prefilling of a survey -> AJAX / http ?? """ - access_data = self._get_access_data(survey_token, answer_token, ensure_token=True) - if access_data['validity_code'] is not True and access_data['validity_code'] != 'answer_done': - return {} - - survey_sudo, answer_sudo = access_data['survey_sudo'], access_data['answer_sudo'] - try: - page_or_question_id = int(page_or_question_id) - except: - page_or_question_id = None - - # Fetch previous answers - if survey_sudo.questions_layout == 'one_page' or not page_or_question_id: - previous_answers = answer_sudo.user_input_line_ids - elif survey_sudo.questions_layout == 'page_per_section': - previous_answers = answer_sudo.user_input_line_ids.filtered(lambda line: line.page_id.id == page_or_question_id) - else: - previous_answers = answer_sudo.user_input_line_ids.filtered(lambda line: line.question_id.id == page_or_question_id) - - # Return non empty answers in a JSON compatible format - ret = {} - for answer in previous_answers: - if not answer.skipped: - answer_tag = '%s_%s' % (answer.survey_id.id, answer.question_id.id) - answer_value = None - if answer.answer_type == 'free_text': - answer_value = answer.value_free_text - elif answer.answer_type == 'text' and answer.question_id.question_type == 'textbox': - answer_value = answer.value_text - elif answer.answer_type == 'text' and answer.question_id.question_type != 'textbox': - # here come comment answers for matrices, simple choice and multiple choice - answer_tag = "%s_%s" % (answer_tag, 'comment') - answer_value = answer.value_text - elif answer.answer_type == 'number': - answer_value = str(answer.value_number) - elif answer.answer_type == 'date': - answer_value = fields.Datetime.to_string(answer.value_date) - elif answer.answer_type == 'datetime': - answer_value = fields.Datetime.to_string(answer.value_datetime) - elif answer.answer_type == 'suggestion' and not answer.value_suggested_row: - answer_value = answer.value_suggested.id - elif answer.answer_type == 'suggestion' and answer.value_suggested_row: - answer_tag = "%s_%s" % (answer_tag, answer.value_suggested_row.id) - answer_value = answer.value_suggested.id - if answer_value: - ret.setdefault(answer_tag, []).append(answer_value) - else: - _logger.warning("[survey] No answer has been found for question %s marked as non skipped" % answer_tag) - return json.dumps(ret, default=str) - - @http.route('/survey/scores//', type='http', auth='public', website=True) - def survey_get_scores(self, survey_id, answer_token, page_id=None, **post): - """ TDE NOTE: original comment: # AJAX scores loading for quiz correction mode -> AJAX / http ?? """ - access_data = self._get_access_data(survey_id, answer_token, ensure_token=True) - if access_data['validity_code'] is not True: - return {} - - survey_sudo, answer_sudo = access_data['survey_sudo'], access_data['answer_sudo'] - - # Compute score for each question - ret = {} - for answer in answer_sudo.user_input_line_ids: - tmp_score = ret.get(answer.question_id.id, 0.0) - ret.update({answer.question_id.id: tmp_score + answer.answer_score}) - return json.dumps(ret) - - @http.route('/survey/submit//', type='http', methods=['POST'], auth='public', website=True) + @http.route('/survey/submit//', type='json', auth='public', website=True) def survey_submit(self, survey_token, answer_token, **post): """ Submit a page from the survey. This will take into account the validation errors and store the answers to the questions. - If the time limit is reached, errors will be skipped, answers wil be ignored and - survey state will be forced to 'done' - - TDE NOTE: original comment: # AJAX submission of a page -> AJAX / http ?? """ + If the time limit is reached, errors will be skipped, answers will be ignored and + survey state will be forced to 'done'""" + # Validation access_data = self._get_access_data(survey_token, answer_token, ensure_token=True) if access_data['validity_code'] is not True: - return {} - + return { + 'error': access_data['validity_code'], + } survey_sudo, answer_sudo = access_data['survey_sudo'], access_data['answer_sudo'] + + questions, page_or_question_id = survey_sudo._get_survey_questions(answer=answer_sudo, + page_id=post.get('page_id'), + question_id=post.get('question_id')) + + errors = {} + for question in questions: + answer_tag = "%s_%s" % (survey_sudo.id, question.id) + errors.update(question.validate_question(post, answer_tag)) + + if errors: + return { + 'error': 'validation', + 'fields': errors, + } + if not answer_sudo.test_entry and not survey_sudo._has_attempts_left(answer_sudo.partner_id, answer_sudo.email, answer_sudo.invite_token): # prevent cheating with users creating multiple 'user_input' before their last attempt return {} - if survey_sudo.questions_layout == 'page_per_section': - page_id = int(post['page_id']) - questions = request.env['survey.question'].sudo().search([('survey_id', '=', survey_sudo.id), ('page_id', '=', page_id)]) - # we need the intersection of the questions of this page AND the questions prepared for that user_input - # (because randomized surveys do not use all the questions of every page) - questions = questions & answer_sudo.question_ids - page_or_question_id = page_id - elif survey_sudo.questions_layout == 'page_per_question': - question_id = int(post['question_id']) - questions = request.env['survey.question'].sudo().browse(question_id) - page_or_question_id = question_id - else: - questions = survey_sudo.question_ids - questions = questions & answer_sudo.question_ids - - errors = {} - # Answer validation + # Submitting survey if not answer_sudo.is_time_limit_reached: for question in questions: answer_tag = "%s_%s" % (survey_sudo.id, question.id) - errors.update(question.validate_question(post, answer_tag)) + request.env['survey.user_input_line'].sudo().save_lines(answer_sudo.id, question, post, answer_tag) - ret = {} - if len(errors): - # Return errors messages to webpage - ret['errors'] = errors - else: - if not answer_sudo.is_time_limit_reached: - for question in questions: - answer_tag = "%s_%s" % (survey_sudo.id, question.id) - request.env['survey.user_input_line'].sudo().save_lines(answer_sudo.id, question, post, answer_tag) + if answer_sudo.is_time_limit_reached or survey_sudo.questions_layout == 'one_page': + answer_sudo._mark_done() + elif 'previous_page_id' in post: + # Go back to specific page using the breadcrumb. Lines are saved and survey continues + return '/survey/fill/%s/%s?previous_page_id=%s' % (survey_sudo.access_token, answer_token, post['previous_page_id']) + elif 'button_submit' in post: + next_page, unused = request.env['survey.survey'].next_page_or_question(answer_sudo, page_or_question_id) + vals = {'last_displayed_page_id': page_or_question_id} - vals = {} - if answer_sudo.is_time_limit_reached or survey_sudo.questions_layout == 'one_page': - go_back = False + if next_page is None: answer_sudo._mark_done() - elif 'button_submit' in post: - go_back = post['button_submit'] == 'previous' - next_page, last = request.env['survey.survey'].next_page_or_question(answer_sudo, page_or_question_id, go_back=go_back) - vals = {'last_displayed_page_id': page_or_question_id} - - if next_page is None and not go_back: - answer_sudo._mark_done() - else: - vals.update({'state': 'skip'}) - - if 'breadcrumb_redirect' in post: - ret['redirect'] = post['breadcrumb_redirect'] else: - if vals: - answer_sudo.write(vals) + vals.update({'state': 'skip'}) - ret['redirect'] = '/survey/fill/%s/%s' % (survey_sudo.access_token, answer_token) - if go_back: - ret['redirect'] += '?prev=prev' + answer_sudo.write(vals) - return json.dumps(ret) + return '/survey/fill/%s/%s' % (survey_sudo.access_token, answer_token) # ------------------------------------------------------------ # COMPLETED SURVEY ROUTES @@ -460,8 +366,9 @@ class Survey(http.Controller): 'review': review, 'survey': survey_sudo, 'answer': answer_sudo, - 'page_nr': 0, - 'quizz_correction': survey_sudo.scoring_type != 'scoring_without_answers' and answer_sudo}) + 'format_datetime': lambda dt: format_datetime(request.env, dt, dt_format=False), + 'format_date': lambda date: format_date(request.env, date) + }) @http.route('/survey/results/', type='http', auth='user', website=True) def survey_report(self, survey, answer_token=None, **post): diff --git a/addons/survey/models/survey_survey.py b/addons/survey/models/survey_survey.py index da38ae060af..7a6a73491ed 100644 --- a/addons/survey/models/survey_survey.py +++ b/addons/survey/models/survey_survey.py @@ -348,19 +348,42 @@ class Survey(models.Model): # ------------------------------------------------------------ # ACTIONS # ------------------------------------------------------------ + @api.model + def _get_pages_or_questions(self, user_input): + if self.questions_layout == 'one_page': + return None + elif self.questions_layout == 'page_per_question' and self.questions_selection == 'random': + return list(enumerate( + user_input.question_ids + )) + else: + return list(enumerate( + self.question_ids if self.questions_layout == 'page_per_question' else self.page_ids + )) @api.model - def next_page_or_question(self, user_input, page_or_question_id, go_back=False): + 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 and go_back == False, it will - return None - - If go_back == True, it will return the *previous* page instead of the - next page. + 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 @@ -369,16 +392,9 @@ class Survey(models.Model): """ survey = user_input.survey_id - if survey.questions_layout == 'one_page': + pages_or_questions = survey._get_pages_or_questions(user_input) + if not pages_or_questions: return (None, False) - elif survey.questions_layout == 'page_per_question' and survey.questions_selection == 'random': - pages_or_questions = list(enumerate( - user_input.question_ids - )) - else: - pages_or_questions = list(enumerate( - survey.question_ids if survey.questions_layout == 'page_per_question' else survey.page_ids - )) # First page if page_or_question_id == 0: @@ -387,18 +403,35 @@ class Survey(models.Model): current_page_index = pages_or_questions.index(next(p for p in pages_or_questions if p[1].id == page_or_question_id)) # All the pages have been displayed - if current_page_index == len(pages_or_questions) - 1 and not go_back: + if current_page_index == len(pages_or_questions) - 1: return (None, False) - # Let's get back, baby! - elif go_back and survey.users_can_go_back: - return (pages_or_questions[current_page_index - 1][1], False) else: - # This will show the last page - if current_page_index == len(pages_or_questions) - 2: - return (pages_or_questions[current_page_index + 1][1], True) - # This will show a regular page - else: - return (pages_or_questions[current_page_index + 1][1], False) + show_last = current_page_index == len(pages_or_questions) - 2 + return (pages_or_questions[current_page_index + 1][1], show_last) + + def _get_survey_questions(self, answer=None, page_id=None, question_id=None): + questions, page_or_question_id = None, None + + if self.questions_layout == 'page_per_section': + if not page_id: + raise ValueError("Page id is needed for question layout 'page_per_section'") + page_id = int(page_id) + questions = self.env['survey.question'].sudo().search([('survey_id', '=', self.id), ('page_id', '=', page_id)]) + page_or_question_id = page_id + elif self.questions_layout == 'page_per_question': + if not question_id: + raise ValueError("Question id is needed for question layout 'page_per_question'") + question_id = int(question_id) + questions = self.env['survey.question'].sudo().browse(question_id) + page_or_question_id = question_id + else: + questions = self.question_ids + + # we need the intersection of the questions of this page AND the questions prepared for that user_input + # (because randomized surveys do not use all the questions of every page) + if answer: + questions = questions & answer.question_ids + return questions, page_or_question_id def action_draft(self): self.write({'state': 'draft'}) diff --git a/addons/survey/static/src/js/survey.js b/addons/survey/static/src/js/survey.js deleted file mode 100644 index 1f12c6ad2a6..00000000000 --- a/addons/survey/static/src/js/survey.js +++ /dev/null @@ -1,244 +0,0 @@ -odoo.define('survey.survey', function (require) { -'use strict'; - -require('web.dom_ready'); -var core = require('web.core'); -var time = require('web.time'); -var ajax = require('web.ajax'); -var field_utils = require('web.field_utils'); - -var _t = core._t; -/* - * This file is intended to add interactivity to survey forms - */ - -var the_form = $('.js_surveyform'); - -if(!the_form.length) { - return Promise.reject("DOM doesn't contain '.js_surveyform'"); -} - - var prefill_controller = the_form.attr("data-prefill"); - var submit_controller = the_form.attr("data-submit"); - var scores_controller = the_form.attr("data-scores"); - var print_mode = false; - var quiz_correction_mode = false; - - // Printing mode: will disable all the controls in the form - if (_.isUndefined(submit_controller)) { - $(".js_surveyform .input-group-text span.fa-calendar").css("pointer-events", "none"); - $('.js_surveyform :input').prop('disabled', true); - print_mode = true; - } - - // Quiz correction mode - if (! _.isUndefined(scores_controller)) { - quiz_correction_mode = true; - } - - - // Custom code for right behavior of radio buttons with comments box - $('.js_comments>input[type="text"]').focusin(function(){ - $(this).prev().find('>input').attr("checked","checked"); - }); - $('.js_radio input[type="radio"][data-oe-survey-otherr!="1"]').click(function(){ - $(this).closest('.js_radio').find('.js_comments>input[type="text"]').val(""); - }); - $('.js_comments input[type="radio"]').click(function(){ - $(this).closest('.js_comments').find('>input[data-oe-survey-othert="1"]').focus(); - }); - // Custom code for right behavior of dropdown menu with comments - $('.js_drop input[data-oe-survey-othert="1"]').hide(); - $('.js_drop select').change(function(){ - var other_val = $(this).find('.js_other_option').val(); - if($(this).val() === other_val){ - $(this).parent().removeClass('col-lg-12').addClass('col-lg-6'); - $(this).closest('.js_drop').find('input[data-oe-survey-othert="1"]').show().focus(); - } - else{ - $(this).parent().removeClass('col-lg-6').addClass('col-lg-12'); - $(this).closest('.js_drop').find('input[data-oe-survey-othert="1"]').val("").hide(); - } - }); - // Custom code for right behavior of checkboxes with comments box - $('.js_ck_comments>input[type="text"]').focusin(function(){ - $(this).prev().find('>input').attr("checked","checked"); - }); - $('.js_ck_comments input[type="checkbox"]').change(function(){ - if (! $(this).prop("checked")){ - $(this).closest('.js_ck_comments').find('input[type="text"]').val(""); - } - }); - - // Pre-filling of the form with previous answers - function prefill(){ - if (! _.isUndefined(prefill_controller)) { - var prefill_def = $.ajax(prefill_controller, {dataType: "json"}) - .done(function(json_data){ - _.each(json_data, function(value, key){ - - // prefill of text/number/date boxes - var input = the_form.find(".form-control[name=" + key + "]"); - if (input.hasClass('datetimepicker-input')) { - // display dates in user timezone - var moment_date = field_utils.parse.date(value[0]); - if ($(input).closest('.input-group.date').data('questiontype') === 'datetime') { - value = field_utils.format.datetime(moment_date, null, {timezone: true}); - } - else { - value = field_utils.format.date(moment_date, null, {timezone: true}); - } - } - input.val(value); - - // special case for comments under multiple suggestions questions - if (_.string.endsWith(key, "_comment") && - (input.parent().hasClass("js_comments") || input.parent().hasClass("js_ck_comments"))) { - input.siblings().find('>input').attr("checked","checked"); - } - - // checkboxes and radios - the_form.find("input[name^='" + key + "_'][type='checkbox']").each(function(){ - $(this).val(value); - }); - the_form.find("input[name=" + key + "][type!='text']").each(function(){ - $(this).val(value); - }); - }); - }) - .fail(function(){ - console.warn("[survey] Unable to load prefill data"); - }); - return prefill_def; - } - } - - // Display score if quiz correction mode - function display_scores(){ - if (! _.isUndefined(scores_controller)) { - var score_def = $.ajax(scores_controller, {dataType: "json"}) - .done(function(json_data){ - _.each(json_data, function(value, key){ - the_form.find("span[data-score-question=" + key + "]").text("Your score: " + value); - }); - }) - .fail(function(){ - console.warn("[survey] Unable to load score data"); - }); - return score_def; - } - } - - $('.o_survey_header .breadcrumb-item a').each(function() { - var $valObj = $(this); - $valObj.click(function(event) { - event.preventDefault(); - $('').attr({type: 'hidden', name: 'breadcrumb_redirect', value: $valObj.attr('href')}).appendTo('.js_surveyform'); - $('.js_surveyform').submit(); - }); - }); - - // Parameters for form submission - $('.js_surveyform').ajaxForm({ - url: submit_controller, - type: 'POST', // submission type - dataType: 'json', // answer expected type - beforeSubmit: function(formData, $form, options){ // hide previous errmsg before resubmitting - var date_fields = $form.find('div.date > input.form-control'); - for (var i=0; i < date_fields.length; i++) { - var el = date_fields[i]; - var questiontype = $(el).closest('.input-group.date').data('questiontype'); - var moment_date = questiontype === 'datetime' ? field_utils.parse.datetime(el.value, null, {timezone: true}) : field_utils.parse.date(el.value); - - var field_obj = _.findWhere(formData, {'name': el.name}); - field_obj.value = moment_date ? moment_date.toJSON() : ''; - } - $('.js_errzone').html("").hide(); - }, - success: function(response, status, xhr, wfe){ // submission attempt - if(_.has(response, 'errors')){ // some questions have errors - _.each(_.keys(response.errors), function(key){ - $("#" + key + '>.js_errzone').append('

' + response.errors[key] + '

').show(); - if (_.keys(response.errors)[_.keys(response.errors).length - 1] === key) { - $('html, body').animate({ - scrollTop: $('.js_errzone:visible:first').closest('.js_question-wrapper').offset().top - $('.o_main_navbar').height() - }, 500); - } - }); - return false; - } - else if (_.has(response, 'redirect')){ // form is ok - window.location.replace(response.redirect); - return true; - } - else { // server sends bad data - console.error("Incorrect answer sent by server"); - return false; - } - }, - timeout: 5000, - error: function(jqXHR, textStatus, errorThrown){ // failure of AJAX request - $('#AJAXErrorModal').modal('show'); - } - }); - - function load_locale(){ - var url = "/web/webclient/locale/" + (document.documentElement.getAttribute('lang') || 'en_US').replace('-', '_'); - return ajax.loadJS(url); - } - - // datetimepicker use moment locale to display date format according to language - // frontend does not load moment locale at all. - // so wait until DOM ready with locale then init datetimepicker - load_locale().then(function(){ - _.each($('.input-group.date'), function(date_field){ - var disabledDates = [] - if ($(date_field).data('mindate')) { - var minDate = field_utils.format.datetime(moment($(date_field).data('mindate')), null, {timezone: true}); - } - if ($(date_field).data('maxdate')) { - var maxDate = field_utils.format.datetime(moment($(date_field).data('maxdate')), null, {timezone: true}); - } - else { - var minDate = $(date_field).data('mindate') || moment({ y: 1900 }); - var maxDate = $(date_field).data('maxdate') || moment().add(200, "y"); - } - var datetimepickerFormat = time.getLangDateFormat() - if ($(date_field).data('questiontype') === 'datetime') { - datetimepickerFormat = time.getLangDatetimeFormat() - } - else{ - maxDate = moment(maxDate).add(1, "d"); - disabledDates = [maxDate] - } - $('#' + date_field.id).datetimepicker({ - format : datetimepickerFormat, - minDate: minDate, - maxDate: maxDate, - disabledDates: disabledDates, - useCurrent: false, - viewDate: moment(new Date()).hours(0).minutes(0).seconds(0).milliseconds(0), - calendarWeeks: true, - icons: { - time: 'fa fa-clock-o', - date: 'fa fa-calendar', - next: 'fa fa-chevron-right', - previous: 'fa fa-chevron-left', - up: 'fa fa-chevron-up', - down: 'fa fa-chevron-down', - }, - locale : moment.locale(), - allowInputToggle: true, - keyBinds: null, - }); - $('#' + date_field.id).on('error.datetimepicker', function () { - return false; - }); - }); - // Launch prefilling - prefill(); - if(quiz_correction_mode === true){ - display_scores(); - } - }); -}); diff --git a/addons/survey/static/src/js/survey_form.js b/addons/survey/static/src/js/survey_form.js new file mode 100644 index 00000000000..23ee96d71ea --- /dev/null +++ b/addons/survey/static/src/js/survey_form.js @@ -0,0 +1,322 @@ +odoo.define('survey.form', function(require) { +'use strict'; + +var ajax = require('web.ajax'); +var field_utils = require('web.field_utils'); +var publicWidget = require('web.public.widget'); +var time = require('web.time'); + +publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({ + selector: '.o_survey_form', + events: { + 'change .o_survey_form_choice_item': '_onChangeChoiceItem', + 'click button[name="button_submit"]': '_onSubmit', + 'click .o_survey_header .breadcrumb-item a': '_onBreadcrumbClick', + }, + + //-------------------------------------------------------------------------- + // Widget + //-------------------------------------------------------------------------- + + /** + * @override + */ + start: function () { + var self = this; + return this._super.apply(this, arguments).then(function() { + self.options = self.$target.find('form').data() + var $timer = self.$('.o_survey_timer'); + if ($timer.length) { + this.surveyTimerWidget = new publicWidget.registry.SurveyTimerWidget(this); + this.surveyTimerWidget.attachTo($timer); + this.surveyTimerWidget.on('time_up', this, function (ev) { + self.$el.find('button[name="button_submit"]').click(); + }); + } + + self.$('div.o_survey_form_date').each(function () { + self._initDateTimePicker($(this)); + }); + }); + }, + + // ------------------------------------------------------------------------- + // Private + // ------------------------------------------------------------------------- + + // Handlers + // ------------------------------------------------------------------------- + + /* + * Checks, if the 'other' choice is checked. Applies only if the comment count as answer. + * In case of dropdownlist: + * If not checked : Clear the comment textarea and hide it + * If checked : show the comment textarea and focus on it + * In case of radio buttons + * If not checked : Clear the comment textarea and disable it + * If checked : enable the comment textarea and focus on it + * + * @private + * @param {Event} event + */ + _onChangeChoiceItem: function (event) { + var $choiceItemGroup = $(event.currentTarget).parents('.o_survey_form_choice'); + var $otherItem = $choiceItemGroup.find('.o_survey_js_form_other_comment'); + var $commentInput = $choiceItemGroup.find('textarea[type="text"]'); + + // Handle dropdownlist + var $dropdownlist = $choiceItemGroup.find('select'); + var isDropdown = $dropdownlist.length > 0 + + var isOtherSelected = (isDropdown && $dropdownlist.val() === $otherItem.val()) || $otherItem.prop('checked') + + if (isOtherSelected || $commentInput.hasClass('o_survey_comment')) { + if (isDropdown) { + $commentInput.toggleClass('d-none', false); + } + if (isOtherSelected) { + $commentInput.enable(); + $commentInput.focus(); + } + } else { + if (isDropdown) { + $commentInput.toggleClass('d-none', true); + } + $commentInput.val(''); + $commentInput.enable(false); + } + }, + + /* + * When clicking on a breadcrumb enabled item, redirect to the target page. + * Uses the submit flow to validate and save the answer before going to the specified page. + * + * @private + * @param {Event} event + */ + _onBreadcrumbClick: function (event) { + event.preventDefault(); + this._submitForm({'previous_page_id': $(event.currentTarget).data('previousPageId')}) + }, + + _onSubmit: function (event) { + event.preventDefault(); + var $target = $(event.currentTarget); + var submitValue = $target.val(); + this._submitForm({'button_submit': submitValue}) + }, + + _submitForm: function (params) { + var self = this; + var $form = this.$('form'); + var formData = new FormData($form[0]); + + this.$('div.o_survey_form_date').each(function () { + self._updateDateForSubmit($(this), formData); + }); + this._prepareSubmitValues(formData, params); + + this._resetErrors(); + + return self._rpc({ + route: '/survey/submit/' + self.options['surveyToken'] + '/' + self.options['answerToken'], + params: params, + }).then(function (result) { + return self._onSubmitDone(result, params); + }); + }, + + _onSubmitDone: function (result, params) { + var self = this; + if (result && !result.error) { + window.location = result; + } + else if (result && result.fields && result.error === 'validation') { + var fieldKeys = _.keys(result.fields); + _.each(fieldKeys, function (key) { + self.$("#" + key + '>.o_survey_question_error').append($('

', {text: result.fields[key]})).toggleClass('d-none', false); + if (fieldKeys[fieldKeys.length - 1] === key) { + self._scrollToError(self.$('.o_survey_question_error:visible:first').closest('.js_question-wrapper')); + } + }); + return false; + } + else { + var $target = self.$('.o_survey_error'); + $target.toggleClass('d-none', false); + self._scrollToError($target); + return false; + } + }, + + // INIT FIELDS + // ------------------------------------------------------------------------- + /* + * Convert the client side date format into server side date format before submit + */ + _updateDateForSubmit: function (dateGroup, formData) { + var input = $(dateGroup).find('input'); + var dateValue = input.val(); + var questionType = $(input).closest('.o_survey_form_date').data('questiontype'); + if (dateValue) { + var momentDate = questionType === 'datetime' ? field_utils.parse.datetime(dateValue, null, {timezone: true}) : field_utils.parse.date(dateValue); + var newDate = momentDate ? momentDate.toJSON() : ''; + formData.set(input.attr('name'), newDate); + input.val(newDate); + } + }, + + // TOOLS + // ------------------------------------------------------------------------- + _prepareSubmitValues: function (formData, params) { + var self = this; + formData.forEach(function(value, key){ + if (value !== -1) { + // Handles Comment + if (key.indexOf('_comment') !== -1){ + key = key.split('_comment')[0]; + value = {'comment': value}; + } + // Handles Matrix - Matrix answer_tag are composed like : 'questionId_rowId_colId' + // and are the only ones with this structure. + var splitKey = key.split('_'); + if (splitKey.length === 3 && splitKey[2] === value) { + params = self._prepareSubmitMatrix(params, splitKey, value); + } + // Handles the rest + else { + params = self._prepareSubmitOther(params, key, value); + } + } + }); + }, + + /** + * Prepare answer before submitting form if question type is matrix. + * This method regroups answers by question and by row to make a object like : + * params = { 'matrixQuestionId' : { 'rowId1' : [colId1, colId2,...], 'rowId2' : [colId1, colId3, ...] } } + */ + _prepareSubmitMatrix: function(params, splitKey, value) { + var key = splitKey[0]; + var rowId = splitKey[1]; + var colId = splitKey[2]; + value = key in params ? params[key] : {}; + if (rowId in value) { + value[rowId].push(colId); + } else { + value[rowId] = [colId]; + } + params[key] = value; + return params; + }, + + /** + * Prepare answer before submitting form (any kind of answer - except Matrix -). + * This method regroups answers by question. + * Lonely answer are directly assigned to questionId. Multiple answers are regrouped in an array: + * params = { 'questionId1' : lonelyAnswer, 'questionId2' : [multipleAnswer1, multipleAnswer2, ...] } + */ + _prepareSubmitOther: function(params, key, value) { + if (key in params) { + if (params[key].constructor === Array) { + params[key].push(value); + } else { + params[key] = [params[key], value]; + } + } else { + params[key] = value; + } + return params; + }, + + /** + * Convert date value in client current timezone (if already answered) + */ + _formatDateValue: function ($dateGroup) { + var input = $dateGroup.find('input'); + var dateValue = input.val(); + if (dateValue !== '') { + var momentDate = field_utils.parse.date(dateValue); + if ($dateGroup.data('questiontype') === 'datetime') { + dateValue = field_utils.format.datetime(momentDate, null, {timezone: true}); + } else { + dateValue = field_utils.format.date(momentDate, null, {timezone: true}); + } + } + input.val(dateValue); + return dateValue + }, + + /** + * Initialize datetimepicker in correct format and with constraints + */ + _initDateTimePicker: function ($dateGroup) { + var disabledDates = [] + + var minDateData = $dateGroup.data('mindate') + var minDate = minDateData ? this._formatDateTime(minDateData) : moment({ y: 1900 }); + var maxDateData = $dateGroup.data('maxdate') + var maxDate = maxDateData ? this._formatDateTime(maxDateData) : moment().add(200, "y"); + + var datetimepickerFormat = time.getLangDateFormat() + if ($dateGroup.data('questiontype') === 'datetime') { + datetimepickerFormat = time.getLangDatetimeFormat() + } else { + // Include min and max date in selectable values + maxDate = moment(maxDate).add(1, "d"); + minDate = moment(minDate).subtract(1, "d"); + disabledDates = [minDate, maxDate] + } + + $dateGroup.datetimepicker({ + format : datetimepickerFormat, + minDate: minDate, + maxDate: maxDate, + disabledDates: disabledDates, + useCurrent: false, + viewDate: moment(new Date()).hours(0).minutes(0).seconds(0).milliseconds(0), + calendarWeeks: true, + icons: { + time: 'fa fa-clock-o', + date: 'fa fa-calendar', + next: 'fa fa-chevron-right', + previous: 'fa fa-chevron-left', + up: 'fa fa-chevron-up', + down: 'fa fa-chevron-down', + }, + locale : moment.locale(), + allowInputToggle: true, + }); + $dateGroup.on('error.datetimepicker', function () { + return false; + }); + }, + + _formatDateTime: function (datetimeValue){ + return field_utils.format.datetime(moment(datetimeValue), null, {timezone: true}); + }, + + _scrollToError: function ($target) { + var scrollLocation = $target.offset().top; + var navbarHeight = $('.o_main_navbar').height(); + if (navbarHeight != undefined) { + scrollLocation -= navbarHeight + } + $('html, body').animate({ + scrollTop: scrollLocation + }, 500); + }, + + /** + * Clean all form errors in order to clean DOM before a new validation + */ + _resetErrors: function () { + this.$('.o_survey_question_error').empty().toggleClass("d-none", true); + this.$('.o_survey_error').toggleClass("d-none", true); + }, + +}); + +return publicWidget.registry.SurveyFormWidget; + +}); diff --git a/addons/survey/static/src/js/survey_timer.js b/addons/survey/static/src/js/survey_timer.js index 7d6c0925819..c9cc7b547fa 100644 --- a/addons/survey/static/src/js/survey_timer.js +++ b/addons/survey/static/src/js/survey_timer.js @@ -35,7 +35,7 @@ var updateTimer = function () { $timer.html(formatTime(timeLeftMinutes) + ':' + formatTime(timeLeftSeconds)); } else { clearInterval(interval); - $('.js_surveyform').submit(); + $('.o_survey_form').submit(); } }; diff --git a/addons/survey/tests/common.py b/addons/survey/tests/common.py index 2563e44aae4..25f600ef5a1 100644 --- a/addons/survey/tests/common.py +++ b/addons/survey/tests/common.py @@ -215,7 +215,9 @@ class TestSurveyCommon(common.SavepointCase): return self.url_open('/survey/fill/%s/%s' % (survey.access_token, token)) def _access_submit(self, survey, token, post_data): - return self.url_open('/survey/submit/%s/%s' % (survey.access_token, token), data=post_data) + base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url') + url = base_url + '/survey/submit/%s/%s' % (survey.access_token, token) + return self.opener.post(url=url, json={'params': post_data}) def _find_csrf_token(self, text): csrf_token_re = re.compile("(input.+csrf_token.+value=\")([_a-zA-Z0-9]{51})", re.MULTILINE) diff --git a/addons/survey/views/assets.xml b/addons/survey/views/assets.xml index b4b042a61e1..3135c064bb0 100644 --- a/addons/survey/views/assets.xml +++ b/addons/survey/views/assets.xml @@ -7,9 +7,9 @@ -