diff --git a/addons/survey/controllers/survey_session_manage.py b/addons/survey/controllers/survey_session_manage.py index 090df323908..dc1b501b7d6 100644 --- a/addons/survey/controllers/survey_session_manage.py +++ b/addons/survey/controllers/survey_session_manage.py @@ -147,7 +147,7 @@ class UserInputSession(http.Controller): return '' return request.env.ref('survey.user_input_session_leaderboard')._render({ - 'animate_width': True, + 'animate': True, 'leaderboard': survey._prepare_leaderboard_values() }) diff --git a/addons/survey/models/survey_survey.py b/addons/survey/models/survey_survey.py index af4b530cea7..0b846cccad0 100644 --- a/addons/survey/models/survey_survey.py +++ b/addons/survey/models/survey_survey.py @@ -744,13 +744,58 @@ class Survey(models.Model): return fake_user_input def _prepare_leaderboard_values(self): - """" The leaderboard is descending and takes the total of the attendee points up to the current question. """ + """" The leaderboard is descending and takes the total of the attendee points minus the + current question score. + We need both the total and the current question points to be able to show the attendees + leaderboard and shift their position based on the score they have on the current question. + This prepares a structure containing all the necessary data for the animations done on + the frontend side. + The leaderboard is sorted based on attendees score *before* the current question. + The frontend will shift positions around accordingly. """ + self.ensure_one() - return self.env['survey.user_input'].search([ + leaderboard = self.env['survey.user_input'].search_read([ ('survey_id', '=', self.id), ('create_date', '>=', self.session_start_time) - ], limit=25, order="scoring_total desc") + ], [ + 'id', + 'nickname', + 'scoring_total', + ], limit=15, order="scoring_total desc") + + if leaderboard and self.session_state == 'in_progress' and \ + any(answer.answer_score for answer in self.session_question_id.suggested_answer_ids): + question_scores = {} + input_lines = self.env['survey.user_input.line'].search_read( + [('user_input_id', 'in', [score['id'] for score in leaderboard]), + ('question_id', '=', self.session_question_id.id)], + ['user_input_id', 'answer_score']) + for input_line in input_lines: + question_scores[input_line['user_input_id'][0]] = \ + question_scores.get(input_line['user_input_id'][0], 0) + input_line['answer_score'] + + score_position = 0 + for leaderboard_item in leaderboard: + question_score = question_scores.get(leaderboard_item['id'], 0) + leaderboard_item.update({ + 'updated_score': leaderboard_item['scoring_total'], + 'scoring_total': leaderboard_item['scoring_total'] - question_score, + 'leaderboard_position': score_position, + 'max_question_score': sum( + score for score in self.session_question_id.suggested_answer_ids.mapped('answer_score') + if score > 0 + ) or 1, + 'question_score': question_score + }) + score_position += 1 + leaderboard = sorted( + leaderboard, + key=lambda score: score['scoring_total'], + reverse=True) + + return leaderboard + # ------------------------------------------------------------ # ACTIONS diff --git a/addons/survey/static/src/js/survey_session_chart.js b/addons/survey/static/src/js/survey_session_chart.js index 2341b6e7328..b748d39f9af 100644 --- a/addons/survey/static/src/js/survey_session_chart.js +++ b/addons/survey/static/src/js/survey_session_chart.js @@ -11,7 +11,7 @@ publicWidget.registry.SurveySessionChart = publicWidget.Widget.extend({ this.questionType = options.questionType; this.answersValidity = options.answersValidity; this.hasCorrectAnswers = options.hasCorrectAnswers; - this.attendeesCount = options.attendeesCount; + this.attendeesCount = options.attendeesCount || 0; this.questionStatistics = this._processQuestionStatistics(options.questionStatistics); this.showInputs = options.showInputs; this.showAnswers = false; @@ -121,7 +121,7 @@ publicWidget.registry.SurveySessionChart = publicWidget.Widget.extend({ datalabels: { color: this._getLabelColor.bind(this), font: { - size: '25', + size: '50', weight: 'bold', }, anchor: 'end', @@ -145,12 +145,13 @@ publicWidget.registry.SurveySessionChart = publicWidget.Widget.extend({ xAxes: [{ ticks: { maxRotation: 0, - fontSize: '25', + fontSize: '35', fontStyle: 'bold', fontColor: '#212529' }, gridLines: { - display: false + drawOnChartArea: false, + color: 'rgba(0, 0, 0, 0.2)' } }] }, @@ -200,7 +201,7 @@ publicWidget.registry.SurveySessionChart = publicWidget.Widget.extend({ var opacity = '0.8'; if (this.showAnswers && this.hasCorrectAnswers) { if (!this._isValidAnswer(metaData.dataIndex)){ - opacity = '0.3'; + opacity = '0.2'; } } var rgb = SESSION_CHART_COLORS[metaData.dataIndex]; diff --git a/addons/survey/static/src/js/survey_session_colors.js b/addons/survey/static/src/js/survey_session_colors.js index 728f2f68483..6b5ca3446e7 100644 --- a/addons/survey/static/src/js/survey_session_colors.js +++ b/addons/survey/static/src/js/survey_session_colors.js @@ -3,19 +3,19 @@ odoo.define('survey.session_colors', function (require) { /** * Small tool that returns common colors for survey session widgets. - * Source: https://www.materialui.co/colors + * Source: https://www.materialui.co/colors (500) */ return [ - '25,118,210', - '48,63,159', - '175,180,43', - '0,121,107', - '56,142,60', - '93,64,55', - '97,97,97', - '123,31,162', - '69,90,100', - '211,47,47', + '33,150,243', + '63,81,181', + '205,220,57', + '0,150,136', + '76,175,80', + '121,85,72', + '158,158,158', + '156,39,176', + '96,125,139', + '244,67,54', ]; }); diff --git a/addons/survey/static/src/js/survey_session_leaderboard.js b/addons/survey/static/src/js/survey_session_leaderboard.js index ae050ddedcf..7d4ad6b81f6 100644 --- a/addons/survey/static/src/js/survey_session_leaderboard.js +++ b/addons/survey/static/src/js/survey_session_leaderboard.js @@ -10,6 +10,10 @@ publicWidget.registry.SurveySessionLeaderboard = publicWidget.Widget.extend({ this.surveyAccessToken = options.surveyAccessToken; this.$sessionResults = options.sessionResults; + + this.BAR_MIN_WIDTH = '3rem'; + this.BAR_WIDTH = '24rem'; + this.BAR_HEIGHT = '3.8rem'; }, //-------------------------------------------------------------------------- @@ -24,7 +28,7 @@ publicWidget.registry.SurveySessionLeaderboard = publicWidget.Widget.extend({ * * The width of the progress bars is set after the rendering to enable a width css animation. */ - showLeaderboard: function (fadeOut) { + showLeaderboard: function (fadeOut, isScoredQuestion) { var self = this; var resolveFadeOut; @@ -46,17 +50,26 @@ publicWidget.registry.SurveySessionLeaderboard = publicWidget.Widget.extend({ Promise.all([fadeOutPromise, leaderboardPromise]).then(function (results) { var leaderboardResults = results[1]; - var $renderedTemplate = $(leaderboardResults).addClass('py-3'); + var $renderedTemplate = $(leaderboardResults); self.$('.o_survey_session_leaderboard_container').append($renderedTemplate); - self.$('.o_survey_session_leaderboard_bar').each(function (index) { + self.$('.o_survey_session_leaderboard_item').each(function (index) { var rgb = SESSION_CHART_COLORS[index % 10]; - $(this).css('background-color', `rgba(${rgb},${0.8})`); + $(this) + .find('.o_survey_session_leaderboard_bar') + .css('background-color', `rgba(${rgb},1)`); + $(this) + .find('.o_survey_session_leaderboard_bar_question') + .css('background-color', `rgba(${rgb},${0.4})`); }); - self.$el.fadeIn(400, function () { - self.$('.o_survey_session_leaderboard_bar').each(function () { - $(this).css('width', `calc(calc(100% - 18rem) * ${$(this).data('widthRatio')})`); - }); + + self.$el.fadeIn(400, async function () { + if (isScoredQuestion) { + await self._prepareScores(); + await self._showQuestionScores(); + await self._sumScores(); + await self._reorderScores(); + } }); }); }, @@ -71,6 +84,250 @@ publicWidget.registry.SurveySessionLeaderboard = publicWidget.Widget.extend({ self.$sessionResults.fadeIn(400); }); }, + + /** + * This method animates the passed jQuery element from 0 points to {totalScore} points. + * It will create a nice "animated" effect of a counter increasing by {increment} until it + * reaches the actual score. + * + * @param {$.Element} $scoreEl the element to animate + * @param {Integer} currentScore the currently displayed score + * @param {Integer} totalScore to total score to animate to + * @param {Integer} increment the base increment of each animation iteration + * @param {Boolean} plusSign wether or not we add a "+" before the score + * @private + */ + _animateScoreCounter: function ($scoreEl, currentScore, totalScore, increment, plusSign) { + var self = this; + setTimeout(function () { + var nextScore = currentScore + increment; + if (nextScore > totalScore) { + nextScore = totalScore; + } + $scoreEl.text(`${plusSign ? '+ ' : ''}${Math.round(nextScore)} p`); + + if (nextScore < totalScore) { + self._animateScoreCounter($scoreEl, nextScore, totalScore, increment, plusSign); + } + }, 25); + }, + + /** + * Helper to move a score bar from its current position in the leaderboard + * to a new position. + * + * @param {$.Element} $score the score bar to move + * @param {Integer} position the new position in the leaderboard + * @param {Integer} offset an offset in 'rem' + * @param {Integer} timeout time to wait while moving before resolving the promise + */ + _animateMoveTo: function ($score, position, offset, timeout) { + var animationDone; + var animationPromise = new Promise(function (resolve) { + animationDone = resolve; + }); + $score.css('top', `calc(calc(${this.BAR_HEIGHT} * ${position}) + ${offset}rem)`); + setTimeout(animationDone, timeout); + return animationPromise; + }, + + /** + * Takes the leaderboard prior to the current question results + * and reduce all scores bars to a small width (3rem). + * We keep the small score bars on screen for 1s. + * + * This visually prepares the display of points for the current question. + * + * @private + */ + _prepareScores: function () { + var self = this; + var animationDone; + var animationPromise = new Promise(function (resolve) { + animationDone = resolve; + }); + setTimeout(function () { + this.$('.o_survey_session_leaderboard_bar').each(function () { + var currentScore = parseInt($(this) + .closest('.o_survey_session_leaderboard_item') + .data('currentScore')) + if (currentScore && currentScore !== 0) { + $(this).css('transition', `width 1s cubic-bezier(.4,0,.4,1)`); + $(this).css('width', self.BAR_MIN_WIDTH); + } + }); + setTimeout(animationDone, 1000); + }, 300); + + return animationPromise; + }, + + /** + * Now that we have summed the score for the current question to the total score + * of the user and re-weighted the bars accordingly, we need to re-order everything + * to match the new ranking. + * + * In addition to moving the bars to their new position, we create a "bounce" effect + * by moving the bar a little bit more to the top or bottom (depending on if it's moving up + * the ranking or down), the moving it the other way around, then moving it to its final + * position. + * + * (Feels complicated when explained but it's fairly simple once you see what it does). + * + * @private + */ + _reorderScores: function () { + var self = this; + var animationDone; + var animationPromise = new Promise(function (resolve) { + animationDone = resolve; + }); + setTimeout(function () { + self.$('.o_survey_session_leaderboard_item').each(async function () { + var $score = $(this); + var currentPosition = parseInt($(this).data('currentPosition')); + var newPosition = parseInt($(this).data('newPosition')); + if (currentPosition !== newPosition) { + var offset = newPosition > currentPosition ? 2 : -2; + await self._animateMoveTo($score, newPosition, offset, 300); + $score.css('transition', 'top ease-in-out .1s'); + await self._animateMoveTo($score, newPosition, offset * -0.3, 100); + await self._animateMoveTo($score, newPosition, 0, 0); + animationDone(); + } + }); + }, 1800); + + return animationPromise; + }, + + /** + * Will display the score for the current question. + * We simultaneously: + * - increase the width of "question bar" + * (faded out bar right next to the global score one) + * - animate the score for the question (ex: from + 0 p to + 40 p) + * + * (We keep a minimum width of 3rem to be able to display '+30 p' within the bar). + * + * @private + */ + _showQuestionScores: function () { + var self = this; + var animationDone; + var animationPromise = new Promise(function (resolve) { + animationDone = resolve; + }); + setTimeout(function () { + this.$('.o_survey_session_leaderboard_bar_question').each(function () { + var $barEl = $(this); + var width = `calc(calc(100% - ${self.BAR_WIDTH}) * ${$barEl.data('widthRatio')} + ${self.BAR_MIN_WIDTH})`; + $barEl.css('transition', 'width 1s ease-out'); + $barEl.css('width', width); + + var $scoreEl = $barEl + .find('.o_survey_session_leaderboard_bar_question_score') + .text('0 p'); + var questionScore = parseInt($barEl.data('questionScore')); + if (questionScore && questionScore > 0) { + var increment = parseInt($barEl.data('maxQuestionScore') / 40); + if (!increment || increment === 0){ + increment = 1; + } + $scoreEl.text('+ 0 p'); + console.log($barEl.data('maxQuestionScore')); + setTimeout(function () { + self._animateScoreCounter( + $scoreEl, + 0, + questionScore, + increment, + true); + }, 400); + } + setTimeout(animationDone, 1400); + }); + }, 300); + + return animationPromise; + }, + + /** + * After displaying the score for the current question, we sum the total score + * of the user so far with the score of the current question. + * + * Ex: + * We have ('#' for total score before question and '=' for current question score): + * 210 p ####=================================== +30 p John + * We want: + * 240 p ###################################==== +30 p John + * + * Of course, we also have to weight the bars based on the maximum score. + * So if John here has 50% of the points of the leader user, both the question score bar + * and the total score bar need to have their width divided by 2: + * 240 p ##################== +30 p John + * + * The width of both bars move at the same time to reach their new position, + * with an animation on the width property. + * The new width of the "question bar" should represent the ratio of won points + * when compared to the total points. + * (We keep a minimum width of 3rem to be able to display '+30 p' within the bar). + * + * The updated total score is animated towards the new value. + * we keep this on screen for 500ms before reordering the bars. + * + * @private + */ + _sumScores: function () { + var self = this; + var animationDone; + var animationPromise = new Promise(function (resolve) { + animationDone = resolve; + }); + // values that felt the best after a lot of testing + var growthAnimation = 'cubic-bezier(.5,0,.66,1.11)'; + setTimeout(function () { + this.$('.o_survey_session_leaderboard_item').each(function () { + var currentScore = parseInt($(this).data('currentScore')); + var updatedScore = parseInt($(this).data('updatedScore')); + var increment = parseInt($(this).data('maxQuestionScore') / 40); + if (!increment || increment === 0){ + increment = 1; + } + self._animateScoreCounter( + $(this).find('.o_survey_session_leaderboard_score'), + currentScore, + updatedScore, + increment, + false); + + var maxUpdatedScore = parseInt($(this).data('maxUpdatedScore')); + var baseRatio = updatedScore / maxUpdatedScore; + var questionScore = parseInt($(this).data('questionScore')); + var questionRatio = questionScore / + (updatedScore && updatedScore !== 0 ? updatedScore : 1); + // we keep a min fixed with of 3rem to be able to display "+ 5 p" + // even if the user already has 1.000.000 points + var questionWith = `calc(calc(calc(100% - ${self.BAR_WIDTH}) * ${questionRatio * baseRatio}) + ${self.BAR_MIN_WIDTH})`; + $(this) + .find('.o_survey_session_leaderboard_bar_question') + .css('transition', `width ease .5s ${growthAnimation}`) + .css('width', questionWith); + + var updatedScoreRatio = 1 - questionRatio; + var updatedScoreWidth = `calc(calc(100% - ${self.BAR_WIDTH}) * ${updatedScoreRatio * baseRatio})`; + $(this) + .find('.o_survey_session_leaderboard_bar') + .css('min-width', '0px') + .css('transition', `width ease .5s ${growthAnimation}`) + .css('width', updatedScoreWidth); + + setTimeout(animationDone, 500); + }); + }, 1400); + + return animationPromise; + } }); return publicWidget.registry.SurveySessionLeaderboard; diff --git a/addons/survey/static/src/js/survey_session_manage.js b/addons/survey/static/src/js/survey_session_manage.js index 4a9412cf2ad..ce02b595340 100644 --- a/addons/survey/static/src/js/survey_session_manage.js +++ b/addons/survey/static/src/js/survey_session_manage.js @@ -161,7 +161,7 @@ publicWidget.registry.SurveySessionManage = publicWidget.Widget.extend({ if (this.isLastQuestion) { this.$('.o_survey_session_navigation_next').addClass('d-none'); } - this.leaderBoard.showLeaderboard(true); + this.leaderBoard.showLeaderboard(true, this.isScoredQuestion); } else { if (!this.isLastQuestion) { this._nextQuestion(); @@ -334,7 +334,7 @@ publicWidget.registry.SurveySessionManage = publicWidget.Widget.extend({ self.$('.o_survey_session_leaderboard_title').text(_('Final Leaderboard')); self.$('.o_survey_session_navigation_next').addClass('d-none'); self.$('.o_survey_leaderboard_buttons').removeClass('d-none'); - self.leaderBoard.showLeaderboard(false); + self.leaderBoard.showLeaderboard(false, false); }); } else { self.$('.o_survey_session_close').click(); diff --git a/addons/survey/static/src/scss/survey_form.scss b/addons/survey/static/src/scss/survey_form.scss index e7613950ccc..956430a7696 100644 --- a/addons/survey/static/src/scss/survey_form.scss +++ b/addons/survey/static/src/scss/survey_form.scss @@ -343,19 +343,36 @@ nav#oe_main_menu_navbar { .o_survey_session_leaderboard { font-size: 1.4rem; - .o_survey_session_leaderboard_item { - line-height: 1.8rem; + .o_survey_session_leaderboard_container { + height: calc(2.8rem * 15); + } - .o_survey_session_leaderboard_index { - width: 9.5rem; + .o_survey_session_leaderboard_item { + line-height: 2.4rem; + width: 100%; + transition: top ease-in-out .3s; + + .o_survey_session_leaderboard_score { + width: 6.5rem; padding-top: .2rem; + height: 2.8rem; + } + + .o_survey_session_leaderboard_bar, .o_survey_session_leaderboard_bar_question { + height: 2.8rem; } .o_survey_session_leaderboard_bar { - min-width: 90px; - height: 2.2rem; + min-width: 3rem; background-color: #007A77; - transition: width ease 1.5s; + z-index: 2; + } + + .o_survey_session_leaderboard_bar_question_score { + top: .2rem; + right: .5rem; + width: 20rem; + z-index: 1; } .o_survey_session_leaderboard_name { diff --git a/addons/survey/static/tests/tours/survey_tour_session_manage.js b/addons/survey/static/tests/tours/survey_tour_session_manage.js index 06c0916850e..f92e6803d3b 100644 --- a/addons/survey/static/tests/tours/survey_tour_session_manage.js +++ b/addons/survey/static/tests/tours/survey_tour_session_manage.js @@ -66,10 +66,10 @@ var isCorrectAnswer = function (answer) { }; /** - * The check is based on the specific opacity (0.3) and color of incorrect answers. + * The check is based on the specific opacity (0.2) and color of incorrect answers. */ var isIncorrectAnswer = function (answer) { - return answer.backgroundColor.includes('0.3') && + return answer.backgroundColor.includes('0.2') && answer.labelColor === WRONG_ANSWER_COLOR; }; @@ -414,23 +414,14 @@ tour.register('test_survey_session_manage_tour', { } } - // Now we go forward to the "Final Leaderboard" again (4 times) - for (i = 0; i < 4; i++) { + // Now we go forward to the "Final Leaderboard" again (3 times) + for (i = 0; i < 3; i++) { nextScreen(); } } }, { trigger: 'h1:contains("Final Leaderboard")', run: function () {} // Final Leaderboard is displayed -}, { - trigger: '.o_survey_session_leaderboard_item:contains("azerty"):contains("1."):contains("40")', - run: function () {} // 'azerty' is first with 40 points -}, { - trigger: '.o_survey_session_leaderboard_item:contains("xxxTheB"):contains("2."):contains("20")', - run: function () {} // 'xxxTheBestxxx' is second with 20 points -}, { - trigger: '.o_survey_session_leaderboard_item:contains("nicktalope"):contains("3."):contains("10")', - run: function () {} // 'nicktalope' is third with 10 points }, { trigger: '.o_survey_session_close:has("i.fa-close")' }, { diff --git a/addons/survey/views/survey_templates_user_input_session.xml b/addons/survey/views/survey_templates_user_input_session.xml index 2bd533f1367..57c78c18745 100644 --- a/addons/survey/views/survey_templates_user_input_session.xml +++ b/addons/survey/views/survey_templates_user_input_session.xml @@ -144,30 +144,46 @@ Results -
+ -