diff --git a/addons/survey/__manifest__.py b/addons/survey/__manifest__.py index e2319ddd7f6..a486acd5329 100644 --- a/addons/survey/__manifest__.py +++ b/addons/survey/__manifest__.py @@ -68,6 +68,7 @@ sent mails with personal token for the invitation of the survey. 'survey/static/src/js/survey_timer.js', 'survey/static/src/js/survey_breadcrumb.js', 'survey/static/src/js/survey_form.js', + 'survey/static/src/js/survey_preload_image_mixin.js', 'survey/static/src/js/survey_print.js', 'survey/static/src/js/survey_result.js', ('include', 'web._assets_helpers'), diff --git a/addons/survey/controllers/main.py b/addons/survey/controllers/main.py index 88c6f483426..74027e25464 100644 --- a/addons/survey/controllers/main.py +++ b/addons/survey/controllers/main.py @@ -319,10 +319,11 @@ class Survey(http.Controller): def _prepare_question_html(self, survey_sudo, answer_sudo, **post): """ Survey page navigation is done in AJAX. This function prepare the 'next page' to display in html - and send back this html to the survey_form widget that will inject it into the page.""" + and send back this html to the survey_form widget that will inject it into the page. + Background url must be given to the caller in order to process its refresh as we don't have the next question + object at frontend side.""" survey_data = self._prepare_survey_data(survey_sudo, answer_sudo, **post) - survey_content = False if answer_sudo.state == 'done': survey_content = request.env.ref('survey.survey_fill_form_done')._render(survey_data) else: @@ -345,10 +346,17 @@ class Survey(http.Controller): 'page_number': page_ids.index(survey_data['question'].id) }) + background_image_url = survey_sudo.background_image_url + if 'question' in survey_data: + background_image_url = survey_data['question'].background_image_url + elif 'page' in survey_data: + background_image_url = survey_data['page'].background_image_url + return { 'survey_content': survey_content, 'survey_progress': survey_progress, 'survey_navigation': request.env.ref('survey.survey_navigation')._render(survey_data), + 'background_image_url': background_image_url } @http.route('/survey//', type='http', auth='public', website=True) @@ -364,18 +372,32 @@ class Survey(http.Controller): return request.render('survey.survey_page_fill', self._prepare_survey_data(access_data['survey_sudo'], answer_sudo, **post)) - @http.route('/survey/get_background_image//', type='http', auth="public", website=True, sitemap=False) - def survey_get_background(self, survey_token, answer_token): - access_data = self._get_access_data(survey_token, answer_token, ensure_token=True) - if access_data['validity_code'] is not True: - return werkzeug.exceptions.Forbidden() + # -------------------------------------------------------------------------- + # ROUTES to handle question images + survey background transitions + Tool + # -------------------------------------------------------------------------- - survey_sudo, answer_sudo = access_data['survey_sudo'], access_data['answer_sudo'] + @http.route('/survey//get_background_image', + type='http', auth="public", website=True, sitemap=False) + def survey_get_background(self, survey_token): + survey_sudo, dummy = self._fetch_from_access_token(survey_token, False) + return self._get_background_image(survey_sudo._name, survey_sudo.id) + @http.route('/survey///get_background_image', + type='http', auth="public", website=True, sitemap=False) + def survey_section_get_background(self, survey_token, section_id): + survey_sudo, dummy = self._fetch_from_access_token(survey_token, False) + + section = survey_sudo.page_ids.filtered(lambda q: q.id == section_id) + if not section: + # trying to access a question that is not in this survey + raise werkzeug.exceptions.Forbidden() + + return self._get_background_image(section._name, section.id) + + def _get_background_image(self, model_name, res_id): status, headers, image_base64 = request.env['ir.http'].sudo().binary_content( - model='survey.survey', id=survey_sudo.id, field='background_image', + model=model_name, id=res_id, field='background_image', default_mimetype='image/png') - return request.env['ir.http']._content_image_get_response(status, headers, image_base64) @http.route('/survey/get_question_image////', type='http', auth="public", website=True, sitemap=False) diff --git a/addons/survey/controllers/survey_session_manage.py b/addons/survey/controllers/survey_session_manage.py index 09dee642bb5..80811927127 100644 --- a/addons/survey/controllers/survey_session_manage.py +++ b/addons/survey/controllers/survey_session_manage.py @@ -87,13 +87,17 @@ class UserInputSession(http.Controller): Writing the next question on the survey is sudo'ed to avoid potential access right issues. e.g: a survey user can create a live session from any survey but he can only write - on its own survey. """ + on its own survey. + + In addition to return a pre-rendered html template with the next question, we also return the background + to display. Background image depends on the next question to display and cannot be extracted from the + html rendered question template. The background needs to be changed at frontend side on a specific selector.""" survey = self._fetch_from_token(survey_token) if not survey or not survey.session_state: # no open session - return '' + return {} if survey.session_state == 'ready': survey._session_open() @@ -107,13 +111,19 @@ class UserInputSession(http.Controller): 'session_question_id': next_question.id, 'session_question_start_time': fields.Datetime.now() + relativedelta(seconds=1) }) - request.env['bus.bus']._sendone(survey.access_token, 'next_question', {'question_start': now.timestamp()}) + request.env['bus.bus']._sendone(survey.access_token, 'next_question', { + 'question_start': now.timestamp() + }) template_values = self._prepare_manage_session_values(survey) template_values['is_rpc_call'] = True - return request.env.ref('survey.user_input_session_manage_content')._render(template_values) + + return { + 'background_image_url': survey.session_question_id.background_image_url, + 'question_html': request.env.ref('survey.user_input_session_manage_content')._render(template_values) + } else: - return False + return {} @http.route('/survey/session/results/', type='json', auth='user', website=True) def survey_session_results(self, survey_token, **kwargs): diff --git a/addons/survey/models/survey_question.py b/addons/survey/models/survey_question.py index 8d0c0b0fdc5..d3fcc78473b 100644 --- a/addons/survey/models/survey_question.py +++ b/addons/survey/models/survey_question.py @@ -58,6 +58,8 @@ class SurveyQuestion(models.Model): 'Description', translate=True, sanitize=False, # TDE TODO: sanitize but find a way to keep youtube iframe media stuff help="Use this field to add additional explanations about your question or to illustrate it with pictures or a video") question_placeholder = fields.Char("Placeholder", translate=True, compute="_compute_question_placeholder", store=True, readonly=False) + background_image = fields.Image("Background Image", compute="_compute_background_image", store=True, readonly=False) + background_image_url = fields.Char("Background Url", compute="_compute_background_image_url") survey_id = fields.Many2one('survey.survey', string='Survey', ondelete='cascade') scoring_type = fields.Selection(related='survey_id.scoring_type', string='Scoring Type', readonly=True) sequence = fields.Integer('Sequence', default=10) @@ -179,6 +181,34 @@ class SurveyQuestion(models.Model): or not question.question_placeholder: # avoid CacheMiss errors question.question_placeholder = False + @api.depends('is_page') + def _compute_background_image(self): + """ Background image is only available on sections. """ + for question in self.filtered(lambda q: not q.is_page): + question.background_image = False + + @api.depends('survey_id.access_token', 'background_image', 'page_id', 'survey_id.background_image_url') + def _compute_background_image_url(self): + """ How the background url is computed: + - For a question: it depends on the related section (see below) + - For a section: + - if a section has a background, then we create the background URL using this section's ID + - if not, then we fallback on the survey background url """ + base_bg_url = "/survey/%s/%s/get_background_image" + for question in self: + if question.is_page: + background_section_id = question.id if question.background_image else False + else: + background_section_id = question.page_id.id if question.page_id.background_image else False + + if background_section_id: + question.background_image_url = base_bg_url % ( + question.survey_id.access_token, + background_section_id + ) + else: + question.background_image_url = question.survey_id.background_image_url + @api.depends('is_page') def _compute_question_type(self): for question in self: diff --git a/addons/survey/models/survey_survey.py b/addons/survey/models/survey_survey.py index 0954691a87d..31e42b53c9e 100644 --- a/addons/survey/models/survey_survey.py +++ b/addons/survey/models/survey_survey.py @@ -54,7 +54,8 @@ class Survey(models.Model): description_done = fields.Html( "End Message", translate=True, help="This message will be displayed when survey is completed") - background_image = fields.Binary("Background Image") + background_image = fields.Image("Background Image") + background_image_url = fields.Char('Background Url', compute="_compute_background_image_url") active = fields.Boolean("Active", default=True) user_id = fields.Many2one( 'res.users', string='Responsible', @@ -171,6 +172,12 @@ class Survey(models.Model): ('badge_uniq', 'unique (certification_badge_id)', "The badge for each survey should be unique!"), ] + @api.depends('background_image', 'access_token') + def _compute_background_image_url(self): + self.background_image_url = False + for survey in self.filtered(lambda survey: survey.background_image and survey.access_token): + survey.background_image_url = "/survey/%s/get_background_image" % survey.access_token + def _compute_users_can_signup(self): signup_allowed = self.env['res.users'].sudo()._get_signup_invitation_scope() == 'b2c' for survey in self: diff --git a/addons/survey/static/src/js/fields_form_page_description.js b/addons/survey/static/src/js/fields_form_page_description.js index 05de3cac32f..ebbe42d015a 100644 --- a/addons/survey/static/src/js/fields_form_page_description.js +++ b/addons/survey/static/src/js/fields_form_page_description.js @@ -22,7 +22,7 @@ var FormDescriptionPage = FieldChar.extend({ var $button = $( '
\ \
' ); diff --git a/addons/survey/static/src/js/survey_form.js b/addons/survey/static/src/js/survey_form.js index 960229e9e76..ae95e15fe63 100644 --- a/addons/survey/static/src/js/survey_form.js +++ b/addons/survey/static/src/js/survey_form.js @@ -9,10 +9,12 @@ var Dialog = require('web.Dialog'); var dom = require('web.dom'); var utils = require('web.utils'); +var SurveyPreloadImageMixin = require('survey.preload_image_mixin'); + var _t = core._t; var isMac = navigator.platform.toUpperCase().includes('MAC'); -publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({ +publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend(SurveyPreloadImageMixin, { selector: '.o_survey_form', events: { 'change .o_survey_form_choice_item': '_onChangeChoiceItem', @@ -441,20 +443,35 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({ self.$(selectorsToFadeout.join(',')).fadeOut(this.fadeInOutDelay, function () { resolveFadeOut(); }); + // Background management - Fade in / out on each transition + if (this.options.refreshBackground) { + $('div.o_survey_background').addClass('o_survey_background_transition'); + } - Promise.all([fadeOutPromise, nextScreenPromise]).then(function (results) { - return self._onNextScreenDone(results[1], options); + var nextScreenWithBackgroundPromise = nextScreenPromise.then(function (result) { + self.nextScreenResult = result; + // once we have the next question, wait for the preload of the background + if (self.options.refreshBackground && result.background_image_url) { + return self._preloadBackground(result.background_image_url); + } else { + return Promise.resolve(); + } + }); + + // Wait for the fade out and the preload of the next background. The next question have already been fetched. + Promise.all([fadeOutPromise, nextScreenWithBackgroundPromise]).then(function () { + return self._onNextScreenDone(options); }); }, /** * Handle server side validation and display eventual error messages. * - * @param {string} result the HTML result of the screen to display * @param {Object} options see '_submitForm' for details */ - _onNextScreenDone: function (result, options) { + _onNextScreenDone: function (options) { var self = this; + var result = this.nextScreenResult; if (!(options && options.isFinish) && !this.options.sessionInProgress) { @@ -511,7 +528,11 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({ // prevent enter submit if we're on a page description (there is nothing to submit) this.preventEnterSubmit = true; } - + // Background management - reset background overlay opacity to 0.7 to discover next background. + if (this.options.refreshBackground) { + $('div.o_survey_background').css("background-image", "url(" + result.background_image_url + ")"); + $('div.o_survey_background').removeClass('o_survey_background_transition'); + } this.$('.o_survey_form_content').fadeIn(this.fadeInOutDelay); $("html, body").animate({ scrollTop: 0 }, this.fadeInOutDelay); self._focusOnFirstInput(); @@ -992,6 +1013,9 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({ } }, + // OTHER TOOLS + // ------------------------------------------------------------------------- + /** * Will automatically focus on the first input to allow the user to complete directly the survey, * without having to manually get the focus (only if the input has the right type - can write something inside -) diff --git a/addons/survey/static/src/js/survey_preload_image_mixin.js b/addons/survey/static/src/js/survey_preload_image_mixin.js new file mode 100644 index 00000000000..8cf97d50c03 --- /dev/null +++ b/addons/survey/static/src/js/survey_preload_image_mixin.js @@ -0,0 +1,34 @@ +odoo.define('survey.preload_image_mixin', function (require) { +"use strict"; + + +return { + /** + * Load the target section background and render it when loaded. + * + * This method is used to pre-load the image during the questions transitions (fade out) in order + * to be sure the image is fully loaded when setting it as background of the next question and + * finally display it (fade in) + * + * This idea is to wait until new background is loaded before changing the background + * (to avoid flickering or loading latency) + * + * @param {string} imageUrl + * @private + */ + _preloadBackground: async function (imageUrl) { + var resolvePreload; + + // We have to manually create a promise here because the "onload" API does not provide one. + var preloadPromise = new Promise(function (resolve, reject) {resolvePreload = resolve;}); + var background = new Image(); + background.onload = function () { + resolvePreload(imageUrl); + }; + background.src = imageUrl; + + return preloadPromise; + } +}; + +}); diff --git a/addons/survey/static/src/js/survey_session_manage.js b/addons/survey/static/src/js/survey_session_manage.js index 59ff388168f..7988f4affdd 100644 --- a/addons/survey/static/src/js/survey_session_manage.js +++ b/addons/survey/static/src/js/survey_session_manage.js @@ -2,13 +2,14 @@ odoo.define('survey.session_manage', function (require) { 'use strict'; var publicWidget = require('web.public.widget'); +var SurveyPreloadImageMixin = require('survey.preload_image_mixin'); 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; -publicWidget.registry.SurveySessionManage = publicWidget.Widget.extend({ +publicWidget.registry.SurveySessionManage = publicWidget.Widget.extend(SurveyPreloadImageMixin, { selector: '.o_survey_session_manage', events: { 'click .o_survey_session_copy': '_onCopySessionLink', @@ -41,6 +42,10 @@ publicWidget.registry.SurveySessionManage = publicWidget.Widget.extend({ // display props self.showBarChart = self.$el.data('showBarChart'); self.showTextAnswers = self.$el.data('showTextAnswers'); + // Question transition + self.stopNextQuestion = false; + // Background Management + self.refreshBackground = self.$el.data('refreshBackground'); var isRpcCall = self.$el.data('isRpcCall'); if (!isRpcCall) { @@ -304,6 +309,12 @@ publicWidget.registry.SurveySessionManage = publicWidget.Widget.extend({ _nextQuestion: function (goBack) { var self = this; + // stop calling multiple times "get next question" process until next question is fully loaded. + if (this.stopNextQuestion) { + return; + } + this.stopNextQuestion = true; + this.isStartScreen = false; if (this.surveyTimerWidget) { this.surveyTimerWidget.destroy(); @@ -315,12 +326,9 @@ publicWidget.registry.SurveySessionManage = publicWidget.Widget.extend({ resolveFadeOut(); }); - var nextQuestionPromise = this._rpc({ - route: _.str.sprintf('/survey/session/next_question/%s', self.surveyAccessToken), - params: { - 'go_back': goBack, - } - }); + if (this.refreshBackground) { + $('div.o_survey_background').addClass('o_survey_background_transition'); + } // avoid refreshing results while transitioning if (this.resultsRefreshInterval) { @@ -328,41 +336,72 @@ publicWidget.registry.SurveySessionManage = publicWidget.Widget.extend({ delete this.resultsRefreshInterval; } - Promise.all([fadeOutPromise, nextQuestionPromise]).then(async function (results) { - if (results[1]) { - var $renderedTemplate = $(results[1]); - self.$el.replaceWith($renderedTemplate); - - // Ensure new question is fully loaded before force loading previous question screen. - await self.attachTo($renderedTemplate); - if (goBack) { - // As we arrive on "question" screen, simulate going to the results screen or leaderboard. - self._setShowInputs(true); - self._setShowAnswers(true); - if (self.sessionShowLeaderboard && self.isScoredQuestion) { - self.currentScreen = 'leaderboard'; - self.leaderBoard.showLeaderboard(false, self.isScoredQuestion); - } else { - self.currentScreen = 'results'; - self._refreshResults(); - } - } else { - self._startTimer(); - } - self.$el.fadeIn(self.fadeInOutTime); - } else if (self.sessionShowLeaderboard) { - // Display last screen if leaderboard activated - self.isLastQuestion = true; - self._setupLeaderboard().then(function () { - self.$('.o_survey_session_leaderboard_title').text(_t('Final Leaderboard')); - self.$('.o_survey_session_navigation_next').addClass('d-none'); - self.$('.o_survey_leaderboard_buttons').removeClass('d-none'); - self.leaderBoard.showLeaderboard(false, false); - }); + var nextQuestionPromise = this._rpc({ + route: _.str.sprintf('/survey/session/next_question/%s', self.surveyAccessToken), + params: { + 'go_back': goBack, + } + }).then(function (result) { + self.nextQuestion = result; + if (self.refreshBackground && result.background_image_url) { + return self._preloadBackground(result.background_image_url); } else { - self.$('.o_survey_session_close').click(); + return Promise.resolve(); } }); + + Promise.all([fadeOutPromise, nextQuestionPromise]).then(function () { + return self._onNextQuestionDone(goBack); + }); + }, + + /** + * Refresh the screen with the next question's rendered template. + * + * @param {boolean} goBack Whether we are going back to the previous question or not + */ + _onNextQuestionDone: async function (goBack) { + var self = this; + + if (this.nextQuestion.question_html) { + var $renderedTemplate = $(this.nextQuestion.question_html); + this.$el.replaceWith($renderedTemplate); + + // Ensure new question is fully loaded before force loading previous question screen. + await this.attachTo($renderedTemplate); + if (goBack) { + // As we arrive on "question" screen, simulate going to the results screen or leaderboard. + this._setShowInputs(true); + this._setShowAnswers(true); + if (this.sessionShowLeaderboard && this.isScoredQuestion) { + this.currentScreen = 'leaderboard'; + this.leaderBoard.showLeaderboard(false, this.isScoredQuestion); + } else { + this.currentScreen = 'results'; + this._refreshResults(); + } + } else { + this._startTimer(); + } + this.$el.fadeIn(this.fadeInOutTime); + } else if (this.sessionShowLeaderboard) { + // Display last screen if leaderboard activated + this.isLastQuestion = true; + this._setupLeaderboard().then(function () { + self.$('.o_survey_session_leaderboard_title').text(_t('Final Leaderboard')); + self.$('.o_survey_session_navigation_next').addClass('d-none'); + self.$('.o_survey_leaderboard_buttons').removeClass('d-none'); + self.leaderBoard.showLeaderboard(false, false); + }); + } else { + this.$('.o_survey_session_close').click(); + } + + // Background Management + if (this.refreshBackground) { + $('div.o_survey_background').css("background-image", "url(" + this.nextQuestion.background_image_url + ")"); + $('div.o_survey_background').removeClass('o_survey_background_transition'); + } }, /** diff --git a/addons/survey/static/src/scss/survey_templates_form.scss b/addons/survey/static/src/scss/survey_templates_form.scss index 30c87c28237..aaa33769a88 100644 --- a/addons/survey/static/src/scss/survey_templates_form.scss +++ b/addons/survey/static/src/scss/survey_templates_form.scss @@ -14,6 +14,22 @@ nav#oe_main_menu_navbar { /********************************************************** Common Style **********************************************************/ +// the survey background image takes all the page background, with a translucent white overlay (box-shadow) +// When changing the background from one section to another, the overlay will become opaque to simulate a fade out of +// the background image. This ensure a smooth transition from one background to another, likewise the question +// transition. +.o_survey_background { + height: 100%; + overflow: auto; + transition: box-shadow 0.3s ease-in-out; + box-shadow: inset 0 0 0 10000px rgba(255,255,255,.7); + background: no-repeat fixed center; + background-size: cover; + &.o_survey_background_transition { + box-shadow: inset 0 0 0 10000px rgba(255,255,255,1); + } +} + .o_survey_wrap { min-height: 100%; } diff --git a/addons/survey/views/survey_question_views.xml b/addons/survey/views/survey_question_views.xml index dbc6cc87247..234f8b7b618 100644 --- a/addons/survey/views/survey_question_views.xml +++ b/addons/survey/views/survey_question_views.xml @@ -13,6 +13,10 @@ +
+