1278 lines
54 KiB
JavaScript
1278 lines
54 KiB
JavaScript
/** @odoo-module **/
|
|
|
|
import publicWidget from "@web/legacy/js/public/public_widget";
|
|
import { _t } from "@web/core/l10n/translation";
|
|
import { rpc } from "@web/core/network/rpc";
|
|
import { cookie } from "@web/core/browser/cookie";
|
|
import { utils as uiUtils } from "@web/core/ui/ui_service";
|
|
import { scrollTo } from "@web_editor/js/common/scrolling";
|
|
|
|
import SurveyPreloadImageMixin from "@survey/js/survey_preload_image_mixin";
|
|
import { SurveyImageZoomer } from "@survey/js/survey_image_zoomer";
|
|
import {
|
|
deserializeDate,
|
|
deserializeDateTime,
|
|
parseDateTime,
|
|
parseDate,
|
|
serializeDateTime,
|
|
serializeDate,
|
|
} from "@web/core/l10n/dates";
|
|
import { resizeTextArea } from "@web/core/utils/autoresize";
|
|
const { DateTime } = luxon;
|
|
|
|
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 (!cookie.get('survey_' + self.options.surveyToken)) {
|
|
cookie.set('survey_' + self.options.surveyToken, self.options.answerToken, 60 * 60 * 24, 'optional');
|
|
}
|
|
|
|
// Init fields
|
|
if (!self.options.isStartScreen && !self.readonly) {
|
|
self._initTimer();
|
|
self._initBreadcrumb();
|
|
}
|
|
self._initChoiceItems();
|
|
self._initTextArea();
|
|
self._focusOnFirstInput();
|
|
// Init event listener
|
|
if (!self.readonly) {
|
|
self.documentKeydownListener = self._onKeyDown.bind(self);
|
|
$(document).on('keydown', self.documentKeydownListener);
|
|
}
|
|
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;
|
|
|
|
if (['one_page', 'page_per_section'].includes(self.options.questionsLayout) && !self.options.isStartScreen) {
|
|
if (this.$("input").is(":focus") && event.key === "Enter") {
|
|
event.preventDefault();
|
|
}
|
|
if (!(event.ctrlKey || event.metaKey) || event.key !== "Enter") {
|
|
return;
|
|
}
|
|
}
|
|
|
|
// 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) || event.key !== "Enter")) {
|
|
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()) && event.key !== "Escape") {
|
|
return;
|
|
}
|
|
|
|
var letter = event.key.toUpperCase();
|
|
|
|
// Handle Start / Next / Submit
|
|
if (event.key === "Enter" || event.key === "ArrowRight") { // Enter or arrow-right: go Next
|
|
event.preventDefault();
|
|
if (!this.preventEnterSubmit) {
|
|
this._submitForm({
|
|
isFinish: this.el.querySelectorAll('button[value="finish"]').length !== 0,
|
|
nextSkipped: this.el.querySelectorAll('button[value="next_skipped"]').length !== 0 ? event.key === "Enter" : false,
|
|
});
|
|
}
|
|
} else if (event.key === "ArrowLeft") { // 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();
|
|
}
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Handle visibility of comment area and conditional questions
|
|
* The form (page) is then automatically submitted if:
|
|
* - Survey is configured with one page per question and participants are allowed to go back,
|
|
* - It is not the last question of the survey,
|
|
* - The question is not waiting for a comment (with "Other" answer),
|
|
*
|
|
* @param event
|
|
*/
|
|
_onChangeChoiceItem: function (event) {
|
|
const $target = $(event.currentTarget);
|
|
const $choiceItemGroup = $target.closest('.o_survey_form_choice');
|
|
|
|
this._applyCommentAreaVisibility($choiceItemGroup);
|
|
const isQuestionComplete = this._checkConditionalQuestionsConfiguration($target, $choiceItemGroup);
|
|
if (isQuestionComplete && this.options.usersCanGoBack) {
|
|
const isLastQuestion = this.$('button[value="finish"]').length !== 0;
|
|
if (!isLastQuestion) {
|
|
const questionHasComment = $target.hasClass('o_survey_js_form_other_comment') || $target
|
|
.closest('.o_survey_form_choice')
|
|
.find('.o_survey_comment').length !== 0;
|
|
if (!questionHasComment) {
|
|
this._submitForm({'nextSkipped': $choiceItemGroup.data('isSkippedQuestion')});
|
|
}
|
|
}
|
|
}
|
|
},
|
|
|
|
/**
|
|
* 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) {
|
|
if (!uiUtils.isSmall()) {
|
|
// On large screen, it prevents the answer to be selected as the user only want to enlarge the image.
|
|
// We don't do it on small device as it can be hard to click outside the picture to select the answer.
|
|
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();
|
|
const options = {};
|
|
const target = event.currentTarget;
|
|
if (target.value === 'previous') {
|
|
options.previousPageId = parseInt(target.dataset['previousPageId']);
|
|
} else if (target.value === 'next_skipped') {
|
|
options.nextSkipped = true;
|
|
} else if (target.value === 'finish') {
|
|
options.isFinish = true;
|
|
}
|
|
this._submitForm(options);
|
|
},
|
|
|
|
// 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';
|
|
let text = _t("or press Enter");
|
|
if (['one_page', 'page_per_section'].includes(this.options.questionsLayout) || isTextbox) {
|
|
text = isMac ? _t("or press ⌘+Enter") : _t("or press CTRL+Enter");
|
|
}
|
|
$('#enter-tooltip').text(text);
|
|
},
|
|
|
|
_onBreadcrumbClick: function (event) {
|
|
this._submitForm({'previousPageId': event.data.previousPageId});
|
|
},
|
|
|
|
/**
|
|
* 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 {object} notification notification of type `next_question` as
|
|
* specified by the bus.
|
|
*/
|
|
_onNextQuestionNotification(notification) {
|
|
let serverDelayMS = (DateTime.now().toSeconds() - notification.question_start) * 1000;
|
|
if (serverDelayMS < 0) {
|
|
serverDelayMS = 0;
|
|
} else if (serverDelayMS > 1000) {
|
|
serverDelayMS = 1000;
|
|
}
|
|
this.fadeInOutDelay = (1000 - serverDelayMS) / 2;
|
|
this._goToNextPage();
|
|
},
|
|
|
|
/**
|
|
* Handle the `end_session` bus event. This will fade out the current page
|
|
* and fade in the end screen.
|
|
*
|
|
* @private
|
|
*/
|
|
_onEndSessionNotification() {
|
|
if (this.options.isStartScreen) {
|
|
// can happen when triggering the same survey session multiple times
|
|
// we received an "old" end_session event that needs to be ignored
|
|
return;
|
|
}
|
|
this.fadeInOutDelay = 400;
|
|
this._goToNextPage({ isFinish: true });
|
|
},
|
|
|
|
/**
|
|
* Go to the next page of the survey.
|
|
*
|
|
* @private
|
|
* @param {Object} param0
|
|
* @param {Object} param0.isFinish Wether the survey is done or not
|
|
*/
|
|
_goToNextPage: function ({ isFinish = false } = {}) {
|
|
this.$(".o_survey_main_title:visible").fadeOut(400);
|
|
this.preventEnterSubmit = false;
|
|
this.readonly = false;
|
|
this._nextScreen(
|
|
rpc(`/survey/next_question/${this.options.surveyToken}/${this.options.answerToken}`),
|
|
{
|
|
initTimer: true,
|
|
isFinish,
|
|
}
|
|
);
|
|
},
|
|
|
|
// 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: async function (options) {
|
|
var params = {};
|
|
if (options.previousPageId) {
|
|
params.previous_page_id = options.previousPageId;
|
|
}
|
|
if (options.nextSkipped) {
|
|
params.next_skipped_page_or_question = true;
|
|
}
|
|
var route = "/survey/submit";
|
|
|
|
if (this.options.isStartScreen) {
|
|
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;
|
|
}
|
|
|
|
const submitPromise = rpc(
|
|
`${route}/${this.options.surveyToken}/${this.options.answerToken}`,
|
|
params
|
|
);
|
|
|
|
if (!this.options.isStartScreen && this.options.scoringType == 'scoring_with_answers_after_page') {
|
|
const [correctAnswers] = await submitPromise;
|
|
if (Object.keys(correctAnswers).length && document.querySelector('.js_question-wrapper')) {
|
|
this._showCorrectAnswers(correctAnswers, submitPromise, options);
|
|
return;
|
|
}
|
|
}
|
|
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: async function (nextScreenPromise, options) {
|
|
var resolveFadeOut;
|
|
var fadeOutPromise = new Promise(function (resolve, reject) {resolveFadeOut = resolve;});
|
|
|
|
var selectorsToFadeout = ['.o_survey_form_content'];
|
|
if (options.isFinish && !this.nextScreenResult?.has_skipped_questions) {
|
|
selectorsToFadeout.push('.breadcrumb', '.o_survey_timer');
|
|
cookie.delete('survey_' + this.options.surveyToken);
|
|
}
|
|
this.$(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');
|
|
}
|
|
|
|
const nextScreenWithBackgroundPromise = (async () => {
|
|
const [,result] = await nextScreenPromise;
|
|
this.nextScreenResult = result;
|
|
// once we have the next question, wait for the preload of the background
|
|
if (this.options.refreshBackground && result.background_image_url) {
|
|
return this._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.
|
|
await Promise.all([fadeOutPromise, nextScreenWithBackgroundPromise]);
|
|
return this._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) || result.has_skipped_questions)
|
|
&& !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;
|
|
}
|
|
|
|
// Start datetime pickers
|
|
self.trigger_up("widgets_start_request", { $target: this.$el.find('.o_survey_form_date') });
|
|
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 && !result.has_skipped_questions) {
|
|
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');
|
|
|
|
this._scrollToFirstError();
|
|
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");
|
|
scrollTo($errorTarget[0]);
|
|
}
|
|
},
|
|
|
|
// 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]) {
|
|
const [parse, deserialize] =
|
|
$input.data("questionType") === "date"
|
|
? [parseDate, deserializeDate]
|
|
: [parseDateTime, deserializeDateTime];
|
|
const date = parse($input.val());
|
|
if (!date || !date.isValid) {
|
|
errors[questionId] = validationDateMsg;
|
|
} else {
|
|
const maxDate = deserialize($input.data('max-date'));
|
|
const minDate = deserialize($input.data('min-date'));
|
|
if (
|
|
(maxDate.isValid && date > maxDate) ||
|
|
(minDate.isValid && date < minDate)
|
|
) {
|
|
errors[questionId] = validationErrorMsg;
|
|
}
|
|
}
|
|
}
|
|
break;
|
|
case 'scale':
|
|
if (questionRequired && !data[questionId]) {
|
|
errors[questionId] = constrErrorMsg;
|
|
}
|
|
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':
|
|
params[this.name] = this.value;
|
|
break;
|
|
case 'numerical_box':
|
|
params[this.name] = this.value;
|
|
break;
|
|
case 'date':
|
|
case 'datetime':{
|
|
const [parse, serialize] =
|
|
$(this).data("questionType") === "date"
|
|
? [parseDate, serializeDate]
|
|
: [parseDateTime, serializeDateTime];
|
|
const date = parse(this.value);
|
|
params[this.name] = date ? serialize(date) : "";
|
|
break;
|
|
}
|
|
case 'scale':
|
|
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 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 () {
|
|
resizeTextArea(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', 'subscribe', 'next_question', this._onNextQuestionNotification.bind(this));
|
|
this.call('bus_service', 'subscribe', 'end_session', this._onEndSessionNotification.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
|
|
});
|
|
});
|
|
}
|
|
},
|
|
|
|
_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
|
|
// -------------------------------------------------------------------------
|
|
|
|
/**
|
|
* 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
|
|
*
|
|
* @param {JQuery<HTMLElement>} $choiceItemGroup
|
|
*/
|
|
_applyCommentAreaVisibility: function ($choiceItemGroup) {
|
|
const $otherItem = $choiceItemGroup.find('.o_survey_js_form_other_comment');
|
|
const $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);
|
|
}
|
|
},
|
|
|
|
|
|
/**
|
|
* 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 && !uiUtils.isSmall()) {
|
|
$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
|
|
// -------------------------------------------------------------------------
|
|
|
|
/**
|
|
* For single and multiple choice questions, propagate questions visibility
|
|
* based on conditional questions and (de)selected triggers
|
|
*
|
|
* @param {JQuery<HTMLElement>} $target
|
|
* @param {JQuery<HTMLElement>} $choiceItemGroup
|
|
* @returns {boolean} Whether the question is considered completed
|
|
*/
|
|
_checkConditionalQuestionsConfiguration: function ($target, $choiceItemGroup) {
|
|
let isQuestionComplete = false;
|
|
const $matrixBtn = $target.closest('.o_survey_matrix_btn');
|
|
if ($target.attr('type') === 'radio') {
|
|
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((id) => {
|
|
if (this.$('tr#' + id).find('input:checked').length !== 0) {
|
|
completedQuestions.push(id);
|
|
}
|
|
});
|
|
isQuestionComplete = completedQuestions.length === subQuestionsIds.length;
|
|
}
|
|
} else {
|
|
const previouslySelectedAnswer = $choiceItemGroup.find('label.o_survey_selected');
|
|
previouslySelectedAnswer.removeClass('o_survey_selected');
|
|
const previouslySelectedAnswerId = previouslySelectedAnswer.find('input').val();
|
|
if (previouslySelectedAnswerId && this.options.questionsLayout !== 'page_per_question') {
|
|
this.selectedAnswers.splice(this.selectedAnswers.indexOf(parseInt(previouslySelectedAnswerId)), 1);
|
|
}
|
|
|
|
const newlySelectedAnswer = $target.closest('label');
|
|
const newlySelectedAnswerId = $target.val();
|
|
const isNewSelection = newlySelectedAnswerId !== previouslySelectedAnswerId;
|
|
if (isNewSelection) {
|
|
newlySelectedAnswer.addClass('o_survey_selected');
|
|
isQuestionComplete = this.options.questionsLayout === 'page_per_question';
|
|
if (!isQuestionComplete) {
|
|
this.selectedAnswers.push(parseInt(newlySelectedAnswerId));
|
|
}
|
|
}
|
|
|
|
if (this.options.questionsLayout !== 'page_per_question') {
|
|
const conditionalQuestionsToRecomputeVisibility = new Set(
|
|
(this.options.triggeredQuestionsByAnswer[previouslySelectedAnswerId] || [])
|
|
.concat(this.options.triggeredQuestionsByAnswer[newlySelectedAnswerId] || [])
|
|
)
|
|
this._applyConditionalQuestionsVisibility(conditionalQuestionsToRecomputeVisibility)
|
|
}
|
|
}
|
|
} else { // $target.attr('type') === 'checkbox'
|
|
if ($matrixBtn.length > 0) {
|
|
$matrixBtn.toggleClass('o_survey_selected', !$matrixBtn.hasClass('o_survey_selected'));
|
|
} else {
|
|
const $label = $target.closest('label');
|
|
$label.toggleClass('o_survey_selected', !$label.hasClass('o_survey_selected'));
|
|
const answerId = $target.val();
|
|
|
|
if (this.options.questionsLayout !== 'page_per_question') {
|
|
$label.hasClass('o_survey_selected')
|
|
? this.selectedAnswers.push(parseInt(answerId))
|
|
: this.selectedAnswers.splice(this.selectedAnswers.indexOf(parseInt(answerId)), 1);
|
|
this._applyConditionalQuestionsVisibility(this.options.triggeredQuestionsByAnswer[answerId]);
|
|
}
|
|
}
|
|
}
|
|
return isQuestionComplete;
|
|
},
|
|
|
|
/**
|
|
* Apply visibility rules of conditional questions.
|
|
* When layout is "one_page", hide the empty sections (the ones without description and
|
|
* which don't have any question to be displayed because of conditional questions).
|
|
*
|
|
* @param {Number[] | String[] | Set | undefined} questionIds Conditional questions ids
|
|
*/
|
|
_applyConditionalQuestionsVisibility: function(questionIds) {
|
|
if (!questionIds || (!questionIds.length && !questionIds.size)) {
|
|
return;
|
|
}
|
|
// Questions visibility
|
|
for (const questionId of questionIds) {
|
|
const dependingQuestion = document.querySelector(`.js_question-wrapper[id="${questionId}"]`);
|
|
if (!dependingQuestion) { // Could be on different page
|
|
continue;
|
|
}
|
|
const hasNoSelectedTriggers = !this.options.triggeringAnswersByQuestion[questionId]
|
|
.some(answerId => this.selectedAnswers.includes(parseInt(answerId)));
|
|
dependingQuestion.classList.toggle('d-none', hasNoSelectedTriggers);
|
|
if (hasNoSelectedTriggers) {
|
|
// Clear / Un-select all the input from the given question
|
|
// + propagate conditional hierarchy by triggering change on choice inputs.
|
|
$(dependingQuestion).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();
|
|
}
|
|
});
|
|
$(dependingQuestion).find('textarea').val('');
|
|
}
|
|
}
|
|
// Sections visibility
|
|
if (this.options.questionsLayout === 'one_page') {
|
|
const sections = document.querySelectorAll('.js_section_wrapper');
|
|
for (const section of sections) {
|
|
if (!section.querySelector('.o_survey_description')) {
|
|
const hasVisibleQuestions = Boolean(section.querySelector('.js_question-wrapper:not(.d-none)'));
|
|
section.classList.toggle('d-none', !hasVisibleQuestions);
|
|
}
|
|
}
|
|
}
|
|
},
|
|
|
|
/**
|
|
* 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 () {
|
|
const inactiveQuestionIds = [];
|
|
for (const [questionId, answerIds] of Object.entries(this.options.triggeringAnswersByQuestion || {})) {
|
|
if (!answerIds.some(answerId => this.selectedAnswers.includes(parseInt(answerId)))) {
|
|
inactiveQuestionIds.push(parseInt(questionId));
|
|
}
|
|
}
|
|
return inactiveQuestionIds;
|
|
},
|
|
|
|
// ANSWERS TOOLS
|
|
// -------------------------------------------------------------------------
|
|
|
|
_showCorrectAnswers: function(correctAnswers, submitPromise, options) {
|
|
// Display the correct answers
|
|
Object.keys(correctAnswers).forEach(questionId => this._showQuestionAnswer(correctAnswers, questionId));
|
|
// Make the form completely readonly
|
|
const form = document.querySelector('form');
|
|
form.querySelectorAll('input, textarea, label, td')?.forEach(node => {
|
|
node.blur();
|
|
node.classList.add("pe-none");
|
|
});
|
|
// Replace the Submit button by a Next button
|
|
form.querySelector('button[type="submit"]').classList.add('d-none');
|
|
const nextPageBtn = form.querySelector('button[id="next_page"]');
|
|
nextPageBtn.classList.remove('d-none');
|
|
nextPageBtn.addEventListener('click', () => {
|
|
this._nextScreen(submitPromise, options);
|
|
});
|
|
// Replacing the original onKeyDown listener to block everything except for the
|
|
// enter or arrow right key down events trigerring the next page display
|
|
const nextPageKeydownListener = (event) => {
|
|
if (event.code === 'Enter' || event.code === 'ArrowRight') {
|
|
// Restore original keydown listener
|
|
document.removeEventListener('keydown', nextPageKeydownListener);
|
|
document.addEventListener('keydown', this.documentKeydownListener);
|
|
this._nextScreen(submitPromise, options);
|
|
}
|
|
}
|
|
document.removeEventListener('keydown', this.documentKeydownListener);
|
|
document.addEventListener('keydown', nextPageKeydownListener);
|
|
},
|
|
|
|
_showQuestionAnswer: function(correctAnswers, questionId) {
|
|
const correctAnswer = correctAnswers[questionId];
|
|
const questionWrapper = document.querySelector(`.js_question-wrapper[id="${questionId}"]`);
|
|
const answerWrapper = questionWrapper.querySelector('.o_survey_answer_wrapper');
|
|
const questionType = questionWrapper.querySelector('[data-question-type]').dataset.questionType;
|
|
|
|
// Only questions supporting correct answer are present here (ex.: scale question doesn't support it)
|
|
if (['numerical_box', 'date', 'datetime'].includes(questionType)) {
|
|
const input = answerWrapper.querySelector('input');
|
|
let isCorrect;
|
|
if (questionType == 'numerical_box') {
|
|
isCorrect = input.valueAsNumber === correctAnswer;
|
|
} else if (questionType == 'datetime') {
|
|
const datetime = parseDateTime(input.value);
|
|
const value = datetime ? datetime.setZone("utc").toFormat("MM/dd/yyyy HH:mm:ss", { numberingSystem: "latn" }) : '';
|
|
isCorrect = value === correctAnswer;
|
|
} else {
|
|
isCorrect = input.value === correctAnswer;
|
|
}
|
|
answerWrapper.classList.add(`bg-${isCorrect ? 'success' : 'danger'}`);
|
|
}
|
|
else if (['simple_choice_radio', 'multiple_choice'].includes(questionType)) {
|
|
answerWrapper.querySelectorAll('.o_survey_choice_btn').forEach((button) => {
|
|
const answerId = button.querySelector('input').value;
|
|
const isCorrect = correctAnswer.includes(parseInt(answerId));
|
|
button.classList.add(`bg-${isCorrect ? 'success' : 'danger'}`, 'text-white');
|
|
// For the user incorrect answers, replace the empty check icon by a crossed check icon
|
|
if (!isCorrect && button.classList.contains('o_survey_selected')) {
|
|
let fromIcon = 'fa-check-circle';
|
|
let toIcon = 'fa-times-circle';
|
|
if (questionType == 'multiple_choice') {
|
|
fromIcon = 'fa-check-square';
|
|
toIcon = 'fa-times-rectangle'; // fa-times-square doesn't exist in fontawesome 4.7
|
|
}
|
|
button.querySelector(`i.${fromIcon}`)?.classList.replace(fromIcon, toIcon);
|
|
}
|
|
});
|
|
}
|
|
},
|
|
|
|
// ERRORS TOOLS
|
|
// -------------------------------------------------------------------------
|
|
|
|
_showErrors: function (errors) {
|
|
var self = this;
|
|
var errorKeys = Object.keys(errors || {});
|
|
errorKeys.forEach(key => {
|
|
self.$("#" + key + '>.o_survey_question_error').append($('<span>', {text: errors[key]})).addClass("slide_in");
|
|
if (errorKeys[0] === key) {
|
|
scrollTo(self.$('.js_question-wrapper#' + key)[0]);
|
|
}
|
|
});
|
|
},
|
|
|
|
/**
|
|
* This method is used to scroll to error generated in the backend.
|
|
* (Those errors are displayed when the user skip mandatory question(s))
|
|
*/
|
|
_scrollToFirstError: function() {
|
|
const errorElem = this.el.querySelector('.o_survey_question_error :not(:empty)');
|
|
errorElem?.scrollIntoView();
|
|
},
|
|
|
|
/**
|
|
* 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;
|