[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

Related: odoo/upgrade#774
Signed-off-by: Thibault Delavallee (tde) <tde@openerp.com>
This commit is contained in:
Odoo's Mergebot
2020-02-27 18:40:19 +01:00
committed by GitHub
16 changed files with 1210 additions and 461 deletions
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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)
@@ -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/<string:survey_token>', 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/<string:survey_token>', 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
}
+38 -13
View File
@@ -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'
})
+1 -1
View File
@@ -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)
File diff suppressed because one or more lines are too long
@@ -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;
});
@@ -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',
];
});
@@ -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;
});
@@ -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;
@@ -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;
});
+127 -114
View File
@@ -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;
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<div t-name="survey.survey_session_text_answer" class="o_survey_session_text_answer d-inline-block m-1">
<div class="o_survey_session_text_answer_container d-inline-block p-2 font-weight-bold"
t-attf-style="border-color: #{borderColor}">
<span class="d-inline-block" t-esc="value" />
</div>
</div>
</templates>
+5
View File
@@ -24,6 +24,11 @@
<!-- survey user input session assets -->
<template id="survey_user_input_session_assets" name="Survey User Input Session Assets">
<script type="text/javascript" src="/survey/static/src/js/libs/chartjs-plugin-datalabels.min.js" />
<script type="text/javascript" src="/survey/static/src/js/survey_session_colors.js" />
<script type="text/javascript" src="/survey/static/src/js/survey_session_chart.js" />
<script type="text/javascript" src="/survey/static/src/js/survey_session_text_answers.js" />
<script type="text/javascript" src="/survey/static/src/js/survey_session_leaderboard.js" />
<script type="text/javascript" src="/survey/static/src/js/survey_session_manage.js" />
</template>
@@ -27,9 +27,9 @@
</template>
<template id="survey_page_statistics_inner" name="Survey: result statistics content">
<div t-if="survey.session_show_ranking" class="o_survey_session_ranking mb-5 mt-1">
<h2 class="mt16 text-uppercase text-muted">Ranking</h2>
<t t-call="survey.user_input_session_ranking" />
<div t-if="survey.session_show_leaderboard" class="o_survey_session_leaderboard mb-5 mt-1">
<h2 class="mt16 text-uppercase text-muted">Leaderboard</h2>
<t t-call="survey.user_input_session_leaderboard" />
</div>
<div t-if="question_and_page_data" class="card d-print-none">
<div class="card-header"><span class="fa fa-filter"></span> Filters <span t-if="search_filters" class="float-right text-primary clear_survey_filter"><i class="fa fa-times"></i> Clear All Filters</span></div>
@@ -2,12 +2,16 @@
<odoo>
<data>
<template id="user_input_session" name="Survey User Input Session" inherit_id="web.frontend_layout" primary="True">
<xpath expr="//div[@id='wrapwrap']" position="before">
<!--TODO DBE Fix me : If one day, there is a survey_livechat bridge module, put this in that module-->
<t t-set="no_livechat" t-value="True"/>
</xpath>
<xpath expr="//div[@id='wrapwrap']" position="attributes">
<attribute name="t-att-style" add="('height: 100%; overflow: auto; background: url(' + '/web/image/survey.survey/%s/background_image' % survey.id + ') no-repeat fixed center; box-shadow: inset 0 0 0 10000px rgba(255,255,255,.7); background-size: cover;') if survey and survey.background_image else 'height: 100%; overflow: auto;'"/>
</xpath>
<xpath expr="//head/t[@t-call-assets][last()]" position="after">
<t t-call-assets="survey.survey_user_input_session_assets" lazy_load="True"/>
<t t-call-assets="survey.survey_assets" lazy_load="True"/>
<t t-call-assets="survey.survey_user_input_session_assets" lazy_load="True"/>
</xpath>
<xpath expr="//header" position="before">
<t t-set="no_header" t-value="True"/>
@@ -20,32 +24,24 @@
<template id="user_input_session_open" name="Survey: Open Session">
<t t-call="survey.user_input_session">
<div t-att-data-survey-access-token="survey.access_token"
<div class="wrap py-3 min-vh-100 align-items-center justify-content-center d-flex o_survey_session_open o_survey_session_manage"
t-att-data-survey-access-token="survey.access_token"
t-att-data-survey-id="survey.id"
t-att-data-is-start-screen="True"
class="wrap py-3 min-vh-100 align-items-center justify-content-center d-flex o_survey_session_open o_survey_session_manage">
<div class="w-75 p-4 o_survey_session_manage_container">
<div class="mb-5 text-center">
<h1 t-field="survey.title" />
<div t-field="survey.description" />
</div>
<div class="row">
<div class="col-lg-12 text-center mb-2" t-if="survey.access_mode == 'public'">
<h1>
<a t-att-href="survey_url" t-esc="survey_url" target="_blank" />
<i class="fa fa-copy font-weight-normal ml-3 o_survey_session_copy" />
</h1>
t-att-data-is-start-screen="True">
<div class="w-75 p-4">
<div class="text-center">
<h1 class="mb-4" t-field="survey.title" />
<h2 class="mb-5 font-weight-normal">
<span>Go to <a t-att-href="survey_url" t-esc="survey_url" target="_blank" /></span>
<i class="fa fa-copy font-weight-normal ml-3 o_survey_session_copy" />
<input class="o_survey_session_copy_url d-none" type="text" t-att-value="survey_url" />
</div>
<div class="col-lg-12 text-center ml-3 font-weight-light o_survey_manage_fontsize_14">
Participants <span class="ml-1 font-weight-bold o_survey_session_attendees_count" t-esc="survey.answer_count" />
</div>
</div>
<div class="text-center mt-5">
<a role="button" class="btn btn-primary font-weight-bold o_survey_session_start" href="#">
Start Session
</a>
</h2>
<h2 class="font-weight-normal"><span>Waiting for attendees...</span>
<span class="ml-1 font-weight-bold o_survey_session_attendees_count" t-esc="survey.session_answer_count" />
</h2>
</div>
<a role="button"
class="font-weight-bold fa fa-chevron-right o_survey_session_navigation o_survey_session_navigation_next p-3" />
</div>
</div>
</t>
@@ -58,142 +54,112 @@
</template>
<template id="user_input_session_manage_content" name="Survey User Input Session Manage">
<div class="wrap py-3 pt-4 min-vh-100 align-items-center d-flex flex-column o_survey_session_manage"
<t t-set="question" t-value="survey.session_question_id" />
<t t-set="is_scored_question" t-value="any(answer.answer_score for answer in question.suggested_answer_ids)" />
<t t-set="show_bar_chart" t-value="question.question_type in ['simple_choice', 'multiple_choice']" />
<t t-set="show_text_answers" t-value="question.question_type in ['char_box', 'date', 'datetime'] and not question.save_as_email and not question.save_as_nickname" />
<div class="wrap min-vh-100 align-items-center justify-content-center d-flex flex-column o_survey_session_manage"
t-att-style="'display: none;' if is_rpc_call else ''"
t-att-data-is-rpc-call="is_rpc_call"
t-att-data-survey-id="survey.id"
t-att-data-attendees-count="survey.session_answer_count"
t-att-data-survey-access-token="survey.access_token"
t-att-data-timer="survey.session_question_start_time.isoformat()"
t-att-data-time-limit-minutes="survey.session_question_id.time_limit / 60">
<nav class="w-75 navbar navbar-expand-lg text-left font-weight-bold pl-0 pb-0">
<div class="collapse navbar-collapse">
<ul class="nav nav-tabs mr-auto" role="tablist">
<li class="nav-item">
<a data-toggle="tab" role="tab" class="nav-link active py-2 px-3"
aria-controls="#o_survey_session_manage_question" href="#o_survey_session_manage_question">
Question
</a>
</li>
<li class="nav-item">
<a data-toggle="tab" role="tab" class="nav-link py-2 px-3 o_survey_session_results_href"
aria-controls="#o_survey_session_results" href="#o_survey_session_results">
Results
</a>
</li>
<li t-if="survey.session_show_ranking" class="nav-item">
<a data-toggle="tab" role="tab" class="nav-link py-2 px-3 o_survey_session_ranking_href"
aria-controls="#o_survey_session_ranking" href="#o_survey_session_ranking">
Ranking
</a>
</li>
</ul>
</div>
</nav>
<div class="tab-content w-75">
<div role="tabpanel" id="o_survey_session_manage_question"
class="p-4 px-5 flex-column tab-pane fade show active o_survey_session_manage_question o_survey_session_manage_container">
<t t-if="survey.access_mode == 'public'">
<h4 class="text-right">
<a t-att-href="survey_url" t-esc="survey_url" target="_blank" />
<i class="fa fa-copy font-weight-normal ml-3 mr-1 o_survey_session_copy" />
</h4>
t-att-data-time-limit-minutes="question.time_limit / 60"
t-att-data-is-scored-question="is_scored_question"
t-att-data-session-show-leaderboard="survey.session_show_leaderboard"
t-att-data-question-statistics="question_statistics_graph"
t-att-data-question-type="question.question_type"
t-att-data-has-correct-answers="any(answer.is_correct for answer in question.suggested_answer_ids)"
t-att-data-answers-validity="answers_validity"
t-att-data-is-last-question="is_last_question"
t-att-data-current-screen="'question' if is_scored_question else 'userInputs'"
t-att-data-show-bar-chart="show_bar_chart"
t-att-data-show-text-answers="show_text_answers">
<div class="p-4 d-flex flex-column o_survey_session_manage_container min-w-75 pt-6">
<h3 class="o_survey_session_link_reminder">
<span>Go to <a t-att-href="survey_url" t-esc="survey_url" target="_blank" /></span>
<i class="fa fa-copy font-weight-normal ml-3 mr-1 o_survey_session_copy" />
<input class="o_survey_session_copy_url d-none" type="text" t-att-value="survey_url" />
</t>
<div class="d-flex flex-column justify-content-center flex-grow-1">
<div>
<fieldset disabled="disabled">
<div class="row mb-4">
<div class="col-lg-8"><h1 t-esc="survey.title"></h1></div>
<h1 t-if="survey.session_question_id.is_time_limited" class="o_survey_timer_container col-lg-4 text-right">
<span class="o_survey_timer"/>
</h1>
</div>
<t t-set="question" t-value="survey.session_question_id" />
<t t-set="answer" t-value="env['survey.user_input']" />
<t t-set="answer_lines" t-value="env['survey.user_input.line']" />
<t t-set="survey_form_readonly" t-value="True"/>
<t t-call="survey.question_container" />
</fieldset>
<div class="mb-5">
<div class="d-inline-block font-weight-bold">
<t t-call="survey.survey_progression">
<t t-set="page_ids" t-value="survey.question_ids.ids"/>
<t t-set="page_number" t-value="page_ids.index(survey.session_question_id.id)"/>
</t>
</div>
<div class="float-right d-inline-block font-weight-bold my-2">
Received answers:
<span class="o_survey_session_answer_count" t-esc="survey.session_question_answer_count" />
/
<t t-esc="survey.answer_count" />
</h3>
<a role="button"
class="font-weight-bold fa fa-chevron-right o_survey_session_navigation o_survey_session_navigation_next p-3" />
<a role="button"
class="font-weight-bold fa fa-chevron-left o_survey_session_navigation o_survey_session_navigation_previous p-3 d-none" />
<div class="o_survey_session_results flex-column flex-grow-1">
<div class="row">
<div class="col-lg-8"><h1 t-esc="question.title"></h1></div>
<div class="col-lg-4 text-right d-flex flex-column justify-content-center">
<h1 t-if="question.is_time_limited" class="o_survey_timer_container">
<span class="o_survey_timer"/>
</h1>
<div t-if="show_bar_chart or show_text_answers">
<div class="progress" title="Attendees are answering the question...">
<div class="progress-bar o_survey_session_progress_small font-weight-bold"
role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
<span class="px-2">0 / <t t-esc="survey.session_answer_count" /></span>
</div>
</div>
</div>
</div>
<div class="text-center position-relative">
<a t-if="not is_last_question" role="button" class="btn btn-primary font-weight-bold o_survey_session_next" href="#">
Next Question
</a>
<a role="button" href="#"
t-attf-class="btn font-weight-bold o_survey_session_end #{'position-absolute btn-secondary' if not is_last_question else 'btn-primary'}">
End Session
</a>
</div>
<div t-attf-class="d-flex flex-column flex-grow-1 #{'justify-content-center' if not show_text_answers else ''} #{'align-items-center' if show_bar_chart else ''}">
<!-- Has to stay in 'style' attribute for Chartjs -->
<div t-if="show_bar_chart" class="p-2 o_survey_session_chart"
style="position: relative; width: 75vw; height: 70vh;">
<!-- canvas element for drawing bar chart -->
<canvas />
</div>
<div t-elif="show_text_answers" class="p-2 pt-4 o_survey_session_text_answers_container">
</div>
<div t-else="" class="mb-6">
<h2 class="font-weight-normal mb-3">
<span>Waiting for attendees...</span>
<span>
<span class="o_survey_session_answer_count">0</span>
/
<span t-esc="survey.session_answer_count" />
</span>
</h2>
<div class="progress">
<div class="progress-bar font-weight-bold" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"></div>
</div>
</div>
</div>
</div>
</div>
<div role="tabpanel" id="o_survey_session_results"
class="p-4 px-5 tab-pane fade o_survey_session_manage_container o_survey_session_results">
<div class="row m-0 pt-2 d-flex align-items-center">
<div class="col-lg-10 m-0 p-0">
<h3 class="m-0">Results</h3>
</div>
<div class="col-lg-2 m-0 p-0 text-right">
<h5 class="m-0">
<i class="fa fa-refresh o_survey_session_refresh_results mr-2" />
</h5>
<div class="o_survey_session_leaderboard w-100 flex-column flex-grow-1" style="display: none;">
<div class="d-flex">
<h1 class="o_survey_session_leaderboard_title flex-grow-1">Leaderboard</h1>
<div t-if="is_last_question" class="o_survey_leaderboard_buttons font-weight-bold">
<a href="#" role="button" class="o_survey_session_close btn btn-primary mr-4"><i class="fa fa-close"/> Close</a>
<a href="#" role="button" class="o_survey_session_close btn btn-primary" t-att-data-show-results="True"><i class="fa fa-bar-chart"/> Results</a>
</div>
</div>
<div class="justify-content-center d-flex flex-column flex-grow-1 mb-5 o_survey_session_leaderboard_container" />
</div>
<div class="o_survey_result">
</div>
</div>
<div role="tabpanel" id="o_survey_session_ranking"
t-if="survey.session_show_ranking"
class="p-4 px-5 tab-pane fade o_survey_session_manage_container o_survey_session_ranking">
<div class="row m-0 pt-2 d-flex align-items-center">
<div class="col-lg-10 m-0 p-0">
<h3 class="m-0">Ranking</h3>
</div>
<div class="col-lg-2 m-0 p-0 text-right">
<h5 class="m-0">
<i class="fa fa-refresh o_survey_session_refresh_ranking mr-2" />
</h5>
</div>
</div>
<div class="o_survey_session_ranking_container">
</div>
</div>
</div>
</div>
</template>
<template id="user_input_session_ranking" name="Survey User Input Ranking">
<div t-if="ranking">
<t t-set="max_score" t-value="ranking[0].scoring_total or 1" />
<t t-foreach="ranking" t-as="ranking_item">
<div class="o_survey_session_ranking_item mb-1">
<div t-esc="'%d.' % (ranking_item_index + 1)" class="o_survey_session_ranking_index d-inline-block font-weight-bold"></div>
<template id="user_input_session_leaderboard" name="Survey User Input Leaderboard">
<div t-if="leaderboard">
<t t-set="max_score" t-value="leaderboard[0].scoring_total or 1" />
<t t-foreach="leaderboard" t-as="leaderboard_item">
<div class="o_survey_session_leaderboard_item mb-1">
<div class="o_survey_session_leaderboard_index d-inline-block font-weight-bold align-top">
<div class="d-inline-block" t-esc="'%d.' % (leaderboard_item_index + 1)" />
<div class="d-inline-block float-right mr-2" t-esc="'%.0f p' % leaderboard_item.scoring_total" />
</div>
<!-- We keep "10rem" of space to display the points.
Then, the length of the bar is a percentage of the attendee's score compared to the max_score. -->
<t t-set="width_ratio" t-value="round(ranking_item.scoring_total / max_score, 3)"/>
<div class="o_survey_session_ranking_bar mr-1 pr-2 align-top d-inline-block text-right font-weight-bold mb-1"
t-esc="ranking_item.nickname"
t-att-style="'width: %s' % ('0%' if animate_width else ('calc(calc(%s - 10rem) * %s' % ('100%', width_ratio)))"
<t t-set="width_ratio" t-value="round(leaderboard_item.scoring_total / max_score, 3)"/>
<div class="o_survey_session_leaderboard_bar mr-1 pr-2 align-top d-inline-block text-right font-weight-bold"
t-att-style="'width: %s' % ('0%' if animate_width else ('calc(calc(%s - 18rem) * %s' % ('100%', width_ratio)))"
t-att-data-width-ratio="width_ratio">
<span t-if="ranking_item.nickname" t-esc="ranking_item.nickname" />
<span t-else="">Anonymous</span>
</div>
<div class="o_survey_session_ranking_name font-weight-bold align-top d-inline-block">
<span t-esc="'%.0f ' % ranking_item.scoring_total" />
<span>points</span>
<div class="o_survey_session_leaderboard_name d-inline-block font-weight-bold">
<span t-if="leaderboard_item.nickname" t-esc="leaderboard_item.nickname" />
<span t-else="">Anonymous</span>
</div>
</div>
</t>