From 84e128980cfd3616f73c4b937c32711fddd50234 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Warnon?= Date: Tue, 11 Feb 2020 15:31:20 +0000 Subject: [PATCH 1/3] [IMP] survey: add chartjs datalabels plugin library This commit adds a chartjs 'datalabel' plugin in the survey module. It is a preparation commit for the general layout improvement of the survey 'live' session feature. Task 2191924 PR #45111 --- .../static/src/js/libs/chartjs-plugin-datalabels.min.js | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 addons/survey/static/src/js/libs/chartjs-plugin-datalabels.min.js diff --git a/addons/survey/static/src/js/libs/chartjs-plugin-datalabels.min.js b/addons/survey/static/src/js/libs/chartjs-plugin-datalabels.min.js new file mode 100644 index 00000000000..75eb4203362 --- /dev/null +++ b/addons/survey/static/src/js/libs/chartjs-plugin-datalabels.min.js @@ -0,0 +1,7 @@ +/*! + * chartjs-plugin-datalabels v0.7.0 + * https://chartjs-plugin-datalabels.netlify.com + * (c) 2019 Chart.js Contributors + * Released under the MIT license + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e(require("chart.js")):"function"==typeof define&&define.amd?define(["chart.js"],e):(t=t||self).ChartDataLabels=e(t.Chart)}(this,function(t){"use strict";var e=(t=t&&t.hasOwnProperty("default")?t.default:t).helpers,r=function(){if("undefined"!=typeof window){if(window.devicePixelRatio)return window.devicePixelRatio;var t=window.screen;if(t)return(t.deviceXDPI||1)/(t.logicalXDPI||1)}return 1}(),n={toTextLines:function(t){var r,n=[];for(t=[].concat(t);t.length;)"string"==typeof(r=t.pop())?n.unshift.apply(n,r.split("\n")):Array.isArray(r)?t.push.apply(t,r):e.isNullOrUndef(t)||n.unshift(""+r);return n},toFontString:function(t){return!t||e.isNullOrUndef(t.size)||e.isNullOrUndef(t.family)?null:(t.style?t.style+" ":"")+(t.weight?t.weight+" ":"")+t.size+"px "+t.family},textSize:function(t,e,r){var n,i=[].concat(e),o=i.length,a=t.font,l=0;for(t.font=r.string,n=0;nr.right&&(n|=l),er.bottom&&(n|=s),n}function d(t,e){var r,n,i=e.anchor,o=t;return e.clamp&&(o=function(t,e){for(var r,n,i,o=t.x0,d=t.y0,c=t.x1,h=t.y1,x=f(o,d,e),y=f(c,h,e);x|y&&!(x&y);)(r=x||y)&u?(n=o+(c-o)*(e.top-d)/(h-d),i=e.top):r&s?(n=o+(c-o)*(e.bottom-d)/(h-d),i=e.bottom):r&l?(i=d+(h-d)*(e.right-o)/(c-o),n=e.right):r&a&&(i=d+(h-d)*(e.left-o)/(c-o),n=e.left),r===x?x=f(o=n,d=i,e):y=f(c=n,h=i,e);return{x0:o,x1:c,y0:d,y1:h}}(o,e.area)),"start"===i?(r=o.x0,n=o.y0):"end"===i?(r=o.x1,n=o.y1):(r=(o.x0+o.x1)/2,n=(o.y0+o.y1)/2),function(t,e,r,n,i){switch(i){case"center":r=n=0;break;case"bottom":r=0,n=1;break;case"right":r=1,n=0;break;case"left":r=-1,n=0;break;case"top":r=0,n=-1;break;case"start":r=-r,n=-n;break;case"end":break;default:i*=Math.PI/180,r=Math.cos(i),n=Math.sin(i)}return{x:t,y:e,vx:r,vy:n}}(r,n,t.vx,t.vy,e.align)}var c={arc:function(t,e){var r=(t.startAngle+t.endAngle)/2,n=Math.cos(r),i=Math.sin(r),o=t.innerRadius,a=t.outerRadius;return d({x0:t.x+n*o,y0:t.y+i*o,x1:t.x+n*a,y1:t.y+i*a,vx:n,vy:i},e)},point:function(t,e){var r=i(t,e.origin),n=r.x*t.radius,o=r.y*t.radius;return d({x0:t.x-n,y0:t.y-o,x1:t.x+n,y1:t.y+o,vx:r.x,vy:r.y},e)},rect:function(t,e){var r=i(t,e.origin),n=t.x,o=t.y,a=0,l=0;return t.horizontal?(n=Math.min(t.x,t.base),a=Math.abs(t.base-t.x)):(o=Math.min(t.y,t.base),l=Math.abs(t.base-t.y)),d({x0:n,y0:o+l,x1:n+a,y1:o,vx:r.x,vy:r.y},e)},fallback:function(t,e){var r=i(t,e.origin);return d({x0:t.x,y0:t.y,x1:t.x,y1:t.y,vx:r.x,vy:r.y},e)}},h=t.helpers,x=n.rasterize;function y(t){var e=t._model.horizontal,r=t._scale||e&&t._xScale||t._yScale;if(!r)return null;if(void 0!==r.xCenter&&void 0!==r.yCenter)return{x:r.xCenter,y:r.yCenter};var n=r.getBasePixel();return e?{x:n,y:null}:{x:null,y:n}}function v(t,e,r){var n=t.shadowBlur,i=r.stroked,o=x(r.x),a=x(r.y),l=x(r.w);i&&t.strokeText(e,o,a,l),r.filled&&(n&&i&&(t.shadowBlur=0),t.fillText(e,o,a,l),n&&i&&(t.shadowBlur=n))}var _=function(t,e,r,n){var i=this;i._config=t,i._index=n,i._model=null,i._rects=null,i._ctx=e,i._el=r};h.extend(_.prototype,{_modelize:function(e,r,i,o){var a,l=this._index,s=h.options.resolve,u=n.parseFont(s([i.font,{}],o,l)),f=s([i.color,t.defaults.global.defaultFontColor],o,l);return{align:s([i.align,"center"],o,l),anchor:s([i.anchor,"center"],o,l),area:o.chart.chartArea,backgroundColor:s([i.backgroundColor,null],o,l),borderColor:s([i.borderColor,null],o,l),borderRadius:s([i.borderRadius,0],o,l),borderWidth:s([i.borderWidth,0],o,l),clamp:s([i.clamp,!1],o,l),clip:s([i.clip,!1],o,l),color:f,display:e,font:u,lines:r,offset:s([i.offset,0],o,l),opacity:s([i.opacity,1],o,l),origin:y(this._el),padding:h.options.toPadding(s([i.padding,0],o,l)),positioner:(a=this._el,a instanceof t.elements.Arc?c.arc:a instanceof t.elements.Point?c.point:a instanceof t.elements.Rectangle?c.rect:c.fallback),rotation:s([i.rotation,0],o,l)*(Math.PI/180),size:n.textSize(this._ctx,r,u),textAlign:s([i.textAlign,"start"],o,l),textShadowBlur:s([i.textShadowBlur,0],o,l),textShadowColor:s([i.textShadowColor,f],o,l),textStrokeColor:s([i.textStrokeColor,f],o,l),textStrokeWidth:s([i.textStrokeWidth,0],o,l)}},update:function(t){var e,r,i,o=this,a=null,l=null,s=o._index,u=o._config,f=h.options.resolve([u.display,!0],t,s);f&&(e=t.dataset.data[s],r=h.valueOrDefault(h.callback(u.formatter,[e,t]),e),(i=h.isNullOrUndef(r)?[]:n.toTextLines(r)).length&&(l=function(t){var e=t.borderWidth||0,r=t.padding,n=t.size.height,i=t.size.width,o=-i/2,a=-n/2;return{frame:{x:o-r.left-e,y:a-r.top-e,w:i+r.width+2*e,h:n+r.height+2*e},text:{x:o,y:a,w:i,h:n}}}(a=o._modelize(f,i,u,t)))),o._model=a,o._rects=l},geometry:function(){return this._rects?this._rects.frame:{}},rotation:function(){return this._model?this._model.rotation:0},visible:function(){return this._model&&this._model.opacity},model:function(){return this._model},draw:function(t,e){var r,i=t.ctx,o=this._model,a=this._rects;this.visible()&&(i.save(),o.clip&&(r=o.area,i.beginPath(),i.rect(r.left,r.top,r.right-r.left,r.bottom-r.top),i.clip()),i.globalAlpha=n.bound(0,o.opacity,1),i.translate(x(e.x),x(e.y)),i.rotate(o.rotation),function(t,e,r){var n=r.backgroundColor,i=r.borderColor,o=r.borderWidth;(n||i&&o)&&(t.beginPath(),h.canvas.roundedRect(t,x(e.x)+o/2,x(e.y)+o/2,x(e.w)-o,x(e.h)-o,r.borderRadius),t.closePath(),n&&(t.fillStyle=n,t.fill()),i&&o&&(t.strokeStyle=i,t.lineWidth=o,t.lineJoin="miter",t.stroke()))}(i,a.frame,o),function(t,e,r,n){var i,o=n.textAlign,a=n.color,l=!!a,s=n.font,u=e.length,f=n.textStrokeColor,d=n.textStrokeWidth,c=f&&d;if(u&&(l||c))for(r=function(t,e,r){var n=r.lineHeight,i=t.w,o=t.x;return"center"===e?o+=i/2:"end"!==e&&"right"!==e||(o+=i),{h:n,w:i,x:o,y:t.y+n/2}}(r,o,s),t.font=s.string,t.textAlign=o,t.textBaseline="middle",t.shadowBlur=n.textShadowBlur,t.shadowColor=n.textShadowColor,l&&(t.fillStyle=a),c&&(t.lineJoin="round",t.lineWidth=d,t.strokeStyle=f),i=0,u=e.length;ie.x+e.w+2||t.y>e.y+e.h+2)},intersects:function(t){var e,r,n,i=this._points(),o=t._points(),a=[k(i[0],i[1]),k(i[0],i[3])];for(this._rotation!==t._rotation&&a.push(k(o[0],o[1]),k(o[0],o[3])),e=0;e=0;--r)for(i=t[r].$layout,n=r-1;n>=0&&i._visible;--n)(o=t[n].$layout)._visible&&i._box.intersects(o._box)&&e(i,o)})(t,function(t,e){var r=t._hidable,n=e._hidable;r&&n||n?e._visible=!1:r&&(t._visible=!1)})}(t)},lookup:function(t,e){var r,n;for(r=t.length-1;r>=0;--r)if((n=t[r].$layout)&&n._visible&&n._box.contains(e))return t[r];return null},draw:function(t,e){var r,n,i,o,a,l;for(r=0,n=e.length;r Date: Tue, 11 Feb 2020 15:31:50 +0000 Subject: [PATCH 2/3] [IMP] survey: improve survey session frontend layouts PURPOSE Improve layouts and usability for the host screens when using survey sessions. We focus on the "host" screens, as the attendee screen is based on regular survey flows/design. SPECS In short: copy mentimeter "wow effect". Main points: - Remove the "question" container (keep only "Results/Ranking") - Remove the tabs concept, views are displayed in full screen - Purpose is to have a "clean view" - The display must be "full screen" with only relevant info - Enable a easy keyboard navigation with left/right keyboard arrows and spacebar - Switching from results to ranking to next question is always linear (next/previous instead of tabs) - Improve charts - Remove the pie chart : make bar charts instead for simple / multiple choices - Show the chart results "as people answer to it" Only if question is not scored, otherwise show when all answers are received / when user goes to "next" - For free text/date/datetime: show small bubbles with attendees answers - Add resize, fade in/out, ... animations for "wow effect" LINKS PR #45111 Upgrade PR odoo/upgrade#774 Task 2191924 --- addons/survey/__manifest__.py | 2 +- addons/survey/controllers/main.py | 4 +- .../controllers/survey_session_manage.py | 74 ++- addons/survey/models/survey_survey.py | 12 +- addons/survey/models/survey_user.py | 2 +- .../static/src/js/survey_session_chart.js | 272 ++++++++ .../static/src/js/survey_session_colors.js | 21 + .../src/js/survey_session_leaderboard.js | 71 +++ .../static/src/js/survey_session_manage.js | 583 +++++++++++++----- .../src/js/survey_session_text_answers.js | 74 +++ .../survey/static/src/scss/survey_form.scss | 241 ++++---- .../survey_session_text_answer_template.xml | 11 + addons/survey/views/assets.xml | 5 + .../views/survey_templates_statistics.xml | 6 +- .../survey_templates_user_input_session.xml | 244 ++++---- 15 files changed, 1169 insertions(+), 453 deletions(-) create mode 100644 addons/survey/static/src/js/survey_session_chart.js create mode 100644 addons/survey/static/src/js/survey_session_colors.js create mode 100644 addons/survey/static/src/js/survey_session_leaderboard.js create mode 100644 addons/survey/static/src/js/survey_session_text_answers.js create mode 100644 addons/survey/static/src/xml/survey_session_text_answer_template.xml diff --git a/addons/survey/__manifest__.py b/addons/survey/__manifest__.py index 8eb338b3704..496d033a0e6 100644 --- a/addons/survey/__manifest__.py +++ b/addons/survey/__manifest__.py @@ -2,7 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. { 'name': 'Surveys', - 'version': '3.0', + 'version': '3.1', 'category': 'Marketing/Survey', 'description': """ Create beautiful surveys and visualize answers diff --git a/addons/survey/controllers/main.py b/addons/survey/controllers/main.py index 2c043b2300c..1c07cb9a715 100644 --- a/addons/survey/controllers/main.py +++ b/addons/survey/controllers/main.py @@ -616,8 +616,8 @@ class Survey(http.Controller): 'search_finished': post.get('finished') == 'true', } - if survey.session_show_ranking: - template_values['ranking'] = survey._prepare_ranking_values() + if survey.session_show_leaderboard: + template_values['leaderboard'] = survey._prepare_leaderboard_values() return request.render('survey.survey_page_statistics', template_values) diff --git a/addons/survey/controllers/survey_session_manage.py b/addons/survey/controllers/survey_session_manage.py index 8f5f32f60f1..c743d6e5faf 100644 --- a/addons/survey/controllers/survey_session_manage.py +++ b/addons/survey/controllers/survey_session_manage.py @@ -1,6 +1,8 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +import json + from werkzeug.exceptions import NotFound from werkzeug.urls import url_join @@ -21,8 +23,8 @@ class UserInputSession(http.Controller): We render a template allowing the host to showcase the different options of the session and to actually start the session. - If the state of the session is 'in_progress' - We render a template allowing the host to show the question results, display the attendee - ranking or go to the next question of the session. """ + We render a template allowing the host to show the question results, display the attendees + leaderboard or go to the next question of the session. """ survey = self._fetch_from_token(survey_token) @@ -71,28 +73,20 @@ class UserInputSession(http.Controller): if not survey or survey.session_state != 'in_progress': # no open session - return '' + return False user_input_lines = request.env['survey.user_input.line'].search([ ('survey_id', '=', survey.id), ('question_id', '=', survey.session_question_id.id) ]) - question_statistics = survey.session_question_id._prepare_statistics(user_input_lines)[0] - return request.env.ref('survey.survey_page_statistics_question').render({ - 'page_record_limit': 10, - 'hide_question_title': True, - 'no_print_url': True, - 'survey': survey, - 'question': survey.session_question_id, - 'question_data': question_statistics, - }) + return self._prepare_question_results_values(survey, user_input_lines) - @http.route('/survey/session/ranking/', type='json', auth='user', website=True) - def survey_session_ranking(self, survey_token, **kwargs): - """ This route is called when the host shows the current question's attendees ranking. + @http.route('/survey/session/leaderboard/', type='json', auth='user', website=True) + def survey_session_leaderboard(self, survey_token, **kwargs): + """ This route is called when the host shows the current question's attendees leaderboard. - It's not a regular 'request.render' route because we handle the display of the ranking + It's not a regular 'request.render' route because we handle the display of the leaderboard using an AJAX request to be able to include the results in the currently displayed page. """ survey = self._fetch_from_token(survey_token) @@ -101,9 +95,9 @@ class UserInputSession(http.Controller): # no open session return '' - return request.env.ref('survey.user_input_session_ranking').render({ + return request.env.ref('survey.user_input_session_leaderboard').render({ 'animate_width': True, - 'ranking': survey._prepare_ranking_values() + 'leaderboard': survey._prepare_leaderboard_values() }) def _prepare_manage_session_values(self, survey): @@ -111,8 +105,50 @@ class UserInputSession(http.Controller): if survey.question_ids: is_last_question = survey.session_question_id == survey.question_ids[-1] - return { + values = { 'survey': survey, 'is_last_question': is_last_question, 'survey_url': url_join(survey.get_base_url(), survey.get_start_short_url()), } + + values.update(self._prepare_question_results_values(survey, request.env['survey.user_input.line'])) + + return values + + def _prepare_question_results_values(self, survey, user_input_lines): + """ Prepares usefull values to display during the host session: + + - question_statistics_graph + The graph data to display the bar chart for questions of type 'choice' + - input_lines_values + The answer values to text/date/datetime questions + - answers_validity + An array containing the is_correct value for all question answers. + We need this special variable because of Chartjs data structure. + The library determines the parameters (color/label/...) by only passing the answer 'index' + (and not the id or anything else we can identify). + In other words, we need to know if the answer at index 2 is correct or not. + - answer_count + The number of answers to the current question. """ + + question = survey.session_question_id + answers_validity = [] + if (any(answer.is_correct for answer in question.suggested_answer_ids)): + answers_validity = [answer.is_correct for answer in question.suggested_answer_ids] + if question.comment_count_as_answer: + answers_validity.append(False) + + full_statistics = question._prepare_statistics(user_input_lines)[0] + input_line_values = [] + if question.question_type in ['char_box', 'date', 'datetime']: + input_line_values = [{ + 'id': line.id, + 'value': line['value_%s' % question.question_type] + } for line in full_statistics.get('table_data', request.env['survey.user_input.line'])[:100]] + + return { + 'question_statistics_graph': full_statistics.get('graph_data'), + 'input_line_values': input_line_values, + 'answers_validity': json.dumps(answers_validity), + 'answer_count': survey.session_question_answer_count + } diff --git a/addons/survey/models/survey_survey.py b/addons/survey/models/survey_survey.py index 6f677693cf4..620a480981a 100644 --- a/addons/survey/models/survey_survey.py +++ b/addons/survey/models/survey_survey.py @@ -121,8 +121,8 @@ class Survey(models.Model): help="The time at which the current question has started, used to handle the timer for attendees.") session_question_answer_count = fields.Integer("Answers Count", compute='_compute_session_question_answer_count') # live sessions - settings - session_show_ranking = fields.Boolean("Show Session Ranking", compute='_compute_session_show_ranking', - help="This mode will display a ranking chart of all attendees.") + session_show_leaderboard = fields.Boolean("Show Session Leaderboard", compute='_compute_session_show_leaderboard', + help="Whether or not we want to show the attendees leaderboard for this survey.") session_speed_rating = fields.Boolean("Reward quick answers", help="Attendees get more points if they answer quickly") _sql_constraints = [ @@ -194,9 +194,9 @@ class Survey(models.Model): survey.session_question_answer_count = answer_count @api.depends('scoring_type', 'question_and_page_ids.save_as_nickname') - def _compute_session_show_ranking(self): + def _compute_session_show_leaderboard(self): for survey in self: - survey.session_show_ranking = survey.scoring_type != 'no_scoring' and \ + survey.session_show_leaderboard = survey.scoring_type != 'no_scoring' and \ any(question.save_as_nickname for question in survey.question_and_page_ids) @api.onchange('scoring_success_min') @@ -563,8 +563,8 @@ class Survey(models.Model): 'type': 'next_question' }) - def _prepare_ranking_values(self): - """" The ranking is descending and takes the total of the attendee points up to the current question. """ + def _prepare_leaderboard_values(self): + """" The leaderboard is descending and takes the total of the attendee points up to the current question. """ self.ensure_one() return self.env['survey.user_input'].search([('survey_id', '=', self.id)], diff --git a/addons/survey/models/survey_user.py b/addons/survey/models/survey_user.py index 7c94a8db82a..af5d745145c 100644 --- a/addons/survey/models/survey_user.py +++ b/addons/survey/models/survey_user.py @@ -40,7 +40,7 @@ class SurveyUserInput(models.Model): invite_token = fields.Char('Invite token', readonly=True, copy=False) # no unique constraint, as it identifies a pool of attempts partner_id = fields.Many2one('res.partner', string='Partner', readonly=True) email = fields.Char('Email', readonly=True) - nickname = fields.Char('Nickname', help="Attendee nickname, mainly used to identify him in survey sessions rankings.") + nickname = fields.Char('Nickname', help="Attendee nickname, mainly used to identify him in the survey session leaderboard.") # questions / answers user_input_line_ids = fields.One2many('survey.user_input.line', 'user_input_id', string='Answers', copy=True) predefined_question_ids = fields.Many2many('survey.question', string='Predefined Questions', readonly=True) diff --git a/addons/survey/static/src/js/survey_session_chart.js b/addons/survey/static/src/js/survey_session_chart.js new file mode 100644 index 00000000000..3dd2fac13ec --- /dev/null +++ b/addons/survey/static/src/js/survey_session_chart.js @@ -0,0 +1,272 @@ +odoo.define('survey.session_chart', function (require) { +'use strict'; + +var publicWidget = require('web.public.widget'); +var SESSION_CHART_COLORS = require('survey.session_colors'); + +publicWidget.registry.SurveySessionChart = publicWidget.Widget.extend({ + init: function (parent, options) { + this._super.apply(this, arguments); + + this.questionType = options.questionType; + this.answersValidity = options.answersValidity; + this.hasCorrectAnswers = options.hasCorrectAnswers; + this.attendeesCount = options.attendeesCount; + this.questionStatistics = this._processQuestionStatistics(options.questionStatistics); + this.showInputs = options.showInputs; + this.showAnswers = false; + }, + + start: function () { + var self = this; + return this._super.apply(this, arguments).then(function () { + self._setupChart(); + }); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Updates the chart data using the latest received question user inputs. + * + * By updating the numbers in the dataset, we take advantage of the Chartjs API + * that will automatically add animations to show the new number. + * + * @param {Object} questionStatistics object containing chart data (counts / labels / ...) + */ + updateChart: function (questionStatistics) { + if (questionStatistics) { + this.questionStatistics = this._processQuestionStatistics(questionStatistics); + } + + if (this.chart) { + // only a single dataset for our bar charts + var chartData = this.chart.data.datasets[0].data; + for (var i = 0; i < chartData.length; i++){ + var value = 0; + if (this.showInputs) { + value = this.questionStatistics[i].count; + } + this.chart.data.datasets[0].data[i] = value; + } + this.chart.update(); + } + }, + + /** + * Toggling this parameter will display or hide the correct and incorrect answers of the current + * question directly on the chart. + * + * @param {Boolean} showAnswers + */ + setShowAnswers: function (showAnswers) { + this.showAnswers = showAnswers; + }, + + /** + * Toggling this parameter will display or hide the user inputs of the current question directly + * on the chart. + * + * @param {Boolean} showInputs + */ + setShowInputs: function (showInputs) { + this.showInputs = showInputs; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _setupChart: function () { + var $canvas = this.$('canvas'); + var ctx = $canvas.get(0).getContext('2d'); + + this.chart = new Chart(ctx, this._buildChartConfiguration()); + }, + + /** + * Custom bar chart configuration for our survey session use case. + * + * Quick summary of enabled features: + * - background_color is one of the 10 custom colors from SESSION_CHART_COLORS + * (see _getBackgroundColor for details) + * - The ticks are bigger and bolded to be able to see them better on a big screen (projector) + * - We don't use tooltips to keep it as simple as possible + * - We use a "suggestedMax" being the attendeesCount +1 + * The '+1' part is a small trick to avoid the datalabels to be clipped in height + * - We use a custom 'datalabels' plugin to be able to display the number value on top of the + * associated bar of the chart. + * This allows the host to discuss results with attendees in a more interactive way. + * + * @private + */ + _buildChartConfiguration: function () { + return { + type: 'bar', + data: { + labels: this._extractChartLabels(), + datasets: [{ + backgroundColor: this._getBackgroundColor.bind(this), + data: this._extractChartData(), + }] + }, + options: { + maintainAspectRatio: false, + plugins: { + datalabels: { + color: this._getLabelColor.bind(this), + font: { + size: '25', + weight: 'bold', + }, + anchor: 'end', + align: 'top', + } + }, + legend: { + display: false, + }, + scales: { + yAxes: [{ + ticks: { + display: false, + suggestedMin: 0, + suggestedMax: this.attendeesCount + 1, + }, + gridLines: { + display: false + } + }], + xAxes: [{ + ticks: { + maxRotation: 0, + fontSize: '25', + fontStyle: 'bold', + fontColor: '#212529' + }, + gridLines: { + display: false + } + }] + }, + tooltips: { + enabled: false, + } + }, + }; + }, + + /** + * Returns the label of the associated survey.question.answer. + * + * @private + */ + _extractChartLabels: function () { + return this.questionStatistics.map(function (point) { + return point.text.length > 20 ? point.text.substring(0, 17) + '...' : point.text; + }); + }, + + /** + * We simply return an array of zeros as initial value. + * The chart will update afterwards as attendees add their user inputs. + * + * @private + */ + _extractChartData: function () { + return this.questionStatistics.map(function () { + return 0; + }); + }, + + /** + * Custom method that returns a color from SESSION_CHART_COLORS. + * It loops through the ten values and assign them sequentially. + * + * We have a special mechanic when the host shows the answers of a question. + * Wrong answers are "faded out" using a 0.3 opacity. + * + * @param {Object} metaData + * @param {Integer} metaData.dataIndex the index of the label, matching the index of the answer + * in 'this.answersValidity' + * @private + */ + _getBackgroundColor: function (metaData) { + var opacity = '0.8'; + if (this.showAnswers && this.hasCorrectAnswers) { + if (this._isValidAnswer(metaData.dataIndex)){ + opacity = 0.3; + } + } + var rgb = SESSION_CHART_COLORS[metaData.dataIndex]; + return `rgba(${rgb},${opacity})`; + }, + + /** + * Custom method that returns the survey.question.answer label color. + * + * Break-down of use cases: + * - Red if the host is showing answer, and the associated answer is not correct + * - Green if the host is showing answer, and the associated answer is correct + * - Black in all other cases + * + * @param {Object} metaData + * @param {Integer} metaData.dataIndex the index of the label, matching the index of the answer + * in 'this.answersValidity' + * @private + */ + _getLabelColor: function (metaData) { + if (this.showAnswers && this.hasCorrectAnswers) { + if (this._isValidAnswer(metaData.dataIndex)){ + return '#2CBB70'; + } else { + return '#D9534F'; + } + } + return '#212529'; + }, + + /** + * Small helper method that returns the validity of the answer based on its index. + * + * We need this special handling because of Chartjs data structure. + * The library determines the parameters (color/label/...) by only passing the answer 'index' + * (and not the id or anything else we can identify). + * + * @param {Integer} answerIndex + * @private + */ + _isValidAnswer: function (answerIndex) { + return this.answersValidity[answerIndex]; + }, + + /** + * Special utility method that will process the statistics we receive from the + * survey.question#_prepare_statistics method. + * + * For multiple choice questions, the values we need are stored in a different place. + * We simply return the values to make the use of the statistics common for both simple and + * multiple choice questions. + * + * See survey.question#_get_stats_data for more details + * + * @param {Object} rawStatistics + * @private + */ + _processQuestionStatistics: function (rawStatistics) { + if (this.questionType === 'multiple_choice') { + return rawStatistics[0].values; + } + + return rawStatistics; + } +}); + +return publicWidget.registry.SurveySessionChart; + +}); diff --git a/addons/survey/static/src/js/survey_session_colors.js b/addons/survey/static/src/js/survey_session_colors.js new file mode 100644 index 00000000000..728f2f68483 --- /dev/null +++ b/addons/survey/static/src/js/survey_session_colors.js @@ -0,0 +1,21 @@ +odoo.define('survey.session_colors', function (require) { +'use strict'; + +/** + * Small tool that returns common colors for survey session widgets. + * Source: https://www.materialui.co/colors + */ +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', +]; + +}); diff --git a/addons/survey/static/src/js/survey_session_leaderboard.js b/addons/survey/static/src/js/survey_session_leaderboard.js new file mode 100644 index 00000000000..0126a00f1c7 --- /dev/null +++ b/addons/survey/static/src/js/survey_session_leaderboard.js @@ -0,0 +1,71 @@ +odoo.define('survey.session_leaderboard', function (require) { +'use strict'; + +var publicWidget = require('web.public.widget'); +var SESSION_CHART_COLORS = require('survey.session_colors'); + +publicWidget.registry.SurveySessionLeaderboard = publicWidget.Widget.extend({ + init: function (parent, options) { + this._super.apply(this, arguments); + + this.surveyAccessToken = options.surveyAccessToken; + this.$sessionResults = options.sessionResults; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Shows the question leaderboard on screen. + * It's based on the attendees score (descending). + * + * We fade out the $sessionResults to fade in our rendered template. + * + * The width of the progress bars is set after the rendering to enable a width css animation. + */ + showLeaderboard: function () { + var self = this; + + var resolveFadeOut; + var fadeOutPromise = new Promise(function (resolve, reject) { resolveFadeOut = resolve; }); + self.$sessionResults.fadeOut(400, function () { + resolveFadeOut(); + }); + + var leaderboardPromise = this._rpc({ + route: _.str.sprintf('/survey/session/leaderboard/%s', this.surveyAccessToken) + }); + + Promise.all([fadeOutPromise, leaderboardPromise]).then(function (results) { + var leaderboardResults = results[1]; + var $renderedTemplate = $(leaderboardResults).addClass('py-3'); + self.$('.o_survey_session_leaderboard_container').append($renderedTemplate); + + self.$('.o_survey_session_leaderboard_bar').each(function (index) { + var rgb = SESSION_CHART_COLORS[index % 10]; + $(this).css('background-color', `rgba(${rgb},${0.8})`); + }); + self.$el.fadeIn(400, function () { + self.$('.o_survey_session_leaderboard_bar').each(function () { + $(this).css('width', `calc(calc(100% - 18rem) * ${$(this).data('widthRatio')})`); + }); + }); + }); + }, + + /** + * Inverse the process, fading out our template to fade int the $sessionResults. + */ + hideLeaderboard: function () { + var self = this; + this.$el.fadeOut(400, function () { + self.$('.o_survey_session_leaderboard_container').empty(); + self.$sessionResults.fadeIn(400); + }); + }, +}); + +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 daa6979b4ab..e3e8091a190 100644 --- a/addons/survey/static/src/js/survey_session_manage.js +++ b/addons/survey/static/src/js/survey_session_manage.js @@ -2,6 +2,9 @@ odoo.define('survey.session_manage', function (require) { 'use strict'; var publicWidget = require('web.public.widget'); +var SurveySessionChart = require('survey.session_chart'); +var SurveySessionTextAnswers = require('survey.session_text_answers'); +var SurveySessionLeaderBoard = require('survey.session_leaderboard'); var core = require('web.core'); var _t = core._t; @@ -9,12 +12,9 @@ publicWidget.registry.SurveySessionManage = publicWidget.Widget.extend({ selector: '.o_survey_session_manage', events: { 'click .o_survey_session_copy': '_onCopySessionLink', - 'click .o_survey_session_next, .o_survey_session_start': '_onNextQuestionClick', - 'click .o_survey_session_end': '_onEndSessionClick', - 'click .o_survey_session_results_href': '_onResultsTabClick', - 'click .o_survey_session_ranking_href': '_onRankingTabClick', - 'click .o_survey_session_refresh_results': '_onRefreshResultsClick', - 'click .o_survey_session_refresh_ranking': '_onRefreshRankingClick', + 'click .o_survey_session_navigation_next, .o_survey_session_start': '_onNext', + 'click .o_survey_session_navigation_previous': '_onBack', + 'click .o_survey_session_close': '_onEndSessionClick', }, /** @@ -27,21 +27,34 @@ publicWidget.registry.SurveySessionManage = publicWidget.Widget.extend({ start: function () { var self = this; return this._super.apply(this, arguments).then(function () { + // general survey props self.surveyId = self.$el.data('surveyId'); self.surveyAccessToken = self.$el.data('surveyAccessToken'); self.isStartScreen = self.$el.data('isStartScreen'); + 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.attendeesCount = self.$el.data('attendeesCount'); + self.showBarChart = self.$el.data('showBarChart'); + self.showTextAnswers = self.$el.data('showTextAnswers'); var isRpcCall = self.$el.data('isRpcCall'); if (!isRpcCall) { self._startTimer(); + $(document).on('keydown', self._onKeyDown.bind(self)); } - if (self.isStartScreen) { - self._refreshAttendeesCount(); - } else if (!self.refreshingAnswers) { - self.stopRefreshingAttendees = true; - self._refreshReceivedAnswers(); - } + self._setupIntervals(); + self._setupCurrentScreen(); + var setupPromises = []; + setupPromises.push(self._setupTextAnswers()); + setupPromises.push(self._setupChart()); + setupPromises.push(self._setupLeaderboard()); + + return Promise.all(setupPromises); }); }, @@ -90,6 +103,183 @@ publicWidget.registry.SurveySessionManage = publicWidget.Widget.extend({ }); }, + /** + * Listeners for keyboard arrow / spacebar keys. + * + * - 39 = arrow-right + * - 32 = spacebar + * - 37 = arrow-left + * + * @param {KeyboardEvent} ev + */ + _onKeyDown: function (ev) { + var keyCode = ev.keyCode; + + if (keyCode === 39 || keyCode === 32) { + this._onNext(ev); + } else if (keyCode === 37) { + 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); + this.$('.o_survey_session_navigation_previous').removeClass('d-none'); + } else if (screenToDisplay === 'results') { + this._setShowAnswers(true); + // when showing results, stop refreshing answers + clearInterval(this.resultsRefreshInterval); + delete this.resultsRefreshInterval; + this.$('.o_survey_session_navigation_previous').removeClass('d-none'); + } else if (['leaderboard', 'leaderboardFinal'].includes(screenToDisplay) + && this.currentScreen !== 'leaderboardFinal') { + if (screenToDisplay === 'leaderboardFinal') { + this.$('.o_survey_session_leaderboard_title').text(_('Final Leaderboard')); + this.$('.o_survey_session_navigation_next').addClass('d-none'); + } + this.leaderBoard.showLeaderboard(); + } else { + if (!this.isLastQuestion) { + this._nextQuestion(); + } + } + + this.currentScreen = screenToDisplay; + }, + + /** + * Reverse behavior of '_onNext'. + * + * @param {Event} ev + */ + _onBack: function (ev) { + ev.preventDefault(); + + var screenToDisplay = this._getPreviousScreen(); + + if (screenToDisplay === 'question') { + this._setShowInputs(false); + this.$('.o_survey_session_navigation_previous').addClass('d-none'); + } 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') { + this.leaderBoard.hideLeaderboard(); + // when showing results, stop refreshing answers + clearInterval(this.resultsRefreshInterval); + delete this.resultsRefreshInterval; + } + + this.currentScreen = screenToDisplay; + }, + + /** + * 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._rpc({ + model: 'survey.survey', + method: 'action_end_session', + args: [[this.surveyId]], + }).then(function () { + if ($(ev.currentTarget).data('showResults')) { + document.location = _.str.sprintf( + '/survey/results/%s', + 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 (['question', 'userInputs', 'results'].includes(this.currentScreen) && + this.isScoredQuestion) { + return 'leaderboard'; + } else if (this.sessionShowLeaderboard && this.isLastQuestion) { + return 'leaderboardFinal'; + } else { + 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.currentScreen === 'leaderboard' && !this.isScoredQuestion)) { + return 'userInputs'; + } else if (this.currentScreen === 'leaderboard' && this.isScoredQuestion) { + return 'results'; + } + + return this.currentScreen; + }, + /** * We use a fade in/out mechanism to display the next question of the session. * @@ -102,10 +292,13 @@ publicWidget.registry.SurveySessionManage = publicWidget.Widget.extend({ * @param {MouseEvent} ev * @private */ - _onNextQuestionClick: function (ev) { + _nextQuestion: function () { var self = this; - ev.preventDefault(); + this.isStartScreen = false; + if (this.surveyTimerWidget) { + this.surveyTimerWidget.destroy(); + } var resolveFadeOut; var fadeOutPromise = new Promise(function (resolve, reject) { resolveFadeOut = resolve; }); @@ -117,6 +310,12 @@ publicWidget.registry.SurveySessionManage = publicWidget.Widget.extend({ route: _.str.sprintf('/survey/session/next_question/%s', self.surveyAccessToken) }); + // avoid refreshing results while transitioning + if (this.resultsRefreshInterval) { + clearInterval(this.resultsRefreshInterval); + delete this.resultsRefreshInterval; + } + Promise.all([fadeOutPromise, nextQuestionPromise]).then(function (results) { var $renderedTemplate = $(results[1]); self.$el.replaceWith($renderedTemplate); @@ -127,72 +326,14 @@ publicWidget.registry.SurveySessionManage = publicWidget.Widget.extend({ }); }, - /** - * Marks this session as 'done' and redirects the user to the results. - * - * @param {MouseEvent} ev - * @private - */ - _onEndSessionClick: function (ev) { - var self = this; - ev.preventDefault(); - - this._rpc({ - model: 'survey.survey', - method: 'action_end_session', - args: [[this.surveyId]], - }).then(function () { - document.location = _.str.sprintf( - '/survey/results/%s', - self.surveyId - ); - }); - }, - - _onResultsTabClick: function (ev) { - this._onRefreshResultsClick(ev, true); - }, - - _onRankingTabClick: function (ev) { - this._onRefreshRankingClick(ev, true); - }, - - _onRefreshResultsClick: function (ev, preventSpin) { - ev.preventDefault(); - - if (this.$('.o_survey_session_refresh_results').hasClass('fa-spin')) { - return; - } - - var refreshPromise = this._refreshResults(); - if (!preventSpin) { - this._spinIcon(this.$('.o_survey_session_refresh_results'), refreshPromise); - } - }, - - _onRefreshRankingClick: function (ev, preventSpin) { - ev.preventDefault(); - - if (this.$('.o_survey_session_refresh_ranking').hasClass('fa-spin')) { - return; - } - - var refreshPromise = this._refreshRanking(); - - if (!preventSpin) { - this._spinIcon(this.$('.o_survey_session_refresh_ranking'), refreshPromise); - } - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - /** * Will start the question timer so that the host may know when the question is done to display - * the results and the ranking. + * 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) { @@ -203,119 +344,225 @@ publicWidget.registry.SurveySessionManage = publicWidget.Widget.extend({ '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 on screen. + * 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 this._rpc({ - route: _.str.sprintf('/survey/session/results/%s', this.surveyAccessToken) - }).then(function (renderedTemplate) { - var $renderedTemplate = $(renderedTemplate).addClass('py-3'); - self.$('.o_survey_result').empty().append($renderedTemplate); - return new publicWidget.registry.SurveyResultWidget().attachTo($renderedTemplate); - }); - }, + this._rpc({ + route: _.str.sprintf('/survey/session/results/%s', self.surveyAccessToken) + }).then(function (questionResults) { + if (questionResults) { + 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); + } - /** - * Refreshes the question ranking on screen. - * We set the width of the progress bars after the rendering to enable a width css animation. - */ - _refreshRanking: function () { - var self = this; + 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}%`); - return this._rpc({ - route: _.str.sprintf('/survey/session/ranking/%s', this.surveyAccessToken) - }).then(function (renderedTemplate) { - var $renderedTemplate = $(renderedTemplate).addClass('py-3'); - self.$('.o_survey_session_ranking_container').empty().append($renderedTemplate); - // delay by 200ms to account for fade out / in - setTimeout(function () { - self.$('.o_survey_session_ranking_bar').each(function () { - $(this).css('width', `calc(calc(100% - 10rem) * ${$(this).data('widthRatio')})`); - }); - }, 200); - - return Promise.resolve(); + 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}` + ); + } + } + }, 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. - * When he leaves the start screen, the "stopRefreshingAttendees" becomes true and we stop - * querying the answer_count. + * */ _refreshAttendeesCount: function () { var self = this; - setTimeout(function () { - self._rpc({ - model: 'survey.survey', - method: 'read', - args: [[self.surveyId], ['answer_count']], - }).then(function (result) { - if (result && result.length === 1){ - self.$('.o_survey_session_attendees_count').text( - result[0].answer_count - ); - } - if (!self.stopRefreshingAttendees) { - self._refreshAttendeesCount(); - } - }); - }, 2000); - }, - - /** - * We refresh the received answers count every 2 seconds for the current question. - * This allows the host to know what he can move on to the next question. - * (Without having to manually refresh the page). - */ - _refreshReceivedAnswers: function () { - var self = this; - - this.refreshingAnswers = true; - setTimeout(function () { - self._rpc({ - model: 'survey.survey', - method: 'read', - args: [[self.surveyId], ['session_question_answer_count']], - }).then(function (result) { - if (result && result.length === 1){ - self.$('.o_survey_session_answer_count').text( - result[0].session_question_answer_count - ); - } - self._refreshReceivedAnswers(); - }); - }, 2000); - }, - - /** - * We need the refresh icon to spin for at least 1s to make the user feel like it's - * refreshing something. - * If we only wait for the 'refreshPromise', it can be so fast that the icon doesn't even move. - * - * @param {$.Element} $target - * @param {Promise} refreshPromise - */ - _spinIcon: function ($target, refreshPromise) { - $target.addClass('fa-spin'); - - var minimumSpinResolve; - var minimumSpinPromise = new Promise(function (resolve) { minimumSpinResolve = resolve;}); - setTimeout(function () { - minimumSpinResolve(); - }, 1000); - - Promise.all([minimumSpinPromise, refreshPromise]).then(function () { - $target.removeClass('fa-spin'); + self._rpc({ + model: 'survey.survey', + method: 'read', + args: [[self.surveyId], ['answer_count']], + }).then(function (result) { + if (result && result.length === 1){ + self.$('.o_survey_session_attendees_count').text( + result[0].answer_count + ); + } + }, function () { + // on failure, stop refreshing + clearInterval(self.attendeesRefreshInterval); }); }, + + /** + * 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, { + attendeesCount: this.attendeesCount, + 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'); + + 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._setShowInputs(this.currentScreen === 'userInputs'); + }, + + /** + * 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(); + } + } }); return publicWidget.registry.SurveySessionManage; diff --git a/addons/survey/static/src/js/survey_session_text_answers.js b/addons/survey/static/src/js/survey_session_text_answers.js new file mode 100644 index 00000000000..4e669c17ea0 --- /dev/null +++ b/addons/survey/static/src/js/survey_session_text_answers.js @@ -0,0 +1,74 @@ +odoo.define('survey.session_text_answers', function (require) { +'use strict'; + +var publicWidget = require('web.public.widget'); +var core = require('web.core'); +var time = require('web.time'); +var SESSION_CHART_COLORS = require('survey.session_colors'); + +var QWeb = core.qweb; + +publicWidget.registry.SurveySessionTextAnswers = publicWidget.Widget.extend({ + xmlDependencies: ['/survey/static/src/xml/survey_session_text_answer_template.xml'], + init: function (parent, options) { + this._super.apply(this, arguments); + + this.answerIds = []; + this.questionType = options.questionType; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Adds the attendees answers on the screen. + * This is used for char_box/date and datetime questions. + * + * We use some tricks with jQuery for wow effect: + * - force a width on the external div container, to reserve space for that answer + * - set the actual width of the answer, and enable a css width animation + * - set the opacity to 1, and enable a css opacity animation + * + * @param {Array} inputLineValues array of survey.user_input.line records in the form + * {id: line.id, value: line.[value_char_box/value_date/value_datetime]} + */ + updateTextAnswers: function (inputLineValues) { + var self = this; + + inputLineValues.forEach(function (inputLineValue) { + if (!self.answerIds.includes(inputLineValue.id) && inputLineValue.value) { + var textValue = inputLineValue.value; + if (self.questionType === 'char_box') { + textValue = textValue.length > 25 ? + textValue.substring(0, 22) + '...' : + textValue; + } else if (self.questionType === 'date') { + textValue = moment(textValue).format(time.getLangDateFormat()); + } else if (self.questionType === 'datetime') { + textValue = moment(textValue).format(time.getLangDatetimeFormat()); + } + + var $textAnswer = $(QWeb.render('survey.survey_session_text_answer', { + value: textValue, + borderColor: `rgb(${SESSION_CHART_COLORS[self.answerIds.length % 10]})` + })); + self.$el.append($textAnswer); + var spanWidth = $textAnswer.find('span').width(); + var calculatedWidth = `calc(${spanWidth}px + 1.2rem)`; + $textAnswer.css('width', calculatedWidth); + setTimeout(function () { + // setTimeout to force jQuery rendering + $textAnswer.find('.o_survey_session_text_answer_container') + .css('width', calculatedWidth) + .css('opacity', '1'); + }, 1); + self.answerIds.push(inputLineValue.id); + } + }); + }, +}); + +return publicWidget.registry.SurveySessionTextAnswers; + +}); diff --git a/addons/survey/static/src/scss/survey_form.scss b/addons/survey/static/src/scss/survey_form.scss index ef026c664d2..aaa62d7f38f 100644 --- a/addons/survey/static/src/scss/survey_form.scss +++ b/addons/survey/static/src/scss/survey_form.scss @@ -18,7 +18,7 @@ nav#oe_main_menu_navbar { min-height: 100%; } -.o_survey_form, .o_survey_print, .o_survey_session_manage, .o_survey_quick_access { +.o_survey_form, .o_survey_print, .o_survey_quick_access { .o_survey_question_error { height: 0px; transition: height .5s ease; @@ -109,48 +109,21 @@ nav#oe_main_menu_navbar { } } -.o_survey_form, .o_survey_session_manage { - min-height: 100%; - - .o_survey_choice_btn { - background-color: rgba($primary, 0.5); - border: 2px solid $primary; - } - - .o_survey_choice_key { - width: 25px; - height: 25px; - border: 2px solid $primary; - span { - font-size: smaller; - top: -2px; - &.o_survey_key { - opacity: 0; - } - } - } - - .o_survey_question_matrix { - th { - background-color: $primary; - } - td { - background-color: rgba($primary, 0.2); - } - } - - .o_survey_progress { - height:0.5em; - } -} - /********************************************************** Form Specific Style **********************************************************/ .o_survey_form { + min-height: 100%; + + .o_survey_progress { + height:0.5em; + } + .o_survey_choice_btn { cursor: pointer; + background-color: rgba($primary, 0.5); + border: 2px solid $primary; &.o_survey_selected { background-color: $primary; @@ -164,21 +137,39 @@ nav#oe_main_menu_navbar { } } - .o_survey_choice_key span.o_survey_key { - width: inherit; - right: 10px; - border: 2px solid $primary; - border-right: 0px; + .o_survey_choice_key { + width: 25px; height: 25px; - transition: opacity 0.4s ease; - white-space: nowrap; + border: 2px solid $primary; + span { + font-size: smaller; + top: -2px; + &.o_survey_key { + width: inherit; + right: 10px; + border: 2px solid $primary; + border-right: 0px; + height: 25px; + transition: opacity 0.4s ease; + white-space: nowrap; + opacity: 0; + } + } } - .o_survey_question_matrix td:hover { - background-color: rgba($primary, 0.5); - cursor: pointer; - .o_survey_choice_key span.o_survey_key { - opacity: 1; + .o_survey_question_matrix { + th { + background-color: $primary; + } + td { + background-color: rgba($primary, 0.2); + &:hover { + background-color: rgba($primary, 0.5); + cursor: pointer; + .o_survey_choice_key span.o_survey_key { + opacity: 1; + } + } } } } @@ -188,26 +179,93 @@ nav#oe_main_menu_navbar { **********************************************************/ .o_survey_session_manage { + h1 { + font-size: 4rem; + } + + h2 { + font-size: 3.2rem; + } + + .o_survey_session_navigation { + position: fixed; + padding: 1rem; + top: calc(50% - 0.5rem); + cursor: pointer; + + &.o_survey_session_navigation_next { + right: 2rem; + } + + &.o_survey_session_navigation_previous { + left: 2rem; + } + } + + .o_survey_session_link_reminder { + position: absolute; + right: 2rem; + top: 2rem; + } + .o_survey_manage_fontsize_14 { font-size: 1.4rem; } .o_survey_session_manage_container { - border: solid 1px #999999; - box-shadow: 0px 1px 1px 0px #999999; - min-height: 75vh; + height: 100vh; + width: 85vw; .js_question-wrapper { padding-bottom: 0px; } - } - .tab-content > .o_survey_session_manage_question.tab-pane { - display: none; - } + &.pt-6 { + padding-top: 5rem !important; + } - .tab-content > .o_survey_session_manage_question.active { - display: flex; + .o_survey_session_results { + display: flex; // here and not d-flex because we need to be able to fade-out + + .mb-6 { + margin-bottom: 6rem; + } + + .progress { + height: 2rem; + border-radius: 0.6rem; + font-size: 1.2rem; + background-color: #cfcfcf; + .progress-bar { + width: 0%; + transition: width 1s ease; + } + } + + .o_survey_session_text_answer { + .o_survey_session_text_answer_container { + border: solid 1.6px; + border-radius: 0.6rem; + font-size: 1.4rem; + width: 2rem; + opacity: .1; + transition: width .4s ease, opacity .4s ease; + overflow: hidden; + } + + span { + white-space: nowrap; + } + } + } + + .o_survey_session_leaderboard { + display: flex; // here and not d-flex because we need to be able to fade-out + .o_survey_leaderboard_buttons { + line-height: 4rem; + font-variant: small-caps; + } + } } .o_survey_session_copy { @@ -218,73 +276,28 @@ nav#oe_main_menu_navbar { opacity: 1; } } - - .btn { - font-variant: small-caps; - &.o_survey_session_end { - right: 0px; - } - } - - .o_survey_session_results, .o_survey_session_ranking { - .fa { - font-size: .9rem; - opacity: .75; - &:hover { - transition: opacity .4s ease; - opacity: 1; - cursor: pointer; - } - } - - .o_survey_result, .o_survey_session_ranking_container { - overflow: hidden; - } - - .survey_filter { - display: none; - } - } - - .o_survey_result { - .nav-tabs .nav-link.active { - background-color: transparent; - border-color: #dee2e6 #dee2e6 transparent; - } - } - - .nav-tabs { - border-bottom: 0px; - .nav-item .nav-link { - border-radius: 0px; - background-color: transparent; - border: solid 1px transparent; - - &.active { - border: solid 1px #999999; - border-bottom: 0px; - } - } - } } -.o_survey_session_ranking { - .o_survey_session_ranking_item { - line-height: 1.4rem; +.o_survey_session_leaderboard { + font-size: 1.4rem; - .o_survey_session_ranking_index { - width: 1.5rem; + .o_survey_session_leaderboard_item { + line-height: 1.8rem; + + .o_survey_session_leaderboard_index { + width: 9.5rem; + padding-top: .2rem; } - .o_survey_session_ranking_bar { + .o_survey_session_leaderboard_bar { min-width: 90px; - height: 1.4rem; - color: white; + height: 2.2rem; background-color: #007A77; transition: width ease 1.5s; } - .o_survey_session_ranking_name { + .o_survey_session_leaderboard_name { + padding-top: .2rem; width: 7.5rem; overflow: hidden; text-overflow: ellipsis; diff --git a/addons/survey/static/src/xml/survey_session_text_answer_template.xml b/addons/survey/static/src/xml/survey_session_text_answer_template.xml new file mode 100644 index 00000000000..d37e0864f73 --- /dev/null +++ b/addons/survey/static/src/xml/survey_session_text_answer_template.xml @@ -0,0 +1,11 @@ + + + +
+
+ +
+
+ +
diff --git a/addons/survey/views/assets.xml b/addons/survey/views/assets.xml index 854274013ba..c13669a5e4f 100644 --- a/addons/survey/views/assets.xml +++ b/addons/survey/views/assets.xml @@ -24,6 +24,11 @@