Files
odoo_source/addons/survey/static/src/js/survey_form.js
T
Pierre Paridans 3fd016a443 [IMP] web,survey,website_slides: remove unused jQuery Form plugin
None of the main methods provided by this plugin were stil used; only
the internal method `enable()`.

This commit replaces these calls by toggling the `disabled` HTML
attribute.

closes odoo/odoo#130817

Signed-off-by: Adrien Dieudonné (adr) <adr@odoo.com>
2023-08-04 15:02:27 +02:00

1209 lines
51 KiB
JavaScript

/** @odoo-module **/
import publicWidget from "@web/legacy/js/public/public_widget";
import { formatDateTime, parseDate, parseDateTime} from "@web/legacy/js/core/dates";
import time from "@web/legacy/js/core/time";
import config from "@web/legacy/js/services/config";
import core from "@web/legacy/js/services/core";
import Dialog from "@web/legacy/js/core/dialog";
import dom from "@web/legacy/js/core/dom";
import {getCookie, setCookie, deleteCookie} from "@web/legacy/js/core/cookie_utils";
import SurveyPreloadImageMixin from "@survey/js/survey_preload_image_mixin";
import { SurveyImageZoomer } from "@survey/js/survey_image_zoomer";
var _t = core._t;
var isMac = navigator.platform.toUpperCase().includes('MAC');
publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend(SurveyPreloadImageMixin, {
selector: '.o_survey_form',
events: {
'change .o_survey_form_choice_item': '_onChangeChoiceItem',
'click .o_survey_matrix_btn': '_onMatrixBtnClick',
'click input[type="radio"]': '_onRadioChoiceClick',
'click button[type="submit"]': '_onSubmit',
'click .o_survey_choice_img img': '_onChoiceImgClick',
'focusin .form-control': '_updateEnterButtonText',
'focusout .form-control': '_updateEnterButtonText'
},
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.$('form').data();
self.readonly = self.options.readonly;
self.selectedAnswers = self.options.selectedAnswers;
self.imgZoomer = false;
// Add Survey cookie to retrieve the survey if you quit the page and restart the survey.
if (!getCookie('survey_' + self.options.surveyToken)) {
setCookie('survey_' + self.options.surveyToken, self.options.answerToken, 60 * 60 * 24, 'optional');
}
// 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));
self.$('button[type="submit"]').removeClass('disabled');
});
},
// -------------------------------------------------------------------------
// 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) {
var self = this;
var keyCode = event.keyCode;
// If user is answering a text input, do not handle keydown
// CTRL+enter will force submission (meta key for Mac)
if ((this.$("textarea").is(":focus") || this.$('input').is(':focus')) &&
(!(event.ctrlKey || event.metaKey) || keyCode !== 13)) {
return;
}
// If in session mode and question already answered, do not handle keydown
if (this.$('fieldset[disabled="disabled"]').length !== 0) {
return;
}
// Disable all navigation keys when zoom modal is open, except the ESC.
if ((this.imgZoomer && !this.imgZoomer.isDestroyed()) && keyCode !== 27) {
return;
}
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.each((idx, $input) => $input.disabled = false);
$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.each((idx, $input) => $input.disabled = true);
}
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');
if ($target.is(':checked')) {
$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
self.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()));
}
}
}
}
},
/**
* Called when an image on an answer in multi-answers question is clicked.
* Starts a widget opening a dialog to display the now zoomable image.
* this.imgZoomer is the zoomer widget linked to the survey form, if any.
*
* @private
* @param {Event} ev
*/
_onChoiceImgClick: function (ev) {
ev.preventDefault();
this.imgZoomer = new SurveyImageZoomer({
sourceImage: $(ev.currentTarget).attr('src')
});
this.imgZoomer.appendTo(document.body);
},
/**
* Invert the related input's "checked" property.
* This will tick or untick the option (based on the previous state).
*
* @param {MouseEvent} event
* @returns
*/
_onMatrixBtnClick: function (event) {
if (this.readonly) {
return;
}
var $target = $(event.currentTarget);
var $input = $target.find('input');
$input.prop("checked", !$input.prop("checked")).trigger('change');
},
/**
* Base browser behavior when clicking on a radio input is to leave the radio checked if it was
* already checked before.
* Here for survey we want to be able to un-tick the choice.
*
* e.g: You select an option but on second thoughts you're unsure it's the right answer, you
* want to be able to remove your answer.
*
* To do so, we use an alternate class "o_survey_form_choice_item_selected" that is added when
* the option is ticked and removed when the option is unticked.
*
* - When it's ticked, we simply add the class (the browser will set the "checked" property
* to true).
* - When it's unticked, we manually set the "checked" property of the element to "false".
* We also trigger the 'change' event to go into '_onChangeChoiceItem'.
*
* @param {MouseEvent} event
*/
_onRadioChoiceClick: function (event) {
var $target = $(event.currentTarget);
if ($target.hasClass("o_survey_form_choice_item_selected")) {
$target.prop("checked", false).removeClass("o_survey_form_choice_item_selected");
$target.trigger('change');
} else {
this.$(`input:radio[name="${$target.prop("name")}"].o_survey_form_choice_item_selected`)
.removeClass("o_survey_form_choice_item_selected");
$target.addClass("o_survey_form_choice_item_selected");
}
},
_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
// -------------------------------------------------------------------------
/**
* Changes the tooltip according to the type of the field.
* @param {Event} event
*/
_updateEnterButtonText: function (event) {
const $target = event.target;
const isTextbox = event.type === "focusin" && $target.tagName.toLowerCase() === 'textarea';
const text = !isTextbox ? _t('or press Enter') : isMac ? _t("or press ⌘+Enter") : _t("or press CTRL+Enter");
$('#enter-tooltip').text(text);
},
_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 {CustomEvent} ev
* @param {Array[]} [ev.detail] notifications structured as specified by the bus feature
*/
_onNotification: function ({ detail: notifications }) {
var nextPageEvent = false;
if (notifications && notifications.length !== 0) {
notifications.forEach(function (notification) {
if (notification.type === 'next_question' ||
notification.type === 'end_session') {
nextPageEvent = notification;
}
});
}
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.payload.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: `${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');
deleteCookie('survey_' + self.options.surveyToken);
}
self.$(selectorsToFadeout.join(',')).fadeOut(this.fadeInOutDelay, function () {
resolveFadeOut();
});
// Background management - Fade in / out on each transition
if (this.options.refreshBackground) {
$('div.o_survey_background').addClass('o_survey_background_transition');
}
var nextScreenWithBackgroundPromise = nextScreenPromise.then(function (result) {
self.nextScreenResult = result;
// once we have the next question, wait for the preload of the background
if (self.options.refreshBackground && result.background_image_url) {
return self._preloadBackground(result.background_image_url);
} else {
return Promise.resolve();
}
});
// Wait for the fade out and the preload of the next background. The next question have already been fetched.
Promise.all([fadeOutPromise, nextScreenWithBackgroundPromise]).then(function () {
return self._onNextScreenDone(options);
});
},
/**
* Handle server side validation and display eventual error messages.
*
* @param {Object} options see '_submitForm' for details
*/
_onNextScreenDone: function (options) {
var self = this;
var result = this.nextScreenResult;
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;
}
// Background management - reset background overlay opacity to 0.7 to discover next background.
if (this.options.refreshBackground) {
$('div.o_survey_background').css("background-image", "url(" + result.background_image_url + ")");
$('div.o_survey_background').removeClass('o_survey_background_transition');
}
this.$('.o_survey_form_content').fadeIn(this.fadeInOutDelay);
$("html, body").animate({ scrollTop: 0 }, this.fadeInOutDelay);
this.$('button[type="submit"]').removeClass('disabled');
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 'text_box':
if (questionRequired && !$input.val()) {
errors[questionId] = constrErrorMsg;
}
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) {
const subQuestionsIds = $questionWrapper.find('table').data('subQuestions');
// Highlight unanswered rows' header
const questionBodySelector = `div[id="${questionId}"] > .o_survey_question_matrix > tbody`;
subQuestionsIds.forEach((subQuestionId) => {
if (!(`${questionId}_${subQuestionId}` in data)) {
errors[questionId] = constrErrorMsg;
self.el.querySelector(`${questionBodySelector} > tr[id="${subQuestionId}"] > th`).classList.add('bg-danger');
}
});
}
break;
}
});
if (Object.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 ? parseDateTime(value, null, {timezone: true}) : parseDate(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);
if (!this._checkisOnMainTab()) {
this.shouldReloadMasterTab = true;
this.masterTabCheckInterval = setInterval(function () {
if (self._checkisOnMainTab()) {
clearInterval(self.masterTabCheckInterval);
}
}, 2000);
}
this.call('bus_service', 'addEventListener', 'notification', this._onNotification.bind(this));
}
},
_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');
const serverTime = $timerData.data('serverTime');
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, {
'serverTime': serverTime,
'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: 'oi oi-chevron-right',
previous: 'oi oi-chevron-left',
up: 'oi oi-chevron-up',
down: 'oi oi-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(formatDateTime(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);
}
},
// OTHER TOOLS
// -------------------------------------------------------------------------
/**
* 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 -
* and if the device is not a mobile device to avoid missing information when the soft keyboard is opened)
*/
_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 && !config.device.isMobile) {
$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
*/
_checkisOnMainTab: function () {
var isOnMainTab = this.call('multi_tab', 'isOnMainTab');
var $errorModal = this.$('#MasterTabErrorModal');
if (isOnMainTab) {
// 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 = Object.keys(errors || {});
errorKeys.forEach(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');
this.el.querySelectorAll('.o_survey_question_matrix th.bg-danger').forEach((row) => {
row.classList.remove('bg-danger');
});
},
});
export default publicWidget.registry.SurveyFormWidget;