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..929ac7e4819 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,21 @@ 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_id', '=', survey.session_question_id.id), + ('create_date', '>=', survey.session_start_time) ]) - 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 +96,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 +106,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..f589629ee5d 100644 --- a/addons/survey/models/survey_survey.py +++ b/addons/survey/models/survey_survey.py @@ -117,12 +117,14 @@ class Survey(models.Model): # live sessions - current question fields session_question_id = fields.Many2one('survey.question', string="Current Question", copy=False, help="The current question of the survey session.") + session_start_time = fields.Datetime("Current Session Start Time", copy=False) session_question_start_time = fields.Datetime("Current Question Start Time", copy=False, 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') + session_answer_count = fields.Integer("Answers Count", compute='_compute_session_answer_count') + session_question_answer_count = fields.Integer("Question 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 = [ @@ -176,27 +178,47 @@ class Survey(models.Model): survey.page_ids = survey.question_and_page_ids.filtered(lambda question: question.is_page) survey.question_ids = survey.question_and_page_ids - survey.page_ids - @api.depends('session_question_id', 'user_input_ids.user_input_line_ids') + @api.depends('session_start_time', 'user_input_ids') + def _compute_session_answer_count(self): + """ We have to loop since our result is dependent of the survey.session_start_time. + This field is currently used to display the count about a single survey, in the + context of sessions, so it should not matter too much. """ + for survey in self: + answer_count = 0 + input_count = self.env['survey.user_input'].read_group( + [('survey_id', '=', survey.id), ('create_date', '>=', survey.session_start_time)], + ['create_uid:count'], + ['survey_id'], + ) + if input_count: + answer_count = input_count[0].get('create_uid', 0) + + survey.session_answer_count = answer_count + + @api.depends('session_question_id', 'session_start_time', 'user_input_ids.user_input_line_ids') def _compute_session_question_answer_count(self): - """ We have to loop since our result is dependent of the survey.session_question_id. + """ We have to loop since our result is dependent of the survey.session_question_id and + the survey.session_start_time. This field is currently used to display the count about a single survey, in the context of sessions, so it should not matter too much. """ for survey in self: answer_count = 0 input_line_count = self.env['survey.user_input.line'].read_group( - [('question_id', '=', survey.session_question_id.id), ('survey_id', '=', survey.id)], + [('question_id', '=', survey.session_question_id.id), + ('survey_id', '=', survey.id), + ('create_date', '>=', survey.session_start_time)], ['user_input_id:count_distinct'], ['question_id'], ) if input_line_count: - answer_count = input_line_count[0].get('user_input_id') + answer_count = input_line_count[0].get('user_input_id', 0) 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,12 +585,14 @@ 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)], - limit=25, order="scoring_total desc") + return self.env['survey.user_input'].search([ + ('survey_id', '=', self.id), + ('create_date', '>=', self.session_start_time) + ], limit=25, order="scoring_total desc") # ------------------------------------------------------------ # ACTIONS @@ -699,6 +723,7 @@ class Survey(models.Model): self.ensure_one() self.sudo().write({ 'questions_layout': 'page_per_question', + 'session_start_time': fields.Datetime.now(), 'session_question_id': None, 'session_state': 'ready' }) 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/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 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..0d473cdc9b9 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], ['session_answer_count']], + }).then(function (result) { + if (result && result.length === 1){ + self.$('.o_survey_session_attendees_count').text( + result[0].session_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 @@