diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/survey/static/src/js/survey_form.js | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/survey/static/src/js/survey_form.js')
| -rw-r--r-- | addons/survey/static/src/js/survey_form.js | 1094 |
1 files changed, 1094 insertions, 0 deletions
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 00000000..e0e12c1c --- /dev/null +++ b/addons/survey/static/src/js/survey_form.js @@ -0,0 +1,1094 @@ +odoo.define('survey.form', function (require) { +'use strict'; + +var field_utils = require('web.field_utils'); +var publicWidget = require('web.public.widget'); +var time = require('web.time'); +var core = require('web.core'); +var Dialog = require('web.Dialog'); +var dom = require('web.dom'); +var utils = require('web.utils'); + +var _t = core._t; + +publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({ + selector: '.o_survey_form', + events: { + 'change .o_survey_form_choice_item': '_onChangeChoiceItem', + 'click .o_survey_matrix_btn': '_onMatrixBtnClick', + 'click button[type="submit"]': '_onSubmit', + }, + custom_events: { + 'breadcrumb_click': '_onBreadcrumbClick', + }, + + //-------------------------------------------------------------------------- + // Widget + //-------------------------------------------------------------------------- + + /** + * @override + */ + start: function () { + var self = this; + this.fadeInOutDelay = 400; + 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; + + // Add Survey cookie to retrieve the survey if you quit the page and restart the survey. + if (!utils.get_cookie('survey_' + self.options.surveyToken)) { + utils.set_cookie('survey_' + self.options.surveyToken, self.options.answerToken, 60*60*24); + } + + // Init fields + if (!self.options.isStartScreen && !self.readonly) { + self._initTimer(); + self._initBreadcrumb(); + } + self.$('div.o_survey_form_date').each(function () { + self._initDateTimePicker($(this)); + }); + self._initChoiceItems(); + self._initTextArea(); + self._focusOnFirstInput(); + // Init event listener + if (!self.readonly) { + $(document).on('keydown', self._onKeyDown.bind(self)); + } + if (self.options.sessionInProgress && + (self.options.isStartScreen || self.options.hasAnswered || self.options.isPageDescription)) { + self.preventEnterSubmit = true; + } + self._initSessionManagement(); + + // Needs global selector as progress/navigation are not within the survey form, but need + //to be updated at the same time + self.$surveyProgress = $('.o_survey_progress_wrapper'); + self.$surveyNavigation = $('.o_survey_navigation_wrapper'); + self.$surveyNavigation.find('.o_survey_navigation_submit').on('click', self._onSubmit.bind(self)); + }); + }, + + // ------------------------------------------------------------------------- + // Private + // ------------------------------------------------------------------------- + + // Handlers + // ------------------------------------------------------------------------- + + /** + * Handle keyboard navigation: + * - 'enter' or 'arrow-right' => submit form + * - 'arrow-left' => submit form (but go back backwards) + * - other alphabetical character ('a', 'b', ...) + * Select the related option in the form (if available) + * + * @param {Event} event + */ + _onKeyDown: function (event) { + // If user is answering a text input, do not handle keydown + if (this.$("textarea").is(":focus") || this.$('input').is(':focus')) { + return; + } + // If in session mode and question already answered, do not handle keydown + if (this.$('fieldset[disabled="disabled"]').length !== 0) { + return; + } + + var self = this; + var keyCode = event.keyCode; + var letter = String.fromCharCode(keyCode).toUpperCase(); + + // Handle Start / Next / Submit + 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}); + } + } 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 + // data that are used in the event handler. + // Again, global selector necessary since the navigation is outside of the form. + $('.o_survey_navigation_submit[value="previous"]').click(); + } else if (self.options.questionsLayout === 'page_per_question' + && letter.match(/[a-z]/i)) { + var $choiceInput = this.$(`input[data-selection-key=${letter}]`); + if ($choiceInput.length === 1) { + $choiceInput.prop("checked", !$choiceInput.prop("checked")).trigger('change'); + + // Avoid selection key to be typed into the textbox if 'other' is selected by key + event.preventDefault(); + } + } + }, + + /** + * Checks, if the 'other' choice is checked. Applies only if the comment count as answer. + * If not checked : Clear the comment textarea, hide and disable it + * If checked : enable the comment textarea, show and focus on it + * + * @private + * @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'); + var $commentInput = $choiceItemGroup.find('textarea[type="text"]'); + + if ($otherItem.prop('checked') || $commentInput.hasClass('o_survey_comment')) { + $commentInput.enable(); + $commentInput.closest('.o_survey_comment_container').removeClass('d-none'); + if ($otherItem.prop('checked')) { + $commentInput.focus(); + } + } else { + $commentInput.val(''); + $commentInput.closest('.o_survey_comment_container').addClass('d-none'); + $commentInput.enable(false); + } + + var $matrixBtn = $target.closest('.o_survey_matrix_btn'); + if ($target.attr('type') === 'radio') { + var isQuestionComplete = false; + if ($matrixBtn.length > 0) { + $matrixBtn.closest('tr').find('td').removeClass('o_survey_selected'); + $matrixBtn.addClass('o_survey_selected'); + if (this.options.questionsLayout === 'page_per_question') { + var subQuestionsIds = $matrixBtn.closest('table').data('subQuestions'); + var completedQuestions = []; + subQuestionsIds.forEach(function (id) { + if (self.$('tr#' + id).find('input:checked').length !== 0) { + completedQuestions.push(id); + } + }); + isQuestionComplete = completedQuestions.length === subQuestionsIds.length; + } + } else { + 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'); + isQuestionComplete = this.options.questionsLayout === 'page_per_question'; + } + + // 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())); + } + } + } + // Auto Submit Form + var isLastQuestion = this.$('button[value="finish"]').length !== 0; + var questionHasComment = $target.closest('.o_survey_form_choice').find('.o_survey_comment').length !== 0 + || $target.hasClass('o_survey_js_form_other_comment'); + if (!isLastQuestion && this.options.usersCanGoBack && isQuestionComplete && !questionHasComment) { + this._submitForm({}); + } + } else { // $target.attr('type') === 'checkbox' + if ($matrixBtn.length > 0) { + $matrixBtn.toggleClass('o_survey_selected', !$matrixBtn.hasClass('o_survey_selected')); + } 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())); + } + } + } + } + }, + + _onMatrixBtnClick: function (event) { + if (this.readonly) { + return; + } + + var $target = $(event.currentTarget); + var $input = $target.find('input'); + if ($input.attr('type') === 'radio') { + $input.prop("checked", true).trigger('change'); + } else { + $input.prop("checked", !$input.prop("checked")).trigger('change'); + } + }, + + _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') { + options.isFinish = true; + } + this._submitForm(options); + }, + + // Custom Events + // ------------------------------------------------------------------------- + + _onBreadcrumbClick: function (event) { + this._submitForm({'previousPageId': event.data.previousPageId}); + }, + + /** + * We listen to 'next_question' and 'end_session' events to load the next + * page of the survey automatically, based on the host pacing. + * + * If the trigger is 'next_question', we handle some extra computation to find + * a suitable "fadeInOutDelay" based on the delay between the time of the question + * change by the host and the time of reception of the event. + * This will allow us to account for a little bit of server lag (up to 1 second) + * while giving everyone a fair experience on the quiz. + * + * e.g 1: + * - The host switches the question + * - We receive the event 200 ms later due to server lag + * - -> The fadeInOutDelay will be 400 ms (200ms delay + 400ms * 2 fade in fade out) + * + * e.g 2: + * - The host switches the question + * - We receive the event 600 ms later due to bigger server lag + * - -> The fadeInOutDelay will be 200ms (600ms delay + 200ms * 2 fade in fade out) + * + * @private + * @param {Array[]} notifications structured as specified by the bus feature + */ + _onNotification: function (notifications) { + var nextPageEvent = false; + if (notifications && notifications.length !== 0) { + notifications.forEach(function (notification) { + if (notification.length >= 2) { + var event = notification[1]; + if (event.type === 'next_question' || + event.type === 'end_session') { + nextPageEvent = event; + } + } + }); + } + + if (this.options.isStartScreen && nextPageEvent.type === 'end_session') { + // can happen when triggering the same survey session multiple times + // we received an "old" end_session event that needs to be ignored + return; + } + + if (nextPageEvent) { + if (nextPageEvent.type === 'next_question') { + var serverDelayMS = moment.utc().valueOf() - moment.unix(nextPageEvent.question_start).utc().valueOf(); + if (serverDelayMS < 0) { + serverDelayMS = 0; + } else if (serverDelayMS > 1000) { + serverDelayMS = 1000; + } + this.fadeInOutDelay = (1000 - serverDelayMS) / 2; + } else { + this.fadeInOutDelay = 400; + } + + this.$('.o_survey_main_title:visible').fadeOut(400); + + this.preventEnterSubmit = false; + this.readonly = false; + this._nextScreen( + this._rpc({ + route: `/survey/next_question/${this.options.surveyToken}/${this.options.answerToken}`, + }), { + initTimer: true, + isFinish: nextPageEvent.type === 'end_session' + } + ); + } + }, + + // SUBMIT + // ------------------------------------------------------------------------- + + /** + * This function will send a json rpc call to the server to + * - start the survey (if we are on start screen) + * - submit the answers of the current page + * Before submitting the answers, they are first validated to avoid latency from the server + * and allow a fade out/fade in transition of the next question. + * + * @param {Array} [options] + * @param {Integer} [options.previousPageId] navigates to page id + * @param {Boolean} [options.skipValidation] skips JS validation + * @param {Boolean} [options.initTime] will force the re-init of the timer after next + * screen transition + * @param {Boolean} [options.isFinish] fades out breadcrumb and timer + * @private + */ + _submitForm: function (options) { + var self = this; + var params = {}; + if (options.previousPageId) { + params.previous_page_id = options.previousPageId; + } + var route = "/survey/submit"; + + if (this.options.isStartScreen) { + route = "/survey/begin"; + // Hide survey title in 'page_per_question' layout: it takes too much space + if (this.options.questionsLayout === 'page_per_question') { + this.$('.o_survey_main_title').fadeOut(400); + } + } else { + var $form = this.$('form'); + var formData = new FormData($form[0]); + + if (!options.skipValidation) { + // Validation pre submit + if (!this._validateForm($form, formData)) { + return; + } + } + + this._prepareSubmitValues(formData, params); + } + + // prevent user from submitting more times using enter key + this.preventEnterSubmit = true; + + if (this.options.sessionInProgress) { + // reset the fadeInOutDelay when attendee is submitting form + this.fadeInOutDelay = 400; + // prevent user from clicking on matrix options when form is submitted + this.readonly = true; + } + + var submitPromise = self._rpc({ + route: _.str.sprintf('%s/%s/%s', route, self.options.surveyToken, self.options.answerToken), + params: params, + }); + this._nextScreen(submitPromise, options); + }, + + /** + * Will fade out / fade in the next screen based on passed promise and options. + * + * @param {Promise} nextScreenPromise + * @param {Object} options see '_submitForm' for details + */ + _nextScreen: function (nextScreenPromise, options) { + var self = this; + + var resolveFadeOut; + var fadeOutPromise = new Promise(function (resolve, reject) {resolveFadeOut = resolve;}); + + var selectorsToFadeout = ['.o_survey_form_content']; + if (options.isFinish) { + selectorsToFadeout.push('.breadcrumb', '.o_survey_timer'); + utils.set_cookie('survey_' + self.options.surveyToken, '', -1); // delete cookie + } + self.$(selectorsToFadeout.join(',')).fadeOut(this.fadeInOutDelay, function () { + resolveFadeOut(); + }); + + Promise.all([fadeOutPromise, nextScreenPromise]).then(function (results) { + return self._onNextScreenDone(results[1], options); + }); + }, + + /** + * Handle server side validation and display eventual error messages. + * + * @param {string} result the HTML result of the screen to display + * @param {Object} options see '_submitForm' for details + */ + _onNextScreenDone: function (result, options) { + var self = this; + + if (!(options && options.isFinish) + && !this.options.sessionInProgress) { + this.preventEnterSubmit = false; + } + + if (result && !result.error) { + this.$(".o_survey_form_content").empty(); + this.$(".o_survey_form_content").html(result.survey_content); + + if (result.survey_progress && this.$surveyProgress.length !== 0) { + this.$surveyProgress.html(result.survey_progress); + } else if (options.isFinish && this.$surveyProgress.length !== 0) { + this.$surveyProgress.remove(); + } + + if (result.survey_navigation && this.$surveyNavigation.length !== 0) { + this.$surveyNavigation.html(result.survey_navigation); + this.$surveyNavigation.find('.o_survey_navigation_submit').on('click', self._onSubmit.bind(self)); + } + + // 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)); + }); + if (this.options.isStartScreen || (options && options.initTimer)) { + this._initTimer(); + this.options.isStartScreen = false; + } else { + if (this.options.sessionInProgress && this.surveyTimerWidget) { + this.surveyTimerWidget.destroy(); + } + } + if (options && options.isFinish) { + this._initResultWidget(); + if (this.surveyBreadcrumbWidget) { + this.$('.o_survey_breadcrumb_container').addClass('d-none'); + this.surveyBreadcrumbWidget.destroy(); + } + if (this.surveyTimerWidget) { + this.surveyTimerWidget.destroy(); + } + } else { + this._updateBreadcrumb(); + } + self._initChoiceItems(); + self._initTextArea(); + + if (this.options.sessionInProgress && this.$('.o_survey_form_content_data').data('isPageDescription')) { + // prevent enter submit if we're on a page description (there is nothing to submit) + this.preventEnterSubmit = true; + } + + this.$('.o_survey_form_content').fadeIn(this.fadeInOutDelay); + $("html, body").animate({ scrollTop: 0 }, this.fadeInOutDelay); + self._focusOnFirstInput(); + } + else if (result && result.fields && result.error === 'validation') { + this.$('.o_survey_form_content').fadeIn(0); + this._showErrors(result.fields); + } else { + var $errorTarget = this.$('.o_survey_error'); + $errorTarget.removeClass("d-none"); + this._scrollToError($errorTarget); + } + }, + + // VALIDATION TOOLS + // ------------------------------------------------------------------------- + /** + * Validation is done in frontend before submit to avoid latency from the server. + * If the validation is incorrect, the errors are displayed before submitting and + * fade in / out of submit is avoided. + * + * Each question type gets its own validation process. + * + * There is a special use case for the 'required' questions, where we use the constraint + * error message that comes from the question configuration ('constr_error_msg' field). + * + * @private + */ + _validateForm: function ($form, formData) { + var self = this; + var errors = {}; + var validationEmailMsg = _t("This answer must be an email address."); + var validationDateMsg = _t("This is not a date"); + + this._resetErrors(); + + var data = {}; + formData.forEach(function (value, key) { + 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'); + switch ($input.data('questionType')) { + case 'char_box': + if (questionRequired && !$input.val()) { + errors[questionId] = constrErrorMsg; + } else if ($input.val() && $input.attr('type') === 'email' && !self._validateEmail($input.val())) { + errors[questionId] = validationEmailMsg; + } else { + var lengthMin = $input.data('validationLengthMin'); + var lengthMax = $input.data('validationLengthMax'); + var length = $input.val().length; + if (lengthMin && (lengthMin > length || length > lengthMax)) { + errors[questionId] = validationErrorMsg; + } + } + break; + case 'numerical_box': + if (questionRequired && !data[questionId]) { + errors[questionId] = constrErrorMsg; + } else { + var floatMin = $input.data('validationFloatMin'); + var floatMax = $input.data('validationFloatMax'); + var value = parseFloat($input.val()); + if (floatMin && (floatMin > value || value > floatMax)) { + errors[questionId] = validationErrorMsg; + } + } + break; + case 'date': + case 'datetime': + if (questionRequired && !data[questionId]) { + errors[questionId] = constrErrorMsg; + } else if (data[questionId]) { + var datetimepickerFormat = $input.data('questionType') === 'datetime' ? time.getLangDatetimeFormat() : time.getLangDateFormat(); + var momentDate = moment($input.val(), datetimepickerFormat); + if (!momentDate.isValid()) { + errors[questionId] = validationDateMsg; + } else { + var $dateDiv = $questionWrapper.find('.o_survey_form_date'); + var maxDate = $dateDiv.data('maxdate'); + var minDate = $dateDiv.data('mindate'); + if ((maxDate && momentDate.isAfter(moment(maxDate))) + || (minDate && momentDate.isBefore(moment(minDate)))) { + errors[questionId] = validationErrorMsg; + } + } + } + break; + case 'simple_choice_radio': + case 'multiple_choice': + if (questionRequired) { + var $textarea = $questionWrapper.find('textarea'); + if (!data[questionId]) { + errors[questionId] = constrErrorMsg; + } else if (data[questionId] === '-1' && !$textarea.val()) { + // if other has been checked and value is null + errors[questionId] = constrErrorMsg; + } + } + break; + case 'matrix': + if (questionRequired) { + var subQuestionsIds = $questionWrapper.find('table').data('subQuestions'); + subQuestionsIds.forEach(function (id) { + if (!((questionId + '_' + id) in data)) { + errors[questionId] = constrErrorMsg; + } + }); + } + break; + } + }); + if (_.keys(errors).length > 0) { + this._showErrors(errors); + return false; + } + return true; + }, + + /** + * Check if the email has an '@', a left part and a right part + * @private + */ + _validateEmail: function (email) { + var emailParts = email.split('@'); + return emailParts.length === 2 && emailParts[0] && emailParts[1]; + }, + + // PREPARE SUBMIT TOOLS + // ------------------------------------------------------------------------- + /** + * For each type of question, extract the answer from inputs or textarea (comment or answer) + * + * + * @private + * @param {Event} event + */ + _prepareSubmitValues: function (formData, params) { + var self = this; + formData.forEach(function (value, key) { + switch (key) { + case 'csrf_token': + case 'token': + case 'page_id': + case 'question_id': + params[key] = value; + break; + } + }); + + // Get all question answers by question type + this.$('[data-question-type]').each(function () { + switch ($(this).data('questionType')) { + case 'text_box': + case 'char_box': + case 'numerical_box': + params[this.name] = this.value; + break; + case 'date': + params = self._prepareSubmitDates(params, this.name, this.value, false); + break; + case 'datetime': + params = self._prepareSubmitDates(params, this.name, this.value, true); + break; + case 'simple_choice_radio': + case 'multiple_choice': + params = self._prepareSubmitChoices(params, $(this), $(this).data('name')); + break; + case 'matrix': + params = self._prepareSubmitAnswersMatrix(params, $(this)); + break; + } + }); + }, + + /** + * Prepare date answer before submitting form. + * Convert date value from client current timezone to UTC Date to correspond to the server format. + * return params = { 'dateQuestionId' : '2019-05-23', 'datetimeQuestionId' : '2019-05-23 14:05:12' } + */ + _prepareSubmitDates: function (params, questionId, value, isDateTime) { + var momentDate = isDateTime ? field_utils.parse.datetime(value, null, {timezone: true}) : field_utils.parse.date(value); + var formattedDate = momentDate ? momentDate.toJSON() : ''; + params[questionId] = formattedDate; + return params; + }, + + /** + * Prepare choice answer before submitting form. + * If the answer is not the 'comment selection' (=Other), calls the _prepareSubmitAnswer method to add the answer to the params + * If there is a comment linked to that question, calls the _prepareSubmitComment method to add the comment to the params + */ + _prepareSubmitChoices: function (params, $parent, questionId) { + var self = this; + $parent.find('input:checked').each(function () { + if (this.value !== '-1') { + params = self._prepareSubmitAnswer(params, questionId, this.value); + } + }); + params = self._prepareSubmitComment(params, $parent, questionId, false); + return params; + }, + + + /** + * Prepare matrix answers before submitting form. + * This method adds matrix answers one by one and add comment if any to a params key,value like : + * params = { 'matrixQuestionId' : {'rowId1': [colId1, colId2,...], 'rowId2': [colId1, colId3, ...], 'comment': comment }} + */ + _prepareSubmitAnswersMatrix: function (params, $matrixTable) { + var self = this; + $matrixTable.find('input:checked').each(function () { + params = self._prepareSubmitAnswerMatrix(params, $matrixTable.data('name'), $(this).data('rowId'), this.value); + }); + params = self._prepareSubmitComment(params, $matrixTable.closest('.js_question-wrapper'), $matrixTable.data('name'), true); + return params; + }, + + /** + * Prepare answer before submitting form if question type is matrix. + * This method regroups answers by question and by row to make an object like : + * params = { 'matrixQuestionId' : { 'rowId1' : [colId1, colId2,...], 'rowId2' : [colId1, colId3, ...] } } + */ + _prepareSubmitAnswerMatrix: function (params, questionId, rowId, colId, isComment) { + var value = questionId in params ? params[questionId] : {}; + if (isComment) { + value['comment'] = colId; + } else { + if (rowId in value) { + value[rowId].push(colId); + } else { + value[rowId] = [colId]; + } + } + params[questionId] = 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, ...] } + */ + _prepareSubmitAnswer: function (params, questionId, value) { + if (questionId in params) { + if (params[questionId].constructor === Array) { + params[questionId].push(value); + } else { + params[questionId] = [params[questionId], value]; + } + } else { + params[questionId] = value; + } + return params; + }, + + /** + * Prepare comment before submitting form. + * This method extract the comment, encapsulate it in a dict and calls the _prepareSubmitAnswer methods + * with the new value. At the end, the result looks like : + * params = { 'questionId1' : {'comment': commentValue}, 'questionId2' : [multipleAnswer1, {'comment': commentValue}, ...] } + */ + _prepareSubmitComment: function (params, $parent, questionId, isMatrix) { + var self = this; + $parent.find('textarea').each(function () { + if (this.value) { + var value = {'comment': this.value}; + if (isMatrix) { + params = self._prepareSubmitAnswerMatrix(params, questionId, this.name, this.value, true); + } else { + params = self._prepareSubmitAnswer(params, questionId, value); + } + } + }); + return params; + }, + + // INIT FIELDS TOOLS + // ------------------------------------------------------------------------- + + /** + * Will allow the textarea to resize on carriage return instead of showing scrollbar. + */ + _initTextArea: function () { + this.$('textarea').each(function () { + dom.autoresize($(this)); + }); + }, + + _initChoiceItems: function () { + this.$("input[type='radio'],input[type='checkbox']").each(function () { + var matrixBtn = $(this).parents('.o_survey_matrix_btn'); + if ($(this).prop("checked")) { + var $target = matrixBtn.length > 0 ? matrixBtn : $(this).closest('label'); + $target.addClass('o_survey_selected'); + } + }); + }, + + /** + * Will initialize the breadcrumb widget that handles navigation to a previously filled in page. + * + * @private + */ + _initBreadcrumb: function () { + var $breadcrumb = this.$('.o_survey_breadcrumb_container'); + var pageId = this.$('input[name=page_id]').val(); + if ($breadcrumb.length) { + this.surveyBreadcrumbWidget = new publicWidget.registry.SurveyBreadcrumbWidget(this, { + 'canGoBack': $breadcrumb.data('canGoBack'), + 'currentPageId': pageId ? parseInt(pageId) : 0, + 'pages': $breadcrumb.data('pages'), + }); + this.surveyBreadcrumbWidget.appendTo($breadcrumb); + $breadcrumb.removeClass('d-none'); // hidden by default to avoid having ghost div in start screen + } + }, + + /** + * Called after survey submit to update the breadcrumb to the right page. + */ + _updateBreadcrumb: function () { + if (this.surveyBreadcrumbWidget) { + var pageId = this.$('input[name=page_id]').val(); + this.surveyBreadcrumbWidget.updateBreadcrumb(parseInt(pageId)); + } else { + this._initBreadcrumb(); + } + }, + + /** + * Will handle bus specific behavior for survey 'sessions' + * + * @private + */ + _initSessionManagement: function () { + var self = this; + if (this.options.surveyToken && this.options.sessionInProgress) { + this.call('bus_service', 'addChannel', this.options.surveyToken); + this.call('bus_service', 'startPolling'); + + if (!this._checkIsMasterTab()) { + this.shouldReloadMasterTab = true; + this.masterTabCheckInterval = setInterval(function() { + if (self._checkIsMasterTab()) { + clearInterval(self.masterTabCheckInterval); + } + }, 2000); + } + + this.call('bus_service', 'onNotification', this, this._onNotification); + } + }, + + _initTimer: function () { + if (this.surveyTimerWidget) { + this.surveyTimerWidget.destroy(); + } + + var self = this; + var $timerData = this.$('.o_survey_form_content_data'); + var questionTimeLimitReached = $timerData.data('questionTimeLimitReached'); + var timeLimitMinutes = $timerData.data('timeLimitMinutes'); + var hasAnswered = $timerData.data('hasAnswered'); + + if (!questionTimeLimitReached && !hasAnswered && timeLimitMinutes) { + var timer = $timerData.data('timer'); + var $timer = $('<span>', { + class: 'o_survey_timer' + }); + this.$('.o_survey_timer_container').append($timer); + this.surveyTimerWidget = new publicWidget.registry.SurveyTimerWidget(this, { + 'timer': timer, + 'timeLimitMinutes': timeLimitMinutes + }); + this.surveyTimerWidget.attachTo($timer); + this.surveyTimerWidget.on('time_up', this, function (ev) { + self._submitForm({ + 'skipValidation': true, + 'isFinish': !this.options.sessionInProgress + }); + }); + } + }, + + /** + * Initialize datetimepicker in correct format and with constraints + */ + _initDateTimePicker: function ($dateGroup) { + var disabledDates = []; + var questionType = $dateGroup.find('input').data('questionType'); + var minDateData = $dateGroup.data('mindate'); + var maxDateData = $dateGroup.data('maxdate'); + + var datetimepickerFormat = questionType === 'datetime' ? time.getLangDatetimeFormat() : time.getLangDateFormat(); + + var minDate = minDateData + ? this._formatDateTime(minDateData, datetimepickerFormat) + : moment({ y: 1000 }); + + var maxDate = maxDateData + ? this._formatDateTime(maxDateData, datetimepickerFormat) + : moment().add(200, "y"); + + if (questionType === 'date') { + // 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(minDate.hours()).minutes(minDate.minutes()).seconds(minDate.seconds()).milliseconds(minDate.milliseconds()), + 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 (err) { + if (err.date) { + if (err.date < minDate) { + Dialog.alert(this, _t('The date you selected is lower than the minimum date: ') + minDate.format(datetimepickerFormat)); + } + + if (err.date > maxDate) { + Dialog.alert(this, _t('The date you selected is greater than the maximum date: ') + maxDate.format(datetimepickerFormat)); + } + } + return false; + }); + }, + + _formatDateTime: function (datetimeValue, format){ + return moment(field_utils.format.datetime(moment(datetimeValue), null, {timezone: true}), format); + }, + + _initResultWidget: function () { + var $result = this.$('.o_survey_result'); + if ($result.length) { + this.surveyResultWidget = new publicWidget.registry.SurveyResultWidget(this); + this.surveyResultWidget.attachTo($result); + $result.fadeIn(this.fadeInOutDelay); + } + }, + + /** + * Will automatically focus on the first input to allow the user to complete directly the survey, + * without having to manually get the focus (only if the input has the right type - can write something inside -) + */ + _focusOnFirstInput: function () { + var $firstTextInput = this.$('.js_question-wrapper').first() // Take first question + .find("input[type='text'],input[type='number'],textarea") // get 'text' inputs + .filter('.form-control') // needed for the auto-resize + .not('.o_survey_comment'); // remove inputs for comments that does not count as answers + if ($firstTextInput.length > 0) { + $firstTextInput.focus(); + } + }, + + /** + * This method check if the current tab is the master tab at the bus level. + * If not, the survey could not receive next question notification anymore from session manager. + * We then ask the participant to close all other tabs on the same hostname before letting them continue. + * + * @private + */ + _checkIsMasterTab: function () { + var isMasterTab = this.call('bus_service', 'isMasterTab'); + var $errorModal = this.$('#MasterTabErrorModal'); + if (isMasterTab) { + // Force reload the page when survey is ready to be followed, to force restart long polling + if (this.shouldReloadMasterTab) { + window.location.reload(); + } + return true; + } else if (!$errorModal.modal._isShown){ + $errorModal.find('.text-danger').text(window.location.hostname); + $errorModal.modal('show'); + } + return false; + }, + + // 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 + // ------------------------------------------------------------------------- + + _showErrors: function (errors) { + var self = this; + var errorKeys = _.keys(errors); + _.each(errorKeys, function (key) { + self.$("#" + key + '>.o_survey_question_error').append($('<p>', {text: errors[key]})).addClass("slide_in"); + if (errorKeys[0] === key) { + self._scrollToError(self.$('.js_question-wrapper#' + key)); + } + }); + }, + + _scrollToError: function ($target) { + var scrollLocation = $target.offset().top; + var navbarHeight = $('.o_main_navbar').height(); + if (navbarHeight) { + // In overflow auto, scrollLocation of target can be negative if target is out of screen (up side) + scrollLocation = scrollLocation >= 0 ? scrollLocation - navbarHeight : scrollLocation + navbarHeight; + } + var scrollinside = $("#wrapwrap").scrollTop(); + $('#wrapwrap').animate({ + scrollTop: scrollinside + scrollLocation + }, 500); + }, + + /** + * Clean all form errors in order to clean DOM before a new validation + */ + _resetErrors: function () { + this.$('.o_survey_question_error').empty().removeClass('slide_in'); + this.$('.o_survey_error').addClass('d-none'); + }, + +}); + +return publicWidget.registry.SurveyFormWidget; + +}); |
