[MERGE][REF] survey: refactor survey page transition in ajax

Purpose
=======
To have a better UX, the transition between questions in a survey should not be done by loading another page, but by loading next questions in AJAX.

Specifications
===========
Do the transition in ajax, no more page loading:
- During submit, load and display next questions in a smooth transition (fade in / fade out).
- Include start and end screen of the survey inside the survey form (same process, load next screen in smooth transition)
- Integrate timer and start only after start survey.
- Integrate breadcrumb and handle manually (at frontend) transition about clickable / active / inactive breadcrumb item. Make the survey breadcrumb a widget to ease the navigation transitions and updates.
- Validate questions at frontend before sending data for submit. (to avoid fading out then fading in the same questions with error messages)

Also include some improvements and fixes :
- Save questions on time limit reached, to take into account unsubmitted answer (if user does not have time to click on submit)
- Block submit if survey has been already completed by the user.
- Do not use previous id on one page mode (fix)
- Remove display mode for simple choice question : that will prepare the next redesign of survey and allow to handle simple choice in a more standard way for all kind of question types.

See sub commit for more detail.

Task ID : 2152223

closes odoo/odoo#42908

Signed-off-by: Thibault Delavallee (tde) <tde@openerp.com>
This commit is contained in:
Odoo's Mergebot
2020-01-13 17:10:40 +01:00
committed by GitHub
32 changed files with 785 additions and 521 deletions
+1
View File
@@ -36,6 +36,7 @@ sent mails with personal token for the invitation of the survey.
'views/survey_question_views.xml',
'views/survey_templates.xml',
'views/survey_templates_management.xml',
'views/survey_templates_print.xml',
'views/survey_templates_statistics.xml',
'views/gamification_badge_views.xml',
'wizard/survey_invite_views.xml',
+84 -81
View File
@@ -5,7 +5,7 @@ import json
import logging
import werkzeug
from datetime import datetime
from datetime import datetime, timedelta
from dateutil.relativedelta import relativedelta
from odoo import fields, http, _
@@ -50,7 +50,6 @@ class Survey(http.Controller):
* token_wrong: given token not recognized;
* token_required: no token given although it is necessary to access the
survey;
* answer_done: token linked to a finished answer;
* answer_deadline: token linked to an expired answer;
:param ensure_token: whether user input existence based on given access token
@@ -79,9 +78,6 @@ class Survey(http.Controller):
if (not survey_sudo.page_ids and survey_sudo.questions_layout == 'page_per_section') or not survey_sudo.question_ids:
return 'survey_void'
if answer_sudo and answer_sudo.state == 'done':
return 'answer_done'
if answer_sudo and answer_sudo.deadline and answer_sudo.deadline < datetime.now():
return 'answer_deadline'
@@ -139,8 +135,6 @@ class Survey(http.Controller):
return request.render("survey.survey_auth_required", {'survey': survey_sudo, 'redirect_url': redirect_url})
elif error_key == 'answer_deadline' and answer_sudo.access_token:
return request.render("survey.survey_closed_expired", {'survey': survey_sudo})
elif error_key == 'answer_done' and answer_sudo.access_token:
return request.render("survey.survey_closed_finished", self._prepare_survey_finished_values(survey_sudo, answer_sudo, token=answer_sudo.access_token))
return werkzeug.utils.redirect("/")
@@ -160,7 +154,7 @@ class Survey(http.Controller):
""" This route is called whenever the user has attempts left and hits the 'Retry' button
after failing the survey."""
access_data = self._get_access_data(survey_token, answer_token, ensure_token=True)
if access_data['validity_code'] is not True and access_data['validity_code'] != 'answer_done':
if access_data['validity_code'] is not True:
return self._redirect_with_error(access_data, access_data['validity_code'])
survey_sudo, answer_sudo = access_data['survey_sudo'], access_data['answer_sudo']
@@ -223,22 +217,20 @@ class Survey(http.Controller):
else:
return request.render("survey.survey_403_page", {'survey': survey_sudo})
# Select the right page
if answer_sudo.state == 'new': # Intro page
data = {'survey': survey_sudo, 'answer': answer_sudo, 'page': 0}
return request.render('survey.survey_page_start', data)
else:
return request.redirect('/survey/fill/%s/%s' % (survey_sudo.access_token, answer_sudo.access_token))
@http.route('/survey/fill/<string:survey_token>/<string:answer_token>', type='http', auth='public', website=True)
def survey_display_page(self, survey_token, answer_token, **post):
access_data = self._get_access_data(survey_token, answer_token, ensure_token=True)
if access_data['validity_code'] is not True:
return self._redirect_with_error(access_data, access_data['validity_code'])
survey_sudo, answer_sudo = access_data['survey_sudo'], access_data['answer_sudo']
return request.redirect('/survey/%s/%s' % (survey_sudo.access_token, answer_sudo.access_token))
def _prepare_survey_data(self, survey_sudo, answer_sudo, **post):
""" This method prepares all the data needed for template rendering, in function of the survey user input state.
:param post:
- previous_page_id : come from the breadcrumb or the back button and force the next questions to load
to be the previous ones. """
data = {
'survey': survey_sudo,
'answer': answer_sudo,
'breadcrumb_pages': [{
'id': page.id,
'title': page.title,
} for page in survey_sudo.page_ids],
'format_datetime': lambda dt: format_datetime(request.env, dt, dt_format=False),
'format_date': lambda date: format_date(request.env, date)
}
@@ -250,48 +242,64 @@ class Survey(http.Controller):
previous_page_or_question_id = int(post['previous_page_id'])
new_previous_id = survey_sudo._previous_page_or_question_id(answer_sudo, previous_page_or_question_id)
data.update({
'survey': survey_sudo,
page_or_question_key: request.env['survey.question'].sudo().browse(previous_page_or_question_id),
'answer': answer_sudo,
'previous_page_id': new_previous_id
})
return request.render('survey.survey_page_main', data)
return data
if survey_sudo.is_time_limited and not answer_sudo.start_datetime:
# init start date when user starts filling in the survey
answer_sudo.write({
'start_datetime': fields.Datetime.now()
})
# Select the right page
if answer_sudo.state == 'new': # First page
page_or_question_id, is_last = survey_sudo.next_page_or_question(answer_sudo, 0)
data.update({
'survey': survey_sudo,
page_or_question_key: page_or_question_id,
'answer': answer_sudo,
})
if is_last:
data.update({'last': True})
return request.render('survey.survey_page_main', data)
elif answer_sudo.state == 'done': # Display success message
return request.render('survey.survey_closed_finished', self._prepare_survey_finished_values(survey_sudo, answer_sudo))
elif answer_sudo.state == 'skip':
page_or_question_id, is_last = survey_sudo.next_page_or_question(answer_sudo, answer_sudo.last_displayed_page_id.id)
previous_id = survey_sudo._previous_page_or_question_id(answer_sudo, page_or_question_id.id)
if answer_sudo.state == 'in_progress':
page_or_question_id, is_last = survey_sudo.next_page_or_question(
answer_sudo,
answer_sudo.last_displayed_page_id.id if answer_sudo.last_displayed_page_id else 0)
data.update({
'survey': survey_sudo,
page_or_question_key: page_or_question_id,
'answer': answer_sudo,
'previous_page_id': previous_id
})
if survey_sudo.questions_layout != 'one_page':
data.update({
'previous_page_id': survey_sudo._previous_page_or_question_id(answer_sudo, page_or_question_id.id)
})
if is_last:
data.update({'last': True})
elif answer_sudo.state == 'done' or answer_sudo.is_time_limit_reached: # Display success message
return self._prepare_survey_finished_values(survey_sudo, answer_sudo)
return request.render('survey.survey_page_main', data)
else:
return request.render("survey.survey_403_page", {'survey': survey_sudo})
return data
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."""
data = self._prepare_survey_data(survey_sudo, answer_sudo, **post)
if answer_sudo.state == 'done':
return request.env.ref('survey.survey_fill_form_done').render(data).decode('UTF-8')
return request.env.ref('survey.survey_fill_form_in_progress').render(data).decode('UTF-8')
@http.route('/survey/<string:survey_token>/<string:answer_token>', type='http', auth='public', website=True)
def survey_display_page(self, survey_token, answer_token, **post):
access_data = self._get_access_data(survey_token, answer_token, ensure_token=True)
if access_data['validity_code'] is not True:
return self._redirect_with_error(access_data, access_data['validity_code'])
return request.render('survey.survey_page_fill',
self._prepare_survey_data(access_data['survey_sudo'], access_data['answer_sudo'], **post))
@http.route('/survey/begin/<string:survey_token>/<string:answer_token>', type='json', auth='public', website=True)
def survey_begin(self, survey_token, answer_token, **post):
""" Route used to start the survey user input and display the first survey page. """
access_data = self._get_access_data(survey_token, answer_token, ensure_token=True)
if access_data['validity_code'] is not True:
return {'error': access_data['validity_code']}
survey_sudo, answer_sudo = access_data['survey_sudo'], access_data['answer_sudo']
if answer_sudo.state != "new":
return {'error': _("The survey has already started.")}
# init start date when user starts filling in the survey
answer_sudo.write({
'start_datetime': fields.Datetime.now(),
'state': 'in_progress'
})
return self._prepare_question_html(survey_sudo, answer_sudo, **post)
@http.route('/survey/submit/<string:survey_token>/<string:answer_token>', type='json', auth='public', website=True)
def survey_submit(self, survey_token, answer_token, **post):
@@ -302,47 +310,42 @@ class Survey(http.Controller):
# Survey Validation
access_data = self._get_access_data(survey_token, answer_token, ensure_token=True)
if access_data['validity_code'] is not True:
return {
'error': access_data['validity_code'],
}
return {'error': access_data['validity_code']}
survey_sudo, answer_sudo = access_data['survey_sudo'], access_data['answer_sudo']
if answer_sudo.state == 'done':
return {'error': 'unauthorized'}
questions, page_or_question_id = survey_sudo._get_survey_questions(answer=answer_sudo,
page_id=post.get('page_id'),
question_id=post.get('question_id'))
if not answer_sudo.test_entry and not survey_sudo._has_attempts_left(answer_sudo.partner_id, answer_sudo.email, answer_sudo.invite_token):
# prevent cheating with users creating multiple 'user_input' before their last attempt
return {}
return {'error': 'unauthorized'}
if not answer_sudo.is_time_limit_reached:
# Prepare answers and comment by question
prepared_questions = {}
for question in questions:
answer_full = post.get(str(question.id))
answer_without_comment, comment = self._extract_comment_from_answers(question, answer_full)
prepared_questions[question.id] = {'answer': answer_without_comment, 'comment': comment}
if answer_sudo.is_time_limit_reached:
time_limit = answer_sudo.start_datetime + timedelta(minutes=survey_sudo.time_limit)
if fields.Datetime.now() > (time_limit + timedelta(seconds=10)):
# prevent cheating with users blocking the JS timer and taking all their time to answer
return {'error': 'unauthorized'}
# Questions Validation
errors = {}
for question in questions:
answer = prepared_questions[question.id]['answer']
comment = prepared_questions[question.id]['comment']
errors.update(question.validate_question(answer, comment))
if errors:
return {'error': 'validation', 'fields': errors}
# Submitting questions
for question in questions:
answer = prepared_questions[question.id]['answer']
comment = prepared_questions[question.id]['comment']
errors = {}
# Prepare answers / comment by question, validate and save answers
for question in questions:
answer, comment = self._extract_comment_from_answers(question, post.get(str(question.id)))
errors.update(question.validate_question(answer, comment))
if not errors.get(question.id):
answer_sudo.save_lines(question, answer, comment)
if errors and not answer_sudo.is_time_limit_reached:
return {'error': 'validation', 'fields': errors}
if answer_sudo.is_time_limit_reached or survey_sudo.questions_layout == 'one_page':
answer_sudo._mark_done()
elif 'previous_page_id' in post:
# Go back to specific page using the breadcrumb. Lines are saved and survey continues
return '/survey/fill/%s/%s?previous_page_id=%s' % (survey_sudo.access_token, answer_token, post['previous_page_id'])
return self._prepare_question_html(survey_sudo, answer_sudo, **post)
else:
next_page, unused = request.env['survey.survey'].next_page_or_question(answer_sudo, page_or_question_id)
vals = {'last_displayed_page_id': page_or_question_id}
@@ -350,11 +353,11 @@ class Survey(http.Controller):
if next_page is None:
answer_sudo._mark_done()
else:
vals.update({'state': 'skip'})
vals.update({'state': 'in_progress'})
answer_sudo.write(vals)
return '/survey/fill/%s/%s' % (survey_sudo.access_token, answer_token)
return self._prepare_question_html(survey_sudo, answer_sudo)
def _extract_comment_from_answers(self, question, answers):
""" Answers is a custom structure depending of the question type
@@ -412,7 +415,7 @@ class Survey(http.Controller):
access_data = self._get_access_data(survey_token, answer_token, ensure_token=False)
if access_data['validity_code'] is not True and (
access_data['has_survey_access'] or
access_data['validity_code'] not in ['token_required', 'survey_closed', 'survey_void', 'answer_done']):
access_data['validity_code'] not in ['token_required', 'survey_closed', 'survey_void']):
return self._redirect_with_error(access_data, access_data['validity_code'])
survey_sudo, answer_sudo = access_data['survey_sudo'], access_data['answer_sudo']
@@ -42,7 +42,6 @@
<field name="sequence">2</field>
<field name="title">Do we sell Acoustic Bloc Screens?</field>
<field name="question_type">simple_choice</field>
<field name="display_mode">dropdown</field>
<field name="constr_mandatory" eval="True" />
</record>
<record model="survey.question.answer" id="vendor_certification_page_1_question_1_choice_1">
@@ -145,7 +144,6 @@
<field name="sequence">5</field>
<field name="title">How many versions of the Corner Desk do we have?</field>
<field name="question_type">simple_choice</field>
<field name="display_mode">dropdown</field>
<field name="constr_mandatory" eval="True" />
</record>
<record model="survey.question.answer" id="vendor_certification_page_1_question_4_choice_1">
@@ -192,7 +190,6 @@
<field name="sequence">8</field>
<field name="title">How much do we sell our Cable Management Box?</field>
<field name="question_type">simple_choice</field>
<field name="display_mode">dropdown</field>
<field name="constr_mandatory" eval="True" />
</record>
<record model="survey.question.answer" id="vendor_certification_page_2_question_1_choice_1">
@@ -280,7 +277,6 @@
<field name="sequence">10</field>
<field name="title">What do you think about our prices (not rated)?</field>
<field name="question_type">simple_choice</field>
<field name="display_mode">dropdown</field>
<field name="constr_mandatory" eval="True" />
</record>
<record model="survey.question.answer" id="vendor_certification_page_2_question_3_choice_1">
+1 -2
View File
@@ -42,7 +42,6 @@
<field name="sequence">4</field>
<field name="title">How frequently do you buy products online ?</field>
<field name="question_type">simple_choice</field>
<field name="display_mode">dropdown</field>
<field name="comments_allowed" eval="True"/>
<field name="comment_count_as_answer" eval="True"/>
<field name="constr_mandatory" eval="True"/>
@@ -217,7 +216,7 @@
<field name="survey_id" ref="survey.survey_feedback" />
<field name="partner_id" eval="False"/>
<field name="email">Martin Tamarre &lt;martin@example.com&gt;</field>
<field name="state">skip</field>
<field name="state">in_progress</field>
</record>
</data></odoo>
-2
View File
@@ -89,7 +89,6 @@
<field name="title">Which category does a tomato belong to</field>
<field name="description" type="html"><span>"Red" is not a category, I know what you are trying to do ;)</span></field>
<field name="question_type">simple_choice</field>
<field name="display_mode">dropdown</field>
<field name="comments_allowed" eval="True"/>
<field name="comment_count_as_answer" eval="True"/>
<field name="constr_mandatory" eval="True"/>
@@ -118,7 +117,6 @@
<field name="sequence">22</field>
<field name="title">Which of the following would you use to pollinate</field>
<field name="question_type">simple_choice</field>
<field name="display_mode">columns</field>
<field name="comments_allowed" eval="True"/>
<field name="comment_count_as_answer" eval="False"/>
<field name="constr_mandatory" eval="True"/>
@@ -28,7 +28,7 @@
<field name="survey_id" ref="survey.survey_demo_quiz"/>
<field name="partner_id" ref="base.res_partner_address_28"/>
<field name="email">colleen.diaz83@example.com</field>
<field name="state">skip</field>
<field name="state">in_progress</field>
</record>
<record id="survey_demo_quiz_answer_6" model="survey.user_input">
<field name="survey_id" ref="survey.survey_demo_quiz"/>
+1 -4
View File
@@ -14,7 +14,7 @@ class SurveyQuestion(models.Model):
""" Questions that will be asked in a survey.
Each question can have one of more suggested answers (eg. in case of
dropdown choices, multi-answer checkboxes, radio buttons...).
multi-answer checkboxes, radio buttons...).
Technical note:
@@ -98,9 +98,6 @@ class SurveyQuestion(models.Model):
('12', '1'), ('6', '2'), ('4', '3'), ('3', '4'), ('2', '6')],
string='Number of columns', default='12',
help='These options refer to col-xx-[12|6|4|3|2] classes in Bootstrap for dropdown-based simple and multiple choice questions.')
display_mode = fields.Selection(
[('columns', 'Radio Buttons'), ('dropdown', 'Selection Box')],
string='Display Mode', default='columns', help='Display mode of simple choice questions.')
# -- comments (simple choice, multiple choice, matrix (without count as an answer))
comments_allowed = fields.Boolean('Show Comments Field')
comments_message = fields.Char('Comment Message', translate=True, default=lambda self: _("If other, please specify:"))
+7 -4
View File
@@ -25,7 +25,7 @@ class SurveyUserInput(models.Model):
deadline = fields.Datetime('Deadline', help="Datetime until customer can open the survey and submit answers")
state = fields.Selection([
('new', 'Not started yet'),
('skip', 'Partially completed'),
('in_progress', 'In Progress'),
('done', 'Completed')], string='Status', default='new', readonly=True)
test_entry = fields.Boolean(readonly=True)
last_displayed_page_id = fields.Many2one('survey.question', string='Last displayed question/page')
@@ -72,8 +72,11 @@ class SurveyUserInput(models.Model):
def _compute_is_time_limit_reached(self):
""" Checks that the user_input is not exceeding the survey's time limit. """
for user_input in self:
user_input.is_time_limit_reached = user_input.survey_id.is_time_limited and fields.Datetime.now() \
> user_input.start_datetime + relativedelta(minutes=user_input.survey_id.time_limit)
if user_input.start_datetime and user_input.survey_id.is_time_limited:
datetime_limit = user_input.start_datetime + relativedelta(minutes=user_input.survey_id.time_limit)
user_input.is_time_limit_reached = fields.Datetime.now() > datetime_limit
else:
user_input.is_time_limit_reached = False
@api.depends('state', 'test_entry', 'survey_id.is_attempts_limited', 'partner_id', 'email', 'invite_token')
def _compute_attempts_number(self):
@@ -261,7 +264,7 @@ class SurveyUserInput(models.Model):
return vals
if answer_type == 'suggestion':
vals['suggested_answer_id'] = answer
vals['suggested_answer_id'] = int(answer)
elif answer_type == 'numerical_box':
vals['value_numerical_box'] = float(answer)
else:
@@ -0,0 +1,50 @@
odoo.define('survey.breadcrumb', function (require) {
'use strict';
var publicWidget = require('web.public.widget');
publicWidget.registry.SurveyBreadcrumbWidget = publicWidget.Widget.extend({
xmlDependencies: ['/survey/static/src/xml/survey_breadcrumb_templates.xml'],
template: "survey.survey_breadcrumb_template",
events: {
'click .breadcrumb-item a': '_onBreadcrumbClick',
},
/**
* @override
*/
init: function (parent, options) {
this._super.apply(this, arguments);
this.canGoBack = options.canGoBack;
this.currentPageId = options.currentPageId;
this.pages = options.pages;
},
// Handlers
// -------------------------------------------------------------------
_onBreadcrumbClick: function (event) {
event.preventDefault();
this.trigger_up('breadcrumb_click', {
'previousPageId': this.$(event.currentTarget)
.closest('.breadcrumb-item')
.data('pageId')
});
},
// PUBLIC METHODS
// -------------------------------------------------------------------
updateBreadcrumb: function (pageId) {
if (pageId) {
this.currentPageId = pageId;
this.renderElement();
} else {
this.$('.breadcrumb').addClass('d-none');
}
},
});
return publicWidget.registry.SurveyBreadcrumbWidget;
});
+318 -91
View File
@@ -1,16 +1,20 @@
odoo.define('survey.form', function(require) {
odoo.define('survey.form', function (require) {
'use strict';
var field_utils = require('web.field_utils');
var publicWidget = require('web.public.widget');
var time = require('web.time');
var core = require('web.core');
var _t = core._t;
publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({
selector: '.o_survey_form',
events: {
'change .o_survey_form_choice_item': '_onChangeChoiceItem',
'click button[type="submit"]': '_onSubmit',
'click .o_survey_header .breadcrumb-item a': '_onBreadcrumbClick',
},
custom_events: {
'breadcrumb_click': '_onBreadcrumbClick',
},
//--------------------------------------------------------------------------
@@ -22,20 +26,12 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({
*/
start: function () {
var self = this;
return this._super.apply(this, arguments).then(function() {
this.fadeInOutDelay = 400;
return this._super.apply(this, arguments).then(function () {
self.options = self.$target.find('form').data();
var $timer = $('.o_survey_timer');
if ($timer.length) {
var timeLimitMinutes = self.options.timeLimitMinutes;
var timer = self.options.timer;
self.surveyTimerWidget = new publicWidget.registry.SurveyTimerWidget(self, {
'timer': timer,
'timeLimitMinutes': timeLimitMinutes
});
self.surveyTimerWidget.attachTo($timer);
self.surveyTimerWidget.on('time_up', self, function (ev) {
self.$el.find('button[type="submit"]').click();
});
if (!self.options.isStartScreen) {
self._initTimer();
self._initBreadcrumb();
}
self.$('div.o_survey_form_date').each(function () {
self._initDateTimePicker($(this));
@@ -50,12 +46,8 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({
// Handlers
// -------------------------------------------------------------------------
/*
/**
* Checks, if the 'other' choice is checked. Applies only if the comment count as answer.
* In case of dropdownlist:
* If not checked : Clear the comment textarea and hide it
* If checked : show the comment textarea and focus on it
* In case of radio buttons
* If not checked : Clear the comment textarea and disable it
* If checked : enable the comment textarea and focus on it
*
@@ -67,93 +59,260 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({
var $otherItem = $choiceItemGroup.find('.o_survey_js_form_other_comment');
var $commentInput = $choiceItemGroup.find('textarea[type="text"]');
// Handle dropdownlist
var $dropdownlist = $choiceItemGroup.find('select');
var isDropdown = $dropdownlist.length > 0;
var isOtherSelected = (isDropdown && $dropdownlist.val() === $otherItem.val()) || $otherItem.prop('checked');
if (isOtherSelected || $commentInput.hasClass('o_survey_comment')) {
if (isDropdown) {
$commentInput.toggleClass('d-none', false);
}
if (isOtherSelected) {
$commentInput.enable();
if ($otherItem.prop('checked') || $commentInput.hasClass('o_survey_comment')) {
$commentInput.enable();
if ($otherItem.prop('checked')) {
$commentInput.focus();
}
} else {
if (isDropdown) {
$commentInput.toggleClass('d-none', true);
}
$commentInput.val('');
$commentInput.enable(false);
}
},
/*
* When clicking on a breadcrumb enabled item, redirect to the target page.
* Uses the submit flow to validate and save the answer before going to the specified page.
*
* @private
* @param {Event} event
*/
_onBreadcrumbClick: function (event) {
event.preventDefault();
this._submitForm({'previous_page_id': $(event.currentTarget).data('previousPageId')});
},
_onSubmit: function (event) {
event.preventDefault();
if ($(event.currentTarget).val() === 'previous') {
this._submitForm({'previous_page_id': $(event.currentTarget).data('previousPageId')});
} else {
this._submitForm({});
var options = {};
var $target = $(event.currentTarget);
if ($target.val() === 'previous') {
options.previousPageId = $target.data('previousPageId');
} else if ($target.val() === 'finish') {
options.isFinish = true;
}
this._submitForm(options);
},
_submitForm: function (params) {
// Custom Events
// -------------------------------------------------------------------------
_onBreadcrumbClick: function (event) {
this._submitForm({'previousPageId': event.data.previousPageId});
},
// SUBMIT
// -------------------------------------------------------------------------
/**
* This function will send a json rpc call to the server to
* - start the survey (if we are on start screen)
* - submit the answers of the current page
* Before submitting the answers, they are first validated to avoid latency from the server
* and allow a fade out/fade in transition of the next question.
*
* @param {Array} [options]
* @param {Integer} [options.previousPageId] navigates to page id
* @param {Boolean} [options.skipValidation] skips JS validation
* @param {Boolean} [options.isFinish] fades out breadcrumb and timer
* @private
*/
_submitForm: function (options) {
var self = this;
var $form = this.$('form');
var formData = new FormData($form[0]);
var params = {};
if (options.previousPageId) {
params.previous_page_id = options.previousPageId;
}
var route = "/survey/submit";
this._prepareSubmitValues(formData, params);
if (this.options.isStartScreen) {
route = "/survey/begin";
} else {
var $form = this.$('form');
var formData = new FormData($form[0]);
this._resetErrors();
if (!options.skipValidation) {
// Validation pre submit
if (!this._validateForm($form, formData)) {
return;
}
}
return self._rpc({
route: '/survey/submit/' + self.options.surveyToken + '/' + self.options.answerToken ,
this._prepareSubmitValues(formData, params);
}
var resolveFadeOut;
var fadeOutPromise = new Promise(function (resolve, reject) {resolveFadeOut = resolve;});
var selectorsToFadeout = ['.o_survey_form_content'];
if (options.isFinish) {
selectorsToFadeout.push('.breadcrumb', '.o_survey_timer');
}
self.$(selectorsToFadeout.join(',')).fadeOut(this.fadeInOutDelay, function () {
resolveFadeOut();
});
var submitPromise = self._rpc({
route: _.str.sprintf('%s/%s/%s', route, self.options.surveyToken, self.options.answerToken),
params: params,
}).then(function (result) {
return self._onSubmitDone(result, params);
});
Promise.all([fadeOutPromise, submitPromise]).then(function (results) {
return self._onSubmitDone(results[1], options.isFinish);
});
},
_onSubmitDone: function (result, params) {
/**
* Follow the submit and handle the transition from one screen to another
* Also handle server side validation and displays eventual error messages.
*/
_onSubmitDone: function (result, isFinish) {
var self = this;
if (result && !result.error) {
window.location = result;
this.$(".o_survey_form_content").empty();
this.$(".o_survey_form_content").html(result);
this.$('div.o_survey_form_date').each(function () {
self._initDateTimePicker($(this));
});
if (this.options.isStartScreen) {
this._initTimer();
this.options.isStartScreen = false;
}
if (isFinish) {
this._initResultWidget();
if (this.surveyBreadcrumbWidget) {
this.$('.o_survey_breadcrumb_container').addClass('d-none');
this.surveyBreadcrumbWidget.destroy();
}
if (this.surveyTimerWidget) {
this.surveyTimerWidget.destroy();
}
} else {
this._updateBreadcrumb();
}
this.$('.o_survey_form_content').fadeIn(this.fadeInOutDelay);
$("html, body").animate({ scrollTop: 0 }, this.fadeInOutDelay);
}
else if (result && result.fields && result.error === 'validation') {
var fieldKeys = _.keys(result.fields);
_.each(fieldKeys, function (key) {
self.$("#" + key + '>.o_survey_question_error').append($('<p>', {text: result.fields[key]})).toggleClass('d-none', false);
if (fieldKeys[fieldKeys.length - 1] === key) {
self._scrollToError(self.$('.o_survey_question_error:visible:first').closest('.js_question-wrapper'));
}
});
return false;
}
else {
var $target = self.$('.o_survey_error');
$target.toggleClass('d-none', false);
self._scrollToError($target);
this.$('.o_survey_form_content').fadeIn(0);
this._showErrors(result.fields);
} else {
var $errorTarget = this.$('.o_survey_error');
$errorTarget.removeClass("d-none");
this._scrollToError($errorTarget);
}
},
// VALIDATION TOOLS
// -------------------------------------------------------------------------
/**
* Validation is done in frontend before submit to avoid latency from the server.
* If the validation is incorrect, the errors are displayed before submitting and
* fade in / out of submit is avoided.
*
* Each question type gets its own validation process.
*
* There is a special use case for the 'required' questions, where we use the constraint
* error message that comes from the question configuration ('constr_error_msg' field).
*
* @private
*/
_validateForm: function ($form, formData) {
var self = this;
var errors = {};
var validationEmailMsg = _t("This answer must be an email address.");
var validationDateMsg = _t("This is not a date");
this._resetErrors();
var data = {};
formData.forEach(function (value, key) {
data[key] = value;
});
$form.find('[data-question-type]').each(function () {
var $input = $(this);
var $questionWrapper = $input.closest(".js_question-wrapper");
var constrErrorMsg = $questionWrapper.data('constrErrorMsg');
var validationErrorMsg = $questionWrapper.data('validationErrorMsg');
var questionId = $questionWrapper.attr('id');
var questionRequired = $questionWrapper.data('required');
switch ($input.data('questionType')) {
case 'char_box':
if (questionRequired && !$input.val()) {
errors[questionId] = constrErrorMsg;
} else if ($input.val() && $input.attr('type') === 'email' && !self._validateEmail($input.val())) {
errors[questionId] = validationEmailMsg;
} else {
var lengthMin = $input.data('validationLengthMin');
var lengthMax = $input.data('validationLengthMax');
var length = $input.val().length;
if (lengthMin && (lengthMin > length || length > lengthMax)) {
errors[questionId] = validationErrorMsg;
}
}
break;
case 'numerical_box':
if (questionRequired && !data[questionId]) {
errors[questionId] = constrErrorMsg;
} else {
var floatMin = $input.data('validationFloatMin');
var floatMax = $input.data('validationFloatMax');
var value = parseFloat($input.val());
if (floatMin && (floatMin > value || value > floatMax)) {
errors[questionId] = validationErrorMsg;
}
}
break;
case 'date':
case 'datetime':
if (questionRequired && !data[questionId]) {
errors[questionId] = constrErrorMsg;
} else if (data[questionId]) {
var momentDate = moment($input.val());
if (!momentDate.isValid()) {
errors[questionId] = validationDateMsg;
} else {
var $dateDiv = $questionWrapper.find('.o_survey_form_date');
var maxDate = $dateDiv.data('maxdate');
var minDate = $dateDiv.data('mindate');
if ((maxDate && momentDate.isAfter(moment(maxDate)))
|| (minDate && momentDate.isBefore(moment(minDate)))) {
errors[questionId] = validationErrorMsg;
}
}
}
break;
case 'simple_choice_radio':
case 'multiple_choice':
if (questionRequired) {
var $textarea = $questionWrapper.find('textarea');
if (!data[questionId]) {
errors[questionId] = constrErrorMsg;
} else if (data[questionId] === '-1' && !$textarea.val()) {
// if other has been checked and value is null
errors[questionId] = constrErrorMsg;
}
}
break;
case 'matrix':
if (questionRequired) {
var subQuestionsIds = $questionWrapper.find('table').data('subQuestions');
subQuestionsIds.forEach(function (id) {
if (!((questionId + '_' + id) in data)) {
errors[questionId] = constrErrorMsg;
}
});
}
break;
}
});
if (_.keys(errors).length > 0) {
this._showErrors(errors);
return false;
}
return true;
},
/**
* Check if the email has an '@', a left part and a right part
* @private
*/
_validateEmail: function (email) {
var emailParts = email.split('@');
return emailParts.length === 2 && emailParts[0] && emailParts[1];
},
// PREPARE SUBMIT TOOLS
// -------------------------------------------------------------------------
/*
/**
* For each type of question, extract the answer from inputs or textarea (comment or answer)
*
*
@@ -162,8 +321,7 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({
*/
_prepareSubmitValues: function (formData, params) {
var self = this;
// Get all context params -- TODO : Use formData instead (test if input with no name are in formData)
formData.forEach(function(value, key){
formData.forEach(function (value, key){
switch (key) {
case 'csrf_token':
case 'token':
@@ -188,12 +346,9 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({
case 'datetime':
params = self._prepareSubmitDates(params, this.name, this.value, true);
break;
case 'simple_choice_dropdown':
params = self._prepareSubmitChoices(params, $(this), $(this).data('name'), 'option:selected');
break;
case 'simple_choice_radio':
case 'multiple_choice':
params = self._prepareSubmitChoices(params, $(this), $(this).data('name'), 'input:checked');
params = self._prepareSubmitChoices(params, $(this), $(this).data('name'));
break;
case 'matrix':
params = self._prepareSubmitAnswersMatrix(params, $(this));
@@ -207,7 +362,7 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({
* Convert date value from client current timezone to UTC Date to correspond to the server format.
* return params = { 'dateQuestionId' : '2019-05-23', 'datetimeQuestionId' : '2019-05-23 14:05:12' }
*/
_prepareSubmitDates: function(params, questionId, value, isDateTime) {
_prepareSubmitDates: function (params, questionId, value, isDateTime) {
var momentDate = isDateTime ? field_utils.parse.datetime(value, null, {timezone: true}) : field_utils.parse.date(value);
var formattedDate = momentDate ? momentDate.toJSON() : '';
params[questionId] = formattedDate;
@@ -219,9 +374,9 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({
* If the answer is not the 'comment selection' (=Other), calls the _prepareSubmitAnswer method to add the answer to the params
* If there is a comment linked to that question, calls the _prepareSubmitComment method to add the comment to the params
*/
_prepareSubmitChoices: function(params, $parent, questionId, selector) {
_prepareSubmitChoices: function (params, $parent, questionId) {
var self = this;
$parent.find(selector).each(function () {
$parent.find('input:checked').each(function () {
if (this.value !== '-1') {
params = self._prepareSubmitAnswer(params, questionId, this.value);
}
@@ -271,7 +426,7 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({
* Lonely answer are directly assigned to questionId. Multiple answers are regrouped in an array:
* params = { 'questionId1' : lonelyAnswer, 'questionId2' : [multipleAnswer1, multipleAnswer2, ...] }
*/
_prepareSubmitAnswer: function(params, questionId, value) {
_prepareSubmitAnswer: function (params, questionId, value) {
if (questionId in params) {
if (params[questionId].constructor === Array) {
params[questionId].push(value);
@@ -290,7 +445,7 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({
* with the new value. At the end, the result looks like :
* params = { 'questionId1' : {'comment': commentValue}, 'questionId2' : [multipleAnswer1, {'comment': commentValue}, ...] }
*/
_prepareSubmitComment: function(params, $parent, questionId, isMatrix) {
_prepareSubmitComment: function (params, $parent, questionId, isMatrix) {
var self = this;
$parent.find('textarea').each(function () {
if (this.value) {
@@ -308,6 +463,58 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({
// INIT FIELDS TOOLS
// -------------------------------------------------------------------------
/**
* Will initialize the breadcrumb widget that handles navigation to a previously filled in page.
*
* @private
*/
_initBreadcrumb: function () {
var $breadcrumb = this.$('.o_survey_breadcrumb_container');
var pageId = this.$('input[name=page_id]').val();
if ($breadcrumb.length) {
this.surveyBreadcrumbWidget = new publicWidget.registry.SurveyBreadcrumbWidget(this, {
'canGoBack': $breadcrumb.data('canGoBack'),
'currentPageId': pageId ? parseInt(pageId) : 0,
'pages': $breadcrumb.data('pages'),
});
this.surveyBreadcrumbWidget.appendTo($breadcrumb);
$breadcrumb.removeClass('d-none'); // hidden by default to avoid having ghost div in start screen
}
},
/**
* Called after survey submit to update the breadcrumb to the right page.
*/
_updateBreadcrumb: function () {
if (this.surveyBreadcrumbWidget) {
var pageId = this.$('input[name=page_id]').val();
this.surveyBreadcrumbWidget.updateBreadcrumb(parseInt(pageId));
} else {
this._initBreadcrumb();
}
},
_initTimer: function () {
var self = this;
var $timer = this.$('.o_survey_timer');
if ($timer.length) {
var timeLimitMinutes = this.options.timeLimitMinutes;
var timer = this.options.timer;
this.surveyTimerWidget = new publicWidget.registry.SurveyTimerWidget(this, {
'timer': timer,
'timeLimitMinutes': timeLimitMinutes
});
this.surveyTimerWidget.attachTo($timer);
this.surveyTimerWidget.on('time_up', this, function (ev) {
self._submitForm({
'skipValidation': true,
'isFinish': true
});
});
$timer.removeClass('d-none');
}
},
/**
* Initialize datetimepicker in correct format and with constraints
*/
@@ -319,7 +526,7 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({
var maxDateData = $dateGroup.data('maxdate');
var maxDate = maxDateData ? this._formatDateTime(maxDateData) : moment().add(200, "y");
var datetimepickerFormat = time.getLangDateFormat()
var datetimepickerFormat = time.getLangDateFormat();
if ($dateGroup.find('input').data('questionType') === 'datetime') {
datetimepickerFormat = time.getLangDatetimeFormat();
} else {
@@ -353,13 +560,33 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({
});
},
_formatDateTime: function (datetimeValue){
_formatDateTime: function (datetimeValue) {
return field_utils.format.datetime(moment(datetimeValue), null, {timezone: true});
},
_initResultWidget: function () {
var $result = this.$('.o_survey_result');
if ($result.length) {
this.surveyResultWidget = new publicWidget.registry.SurveyResultWidget(this);
this.surveyResultWidget.attachTo($result);
$result.fadeIn(this.fadeInOutDelay);
}
},
// ERRORS TOOLS
// -------------------------------------------------------------------------
_showErrors: function (errors) {
var self = this;
var errorKeys = _.keys(errors);
_.each(errorKeys, function (key) {
self.$("#" + key + '>.o_survey_question_error').append($('<p>', {text: errors[key]})).addClass("slide_in");
if (errorKeys[0] === key) {
self._scrollToError(self.$('.js_question-wrapper#' + key));
}
});
},
_scrollToError: function ($target) {
var scrollLocation = $target.offset().top;
var navbarHeight = $('.o_main_navbar').height();
@@ -375,8 +602,8 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({
* Clean all form errors in order to clean DOM before a new validation
*/
_resetErrors: function () {
this.$('.o_survey_question_error').empty().toggleClass("d-none", true);
this.$('.o_survey_error').toggleClass("d-none", true);
this.$('.o_survey_question_error').empty().removeClass('slide_in');
this.$('.o_survey_error').addClass('d-none');
},
});
+10 -13
View File
@@ -3,10 +3,6 @@ odoo.define('survey.timer', function (require) {
var publicWidget = require('web.public.widget');
var interval = null;
var $timer = null;
var countDownDate = null;
publicWidget.registry.SurveyTimerWidget = publicWidget.Widget.extend({
//--------------------------------------------------------------------------
// Widget
@@ -17,8 +13,9 @@ publicWidget.registry.SurveyTimerWidget = publicWidget.Widget.extend({
*/
init: function (parent, params) {
this._super.apply(this, arguments);
this.$timer = params.timer;
this.$timeLimitMinutes = params.timeLimitMinutes;
this.timer = params.timer;
this.timeLimitMinutes = params.timeLimitMinutes;
this.surveyTimerInterval = null;
},
@@ -30,13 +27,13 @@ publicWidget.registry.SurveyTimerWidget = publicWidget.Widget.extend({
start: function () {
var superDef = this._super.apply(this, arguments);
countDownDate = moment.utc(this.$timer).add(this.$timeLimitMinutes, 'minutes');
if (this.$timeLimitMinutes <= 0 || countDownDate.diff(moment.utc(), 'seconds') < 0) {
this.countDownDate = moment.utc(this.timer).add(this.timeLimitMinutes, 'minutes');
if (this.timeLimitMinutes <= 0 || this.countDownDate.diff(moment.utc(), 'seconds') < 0) {
this.trigger_up('time_up');
} else {
$timer = this.$el.find('.timer');
this.$timer = this.$el.find('.timer');
this._updateTimer(this);
interval = setInterval(this._updateTimer.bind(this), 1000);
this.surveyTimerInterval = setInterval(this._updateTimer.bind(this), 1000);
}
return superDef;
@@ -55,14 +52,14 @@ publicWidget.registry.SurveyTimerWidget = publicWidget.Widget.extend({
* When the time runs out, it triggers a 'time_up' event to notify the parent widget.
*/
_updateTimer: function () {
var timeLeft = countDownDate.diff(moment.utc(), 'seconds');
var timeLeft = this.countDownDate.diff(moment.utc(), 'seconds');
if (timeLeft >= 0) {
var timeLeftMinutes = parseInt(timeLeft / 60);
var timeLeftSeconds = timeLeft - (timeLeftMinutes * 60);
$timer.html(this._formatTime(timeLeftMinutes) + ':' + this._formatTime(timeLeftSeconds));
this.$timer.html(this._formatTime(timeLeftMinutes) + ':' + this._formatTime(timeLeftSeconds));
} else {
clearInterval(interval);
clearInterval(this.surveyTimerInterval);
this.trigger_up('time_up');
}
},
@@ -10,3 +10,19 @@
.js_question-wrapper {
padding-bottom: 25px;
}
.o_survey_breadcrumb_container {
min-height: 3rem;
}
.o_survey_question_error {
overflow: hidden;
height: 0px;
border: 0px;
padding: 0rem 0.75rem;
transition: height .5s ease;
line-height: 4rem;
&.slide_in {
height: 4rem;
}
}
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="survey.survey_breadcrumb_template">
<ol class="breadcrumb justify-content-end">
<t t-set="canGoBack" t-value="widget.canGoBack"/>
<t t-foreach="widget.pages" t-as="page">
<li t-att-class="'breadcrumb-item' + (page.id === widget.currentPageId ? ' active' : '')"
t-att-data-page-id="page.id"
t-att-data-page-title="page.title">
<t t-if="widget.currentPageId === page.id">
<!-- Users can only go back and not forward -->
<!-- As soon as we reach the current page, set "can_go_back" to False -->
<t t-set="canGoBack" t-value="false" />
</t>
<t t-if="canGoBack">
<a href="#">
<span t-esc="page.title" />
</a>
</t>
<t t-else="">
<span t-esc="page.title" />
</t>
</li>
</t>
</ol>
</t>
</templates>
@@ -1,6 +1,17 @@
odoo.define('survey.tour_test_certification_failure', function (require) {
'use strict';
var SurveyFormWidget = require('survey.form');
/**
* Speed up fade-in fade-out to avoid useless delay in tests.
*/
SurveyFormWidget.include({
_submitForm: function () {
this.fadeInOutDelay = 0;
return this._super.apply(this, arguments);
}
});
/**
* This tour will test that, for the demo certification allowing 2 attempts, a user can
* try and fail twice and will no longer be able to take the certification.
@@ -10,11 +21,10 @@ var tour = require('web_tour.tour');
var failSteps = [{ // Page-1
content: "Clicking on Start Certification",
trigger: 'a.btn.btn-primary.btn-lg:contains("Start Certification")',
trigger: 'button.btn.btn-primary.btn-lg:contains("Start Certification")',
}, { // Question: Do we sell Acoustic Bloc Screens?
content: "Selecting answer 'No'",
trigger: 'div.js_question-wrapper:contains("Do we sell Acoustic Bloc Screens") select',
run: 'text No',
trigger: 'div.js_question-wrapper:contains("Do we sell Acoustic Bloc Screens") label:contains("No") input',
}, { // Question: Select all the existing products
content: "Ticking answer 'Fanta'",
trigger: 'div.js_question-wrapper:contains("Select all the existing products") label:contains("Fanta") input'
@@ -32,19 +42,14 @@ var failSteps = [{ // Page-1
trigger: 'div.js_question-wrapper:contains("Select all the available customizations for our Customizable Desk") label:contains("Height") input'
}, { // Question: How many versions of the Corner Desk do we have?
content: "Selecting answer '2'",
trigger: 'div.js_question-wrapper:contains("How many versions of the Corner Desk do we have") select',
run: 'text 2',
trigger: 'div.js_question-wrapper:contains("How many versions of the Corner Desk do we have") label:contains("2") input',
}, { // Question: Do you think we have missing products in our catalog? (not rated)
content: "Missing products",
trigger: 'div.js_question-wrapper:contains("Do you think we have missing products in our catalog") textarea',
run: "text I don't know products enough to be able to answer that",
}, { // Page-2 Question: How much do we sell our Cable Management Box?
content: "Selecting answer '80$'",
trigger: 'div.js_question-wrapper:contains("How much do we sell our Cable Management Box") select',
run: function () {
var $select = $('div.js_question-wrapper:contains("How much do we sell our Cable Management Box") select');
$select.val($('option:contains("80$")').val());
}
trigger: 'div.js_question-wrapper:contains("How much do we sell our Cable Management Box") label:contains("80$") input',
}, { // Question: Select all the the products that sell for 100$ or more
content: "Ticking answer 'Corner Desk Right Sit'",
trigger: 'div.js_question-wrapper:contains("Select all the the products that sell for 100$ or more") label:contains("Corner Desk Right Sit") input'
@@ -55,11 +60,7 @@ var failSteps = [{ // Page-1
content: "Ticking answer 'Office Chair Black'",
trigger: 'div.js_question-wrapper:contains("Select all the the products that sell for 100$ or more") label:contains("Office Chair Black") input'
}, { // Question: What do you think about our prices (not rated)?
trigger: 'div.js_question-wrapper:contains("What do you think about our prices") select',
run: function () {
var $select = $('div.js_question-wrapper:contains("What do you think about our prices") select');
$select.val($('option:contains("Correctly priced")').val());
}
trigger: 'div.js_question-wrapper:contains("What do you think about our prices") label:contains("Correctly priced") input',
}, {
content: "Finish Survey",
trigger: 'button[type="submit"]',
@@ -1,6 +1,17 @@
odoo.define('survey.tour_test_certification_success', function (require) {
'use strict';
var SurveyFormWidget = require('survey.form');
/**
* Speed up fade-in fade-out to avoid useless delay in tests.
*/
SurveyFormWidget.include({
_submitForm: function () {
this.fadeInOutDelay = 0;
return this._super.apply(this, arguments);
}
});
var tour = require('web_tour.tour');
tour.register('test_certification_success', {
@@ -9,11 +20,10 @@ tour.register('test_certification_success', {
},
[{ // Page-1
content: "Clicking on Start Certification",
trigger: 'a.btn.btn-primary.btn-lg:contains("Start Certification")',
trigger: 'button.btn.btn-primary.btn-lg:contains("Start Certification")',
}, { // Question: Do we sell Acoustic Bloc Screens?
content: "Selecting answer 'Yes'",
trigger: 'div.js_question-wrapper:contains("Do we sell Acoustic Bloc Screens") select',
run: 'text Yes',
trigger: 'div.js_question-wrapper:contains("Do we sell Acoustic Bloc Screens") label:contains("Yes") input',
}, { // Question: Select all the existing products
content: "Ticking answer 'Chair floor protection'",
trigger: 'div.js_question-wrapper:contains("Select all the existing products") label:contains("Chair floor protection") input'
@@ -31,19 +41,14 @@ tour.register('test_certification_success', {
trigger: 'div.js_question-wrapper:contains("Select all the available customizations for our Customizable Desk") label:contains("Legs") input'
}, { // Question: How many versions of the Corner Desk do we have?
content: "Selecting answer '2'",
trigger: 'div.js_question-wrapper:contains("How many versions of the Corner Desk do we have") select',
run: 'text 2',
trigger: 'div.js_question-wrapper:contains("How many versions of the Corner Desk do we have") label:contains("2") input',
}, { // Question: Do you think we have missing products in our catalog? (not rated)
content: "Missing products",
trigger: 'div.js_question-wrapper:contains("Do you think we have missing products in our catalog") textarea',
run: "text I think we should make more versions of the customizable desk, it's such an amazing product!",
}, { // Page-2 Question: How much do we sell our Cable Management Box?
content: "Selecting answer '80$' (wrong one)",
trigger: 'div.js_question-wrapper:contains("How much do we sell our Cable Management Box") select',
run: function () {
var $select = $('div.js_question-wrapper:contains("How much do we sell our Cable Management Box") select');
$select.val($('option:contains("80$")').val());
}
trigger: 'div.js_question-wrapper:contains("How much do we sell our Cable Management Box") label:contains("80$") input',
}, { // Question: Select all the the products that sell for 100$ or more
content: "Ticking answer 'Corner Desk Right Sit'",
trigger: 'div.js_question-wrapper:contains("Select all the the products that sell for 100$ or more") label:contains("Corner Desk Right Sit") input'
@@ -55,11 +60,7 @@ tour.register('test_certification_success', {
trigger: 'div.js_question-wrapper:contains("Select all the the products that sell for 100$ or more") label:contains("Large Desk") input'
}, { // Question: What do you think about our prices (not rated)?
content: "Selecting answer 'Underpriced'",
trigger: 'div.js_question-wrapper:contains("What do you think about our prices") select',
run: function () {
var $select = $('div.js_question-wrapper:contains("What do you think about our prices") select');
$select.val($('option:contains("Underpriced")').val());
}
trigger: 'div.js_question-wrapper:contains("What do you think about our prices") label:contains("Underpriced") input',
}, {
content: "Finish Survey",
trigger: 'button[type="submit"]',
+2 -6
View File
@@ -10,7 +10,7 @@ tour.register('test_survey', {
// Page-1
{
content: 'Click on Start',
trigger: 'a.btn:contains("Start")',
trigger: 'button.btn:contains("Start")',
}, {
content: 'Answer Where do you live',
trigger: 'div.js_question-wrapper:contains("Where do you live") input',
@@ -21,11 +21,7 @@ tour.register('test_survey', {
run: 'text 05/05/1980',
}, {
content: 'Answer How frequently do you buy products online',
trigger: 'div.js_question-wrapper:contains("How frequently do you buy products online") select',
run: function () {
var $select = $('div.js_question-wrapper:contains("How frequently do you buy products online") select');
$select.val($('option:contains("Once a month")').val());
}
trigger: 'div.js_question-wrapper:contains("How frequently do you buy products online") label:contains("Once a month") input',
}, {
content: 'Answer How many times did you order products on our website',
trigger: 'div.js_question-wrapper:contains("How many times did you order products on our website") input',
@@ -8,7 +8,7 @@ tour.register('test_survey_prefill', {
url: '/survey/start/b137640d-14d4-4748-9ef6-344caaaaaae'
},
[{ // Page-1
trigger: 'a.btn.btn-primary.btn-lg:contains("Start Survey")',
trigger: 'button.btn.btn-primary.btn-lg:contains("Start Survey")',
}, { // Question: Where do you live ?
trigger: 'div.js_question-wrapper:contains("Where do you live ?") input',
run: 'text Grand-Rosiere',
@@ -16,11 +16,7 @@ tour.register('test_survey_prefill', {
trigger: 'div.js_question-wrapper:contains("When is your date of birth ?") input',
run: 'text 05/05/1980',
}, { // Question: How frequently do you buy products online ?
trigger: 'div.js_question-wrapper:contains("How frequently do you buy products online ?") select',
run: function() {
var optionID = $('option:contains("Once a week")').val();
$('div.js_question-wrapper:contains("How frequently do you buy products online ?") select').val(optionID);
},
trigger: 'div.js_question-wrapper:contains("How frequently do you buy products online ?") label:contains("Once a week") input',
}, { // Question: How many times did you order products on our website ?
trigger: 'div.js_question-wrapper:contains("How many times did you order products on our website ?") input',
run: 'text 42',
@@ -80,8 +76,8 @@ tour.register('test_survey_prefill', {
return;
}
var $selectQ3 = $('div.js_question-wrapper:contains("How frequently do you buy products online ?") select');
if ($selectQ3.val() !== $('option:contains("Once a week")').val()) {
var $inputQ3 = $('div.js_question-wrapper:contains("How frequently do you buy products online ?") label:contains("Once a week") input');
if (!$inputQ3.is(':checked')) {
return;
}
+1 -1
View File
@@ -214,7 +214,7 @@ class TestSurveyCommon(common.SavepointCase):
return self.url_open('/survey/start/%s' % survey.access_token)
def _access_page(self, survey, token):
return self.url_open('/survey/fill/%s/%s' % (survey.access_token, token))
return self.url_open('/survey/%s/%s' % (survey.access_token, token))
def _access_submit(self, survey, token, post_data):
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
+1 -1
View File
@@ -96,7 +96,7 @@ class TestSurveyFlow(common.TestSurveyCommon, HttpCase):
answers.invalidate_cache() # TDE note: necessary as lots of sudo in controllers messing with cache
# -> this should have generated answer lines
self.assertAnswer(answers, 'skip', page_0)
self.assertAnswer(answers, 'in_progress', page_0)
self.assertAnswerLines(page_0, answers, answer_data)
# Customer is redirected on second page and begins filling it
@@ -37,7 +37,6 @@ class TestUiCertification(HttpCaseWithUserDemo):
'title': 'Do we sell Acoustic Bloc Screens?',
'sequence': 2,
'question_type': 'simple_choice',
'display_mode': 'dropdown',
'constr_mandatory': True,
'suggested_answer_ids': [
(0, 0, {
@@ -116,7 +115,6 @@ class TestUiCertification(HttpCaseWithUserDemo):
'title': 'How many versions of the Corner Desk do we have?',
'sequence': 5,
'question_type': 'simple_choice',
'display_mode': 'dropdown',
'constr_mandatory': True,
'suggested_answer_ids': [
(0, 0, {
@@ -149,7 +147,6 @@ class TestUiCertification(HttpCaseWithUserDemo):
'title': 'How much do we sell our Cable Management Box?',
'sequence': 8,
'question_type': 'simple_choice',
'display_mode': 'dropdown',
'constr_mandatory': True,
'suggested_answer_ids': [
(0, 0, {
@@ -213,7 +210,6 @@ class TestUiCertification(HttpCaseWithUserDemo):
'title': 'What do you think about our prices (not rated)?',
'sequence': 10,
'question_type': 'simple_choice',
'display_mode': 'dropdown',
'constr_mandatory': True,
'suggested_answer_ids': [
(0, 0, {
@@ -40,7 +40,6 @@ class TestUiFeedback(HttpCaseWithUserDemo):
'title': 'How frequently do you buy products online ?',
'sequence': 4,
'question_type': 'simple_choice',
'display_mode': 'dropdown',
'comments_allowed': True,
'comment_count_as_answer': True,
'constr_mandatory': True,
+1
View File
@@ -8,6 +8,7 @@
<script type="text/javascript" src="/web/static/src/js/fields/field_utils.js"></script>
<script type="text/javascript" src="/survey/static/src/js/survey_timer.js" />
<script type="text/javascript" src="/survey/static/src/js/survey_breadcrumb.js" />
<script type="text/javascript" src="/survey/static/src/js/survey_form.js" />
<script type="text/javascript" src="/survey/static/src/js/survey_result.js" />
@@ -148,9 +148,8 @@
<field name="matrix_subtype" attrs="{'invisible':[('question_type','not in',['matrix'])],'required':[('question_type','=','matrix')]}"/>
</group>
</group>
<group string="Display mode" attrs="{'invisible':[('question_type','not in',['simple_choice', 'multiple_choice'])]}">
<field name="display_mode" string="Format" attrs="{'invisible':[('question_type','not in',['simple_choice'])],'required':[('question_type','=','simple_choice')]}"/>
<field name="column_nb" string="Number of columns" attrs="{'invisible':[('display_mode','=','dropdown'), ('question_type','=','simple_choice')]}"/>
<group string="Display" attrs="{'invisible':[('question_type','not in',['simple_choice', 'multiple_choice'])]}">
<field name="column_nb" string="Number of columns"/>
</group>
<group string="Allow Comments" attrs="{'invisible':[('question_type','not in',['simple_choice','multiple_choice', 'matrix'])]}">
<field name='comments_allowed' />
+164 -193
View File
@@ -15,108 +15,56 @@
</xpath>
</template>
<!-- First page of a survey -->
<template id="survey_page_start" name="Survey: start page">
<template id="survey_page_fill" name="Survey: main page (take survey)">
<t t-call="survey.layout">
<div class="wrap">
<div class="oe_structure" id="oe_structure_survey_init_1"/>
<div class="container">
<t t-if="answer.test_entry" t-call="survey.survey_button_form_view" />
<div class='jumbotron mt32'>
<h1 t-field='survey.title' />
<div t-field='survey.description' class="oe_no_empty"/>
<div t-if="survey.is_time_limited">
<p>
<span>Time limit for this survey: </span>
<span class="font-weight-bold text-danger" t-field="survey.time_limit" t-options="{'widget': 'duration', 'unit': 'minute'}"></span>
</p>
</div>
<a role="button" class="btn btn-primary btn-lg" t-att-href="'/survey/fill/%s/%s' % (survey.access_token, answer.access_token)">
<t t-if="survey.certification">
Start Certification
</t>
<t t-else="">
Start Survey
</t>
</a>
</div>
</div>
<div class="oe_structure" id="oe_structure_survey_init_2"/>
</div>
</t>
</template>
<template id="survey_page_main" name="Survey: main page (take survey)">
<t t-call="survey.layout">
<div class="wrap">
<div class="oe_structure" id="oe_structure_survey_survey_1"/>
<div class="container o_survey_form">
<t t-if="answer.test_entry" t-call="survey.survey_button_form_view" />
<t t-call="survey.survey_page_main_header" />
<t t-call="survey.survey_page_content" />
<t t-call="survey.survey_fill_header" />
<t t-call="survey.survey_fill_form" />
</div>
<div class="oe_structure" id="oe_structure_survey_survey_2"/>
</div>
</t>
</template>
<template id="survey_page_content" name="Survey: main page content">
<t t-set="survey_form_readonly" t-value="false"/>
<t t-if="survey.questions_layout == 'one_page'" t-set="page_or_question_id" t-value="None" />
<t t-if="survey.questions_layout == 'page_per_section'" t-set="page_or_question_id" t-value="page.id" />
<t t-if="survey.questions_layout == 'page_per_question'" t-set="page_or_question_id" t-value="question.id" />
<template id="survey_fill_header" name="Survey: main page header">
<div class="o_page_header o_survey_header">
<div class="container m-0 p-0">
<div class="row">
<div class="col-lg-8"><h1 t-esc="survey.title"></h1></div>
<div t-if="survey.is_time_limited and answer.state != 'done'" class="o_survey_timer col-lg-4 d-none">
<h1 class="timer text-right">00:00</h1>
</div>
</div>
</div>
<div t-att-class="'o_survey_breadcrumb_container mt8 bg-200' + (' d-none ' if answer.state != 'in_progress' else '')"
t-if="survey.questions_layout == 'page_per_section' and answer.state != 'done'"
t-att-data-can-go-back="survey.users_can_go_back"
t-att-data-pages="json.dumps(breadcrumb_pages)" />
</div>
</template>
<template id="survey_fill_form" name="Survey: main page content">
<t t-set="survey_form_readonly" t-value="false"/>
<form role="form" method="post" t-att-name="survey.id"
t-att-data-answer-token="answer.access_token"
t-att-data-survey-token="survey.access_token"
t-att-data-timer="answer.start_datetime.isoformat() if survey.is_time_limited else False"
t-att-data-time-limit-minutes="survey.time_limit">
t-att-data-timer="answer.start_datetime.isoformat() if survey.is_time_limited
and answer.start_datetime and answer.state != 'done' else False"
t-att-data-time-limit-minutes="survey.time_limit"
t-att-data-is-start-screen="answer.state == 'new'">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<input type="hidden" name="token" t-att-value="answer.access_token" />
<div class="o_survey_error alert alert-danger d-none" role="alert">
<p>There was an error during the validation of the survey.</p>
</div>
<t t-if="survey.questions_layout == 'one_page'">
<t t-foreach='survey.question_and_page_ids' t-as='question'>
<h2 t-if="question.is_page" t-field='question.title' class="o_survey_title" />
<t t-if="not question.is_page and question in answer.predefined_question_ids" t-call="survey.question_container"/>
</t>
<div class="text-center mt16 mb256">
<button type="submit" class="btn btn-primary">Submit Survey</button>
</div>
</t>
<t t-if="survey.questions_layout == 'page_per_section'">
<h2 t-field='page.title' class="o_survey_title" />
<div t-field='page.description' class="oe_no_empty"/>
<input type="hidden" name="page_id" t-att-value="page.id" />
<t t-foreach='page.question_ids' t-as='question'>
<t t-if="question in answer.predefined_question_ids" t-call="survey.question_container"/>
</t>
<div class="text-center mt16 mb256">
<button t-if="survey.users_can_go_back and page != survey.page_ids[0]" type="submit" class="btn btn-secondary"
name="button_submit" value="previous" t-att-data-previous-page-id="previous_page_id">Previous page</button>
<button type="submit" t-att-value="'next' if not last else 'finish'" class="btn btn-primary">
<t t-esc="'Next Page' if not last else 'Submit Survey'"/>
</button>
</div>
</t>
<t t-if="survey.questions_layout == 'page_per_question'">
<input type="hidden" name="question_id" t-att-value="question.id" />
<t t-call="survey.question_container"/>
<div class="text-center mt16 mb256">
<button t-if="survey.users_can_go_back and question != answer.predefined_question_ids[0]" type="submit" class="btn btn-secondary"
name="button_submit" value="previous" t-att-data-previous-page-id="previous_page_id">Previous page</button>
<button type="submit" t-att-value="'next' if not last else 'finish'" class="btn btn-primary">
<t t-esc="'Next Page' if not last else 'Submit Survey'"/>
</button>
</div>
</t>
<div class="o_survey_form_content">
<t t-if="answer.state == 'new'" t-call="survey.survey_fill_form_start"/>
<t t-elif="answer.state == 'in_progress'" t-call="survey.survey_fill_form_in_progress" />
<t t-else="" t-call="survey.survey_fill_form_done"/>
</div>
</form>
<!-- Modal used to display error message, i.c.o. ajax error -->
@@ -134,43 +82,131 @@
</div>
</template>
<template id="survey_page_main_header" name="Survey: main page header">
<div class="o_page_header o_survey_header">
<div class="container m-0 p-0">
<template id="survey_fill_form_start" name="Survey: start form content">
<div class="wrap o_survey_start">
<div class='mt32 mb32'>
<div t-field='survey.description' class="oe_no_empty"/>
<div t-if="survey.is_time_limited">
<p>
<span>Time limit for this survey: </span>
<span class="font-weight-bold text-danger" t-field="survey.time_limit" t-options="{'widget': 'duration', 'unit': 'minute'}"></span>
</p>
</div>
<button type="submit" class="btn btn-primary btn-lg">
<t t-if="survey.certification">
Start Certification
</t>
<t t-else="">
Start Survey
</t>
</button>
</div>
</div>
</template>
<template id="survey_fill_form_in_progress" name="Survey: form with questions">
<t t-if="survey.questions_layout == 'one_page'">
<t t-foreach='survey.question_and_page_ids' t-as='question'>
<h2 t-if="question.is_page" t-field='question.title' class="o_survey_title" />
<t t-if="not question.is_page and question in answer.predefined_question_ids" t-call="survey.question_container"/>
</t>
<div class="text-center mt16 mb256">
<button type="submit" value="finish" class="btn btn-primary">Submit Survey</button>
</div>
</t>
<t t-if="survey.questions_layout == 'page_per_section'">
<h2 t-field='page.title' class="o_survey_title" />
<div t-field='page.description' class="oe_no_empty"/>
<input type="hidden" name="page_id" t-att-value="page.id" />
<t t-foreach='page.question_ids' t-as='question'>
<t t-if="question in answer.predefined_question_ids" t-call="survey.question_container"/>
</t>
<div class="text-center mt16 mb256">
<button t-if="survey.users_can_go_back and page != survey.page_ids[0]" type="submit" class="btn btn-secondary"
name="button_submit" value="previous" t-att-data-previous-page-id="previous_page_id">Previous page</button>
<button type="submit" t-att-value="'next' if not last else 'finish'" class="btn btn-primary">
<t t-esc="'Next Page' if not last else 'Submit Survey'"/>
</button>
</div>
</t>
<t t-if="survey.questions_layout == 'page_per_question'">
<input type="hidden" name="question_id" t-att-value="question.id" />
<t t-call="survey.question_container"/>
<div class="text-center mt16 mb256">
<button t-if="survey.users_can_go_back and question != answer.predefined_question_ids[0]" type="submit" class="btn btn-secondary"
name="button_submit" value="previous" t-att-data-previous-page-id="previous_page_id">Previous page</button>
<button type="submit" t-att-value="'next' if not last else 'finish'" class="btn btn-primary">
<t t-esc="'Next Page' if not last else 'Submit Survey'"/>
</button>
</div>
</t>
</template>
<!-- Finished (taken and finished) survey page -->
<template id="survey_fill_form_done" name="Survey: finished">
<div class="wrap">
<div class="o_survey_finished mt32 mb32">
<h1>Thank you!</h1>
<div t-field="survey.description_done" class="oe_no_empty" />
<div class="row">
<div class="col-lg-8"><h1 t-esc="survey.title"></h1></div>
<div t-if="survey.is_time_limited" class="o_survey_timer col-lg-4">
<h1 class="timer text-right">00:00</h1>
<div class="col">
<t t-if="survey.scoring_type != 'no_scoring'">
<div>You scored <t t-esc="answer.scoring_percentage" />%</div>
<t t-if="answer.scoring_success">
<div>Congratulations, you have passed the test!</div>
<div t-if="survey.certification" class="mt16 mb16">
<a role="button"
class="btn btn-primary btn-lg"
t-att-href="'/survey/%s/get_certification' % survey.id">
<i class="fa fa-fw fa-trophy" role="img" aria-label="Download certification" title="Download certification"/>
Download certification
</a>
</div>
</t>
<t t-else="">
<div>Unfortunately, you have failed the test.</div>
</t>
</t>
<t t-call="survey.survey_button_retake"/>
<div t-if="survey.scoring_type != 'scoring_without_answers'">
If you wish, you can <a t-att-href="'/survey/print/%s?answer_token=%s&amp;review=True' % (survey.access_token, answer.access_token)">review your answers</a>
</div>
</div>
<div class="col-6 text-center" t-if="survey.certification_give_badge and answer.scoring_success">
<img t-att-src="'/web/image/gamification.badge/%s/image_128' % survey.certification_badge_id.id"/>
<div>You received the badge <span class="font-weight-bold" t-esc="survey.certification_badge_id.name"/>!</div>
</div>
</div>
</div>
<div class="container o_survey_result p-4" t-if="graph_data">
<div class="tab-content">
<div role="tabpanel" class="tab-pane active survey_graph"
t-att-data-scoring_percentage="answer.scoring_percentage"
t-att-id="'survey_graph_question_%d' % answer.id"
t-att-data-question_id="answer.id"
data-graph-type="doughnut"
t-att-data-graph-data="graph_data">
<canvas id="doughnut_chart"></canvas>
<span class="o_overall_performance"></span>
</div>
</div>
</div>
<ol t-if="survey.questions_layout == 'page_per_section'" class="breadcrumb mt8 justify-content-end">
<t t-set="can_go_back" t-value="survey.users_can_go_back" />
<t t-foreach='survey.page_ids' t-as='breadcrumb_page'>
<li t-attf-class="breadcrumb-item #{'active' if breadcrumb_page == page else ''}">
<t t-if="page == breadcrumb_page">
<!-- Users can only go back and not forward -->
<!-- As soon as we reach the current page, set "can_go_back" to False -->
<t t-set="can_go_back" t-value="False" />
</t>
<t t-if="can_go_back">
<a href="#" t-att-data-previous-page-id="breadcrumb_page.id">
<span t-field='breadcrumb_page.title' />
</a>
</t>
<t t-else="">
<span t-field='breadcrumb_page.title' />
</t>
</li>
</t>
</ol>
</div>
</template>
<!-- Question widgets -->
<template id="question_container" name="Survey: question container">
<t t-set="answer_lines" t-value="answer.user_input_line_ids.filtered(lambda line: line.question_id == question)"/>
<div class="js_question-wrapper" t-att-id="question.id">
<div class="js_question-wrapper" t-att-id="question.id" t-att-data-required="question.constr_mandatory"
t-att-data-constr-error-msg="question.constr_error_msg"
t-att-data-validation-error-msg="question.validation_error_msg">
<h4>
<span t-field='question.title' />
<span t-if="question.constr_mandatory" class="text-danger">*</span>
@@ -184,7 +220,7 @@
<t t-if="question.question_type == 'simple_choice'"><t t-call="survey.question_simple_choice"/></t>
<t t-if="question.question_type == 'multiple_choice'"><t t-call="survey.question_multiple_choice"/></t>
<t t-if="question.question_type == 'matrix'"><t t-call="survey.question_matrix"/></t>
<div class="o_survey_question_error alert alert-danger d-none" role="alert"></div>
<div class="o_survey_question_error alert alert-danger" role="alert"></div>
</div>
</template>
@@ -194,13 +230,19 @@
</template>
<template id="question_char_box" name="Question: text box">
<input type="text" class="form-control o_survey_question_text_box" t-att-name="question.id" t-att-value="answer_lines[0].value_char_box if answer_lines else None"
t-att-data-question-type="question.question_type"/>
<input t-att-type="'email' if question.validation_email else 'text'"
class="form-control o_survey_question_text_box" t-att-name="question.id"
t-att-value="answer_lines[0].value_char_box if answer_lines else None"
t-att-data-question-type="question.question_type"
t-att-data-validation-length-min="question.validation_length_min if question.validation_required else False"
t-att-data-validation-length-max="question.validation_length_max if question.validation_required else False"/>
</template>
<template id="question_numerical_box" name="Question: numerical box">
<input type="number" step="any" class="form-control o_survey_question_numerical_box" t-att-name="question.id" t-att-value="answer_lines[0].value_numerical_box if answer_lines else None"
t-att-data-question-type="question.question_type"/>
t-att-data-question-type="question.question_type"
t-att-data-validation-float-min="question.validation_min_float_value if question.validation_required else False"
t-att-data-validation-float-max="question.validation_max_float_value if question.validation_required else False"/>
</template>
<template id="question_date" name="Question: date box">
@@ -234,40 +276,13 @@
<template id="question_simple_choice" name="Question: simple choice">
<t t-set="answer_line" t-value="answer_lines.filtered(lambda line: line.suggested_answer_id)"/>
<t t-set="comment_line" t-value="answer_lines.filtered(lambda line: line.value_char_box)"/>
<div t-if="question.display_mode == 'dropdown'" class="o_survey_form_choice o_survey_question_simple_choice_dropdown row"
t-att-data-name="question.id"
data-question-type="simple_choice_dropdown">
<div class="col-lg-12">
<select class="form-control o_survey_form_choice_item">
<option disabled="1" selected="1" value=""
t-att-selected="not answer_line and (not comment_line or not question.comment_count_as_answer) and 'selected' or None">Choose...</option>
<t t-foreach='question.suggested_answer_ids' t-as='label'>
<option t-att-value='label.id'
t-att-selected="answer_line and answer_line.suggested_answer_id == label and 'selected' or None"><t t-esc='label.value'/></option>
</t>
<t t-if='question.comments_allowed and question.comment_count_as_answer'>
<option class="o_survey_js_form_other_comment" value="-1"
t-att-selected="comment_line and question.comment_count_as_answer and 'selected' or None">
<span t-esc="question.comments_message"/>
</option>
</t>
</select>
</div>
<div t-if='question.comments_allowed and question.comment_count_as_answer' class="col-lg-6">
<textarea type="text" class="form-control"
t-attf-class="form-control #{'' if comment_line else 'd-none'}"><t t-esc="comment_line.value_char_box if comment_line else ''"/></textarea>
</div>
<div t-if='question.comments_allowed and not question.comment_count_as_answer' class="col-lg-12 mt16">
<span t-field="question.comments_message"/>
<textarea type="text" class="form-control o_survey_comment"><t t-esc="comment_line.value_char_box if comment_line else ''"/></textarea>
</div>
</div>
<div t-if="question.display_mode == 'columns'" class="row o_survey_form_choice o_survey_question_simple_choice_radio"
<div class="row o_survey_form_choice"
t-att-data-name="question.id"
data-question-type="simple_choice_radio">
<div t-foreach='question.suggested_answer_ids' t-as='label' t-attf-class="col-lg-#{question.column_nb}">
<label t-att-class="' bg-success ' if quizz_correction and label.answer_score > 0.0 else None">
<input type="radio" t-att-value='label.id' class="o_survey_form_choice_item"
t-att-name='question.id'
t-att-checked="answer_line and answer_line.suggested_answer_id.id == label.id and 'checked' or None"/>
<span t-field='label.value'/>
</label>
@@ -275,6 +290,7 @@
<div t-if='question.comments_allowed and question.comment_count_as_answer' class="js_comments col-lg-12" >
<label>
<input type="radio" class="o_survey_form_choice_item o_survey_js_form_other_comment" value="-1"
t-att-name='question.id'
t-att-checked="comment_line and 'checked' or None"/>
<span t-field="question.comments_message" />
</label>
@@ -297,6 +313,7 @@
<t t-set="answer_line" t-value="answer_lines.filtered(lambda line: line.suggested_answer_id == label)"/>
<label t-att-class="' bg-success ' if quizz_correction and label.answer_score > 0.0 else None">
<input type="checkbox" t-att-value='label.id' class="o_survey_form_choice_item"
t-att-name="question.id"
t-att-checked="'checked' if answer_line else None"/>
<span t-field='label.value'/>
</label>
@@ -305,6 +322,7 @@
<div t-if='question.comments_allowed and question.comment_count_as_answer' class="js_ck_comments col-lg-12" >
<label>
<input type="checkbox" class="o_survey_form_choice_item o_survey_js_form_other_comment" value="-1"
t-att-name="question.id"
t-att-checked="comment_line and 'checked' or None"/>
<span t-field="question.comments_message" />
</label>
@@ -322,7 +340,8 @@
<t t-set="comment_line" t-value="answer_lines.filtered(lambda line: line.value_char_box)"/>
<table class="table table-hover o_survey_question_matrix"
t-att-data-name="question.id"
t-att-data-question-type="question.question_type">
t-att-data-question-type="question.question_type"
t-att-data-sub-questions="question.matrix_row_ids.ids">
<thead>
<tr>
<th> </th>
@@ -335,7 +354,7 @@
<td t-foreach="question.suggested_answer_ids" t-as="col_label">
<t t-set="answer" t-value="answer_lines.filtered(lambda line: line.suggested_answer_id == col_label and line.matrix_row_id == row_label)"/>
<input t-att-type="'checkbox' if question.matrix_subtype == 'multiple' else 'radio'"
t-att-name="'%s_%s' % (question_id, row_label.id)" t-att-value='col_label.id'
t-att-name="'%s_%s' % (question.id, row_label.id)" t-att-value='col_label.id'
t-att-checked="'checked' if answer else None"
t-att-data-row-id="row_label.id"/>
</td>
@@ -347,53 +366,5 @@
<textarea type="text" class="form-control" t-att-name="'%s_%s' % (question.id, 'comment')"><t t-esc="comment_line.value_char_box if comment_line else ''"/></textarea>
</div>
</template>
<!-- Survey: printable page view (all pages) -->
<template id="survey_page_print" name="Survey: print page">
<t t-call="survey.layout">
<t t-set="survey_form_readonly" t-value="true"/>
<div class="wrap">
<div class="container">
<t t-if="answer.test_entry" t-call="survey.survey_button_form_view" />
<div class='jumbotron mt32'>
<h1><span t-field='survey.title'/></h1>
<t t-if="survey.description"><div t-field='survey.description' class="oe_no_empty"/></t>
<t t-if="review" t-call="survey.survey_button_retake"/>
</div>
<div role="form">
<fieldset disabled="disabled">
<t t-foreach='survey.question_and_page_ids' t-as='question'>
<hr t-if="question.is_page and question != survey.page_ids[0]" />
<div t-if="question.is_page" class="o_page_header">
<h1 t-field='question.title' />
<div t-if="question.description" t-field='question.description' class="oe_no_empty"/>
</div>
<t t-if="not question.is_page and not answer or (question in answer.predefined_question_ids)" >
<t t-set="answer_lines" t-value="answer.user_input_line_ids.filtered(lambda line: line.question_id == question)"/>
<div class="js_question-wrapper" t-att-id="question.id">
<h2>
<span t-field='question.title'/>
<span t-if="question.constr_mandatory" class="text-danger">*</span>
<span t-if="quizz_correction" class="badge badge-pill" t-att-data-score-question="question.id"></span>
</h2>
<t t-if="question.description"><div class="text-muted oe_no_empty" t-field='question.description'/></t>
<t t-if="question.question_type == 'text_box'"><t t-call="survey.question_text_box"/></t>
<t t-if="question.question_type == 'char_box'"><t t-call="survey.question_char_box"/></t>
<t t-if="question.question_type == 'numerical_box'"><t t-call="survey.question_numerical_box"/></t>
<t t-if="question.question_type == 'date'"><t t-call="survey.question_date"/></t>
<t t-if="question.question_type == 'datetime'"><t t-call="survey.question_datetime"/></t>
<t t-if="question.question_type == 'simple_choice'"><t t-call="survey.question_simple_choice"/></t>
<t t-if="question.question_type == 'multiple_choice'"><t t-call="survey.question_multiple_choice"/></t>
<t t-if="question.question_type == 'matrix'"><t t-call="survey.question_matrix"/></t>
<div class="o_survey_question_error alert alert-danger d-none" role="alert"></div>
</div>
</t>
</t>
</fieldset>
</div>
</div>
</div>
</t>
</template>
</data>
</odoo>
@@ -69,65 +69,6 @@
</t>
</template>
<!-- Finished (taken and finished) survey page -->
<template id="survey_closed_finished" name="Survey: finished">
<t t-call="survey.layout">
<div class="wrap">
<div class="container">
<t t-if="answer.test_entry" t-call="survey.survey_button_form_view" />
<div class="jumbotron mt32">
<h1>Thank you!</h1>
<div t-field="survey.description_done" class="oe_no_empty" />
<div class="row">
<div class="col">
<t t-if="survey.scoring_type != 'no_scoring'">
<div>You scored <t t-esc="answer.scoring_percentage" />%</div>
<t t-if="answer.scoring_success">
<div>Congratulations, you have passed the test!</div>
<div t-if="survey.certification" class="mt16 mb16">
<a role="button"
class="btn btn-primary btn-lg"
t-att-href="'/survey/%s/get_certification' % survey.id">
<i class="fa fa-fw fa-trophy" role="img" aria-label="Download certification" title="Download certification"/>
Download certification
</a>
</div>
</t>
<t t-else="">
<div>Unfortunately, you have failed the test.</div>
</t>
</t>
<t t-call="survey.survey_button_retake"/>
<div t-if="survey.scoring_type != 'scoring_without_answers'">
If you wish, you can <a t-att-href="'/survey/print/%s?answer_token=%s&amp;review=True' % (survey.access_token, answer.access_token)">review your answers</a>
</div>
</div>
<div class="col-6 text-center" t-if="survey.certification_give_badge and answer.scoring_success">
<img t-att-src="'/web/image/gamification.badge/%s/image_128' % survey.certification_badge_id.id"/>
<div>You received the badge <span class="font-weight-bold" t-esc="survey.certification_badge_id.name"/>!</div>
</div>
</div>
</div>
</div>
<div class="container o_survey_result p-4" t-if="graph_data">
<div class="tab-content">
<div role="tabpanel" class="tab-pane active survey_graph"
t-att-data-scoring_percentage="answer.scoring_percentage"
t-att-id="'survey_graph_question_%d' % answer.id"
t-att-data-question_id="answer.id"
data-graph-type="doughnut"
t-att-data-graph-data="graph_data">
<canvas id="doughnut_chart"></canvas>
<span class="o_overall_performance"></span>
</div>
</div>
</div>
<div class="oe_structure"/>
</div>
</t>
</template>
<!-- ============================================================ -->
<!-- Tools / Utilities -->
<!-- ============================================================ -->
@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<!-- Survey: printable page view (all pages) -->
<template id="survey_page_print" name="Survey: print page">
<t t-call="survey.layout">
<t t-set="survey_form_readonly" t-value="true"/>
<div class="wrap">
<div class="container">
<t t-if="answer.test_entry" t-call="survey.survey_button_form_view" />
<div class='jumbotron mt32'>
<h1><span t-field='survey.title'/></h1>
<t t-if="survey.description"><div t-field='survey.description' class="oe_no_empty"/></t>
<t t-if="review" t-call="survey.survey_button_retake"/>
</div>
<div role="form">
<fieldset disabled="disabled">
<t t-foreach='survey.question_and_page_ids' t-as='question'>
<hr t-if="question.is_page and question != survey.page_ids[0]" />
<div t-if="question.is_page" class="o_page_header">
<h1 t-field='question.title' />
<div t-if="question.description" t-field='question.description' class="oe_no_empty"/>
</div>
<t t-if="not question.is_page and not answer or (question in answer.predefined_question_ids)" >
<t t-set="answer_lines" t-value="answer.user_input_line_ids.filtered(lambda line: line.question_id == question)"/>
<div class="js_question-wrapper" t-att-id="question.id">
<h2>
<span t-field='question.title'/>
<span t-if="question.constr_mandatory" class="text-danger">*</span>
<span t-if="quizz_correction" class="badge badge-pill" t-att-data-score-question="question.id"></span>
</h2>
<t t-if="question.description"><div class="text-muted oe_no_empty" t-field='question.description'/></t>
<t t-if="question.question_type == 'text_box'"><t t-call="survey.question_text_box"/></t>
<t t-if="question.question_type == 'char_box'"><t t-call="survey.question_char_box"/></t>
<t t-if="question.question_type == 'numerical_box'"><t t-call="survey.question_numerical_box"/></t>
<t t-if="question.question_type == 'date'"><t t-call="survey.question_date"/></t>
<t t-if="question.question_type == 'datetime'"><t t-call="survey.question_datetime"/></t>
<t t-if="question.question_type == 'simple_choice'"><t t-call="survey.question_simple_choice"/></t>
<t t-if="question.question_type == 'multiple_choice'"><t t-call="survey.question_multiple_choice"/></t>
<t t-if="question.question_type == 'matrix'"><t t-call="survey.question_matrix"/></t>
<div class="o_survey_question_error alert alert-danger" role="alert"></div>
</div>
</t>
</t>
</fieldset>
</div>
</div>
</div>
</t>
</template>
</data>
</odoo>
@@ -4,7 +4,6 @@
<template id="survey_page_statistics" name="Survey: result statistics page">
<t t-call="survey.layout">
<t t-set="page_record_limit" t-value="10"/><!-- Change this record_limit to change number of record per page-->
<div class="oe_structure" id="oe_structure_survey_result_1"/>
<div class="container o_survey_result">
<t t-call="survey.survey_button_form_view" />
<div class="jumbotron mt32">
+2 -2
View File
@@ -11,7 +11,7 @@
<field name="email"/>
<field name="partner_id"/>
<filter name="completed" string="Completed" domain="[('state', '=', 'done')]"/>
<filter string="Partially Completed" name="partially_completed" domain="[('state', '=', 'skip')]"/>
<filter string="In Progress" name="in_progress" domain="[('state', '=', 'in_progress')]"/>
<filter string="New" name="new" domain="[('state', '=', 'new')]"/>
<separator/>
<filter string="Quizz passed" name="scoring_success" domain="[('scoring_success','=', True)]"/>
@@ -122,7 +122,7 @@
<field name="create_date"/>
</div>
<div class="oe_kanban_bottom_right mr4">
<field name="state" widget="label_selection" options="{'classes': {'new': 'default', 'done': 'success', 'skip':'warning'}}"/>
<field name="state" widget="label_selection" options="{'classes': {'new': 'default', 'done': 'success', 'in_progress':'warning'}}"/>
</div>
</div>
</div>
@@ -74,7 +74,6 @@ class TestUi(TestUICommon):
'title': 'What type of wood is the best for furniture?',
'sequence': 2,
'question_type': 'simple_choice',
'display_mode': 'dropdown',
'constr_mandatory': True,
'suggested_answer_ids': [
(0, 0, {
@@ -86,11 +86,10 @@ var buyCertificationSteps = [{
var failCertificationSteps = [{
content: 'eLearning: start certification',
trigger: 'a:contains("Start Certification")'
trigger: 'button:contains("Start Certification")'
}, { // Question: What type of wood is the best for furniture?
content: 'Survey: selecting answer "Fir"',
trigger: 'div.js_question-wrapper:contains("What type of wood is the best for furniture") select',
run: 'text Fir',
trigger: 'div.js_question-wrapper:contains("What type of wood is the best for furniture") label:contains("Fir") input',
}, { // Question: Select all the furniture shown in the video
content: 'Survey: ticking answer "Table"',
trigger: 'div.js_question-wrapper:contains("Select all the furniture shown in the video") label:contains("Table") input'
@@ -109,11 +108,10 @@ var retrySteps = [{
var succeedCertificationSteps = [{
content: 'eLearning: start certification',
trigger: 'a:contains("Start Certification")'
trigger: 'button:contains("Start Certification")'
}, { // Question: What type of wood is the best for furniture?
content: 'Survey: selecting answer "Oak"',
trigger: 'div.js_question-wrapper:contains("What type of wood is the best for furniture") select',
run: 'text Oak',
trigger: 'div.js_question-wrapper:contains("What type of wood is the best for furniture") label:contains("Oak") input',
}, { // Question: Select all the furniture shown in the video
content: 'Survey: ticking answer "Chair"',
trigger: 'div.js_question-wrapper:contains("Select all the furniture shown in the video") label:contains("Chair") input'
@@ -30,7 +30,6 @@
<field name="sequence">2</field>
<field name="title">What type of wood is the best for furniture?</field>
<field name="question_type">simple_choice</field>
<field name="display_mode">dropdown</field>
<field name="constr_mandatory" eval="True" />
</record>
<record model="survey.question.answer" id="furniture_certification_page_1_question_1_choice_1">
@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<template id="survey_closed_finished_inherit_website_slides" inherit_id="survey.survey_closed_finished">
<xpath expr="//div[hasclass('jumbotron')]/div[last()]" position="after">
<template id="survey_fill_form_done_inherit_website_slides" inherit_id="survey.survey_fill_form_done">
<xpath expr="//div[hasclass('o_survey_finished')]/div[last()]" position="after">
<div t-if="channel_url" class="mt16">
<a role="button"
class="btn btn-primary btn-lg"