/** @odoo-module **/ import publicWidget from "@web/legacy/js/public/public_widget"; import SurveyPreloadImageMixin from "@survey/js/survey_preload_image_mixin"; import SurveySessionChart from "@survey/js/survey_session_chart"; import SurveySessionTextAnswers from "@survey/js/survey_session_text_answers"; import SurveySessionLeaderBoard from "@survey/js/survey_session_leaderboard"; import { _t } from "@web/core/l10n/translation"; import { rpc } from "@web/core/network/rpc"; import { browser } from "@web/core/browser/browser"; const nextPageTooltips = { closingWords: _t('End of Survey'), leaderboard: _t('Show Leaderboard'), leaderboardFinal: _t('Show Final Leaderboard'), nextQuestion: _t('Next'), results: _t('Show Correct Answer(s)'), startScreen: _t('Start'), userInputs: _t('Show Results'), }; publicWidget.registry.SurveySessionManage = publicWidget.Widget.extend(SurveyPreloadImageMixin, { selector: '.o_survey_session_manage', events: { 'click .o_survey_session_copy': '_onCopySessionLink', 'click .o_survey_session_navigation_next, .o_survey_session_start': '_onNext', 'click .o_survey_session_navigation_previous': '_onBack', 'click .o_survey_session_close': '_onEndSessionClick', }, init() { this._super(...arguments); this.orm = this.bindService("orm"); }, /** * Overridden to set a few properties that come from the python template rendering. * * We also handle the timer IF we're not "transitioning", meaning a fade out of the previous * $el to the next question (the fact that we're transitioning is in the isRpcCall data). * If we're transitioning, the timer is handled manually at the end of the transition. */ start: function () { var self = this; this.fadeInOutTime = 500; return this._super.apply(this, arguments).then(function () { if (self.$el.data('isSessionClosed')) { self._displaySessionClosedPage(); self.$el.removeClass('invisible'); return; } // general survey props self.surveyId = self.$el.data('surveyId'); self.surveyHasConditionalQuestions = self.$el.data('surveyHasConditionalQuestions'); self.surveyAccessToken = self.$el.data('surveyAccessToken'); self.isStartScreen = self.$el.data('isStartScreen'); self.isFirstQuestion = self.$el.data('isFirstQuestion'); self.isLastQuestion = self.$el.data('isLastQuestion'); // scoring props self.isScoredQuestion = self.$el.data('isScoredQuestion'); self.sessionShowLeaderboard = self.$el.data('sessionShowLeaderboard'); self.hasCorrectAnswers = self.$el.data('hasCorrectAnswers'); // display props self.showBarChart = self.$el.data('showBarChart'); self.showTextAnswers = self.$el.data('showTextAnswers'); // Question transition self.stopNextQuestion = false; // Background Management self.refreshBackground = self.$el.data('refreshBackground'); // Copy link tooltip self.$('.o_survey_session_copy').tooltip({delay: 0, title: 'Click to copy link', placement: 'right'}); var isRpcCall = self.$el.data('isRpcCall'); if (!isRpcCall) { self._startTimer(); $(document).on('keydown', self._onKeyDown.bind(self)); } self._setupIntervals(); self._setupCurrentScreen(); var setupPromises = []; setupPromises.push(self._setupTextAnswers()); setupPromises.push(self._setupChart()); setupPromises.push(self._setupLeaderboard()); self.$el.removeClass('invisible'); return Promise.all(setupPromises); }); }, //-------------------------------------------------------------------------- // Handlers //-------------------------------------------------------------------------- /** * Copies the survey URL link to the clipboard. * We avoid having to print the URL in a standard text input. * * @param {MouseEvent} ev */ _onCopySessionLink: async function (ev) { ev.preventDefault(); var $clipboardBtn = this.$('.o_survey_session_copy'); $clipboardBtn.tooltip('dispose'); $clipboardBtn.popover({ placement: 'right', container: 'body', offset: '0, 3', content: function () { return _t("Copied!"); } }); await browser.navigator.clipboard.writeText(this.target.querySelector('.o_survey_session_copy_url').textContent); $clipboardBtn.popover('show'); setTimeout(() => $clipboardBtn.popover('dispose'), 800); }, /** * Listeners for keyboard arrow / spacebar keys. * * @param {KeyboardEvent} ev */ _onKeyDown: function (ev) { if (ev.key === "ArrowRight" || ev.key === " ") { this._onNext(ev); } else if (ev.key === "ArrowLeft") { this._onBack(ev); } }, /** * Handles the "next screen" behavior. * It happens when the host uses the keyboard key / button to go to the next screen. * The result depends on the current screen we're on. * * Possible values of the "next screen" to display are: * - 'userInputs' when going from a question to the display of attendees' survey.user_input.line * for that question. * - 'results' when going from the inputs to the actual correct / incorrect answers of that * question. Only used for scored simple / multiple choice questions. * - 'leaderboard' (or 'leaderboardFinal') when going from the correct answers of a question to * the leaderboard of attendees. Only used for scored simple / multiple choice questions. * - If it's not one of the above: we go to the next question, or end the session if we're on * the last question of this session. * * See '_getNextScreen' for a detailed logic. * * @param {Event} ev */ _onNext: function (ev) { ev.preventDefault(); var screenToDisplay = this._getNextScreen(); if (screenToDisplay === 'userInputs') { this._setShowInputs(true); } else if (screenToDisplay === 'results') { this._setShowAnswers(true); // when showing results, stop refreshing answers clearInterval(this.resultsRefreshInterval); delete this.resultsRefreshInterval; } else if (['leaderboard', 'leaderboardFinal'].includes(screenToDisplay) && !['leaderboard', 'leaderboardFinal'].includes(this.currentScreen)) { if (this.isLastQuestion) { this.$('.o_survey_session_navigation_next').addClass('d-none'); } this.leaderBoard.showLeaderboard(true, this.isScoredQuestion); } else if (!this.isLastQuestion || !this.sessionShowLeaderboard) { this._nextQuestion(); } this.currentScreen = screenToDisplay; // To avoid a flicker, we do not update the tooltip when going to the next question, // as it will be done in "_setupCurrentScreen" if (!['question', 'nextQuestion'].includes(screenToDisplay)) { this._updateNextScreenTooltip(); } }, /** * Reverse behavior of '_onNext'. * * @param {Event} ev */ _onBack: function (ev) { ev.preventDefault(); var screenToDisplay = this._getPreviousScreen(); if (screenToDisplay === 'question') { this._setShowInputs(false); } else if (screenToDisplay === 'userInputs') { this._setShowAnswers(false); // resume refreshing answers if necessary if (!this.resultsRefreshInterval) { this.resultsRefreshInterval = setInterval(this._refreshResults.bind(this), 2000); } } else if (screenToDisplay === 'results') { if (this.leaderBoard) { this.leaderBoard.hideLeaderboard(); } // when showing results, stop refreshing answers clearInterval(this.resultsRefreshInterval); delete this.resultsRefreshInterval; } else if (screenToDisplay === 'previousQuestion') { if (this.isFirstQuestion) { return; // nothing to go back to, we're on the first question } this._nextQuestion(true); } this.currentScreen = screenToDisplay; // To avoid a flicker, we do not update the tooltip when going to the next question, // as it will be done in "_setupCurrentScreen" if (!['question', 'nextQuestion'].includes(screenToDisplay)) { this._updateNextScreenTooltip(); } }, /** * Marks this session as 'done' and redirects the user to the results based on the clicked link. * * @param {MouseEvent} ev * @private */ _onEndSessionClick: function (ev) { var self = this; ev.preventDefault(); this.orm.call( "survey.survey", "action_end_session", [[this.surveyId]] ).then(function () { if ($(ev.currentTarget).data('showResults')) { document.location = `/survey/results/${encodeURIComponent(self.surveyId)}`; } else { window.history.back(); } }); }, //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- /** * Business logic that determines the 'next screen' based on the current screen and the question * configuration. * * Breakdown of use cases: * - If we're on the 'question' screen, and the question is scored, we move to the 'userInputs' * - If we're on the 'question' screen and it's NOT scored, then we move to * - 'results' if the question has correct / incorrect answers * (but not scored, which is kind of a corner case) * - 'nextQuestion' otherwise * - If we're on the 'userInputs' screen and the question has answers, we move to the 'results' * - If we're on the 'results' and the question is scored, we move to the 'leaderboard' * - In all other cases, we show the next question * - (Small exception for the last question: we show the "final leaderboard") * * (For details about which screen shows what, see '_onNext') */ _getNextScreen: function () { if (this.currentScreen === 'question' && this.isScoredQuestion) { return 'userInputs'; } else if (this.hasCorrectAnswers && ['question', 'userInputs'].includes(this.currentScreen)) { return 'results'; } else if (this.sessionShowLeaderboard) { if (['question', 'userInputs', 'results'].includes(this.currentScreen) && this.isScoredQuestion) { return 'leaderboard'; } else if (this.isLastQuestion) { return 'leaderboardFinal'; } } return 'nextQuestion'; }, /** * Reverse behavior of '_getNextScreen'. * * @param {Event} ev */ _getPreviousScreen: function () { if (this.currentScreen === 'userInputs' && this.isScoredQuestion) { return 'question'; } else if ((this.currentScreen === 'results' && this.isScoredQuestion) || (this.currentScreen === 'leaderboard' && !this.isScoredQuestion) || (this.currentScreen === 'leaderboardFinal' && this.isScoredQuestion)) { return 'userInputs'; } else if ((this.currentScreen === 'leaderboard' && this.isScoredQuestion) || (this.currentScreen === 'leaderboardFinal' && !this.isScoredQuestion)){ return 'results'; } return 'previousQuestion'; }, /** * We use a fade in/out mechanism to display the next question of the session. * * The fade out happens at the same moment as the _rpc to get the new question template. * When they're both finished, we update the HTML of this widget with the new template and then * fade in the updated question to the user. * * The timer (if configured) starts at the end of the fade in animation. * * @param {MouseEvent} ev * @private */ _nextQuestion: function (goBack) { var self = this; // stop calling multiple times "get next question" process until next question is fully loaded. if (this.stopNextQuestion) { return; } this.stopNextQuestion = true; this.isStartScreen = false; if (this.surveyTimerWidget) { this.surveyTimerWidget.destroy(); } var resolveFadeOut; var fadeOutPromise = new Promise(function (resolve, reject) { resolveFadeOut = resolve; }); this.$el.fadeOut(this.fadeInOutTime, function () { resolveFadeOut(); }); if (this.refreshBackground) { $('div.o_survey_background').addClass('o_survey_background_transition'); } // avoid refreshing results while transitioning if (this.resultsRefreshInterval) { clearInterval(this.resultsRefreshInterval); delete this.resultsRefreshInterval; } var nextQuestionPromise = rpc( `/survey/session/next_question/${self.surveyAccessToken}`, { 'go_back': goBack, } ).then(function (result) { self.nextQuestion = result; if (self.refreshBackground && result.background_image_url) { return self._preloadBackground(result.background_image_url); } else { return Promise.resolve(); } }); Promise.all([fadeOutPromise, nextQuestionPromise]).then(function () { return self._onNextQuestionDone(goBack); }); }, _displaySessionClosedPage:function () { this.$('.o_survey_question_header').addClass('invisible'); this.$('.o_survey_session_results, .o_survey_session_navigation_previous, .o_survey_session_navigation_next') .addClass('d-none'); this.$('.o_survey_session_description_done').removeClass('d-none'); }, /** * Refresh the screen with the next question's rendered template. * * @param {boolean} goBack Whether we are going back to the previous question or not */ _onNextQuestionDone: async function (goBack) { var self = this; if (this.nextQuestion.question_html) { var $renderedTemplate = $(this.nextQuestion.question_html); this.$el.replaceWith($renderedTemplate); // Ensure new question is fully loaded before force loading previous question screen. await this.attachTo($renderedTemplate); if (goBack) { // As we arrive on "question" screen, simulate going to the results screen or leaderboard. this._setShowInputs(true); this._setShowAnswers(true); if (this.sessionShowLeaderboard && this.isScoredQuestion) { this.currentScreen = 'leaderboard'; this.leaderBoard.showLeaderboard(false, this.isScoredQuestion); } else { this.currentScreen = 'results'; this._refreshResults(); } } else { this._startTimer(); } this.$el.fadeIn(this.fadeInOutTime); } else if (this.sessionShowLeaderboard) { // Display last screen if leaderboard activated this.isLastQuestion = true; this._setupLeaderboard().then(function () { self.$('.o_survey_session_leaderboard_title').text(_t('Final Leaderboard')); self.$('.o_survey_session_navigation_next').addClass('d-none'); self.$('.o_survey_leaderboard_buttons').removeClass('d-none'); self.leaderBoard.showLeaderboard(false, false); }); } else { self.$('.o_survey_session_close').first().click(); self._displaySessionClosedPage(); } // Background Management if (this.refreshBackground) { $('div.o_survey_background').css("background-image", "url(" + this.nextQuestion.background_image_url + ")"); $('div.o_survey_background').removeClass('o_survey_background_transition'); } }, /** * Will start the question timer so that the host may know when the question is done to display * the results and the leaderboard. * * If the question is scored, the timer ending triggers the display of attendees inputs. */ _startTimer: function () { var self = this; var $timer = this.$('.o_survey_timer'); if ($timer.length) { var timeLimitMinutes = this.$el.data('timeLimitMinutes'); var timer = this.$el.data('timer'); this.surveyTimerWidget = new publicWidget.registry.SurveyTimerWidget(this, { 'timer': timer, 'timeLimitMinutes': timeLimitMinutes }); this.surveyTimerWidget.attachTo($timer); this.surveyTimerWidget.on('time_up', this, function () { if (self.currentScreen === 'question' && this.isScoredQuestion) { self.$('.o_survey_session_navigation_next').click(); } }); } }, /** * Refreshes the question results. * * What we get from this call: * - The 'question statistics' used to display the bar chart when appropriate * - The 'user input lines' that are used to display text/date/datetime answers on the screen * - The number of answers, useful for refreshing the progress bar */ _refreshResults: function () { var self = this; return rpc( `/survey/session/results/${self.surveyAccessToken}` ).then(function (questionResults) { if (questionResults) { self.attendeesCount = questionResults.attendees_count; if (self.resultsChart && questionResults.question_statistics_graph) { self.resultsChart.updateChart(JSON.parse(questionResults.question_statistics_graph)); } else if (self.textAnswers) { self.textAnswers.updateTextAnswers(questionResults.input_line_values); } var max = self.attendeesCount > 0 ? self.attendeesCount : 1; var percentage = Math.min(Math.round((questionResults.answer_count / max) * 100), 100); self.$('.progress-bar').css('width', `${percentage}%`); if (self.attendeesCount && self.attendeesCount > 0) { var answerCount = Math.min(questionResults.answer_count, self.attendeesCount); self.$('.o_survey_session_answer_count').text(answerCount); self.$('.progress-bar.o_survey_session_progress_small span').text( `${answerCount} / ${self.attendeesCount}` ); } } return Promise.resolve(); }, function () { // on failure, stop refreshing clearInterval(self.resultsRefreshInterval); delete self.resultsRefreshInterval; }); }, /** * We refresh the attendees count every 2 seconds while the user is on the start screen. * */ _refreshAttendeesCount: function () { var self = this; return self.orm.read( "survey.survey", [self.surveyId], ['session_answer_count'], ).then(function (result) { if (result && result.length === 1){ self.$('.o_survey_session_attendees_count').text( result[0].session_answer_count ); } }, function (err) { // on failure, stop refreshing clearInterval(self.attendeesRefreshInterval); console.error(err); }); }, /** * For simple/multiple choice questions, we display a bar chart with: * * - answers of attendees * - correct / incorrect answers when relevant * * see SurveySessionChart widget doc for more information. * */ _setupChart: function () { if (this.resultsChart) { this.resultsChart.setElement(null); this.resultsChart.destroy(); delete this.resultsChart; } if (!this.isStartScreen && this.showBarChart) { this.resultsChart = new SurveySessionChart(this, { questionType: this.$el.data('questionType'), answersValidity: this.$el.data('answersValidity'), hasCorrectAnswers: this.hasCorrectAnswers, questionStatistics: this.$el.data('questionStatistics'), showInputs: this.showInputs }); return this.resultsChart.attachTo(this.$('.o_survey_session_chart')); } else { return Promise.resolve(); } }, /** * Leaderboard of all the attendees based on their score. * see SurveySessionLeaderBoard widget doc for more information. * */ _setupLeaderboard: function () { if (this.leaderBoard) { this.leaderBoard.setElement(null); this.leaderBoard.destroy(); delete this.leaderBoard; } if (this.isScoredQuestion || this.isLastQuestion) { this.leaderBoard = new SurveySessionLeaderBoard(this, { surveyAccessToken: this.surveyAccessToken, sessionResults: this.$('.o_survey_session_results') }); return this.leaderBoard.attachTo(this.$('.o_survey_session_leaderboard')); } else { return Promise.resolve(); } }, /** * Shows attendees answers for char_box/date and datetime questions. * see SurveySessionTextAnswers widget doc for more information. * */ _setupTextAnswers: function () { if (this.textAnswers) { this.textAnswers.setElement(null); this.textAnswers.destroy(); delete this.textAnswers; } if (!this.isStartScreen && this.showTextAnswers) { this.textAnswers = new SurveySessionTextAnswers(this, { questionType: this.$el.data('questionType') }); return this.textAnswers.attachTo(this.$('.o_survey_session_text_answers_container')); } else { return Promise.resolve(); } }, /** * Setup the 2 refresh intervals of 2 seconds for our widget: * - The refresh of attendees count (only on the start screen) * - The refresh of results (used for chart/text answers/progress bar) */ _setupIntervals: function () { this.attendeesCount = this.$el.data('attendeesCount') ? this.$el.data('attendeesCount') : 0; if (this.isStartScreen) { this.attendeesRefreshInterval = setInterval(this._refreshAttendeesCount.bind(this), 2000); } else { if (this.attendeesRefreshInterval) { clearInterval(this.attendeesRefreshInterval); } if (!this.resultsRefreshInterval) { this.resultsRefreshInterval = setInterval(this._refreshResults.bind(this), 2000); } } }, /** * Setup current screen based on question properties. * If it's a non-scored question with a chart, we directly display the user inputs. */ _setupCurrentScreen: function () { if (this.isStartScreen) { this.currentScreen = 'startScreen'; } else if (!this.isScoredQuestion && this.showBarChart) { this.currentScreen = 'userInputs'; } else { this.currentScreen = 'question'; } this.$('.o_survey_session_navigation_previous').toggleClass('d-none', !!this.isFirstQuestion); this._setShowInputs(this.currentScreen === 'userInputs'); this._updateNextScreenTooltip(); }, /** * When we go from the 'question' screen to the 'userInputs' screen, we toggle this boolean * and send the information to the chart. * The chart will show attendees survey.user_input.lines. * * @param {Boolean} showInputs */ _setShowInputs(showInputs) { this.showInputs = showInputs; if (this.resultsChart) { this.resultsChart.setShowInputs(showInputs); this.resultsChart.updateChart(); } }, /** * When we go from the 'userInputs' screen to the 'results' screen, we toggle this boolean * and send the information to the chart. * The chart will show the question survey.question.answers. * (Only used for simple / multiple choice questions). * * @param {Boolean} showAnswers */ _setShowAnswers(showAnswers) { this.showAnswers = showAnswers; if (this.resultsChart) { this.resultsChart.setShowAnswers(showAnswers); this.resultsChart.updateChart(); } }, /** * @private * Updates the tooltip for current page (on right arrow icon for 'Next' content). * this method will be called on Clicking of Next and Previous Arrow to show the * tooltip for the Next Content. */ _updateNextScreenTooltip() { let tooltip; if (this.currentScreen === 'startScreen') { tooltip = nextPageTooltips['startScreen']; } else if (this.isLastQuestion && !this.surveyHasConditionalQuestions && !this.isScoredQuestion && !this.sessionShowLeaderboard) { tooltip = nextPageTooltips['closingWords']; } else { const nextScreen = this._getNextScreen(); if (nextScreen === 'nextQuestion' || this.surveyHasConditionalQuestions) { tooltip = nextPageTooltips['nextQuestion']; } tooltip = nextPageTooltips[nextScreen]; } const sessionNavigationNextEl = this.el.querySelector('.o_survey_session_navigation_next_label'); if (sessionNavigationNextEl && tooltip) { sessionNavigationNextEl.textContent = tooltip; } } }); export default publicWidget.registry.SurveySessionManage;