From fd75481d52878962a782cfe740be25b731c99efd Mon Sep 17 00:00:00 2001 From: David Beguin Date: Tue, 24 Mar 2020 13:49:00 +0000 Subject: [PATCH] [IMP] survey: add background option per section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit adds the background per section feature. If the section to display has a background image configured, the background will be refreshed using that section background image. If the section has no background, the survey background is used. The background is refreshed at the same time that the next question is loaded. To ease technical maintenance and to keep it simple, the background is always faded out/in at each page change only if there are some sections on the survey that has a specific background image. If no background image are set on the survey sections, the background will never be faded out. Note: The next section to display depends on free text (section with description) configuration and on conditional questions. The next section to display can be the next question's section or directly the next question itself (if the question is a section). This section's background image implementation works both for the regular survey form as well as the "live session" mode of the survey module. Task-2225393 closes odoo/odoo#79156 Signed-off-by: Warnon Aurélien (awa) --- addons/survey/__manifest__.py | 1 + addons/survey/controllers/main.py | 42 +++++-- .../controllers/survey_session_manage.py | 20 ++- addons/survey/models/survey_question.py | 30 +++++ addons/survey/models/survey_survey.py | 9 +- .../src/js/fields_form_page_description.js | 2 +- addons/survey/static/src/js/survey_form.js | 36 +++++- .../src/js/survey_preload_image_mixin.js | 34 +++++ .../static/src/js/survey_session_manage.js | 117 ++++++++++++------ .../src/scss/survey_templates_form.scss | 16 +++ addons/survey/views/survey_question_views.xml | 4 + addons/survey/views/survey_survey_views.xml | 1 + addons/survey/views/survey_templates.xml | 18 ++- .../survey/views/survey_templates_print.xml | 2 + .../survey_templates_user_input_session.xml | 13 +- 15 files changed, 279 insertions(+), 66 deletions(-) create mode 100644 addons/survey/static/src/js/survey_preload_image_mixin.js 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 @@ +
+