summaryrefslogtreecommitdiff
path: root/addons/survey/static
diff options
context:
space:
mode:
authorAurélien Warnon <awa@odoo.com>2020-02-11 15:31:50 +0000
committerThibault Delavallée <tde@odoo.com>2020-02-27 16:40:57 +0000
commitde2fa261656244688b6b9952459cb4052e4d22f8 (patch)
treef77784d3f3d2811c3a4bc201a16da6eae859363f /addons/survey/static
parent84e128980cfd3616f73c4b937c32711fddd50234 (diff)
[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
Diffstat (limited to 'addons/survey/static')
-rw-r--r--addons/survey/static/src/js/survey_session_chart.js272
-rw-r--r--addons/survey/static/src/js/survey_session_colors.js21
-rw-r--r--addons/survey/static/src/js/survey_session_leaderboard.js71
-rw-r--r--addons/survey/static/src/js/survey_session_manage.js553
-rw-r--r--addons/survey/static/src/js/survey_session_text_answers.js74
-rw-r--r--addons/survey/static/src/scss/survey_form.scss217
-rw-r--r--addons/survey/static/src/xml/survey_session_text_answer_template.xml11
7 files changed, 964 insertions, 255 deletions
diff --git a/addons/survey/static/src/js/survey_session_chart.js b/addons/survey/static/src/js/survey_session_chart.js
new file mode 100644
index 00000000000..3dd2fac13ec
--- /dev/null
+++ b/addons/survey/static/src/js/survey_session_chart.js
@@ -0,0 +1,272 @@
+odoo.define('survey.session_chart', function (require) {
+'use strict';
+
+var publicWidget = require('web.public.widget');
+var SESSION_CHART_COLORS = require('survey.session_colors');
+
+publicWidget.registry.SurveySessionChart = publicWidget.Widget.extend({
+ init: function (parent, options) {
+ this._super.apply(this, arguments);
+
+ this.questionType = options.questionType;
+ this.answersValidity = options.answersValidity;
+ this.hasCorrectAnswers = options.hasCorrectAnswers;
+ this.attendeesCount = options.attendeesCount;
+ this.questionStatistics = this._processQuestionStatistics(options.questionStatistics);
+ this.showInputs = options.showInputs;
+ this.showAnswers = false;
+ },
+
+ start: function () {
+ var self = this;
+ return this._super.apply(this, arguments).then(function () {
+ self._setupChart();
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Updates the chart data using the latest received question user inputs.
+ *
+ * By updating the numbers in the dataset, we take advantage of the Chartjs API
+ * that will automatically add animations to show the new number.
+ *
+ * @param {Object} questionStatistics object containing chart data (counts / labels / ...)
+ */
+ updateChart: function (questionStatistics) {
+ if (questionStatistics) {
+ this.questionStatistics = this._processQuestionStatistics(questionStatistics);
+ }
+
+ if (this.chart) {
+ // only a single dataset for our bar charts
+ var chartData = this.chart.data.datasets[0].data;
+ for (var i = 0; i < chartData.length; i++){
+ var value = 0;
+ if (this.showInputs) {
+ value = this.questionStatistics[i].count;
+ }
+ this.chart.data.datasets[0].data[i] = value;
+ }
+ this.chart.update();
+ }
+ },
+
+ /**
+ * Toggling this parameter will display or hide the correct and incorrect answers of the current
+ * question directly on the chart.
+ *
+ * @param {Boolean} showAnswers
+ */
+ setShowAnswers: function (showAnswers) {
+ this.showAnswers = showAnswers;
+ },
+
+ /**
+ * Toggling this parameter will display or hide the user inputs of the current question directly
+ * on the chart.
+ *
+ * @param {Boolean} showInputs
+ */
+ setShowInputs: function (showInputs) {
+ this.showInputs = showInputs;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _setupChart: function () {
+ var $canvas = this.$('canvas');
+ var ctx = $canvas.get(0).getContext('2d');
+
+ this.chart = new Chart(ctx, this._buildChartConfiguration());
+ },
+
+ /**
+ * Custom bar chart configuration for our survey session use case.
+ *
+ * Quick summary of enabled features:
+ * - background_color is one of the 10 custom colors from SESSION_CHART_COLORS
+ * (see _getBackgroundColor for details)
+ * - The ticks are bigger and bolded to be able to see them better on a big screen (projector)
+ * - We don't use tooltips to keep it as simple as possible
+ * - We use a "suggestedMax" being the attendeesCount +1
+ * The '+1' part is a small trick to avoid the datalabels to be clipped in height
+ * - We use a custom 'datalabels' plugin to be able to display the number value on top of the
+ * associated bar of the chart.
+ * This allows the host to discuss results with attendees in a more interactive way.
+ *
+ * @private
+ */
+ _buildChartConfiguration: function () {
+ return {
+ type: 'bar',
+ data: {
+ labels: this._extractChartLabels(),
+ datasets: [{
+ backgroundColor: this._getBackgroundColor.bind(this),
+ data: this._extractChartData(),
+ }]
+ },
+ options: {
+ maintainAspectRatio: false,
+ plugins: {
+ datalabels: {
+ color: this._getLabelColor.bind(this),
+ font: {
+ size: '25',
+ weight: 'bold',
+ },
+ anchor: 'end',
+ align: 'top',
+ }
+ },
+ legend: {
+ display: false,
+ },
+ scales: {
+ yAxes: [{
+ ticks: {
+ display: false,
+ suggestedMin: 0,
+ suggestedMax: this.attendeesCount + 1,
+ },
+ gridLines: {
+ display: false
+ }
+ }],
+ xAxes: [{
+ ticks: {
+ maxRotation: 0,
+ fontSize: '25',
+ fontStyle: 'bold',
+ fontColor: '#212529'
+ },
+ gridLines: {
+ display: false
+ }
+ }]
+ },
+ tooltips: {
+ enabled: false,
+ }
+ },
+ };
+ },
+
+ /**
+ * Returns the label of the associated survey.question.answer.
+ *
+ * @private
+ */
+ _extractChartLabels: function () {
+ return this.questionStatistics.map(function (point) {
+ return point.text.length > 20 ? point.text.substring(0, 17) + '...' : point.text;
+ });
+ },
+
+ /**
+ * We simply return an array of zeros as initial value.
+ * The chart will update afterwards as attendees add their user inputs.
+ *
+ * @private
+ */
+ _extractChartData: function () {
+ return this.questionStatistics.map(function () {
+ return 0;
+ });
+ },
+
+ /**
+ * Custom method that returns a color from SESSION_CHART_COLORS.
+ * It loops through the ten values and assign them sequentially.
+ *
+ * We have a special mechanic when the host shows the answers of a question.
+ * Wrong answers are "faded out" using a 0.3 opacity.
+ *
+ * @param {Object} metaData
+ * @param {Integer} metaData.dataIndex the index of the label, matching the index of the answer
+ * in 'this.answersValidity'
+ * @private
+ */
+ _getBackgroundColor: function (metaData) {
+ var opacity = '0.8';
+ if (this.showAnswers && this.hasCorrectAnswers) {
+ if (this._isValidAnswer(metaData.dataIndex)){
+ opacity = 0.3;
+ }
+ }
+ var rgb = SESSION_CHART_COLORS[metaData.dataIndex];
+ return `rgba(${rgb},${opacity})`;
+ },
+
+ /**
+ * Custom method that returns the survey.question.answer label color.
+ *
+ * Break-down of use cases:
+ * - Red if the host is showing answer, and the associated answer is not correct
+ * - Green if the host is showing answer, and the associated answer is correct
+ * - Black in all other cases
+ *
+ * @param {Object} metaData
+ * @param {Integer} metaData.dataIndex the index of the label, matching the index of the answer
+ * in 'this.answersValidity'
+ * @private
+ */
+ _getLabelColor: function (metaData) {
+ if (this.showAnswers && this.hasCorrectAnswers) {
+ if (this._isValidAnswer(metaData.dataIndex)){
+ return '#2CBB70';
+ } else {
+ return '#D9534F';
+ }
+ }
+ return '#212529';
+ },
+
+ /**
+ * Small helper method that returns the validity of the answer based on its index.
+ *
+ * We need this special handling because of Chartjs data structure.
+ * The library determines the parameters (color/label/...) by only passing the answer 'index'
+ * (and not the id or anything else we can identify).
+ *
+ * @param {Integer} answerIndex
+ * @private
+ */
+ _isValidAnswer: function (answerIndex) {
+ return this.answersValidity[answerIndex];
+ },
+
+ /**
+ * Special utility method that will process the statistics we receive from the
+ * survey.question#_prepare_statistics method.
+ *
+ * For multiple choice questions, the values we need are stored in a different place.
+ * We simply return the values to make the use of the statistics common for both simple and
+ * multiple choice questions.
+ *
+ * See survey.question#_get_stats_data for more details
+ *
+ * @param {Object} rawStatistics
+ * @private
+ */
+ _processQuestionStatistics: function (rawStatistics) {
+ if (this.questionType === 'multiple_choice') {
+ return rawStatistics[0].values;
+ }
+
+ return rawStatistics;
+ }
+});
+
+return publicWidget.registry.SurveySessionChart;
+
+});
diff --git a/addons/survey/static/src/js/survey_session_colors.js b/addons/survey/static/src/js/survey_session_colors.js
new file mode 100644
index 00000000000..728f2f68483
--- /dev/null
+++ b/addons/survey/static/src/js/survey_session_colors.js
@@ -0,0 +1,21 @@
+odoo.define('survey.session_colors', function (require) {
+'use strict';
+
+/**
+ * Small tool that returns common colors for survey session widgets.
+ * Source: https://www.materialui.co/colors
+ */
+return [
+ '25,118,210',
+ '48,63,159',
+ '175,180,43',
+ '0,121,107',
+ '56,142,60',
+ '93,64,55',
+ '97,97,97',
+ '123,31,162',
+ '69,90,100',
+ '211,47,47',
+];
+
+});
diff --git a/addons/survey/static/src/js/survey_session_leaderboard.js b/addons/survey/static/src/js/survey_session_leaderboard.js
new file mode 100644
index 00000000000..0126a00f1c7
--- /dev/null
+++ b/addons/survey/static/src/js/survey_session_leaderboard.js
@@ -0,0 +1,71 @@
+odoo.define('survey.session_leaderboard', function (require) {
+'use strict';
+
+var publicWidget = require('web.public.widget');
+var SESSION_CHART_COLORS = require('survey.session_colors');
+
+publicWidget.registry.SurveySessionLeaderboard = publicWidget.Widget.extend({
+ init: function (parent, options) {
+ this._super.apply(this, arguments);
+
+ this.surveyAccessToken = options.surveyAccessToken;
+ this.$sessionResults = options.sessionResults;
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Shows the question leaderboard on screen.
+ * It's based on the attendees score (descending).
+ *
+ * We fade out the $sessionResults to fade in our rendered template.
+ *
+ * The width of the progress bars is set after the rendering to enable a width css animation.
+ */
+ showLeaderboard: function () {
+ var self = this;
+
+ var resolveFadeOut;
+ var fadeOutPromise = new Promise(function (resolve, reject) { resolveFadeOut = resolve; });
+ self.$sessionResults.fadeOut(400, function () {
+ resolveFadeOut();
+ });
+
+ var leaderboardPromise = this._rpc({
+ route: _.str.sprintf('/survey/session/leaderboard/%s', this.surveyAccessToken)
+ });
+
+ Promise.all([fadeOutPromise, leaderboardPromise]).then(function (results) {
+ var leaderboardResults = results[1];
+ var $renderedTemplate = $(leaderboardResults).addClass('py-3');
+ self.$('.o_survey_session_leaderboard_container').append($renderedTemplate);
+
+ self.$('.o_survey_session_leaderboard_bar').each(function (index) {
+ var rgb = SESSION_CHART_COLORS[index % 10];
+ $(this).css('background-color', `rgba(${rgb},${0.8})`);
+ });
+ self.$el.fadeIn(400, function () {
+ self.$('.o_survey_session_leaderboard_bar').each(function () {
+ $(this).css('width', `calc(calc(100% - 18rem) * ${$(this).data('widthRatio')})`);
+ });
+ });
+ });
+ },
+
+ /**
+ * Inverse the process, fading out our template to fade int the $sessionResults.
+ */
+ hideLeaderboard: function () {
+ var self = this;
+ this.$el.fadeOut(400, function () {
+ self.$('.o_survey_session_leaderboard_container').empty();
+ self.$sessionResults.fadeIn(400);
+ });
+ },
+});
+
+return publicWidget.registry.SurveySessionLeaderboard;
+
+});
diff --git a/addons/survey/static/src/js/survey_session_manage.js b/addons/survey/static/src/js/survey_session_manage.js
index daa6979b4ab..e3e8091a190 100644
--- a/addons/survey/static/src/js/survey_session_manage.js
+++ b/addons/survey/static/src/js/survey_session_manage.js
@@ -2,6 +2,9 @@ odoo.define('survey.session_manage', function (require) {
'use strict';
var publicWidget = require('web.public.widget');
+var SurveySessionChart = require('survey.session_chart');
+var SurveySessionTextAnswers = require('survey.session_text_answers');
+var SurveySessionLeaderBoard = require('survey.session_leaderboard');
var core = require('web.core');
var _t = core._t;
@@ -9,12 +12,9 @@ publicWidget.registry.SurveySessionManage = publicWidget.Widget.extend({
selector: '.o_survey_session_manage',
events: {
'click .o_survey_session_copy': '_onCopySessionLink',
- 'click .o_survey_session_next, .o_survey_session_start': '_onNextQuestionClick',
- 'click .o_survey_session_end': '_onEndSessionClick',
- 'click .o_survey_session_results_href': '_onResultsTabClick',
- 'click .o_survey_session_ranking_href': '_onRankingTabClick',
- 'click .o_survey_session_refresh_results': '_onRefreshResultsClick',
- 'click .o_survey_session_refresh_ranking': '_onRefreshRankingClick',
+ 'click .o_survey_session_navigation_next, .o_survey_session_start': '_onNext',
+ 'click .o_survey_session_navigation_previous': '_onBack',
+ 'click .o_survey_session_close': '_onEndSessionClick',
},
/**
@@ -27,21 +27,34 @@ publicWidget.registry.SurveySessionManage = publicWidget.Widget.extend({
start: function () {
var self = this;
return this._super.apply(this, arguments).then(function () {
+ // general survey props
self.surveyId = self.$el.data('surveyId');
self.surveyAccessToken = self.$el.data('surveyAccessToken');
self.isStartScreen = self.$el.data('isStartScreen');
+ self.isLastQuestion = self.$el.data('isLastQuestion');
+ // scoring props
+ self.isScoredQuestion = self.$el.data('isScoredQuestion');
+ self.sessionShowLeaderboard = self.$el.data('sessionShowLeaderboard');
+ self.hasCorrectAnswers = self.$el.data('hasCorrectAnswers');
+ // display props
+ self.attendeesCount = self.$el.data('attendeesCount');
+ self.showBarChart = self.$el.data('showBarChart');
+ self.showTextAnswers = self.$el.data('showTextAnswers');
var isRpcCall = self.$el.data('isRpcCall');
if (!isRpcCall) {
self._startTimer();
+ $(document).on('keydown', self._onKeyDown.bind(self));
}
- if (self.isStartScreen) {
- self._refreshAttendeesCount();
- } else if (!self.refreshingAnswers) {
- self.stopRefreshingAttendees = true;
- self._refreshReceivedAnswers();
- }
+ self._setupIntervals();
+ self._setupCurrentScreen();
+ var setupPromises = [];
+ setupPromises.push(self._setupTextAnswers());
+ setupPromises.push(self._setupChart());
+ setupPromises.push(self._setupLeaderboard());
+
+ return Promise.all(setupPromises);
});
},
@@ -91,6 +104,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.
*
* The fade out happens at the same moment as the _rpc to get the new question template.
@@ -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);
@@ -128,71 +327,13 @@ 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);
+ }
+
+ 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}%`);
+
+ 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;
});
},
/**
- * Refreshes the question ranking on screen.
- * We set the width of the progress bars after the rendering to enable a width css animation.
+ * We refresh the attendees count every 2 seconds while the user is on the start screen.
+ *
*/
- _refreshRanking: function () {
+ _refreshAttendeesCount: function () {
var self = this;
- 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();
+ self._rpc({
+ model: 'survey.survey',
+ method: 'read',
+ args: [[self.surveyId], ['answer_count']],
+ }).then(function (result) {
+ if (result && result.length === 1){
+ self.$('.o_survey_session_attendees_count').text(
+ result[0].answer_count
+ );
+ }
+ }, function () {
+ // on failure, stop refreshing
+ clearInterval(self.attendeesRefreshInterval);
});
},
/**
- * 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.
+ * 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.
+ *
*/
- _refreshAttendeesCount: function () {
- var self = this;
+ _setupChart: function () {
+ if (this.resultsChart) {
+ this.resultsChart.setElement(null);
+ this.resultsChart.destroy();
+ delete this.resultsChart;
+ }
- 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();
- }
+ 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
});
- }, 2000);
+
+ return this.resultsChart.attachTo(this.$('.o_survey_session_chart'));
+ } else {
+ return Promise.resolve();
+ }
},
/**
- * 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).
+ * Leaderboard of all the attendees based on their score.
+ * see SurveySessionLeaderBoard widget doc for more information.
+ *
*/
- _refreshReceivedAnswers: function () {
- var self = this;
+ _setupLeaderboard: function () {
+ if (this.leaderBoard) {
+ this.leaderBoard.setElement(null);
+ this.leaderBoard.destroy();
+ delete this.leaderBoard;
+ }
- 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();
+ if (this.isScoredQuestion || this.isLastQuestion) {
+ this.leaderBoard = new SurveySessionLeaderBoard(this, {
+ surveyAccessToken: this.surveyAccessToken,
+ sessionResults: this.$('.o_survey_session_results')
});
- }, 2000);
+
+ return this.leaderBoard.attachTo(this.$('.o_survey_session_leaderboard'));
+ } else {
+ return Promise.resolve();
+ }
},
/**
- * 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.
+ * Shows attendees answers for char_box/date and datetime questions.
+ * see SurveySessionTextAnswers widget doc for more information.
*
- * @param {$.Element} $target
- * @param {Promise} refreshPromise
*/
- _spinIcon: function ($target, refreshPromise) {
- $target.addClass('fa-spin');
+ _setupTextAnswers: function () {
+ if (this.textAnswers) {
+ this.textAnswers.setElement(null);
+ this.textAnswers.destroy();
+ delete this.textAnswers;
+ }
- var minimumSpinResolve;
- var minimumSpinPromise = new Promise(function (resolve) { minimumSpinResolve = resolve;});
- setTimeout(function () {
- minimumSpinResolve();
- }, 1000);
+ if (!this.isStartScreen && this.showTextAnswers) {
+ this.textAnswers = new SurveySessionTextAnswers(this, {
+ questionType: this.$el.data('questionType')
+ });
- Promise.all([minimumSpinPromise, refreshPromise]).then(function () {
- $target.removeClass('fa-spin');
- });
+ 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,12 +109,32 @@ nav#oe_main_menu_navbar {
}
}
-.o_survey_form, .o_survey_session_manage {
+/**********************************************************
+ 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;
+ }
+
+ &:hover {
+ background-color: rgba($primary, 0.8);
+ .o_survey_choice_key span.o_survey_key {
+ opacity: 1;
+ }
+ }
}
.o_survey_choice_key {
@@ -125,6 +145,13 @@ nav#oe_main_menu_navbar {
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;
}
}
@@ -136,155 +163,141 @@ nav#oe_main_menu_navbar {
}
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;
+ }
+ }
}
}
-
- .o_survey_progress {
- height:0.5em;
- }
}
/**********************************************************
- Form Specific Style
+ Survey Session Specific Style
**********************************************************/
-.o_survey_form {
- .o_survey_choice_btn {
- cursor: pointer;
-
- &.o_survey_selected {
- background-color: $primary;
- }
-
- &:hover {
- background-color: rgba($primary, 0.8);
- .o_survey_choice_key span.o_survey_key {
- opacity: 1;
- }
- }
+.o_survey_session_manage {
+ h1 {
+ font-size: 4rem;
}
- .o_survey_choice_key span.o_survey_key {
- width: inherit;
- right: 10px;
- border: 2px solid $primary;
- border-right: 0px;
- height: 25px;
- transition: opacity 0.4s ease;
- white-space: nowrap;
+ h2 {
+ font-size: 3.2rem;
}
- .o_survey_question_matrix td:hover {
- background-color: rgba($primary, 0.5);
+ .o_survey_session_navigation {
+ position: fixed;
+ padding: 1rem;
+ top: calc(50% - 0.5rem);
cursor: pointer;
- .o_survey_choice_key span.o_survey_key {
- opacity: 1;
+
+ &.o_survey_session_navigation_next {
+ right: 2rem;
+ }
+
+ &.o_survey_session_navigation_previous {
+ left: 2rem;
}
}
-}
-/**********************************************************
- Survey Session Specific Style
- **********************************************************/
+ .o_survey_session_link_reminder {
+ position: absolute;
+ right: 2rem;
+ top: 2rem;
+ }
-.o_survey_session_manage {
.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;
- }
-
- .tab-content > .o_survey_session_manage_question.active {
- display: flex;
- }
- .o_survey_session_copy {
- cursor: pointer;
- opacity: .75;
- transition: opacity .3s ease;
- &:hover {
- opacity: 1;
+ &.pt-6 {
+ padding-top: 5rem !important;
}
- }
- .btn {
- font-variant: small-caps;
- &.o_survey_session_end {
- right: 0px;
- }
- }
+ .o_survey_session_results {
+ display: flex; // here and not d-flex because we need to be able to fade-out
- .o_survey_session_results, .o_survey_session_ranking {
- .fa {
- font-size: .9rem;
- opacity: .75;
- &:hover {
- transition: opacity .4s ease;
- opacity: 1;
- cursor: pointer;
+ .mb-6 {
+ margin-bottom: 6rem;
}
- }
- .o_survey_result, .o_survey_session_ranking_container {
- overflow: hidden;
- }
+ .progress {
+ height: 2rem;
+ border-radius: 0.6rem;
+ font-size: 1.2rem;
+ background-color: #cfcfcf;
+ .progress-bar {
+ width: 0%;
+ transition: width 1s ease;
+ }
+ }
- .survey_filter {
- display: none;
+ .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_result {
- .nav-tabs .nav-link.active {
- background-color: transparent;
- border-color: #dee2e6 #dee2e6 transparent;
+ .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;
+ }
}
}
- .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_copy {
+ cursor: pointer;
+ opacity: .75;
+ transition: opacity .3s ease;
+ &:hover {
+ opacity: 1;
}
}
}
-.o_survey_session_ranking {
- .o_survey_session_ranking_item {
- line-height: 1.4rem;
+.o_survey_session_leaderboard {
+ font-size: 1.4rem;
+
+ .o_survey_session_leaderboard_item {
+ line-height: 1.8rem;
- .o_survey_session_ranking_index {
- width: 1.5rem;
+ .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 @@
+<?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>