From 4abe3ea9c7c098a035103973fa9d5df69831af9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Warnon?= Date: Mon, 11 May 2020 07:55:15 +0000 Subject: [PATCH] [IMP] survey: animate leaderboard in survey sessions Purpose ======= This commit intends to improve the animations on the leaderboard during survey sessions. We now show both the ranking of each attendee before and after taking into account the current question scores. It allows attendees to see their position in the leaderboard shifting up / down depending on how well they answered, and adds a bit more interactivity during the session. Specs ===== The code changes to make this happen implied having multiple layers of animations running one after the other. The use of async/await in the JS allowed for a much smoother and less indented code, simplifying the reading. We now have 2 different "bars" to handle: The bar that shows the previous leaderboard position. The bar that shows the current question score. This means we handle both jQuery items and modify their size & transition properties depending on our animation. Unfortunately, all these animations required a lot of testing to find fitting timings, which means using quite a few setTimeout with "magic" delays. These were just the most good looking ones and it feels like naming them with a constant would not really help. We also slightly improved the graphic font size / styling to make it easier to read for potentially far away attendees. Task 2235815 closes odoo/odoo#50991 Signed-off-by: Thibault Delavallee (tde) --- .../controllers/survey_session_manage.py | 2 +- addons/survey/models/survey_survey.py | 51 +++- .../static/src/js/survey_session_chart.js | 11 +- .../static/src/js/survey_session_colors.js | 22 +- .../src/js/survey_session_leaderboard.js | 273 +++++++++++++++++- .../static/src/js/survey_session_manage.js | 4 +- .../survey/static/src/scss/survey_form.scss | 31 +- .../tests/tours/survey_tour_session_manage.js | 17 +- .../survey_templates_user_input_session.xml | 44 ++- 9 files changed, 391 insertions(+), 64 deletions(-) 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 -
+