diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/survey/static/src | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/survey/static/src')
39 files changed, 4225 insertions, 0 deletions
diff --git a/addons/survey/static/src/css/survey_print.css b/addons/survey/static/src/css/survey_print.css new file mode 100644 index 00000000..a28f672b --- /dev/null +++ b/addons/survey/static/src/css/survey_print.css @@ -0,0 +1,17 @@ +@media print { + .js_surveyform { + font-size: 13px; + } + .js_surveyform textarea.form-control { + height: 10em; + } + .js_surveyform h1 { + font-size: 28px; + } + .js_surveyform h2 { + font-size: 20px; + } + .js_question-wrapper { + page-break-inside: avoid; + } +} diff --git a/addons/survey/static/src/css/survey_result.css b/addons/survey/static/src/css/survey_result.css new file mode 100644 index 00000000..dda0022a --- /dev/null +++ b/addons/survey/static/src/css/survey_result.css @@ -0,0 +1,77 @@ +.only_right_radius { + border-top-right-radius: 2em; + border-bottom-right-radius: 2em; + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +.only_left_radius { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + border-top-left-radius: 2em; + border-bottom-left-radius: 2em; +} + +.no_radius { + border-radius: 0; +} + +.clear_survey_filter, .filter-all, .filter-finished{ + cursor: pointer; +} + +.o_active_filter { + cursor:default; +} + +.nvtooltip h5 { + margin: 0; + line-height: 18px; + font-weight: bold; + background-color: rgba(247,247,247,0.75); + text-align: center; + border-bottom: 1px solid #ebebeb; + -webkit-border-radius: 5px 5px 0 0; + -moz-border-radius: 5px 5px 0 0; + border-radius: 5px 5px 0 0; +} + +.survey_answer i { + padding:3px; + cursor:pointer; + visibility:hidden; +} +.survey_answer:hover i { + visibility: visible; +} + +@media print { + .tab-content > .tab-pane { + display: block; + } + + .tab-content > .survey_graph > svg { + width: 1150px; + } +} + +.o_preview_questions { + border: 3px solid #C9C6C6; + width: auto; + padding: 10px 30px 5px; + margin-top: 15px; + color: #C9C6C6; +} + +.o_preview_questions .o_datetime { + border: 1px solid #D8D7D7; + margin-bottom: 5px; +} + +.o_preview_questions .o_matrix_head { + border-bottom: 1px solid #D8D7D7; +} + +.o_preview_questions .o_matrix_row { + border-top: 1px solid #D8D7D7; +} diff --git a/addons/survey/static/src/fonts/ETHOS-REGULAR.OTF b/addons/survey/static/src/fonts/ETHOS-REGULAR.OTF Binary files differnew file mode 100755 index 00000000..7069cc84 --- /dev/null +++ b/addons/survey/static/src/fonts/ETHOS-REGULAR.OTF diff --git a/addons/survey/static/src/fonts/MrDeHaviland-Regular.ttf b/addons/survey/static/src/fonts/MrDeHaviland-Regular.ttf Binary files differnew file mode 100644 index 00000000..20e83445 --- /dev/null +++ b/addons/survey/static/src/fonts/MrDeHaviland-Regular.ttf diff --git a/addons/survey/static/src/fonts/MrDeHaviland-ofl.txt b/addons/survey/static/src/fonts/MrDeHaviland-ofl.txt new file mode 100644 index 00000000..f0f0a57a --- /dev/null +++ b/addons/survey/static/src/fonts/MrDeHaviland-ofl.txt @@ -0,0 +1,94 @@ +Copyright (c) 2011 Alejandro Paul (sudtipos@sudtipos.com),
+with Reserved Font Name "Mr De Haviland"
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/addons/survey/static/src/fonts/Oswald-Light.ttf b/addons/survey/static/src/fonts/Oswald-Light.ttf Binary files differnew file mode 100644 index 00000000..b210bd7f --- /dev/null +++ b/addons/survey/static/src/fonts/Oswald-Light.ttf diff --git a/addons/survey/static/src/img/burger_quiz_background.jpg b/addons/survey/static/src/img/burger_quiz_background.jpg Binary files differnew file mode 100644 index 00000000..b52eac68 --- /dev/null +++ b/addons/survey/static/src/img/burger_quiz_background.jpg diff --git a/addons/survey/static/src/img/burger_quiz_cubism_klein.jpg b/addons/survey/static/src/img/burger_quiz_cubism_klein.jpg Binary files differnew file mode 100644 index 00000000..95f58804 --- /dev/null +++ b/addons/survey/static/src/img/burger_quiz_cubism_klein.jpg diff --git a/addons/survey/static/src/img/burger_quiz_don_quixote.jpg b/addons/survey/static/src/img/burger_quiz_don_quixote.jpg Binary files differnew file mode 100644 index 00000000..616bc388 --- /dev/null +++ b/addons/survey/static/src/img/burger_quiz_don_quixote.jpg diff --git a/addons/survey/static/src/img/burger_quiz_guernica.jpg b/addons/survey/static/src/img/burger_quiz_guernica.jpg Binary files differnew file mode 100644 index 00000000..3301982c --- /dev/null +++ b/addons/survey/static/src/img/burger_quiz_guernica.jpg diff --git a/addons/survey/static/src/img/burger_quiz_self_portrait.jpg b/addons/survey/static/src/img/burger_quiz_self_portrait.jpg Binary files differnew file mode 100644 index 00000000..efd7e833 --- /dev/null +++ b/addons/survey/static/src/img/burger_quiz_self_portrait.jpg diff --git a/addons/survey/static/src/img/certification_bg_classic_blue.jpg b/addons/survey/static/src/img/certification_bg_classic_blue.jpg Binary files differnew file mode 100644 index 00000000..4145476d --- /dev/null +++ b/addons/survey/static/src/img/certification_bg_classic_blue.jpg diff --git a/addons/survey/static/src/img/certification_bg_classic_default.jpg b/addons/survey/static/src/img/certification_bg_classic_default.jpg Binary files differnew file mode 100644 index 00000000..b638bad1 --- /dev/null +++ b/addons/survey/static/src/img/certification_bg_classic_default.jpg diff --git a/addons/survey/static/src/img/certification_bg_classic_gold.jpg b/addons/survey/static/src/img/certification_bg_classic_gold.jpg Binary files differnew file mode 100644 index 00000000..6e2fdba0 --- /dev/null +++ b/addons/survey/static/src/img/certification_bg_classic_gold.jpg diff --git a/addons/survey/static/src/img/certification_bg_modern.png b/addons/survey/static/src/img/certification_bg_modern.png Binary files differnew file mode 100644 index 00000000..c0046186 --- /dev/null +++ b/addons/survey/static/src/img/certification_bg_modern.png diff --git a/addons/survey/static/src/img/certification_seal.svg b/addons/survey/static/src/img/certification_seal.svg new file mode 100644 index 00000000..2b0b5803 --- /dev/null +++ b/addons/survey/static/src/img/certification_seal.svg @@ -0,0 +1 @@ +<svg width="106" height="106" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><linearGradient x1="50%" y1="0%" x2="50%" y2="100%" id="a"><stop stop-color="#C80000" offset="0%"/><stop stop-color="#8F0000" offset="100%"/></linearGradient><path d="M51.41 101.824l-1.112 1.459a2 2 0 0 1-3.368-.295l-.842-1.63a2 2 0 0 0-3.133-.552l-1.348 1.243a2 2 0 0 1-3.265-.875l-.546-1.75a2 2 0 0 0-2.99-1.089l-1.543.99a2 2 0 0 1-3.064-1.428l-.233-1.819a2 2 0 0 0-2.756-1.59l-1.692.707a2 2 0 0 1-2.769-1.94l.086-1.831a2 2 0 0 0-2.437-2.045l-1.79.403a2 2 0 0 1-2.39-2.39l.403-1.79a2 2 0 0 0-2.045-2.437l-1.832.086a2 2 0 0 1-1.939-2.77l.708-1.691a2 2 0 0 0-1.591-2.756l-1.82-.233a2 2 0 0 1-1.428-3.064l.99-1.543a2 2 0 0 0-1.087-2.99l-1.751-.546a2 2 0 0 1-.875-3.265l1.243-1.348a2 2 0 0 0-.552-3.133l-1.63-.842a2 2 0 0 1-.295-3.368l1.46-1.111a2 2 0 0 0 0-3.182l-1.46-1.111a2 2 0 0 1 .295-3.368l1.63-.842a2 2 0 0 0 .552-3.133l-1.243-1.348a2 2 0 0 1 .875-3.265l1.75-.546a2 2 0 0 0 1.089-2.99l-.99-1.543a2 2 0 0 1 1.428-3.064l1.819-.233a2 2 0 0 0 1.59-2.756l-.707-1.692a2 2 0 0 1 1.94-2.769l1.831.086a2 2 0 0 0 2.045-2.437l-.403-1.79a2 2 0 0 1 2.39-2.39l1.79.403a2 2 0 0 0 2.437-2.045l-.086-1.832a2 2 0 0 1 2.77-1.939l1.691.708a2 2 0 0 0 2.756-1.591l.233-1.82a2 2 0 0 1 3.064-1.428l1.543.99a2 2 0 0 0 2.99-1.087l.546-1.751a2 2 0 0 1 3.265-.875l1.348 1.243a2 2 0 0 0 3.133-.552l.842-1.63a2 2 0 0 1 3.368-.295l1.111 1.46a2 2 0 0 0 3.182 0l1.111-1.46a2 2 0 0 1 3.368.295l.842 1.63a2 2 0 0 0 3.133.552l1.348-1.243a2 2 0 0 1 3.265.875l.546 1.75a2 2 0 0 0 2.99 1.089l1.543-.99a2 2 0 0 1 3.064 1.428l.233 1.819a2 2 0 0 0 2.756 1.59l1.692-.707a2 2 0 0 1 2.769 1.94l-.086 1.831a2 2 0 0 0 2.437 2.045l1.79-.403a2 2 0 0 1 2.39 2.39l-.403 1.79a2 2 0 0 0 2.045 2.437l1.832-.086a2 2 0 0 1 1.939 2.77l-.708 1.691a2 2 0 0 0 1.591 2.756l1.82.233a2 2 0 0 1 1.428 3.064l-.99 1.543a2 2 0 0 0 1.087 2.99l1.751.546a2 2 0 0 1 .875 3.265l-1.243 1.348a2 2 0 0 0 .552 3.133l1.63.842a2 2 0 0 1 .295 3.368l-1.46 1.111a2 2 0 0 0 0 3.182l1.46 1.111a2 2 0 0 1-.295 3.368l-1.63.842a2 2 0 0 0-.552 3.133l1.243 1.348a2 2 0 0 1-.875 3.265l-1.75.546a2 2 0 0 0-1.089 2.99l.99 1.543a2 2 0 0 1-1.428 3.064l-1.819.233a2 2 0 0 0-1.59 2.756l.707 1.692a2 2 0 0 1-1.94 2.769l-1.831-.086a2 2 0 0 0-2.045 2.437l.403 1.79a2 2 0 0 1-2.39 2.39l-1.79-.403a2 2 0 0 0-2.437 2.045l.086 1.832a2 2 0 0 1-2.77 1.939l-1.691-.708a2 2 0 0 0-2.756 1.591l-.233 1.82a2 2 0 0 1-3.064 1.428l-1.543-.99a2 2 0 0 0-2.99 1.087l-.546 1.751a2 2 0 0 1-3.265.875l-1.348-1.243a2 2 0 0 0-3.133.552l-.842 1.63a2 2 0 0 1-3.368.295l-1.111-1.46a2 2 0 0 0-3.182 0z" id="b"/><filter x=".9%" y=".9%" width="98.2%" height="98.2%" filterUnits="objectBoundingBox" id="c"><feGaussianBlur stdDeviation=".5" in="SourceAlpha" result="shadowBlurInner1"/><feOffset dy="1" in="shadowBlurInner1" result="shadowOffsetInner1"/><feComposite in="shadowOffsetInner1" in2="SourceAlpha" operator="arithmetic" k2="-1" k3="1" result="shadowInnerInner1"/><feColorMatrix values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.785265516 0" in="shadowInnerInner1" result="shadowMatrixInner1"/><feOffset dy="-1" in="SourceAlpha" result="shadowOffsetInner2"/><feComposite in="shadowOffsetInner2" in2="SourceAlpha" operator="arithmetic" k2="-1" k3="1" result="shadowInnerInner2"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.291220498 0" in="shadowInnerInner2" result="shadowMatrixInner2"/><feMerge><feMergeNode in="shadowMatrixInner1"/><feMergeNode in="shadowMatrixInner2"/></feMerge></filter><radialGradient fx="50%" fy="50%" id="d"><stop stop-color="#C40505" offset="0%"/><stop stop-color="#8D0101" offset="100%"/></radialGradient></defs><g fill="none" fill-rule="evenodd"><use fill="url(#a)" xlink:href="#b"/><use fill="#000" filter="url(#c)" xlink:href="#b"/><circle fill="url(#d)" cx="53" cy="53" r="42"/></g></svg>
\ No newline at end of file diff --git a/addons/survey/static/src/img/survey_background.jpg b/addons/survey/static/src/img/survey_background.jpg Binary files differnew file mode 100644 index 00000000..ba7c4f02 --- /dev/null +++ b/addons/survey/static/src/img/survey_background.jpg diff --git a/addons/survey/static/src/img/survey_background_2.jpg b/addons/survey/static/src/img/survey_background_2.jpg Binary files differnew file mode 100644 index 00000000..b2e076ed --- /dev/null +++ b/addons/survey/static/src/img/survey_background_2.jpg diff --git a/addons/survey/static/src/img/trophy-solid.svg b/addons/survey/static/src/img/trophy-solid.svg new file mode 100644 index 00000000..543ed227 --- /dev/null +++ b/addons/survey/static/src/img/trophy-solid.svg @@ -0,0 +1 @@ +<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="trophy" class="svg-inline--fa fa-trophy fa-w-18" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path fill="currentColor" d="M552 64H448V24c0-13.3-10.7-24-24-24H152c-13.3 0-24 10.7-24 24v40H24C10.7 64 0 74.7 0 88v56c0 35.7 22.5 72.4 61.9 100.7 31.5 22.7 69.8 37.1 110 41.7C203.3 338.5 240 360 240 360v72h-48c-35.3 0-64 20.7-64 56v12c0 6.6 5.4 12 12 12h296c6.6 0 12-5.4 12-12v-12c0-35.3-28.7-56-64-56h-48v-72s36.7-21.5 68.1-73.6c40.3-4.6 78.6-19 110-41.7 39.3-28.3 61.9-65 61.9-100.7V88c0-13.3-10.7-24-24-24zM99.3 192.8C74.9 175.2 64 155.6 64 144v-16h64.2c1 32.6 5.8 61.2 12.8 86.2-15.1-5.2-29.2-12.4-41.7-21.4zM512 144c0 16.1-17.7 36.1-35.3 48.8-12.5 9-26.7 16.2-41.8 21.4 7-25 11.8-53.6 12.8-86.2H512v16z"></path></svg>
\ No newline at end of file diff --git a/addons/survey/static/src/img/watermark.png b/addons/survey/static/src/img/watermark.png Binary files differnew file mode 100644 index 00000000..91ec26fc --- /dev/null +++ b/addons/survey/static/src/img/watermark.png diff --git a/addons/survey/static/src/js/fields_form_page_description.js b/addons/survey/static/src/js/fields_form_page_description.js new file mode 100644 index 00000000..05de3cac --- /dev/null +++ b/addons/survey/static/src/js/fields_form_page_description.js @@ -0,0 +1,53 @@ +odoo.define('survey.fields_form', function (require) { +"use strict"; + +var FieldRegistry = require('web.field_registry'); +var FieldChar = require('web.basic_fields').FieldChar; + +var FormDescriptionPage = FieldChar.extend({ + + //-------------------------------------------------------------------------- + // Widget API + //-------------------------------------------------------------------------- + + /** + * @private + * @override + */ + _renderEdit: function () { + var def = this._super.apply(this, arguments); + this.$el.addClass('col'); + var $inputGroup = $('<div class="input-group">'); + this.$el = $inputGroup.append(this.$el); + var $button = $( + '<div class="input-group-append">\ + <button type="button" title="Open section" class="btn oe_edit_only o_icon_button">\ + <i class="fa fa-fw o_button_icon fa-info-circle"/>\ + </button>\ + </div>' + ); + this.$el = this.$el.append($button); + $button.on('click', this._onClickEdit.bind(this)); + + return def; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onClickEdit: function (ev) { + ev.stopPropagation(); + var id = this.record.id; + if (id) { + this.trigger_up('open_record', {id: id, target: ev.target}); + } + }, +}); + +FieldRegistry.add('survey_description_page', FormDescriptionPage); + +}); diff --git a/addons/survey/static/src/js/fields_section_one2many.js b/addons/survey/static/src/js/fields_section_one2many.js new file mode 100644 index 00000000..80a637bc --- /dev/null +++ b/addons/survey/static/src/js/fields_section_one2many.js @@ -0,0 +1,185 @@ +odoo.define('survey.question_page_one2many', function (require) { +"use strict"; + +var Context = require('web.Context'); +var FieldOne2Many = require('web.relational_fields').FieldOne2Many; +var FieldRegistry = require('web.field_registry'); +var ListRenderer = require('web.ListRenderer'); +var config = require('web.config'); + +var SectionListRenderer = ListRenderer.extend({ + init: function (parent, state, params) { + this.sectionFieldName = "is_page"; + this._super.apply(this, arguments); + }, + _checkIfRecordIsSection: function (id) { + var record = this._findRecordById(id); + return record && record.data[this.sectionFieldName]; + }, + _findRecordById: function (id) { + return _.find(this.state.data, function (record) { + return record.id === id; + }); + }, + /** + * Allows to hide specific field in case the record is a section + * and, in this case, makes the 'title' field take the space of all the other + * fields + * @private + * @override + * @param {*} record + * @param {*} node + * @param {*} index + * @param {*} options + */ + _renderBodyCell: function (record, node, index, options) { + var $cell = this._super.apply(this, arguments); + + var isSection = record.data[this.sectionFieldName]; + + if (isSection) { + if (node.attrs.widget === "handle" || node.attrs.name === "random_questions_count") { + return $cell; + } else if (node.attrs.name === "title") { + var nbrColumns = this._getNumberOfCols(); + if (this.handleField) { + nbrColumns--; + } + if (this.addTrashIcon) { + nbrColumns--; + } + if (record.data.questions_selection === "random") { + nbrColumns--; + } + $cell.attr('colspan', nbrColumns); + } else { + $cell.removeClass('o_invisible_modifier'); + return $cell.addClass('o_hidden'); + } + } + return $cell; + }, + /** + * Adds specific classes to rows that are sections + * to apply custom css on them + * @private + * @override + * @param {*} record + * @param {*} index + */ + _renderRow: function (record, index) { + var $row = this._super.apply(this, arguments); + if (record.data[this.sectionFieldName]) { + $row.addClass("o_is_section"); + } + return $row; + }, + /** + * Adding this class after the view is rendered allows + * us to limit the custom css scope to this particular case + * and no other + * @private + * @override + */ + _renderView: function () { + var def = this._super.apply(this, arguments); + var self = this; + return def.then(function () { + self.$('table.o_list_table').addClass('o_section_list_view'); + }); + }, + // Handlers + /** + * Overridden to allow different behaviours depending on + * the row the user clicked on. + * If the row is a section: edit inline + * else use a normal modal + * @private + * @override + * @param {*} ev + */ + _onRowClicked: function (ev) { + var parent = this.getParent(); + var recordId = $(ev.currentTarget).data('id'); + var is_section = this._checkIfRecordIsSection(recordId); + if (is_section && parent.mode === "edit") { + this.editable = "bottom"; + } else { + this.editable = null; + } + this._super.apply(this, arguments); + }, + /** + * Overridden to allow different behaviours depending on + * the cell the user clicked on. + * If the cell is part of a section: edit inline + * else use a normal edit modal + * @private + * @override + * @param {*} ev + */ + _onCellClick: function (ev) { + var parent = this.getParent(); + var recordId = $(ev.currentTarget.parentElement).data('id'); + var is_section = this._checkIfRecordIsSection(recordId); + if (is_section && parent.mode === "edit") { + this.editable = "bottom"; + } else { + this.editable = null; + this.unselectRow(); + } + this._super.apply(this, arguments); + }, + /** + * In this case, navigating in the list caused issues. + * For example, editing a section then pressing enter would trigger + * the inline edition of the next element in the list. Which is not desired + * if the next element ends up being a question and not a section + * @override + * @param {*} ev + */ + _onNavigationMove: function (ev) { + this.unselectRow(); + }, +}); + +var SectionFieldOne2Many = FieldOne2Many.extend({ + init: function (parent, name, record, options) { + this._super.apply(this, arguments); + this.sectionFieldName = "is_page"; + this.rendered = false; + }, + /** + * Overridden to use our custom renderer + * @private + * @override + */ + _getRenderer: function () { + if (this.view.arch.tag === 'tree') { + return SectionListRenderer; + } + return this._super.apply(this, arguments); + }, + /** + * Overridden to allow different behaviours depending on + * the object we want to add. Adding a section would be done inline + * while adding a question would render a modal. + * @private + * @override + * @param {*} ev + */ + _onAddRecord: function (ev) { + this.editable = null; + if (!config.device.isMobile) { + var context_str = ev.data.context && ev.data.context[0]; + var context = new Context(context_str).eval(); + if (context['default_' + this.sectionFieldName]) { + this.editable = "bottom"; + } + } + this._super.apply(this, arguments); + }, +}); + +FieldRegistry.add('question_page_one2many', SectionFieldOne2Many); +}); diff --git a/addons/survey/static/src/js/libs/chartjs-plugin-datalabels.min.js b/addons/survey/static/src/js/libs/chartjs-plugin-datalabels.min.js new file mode 100644 index 00000000..75eb4203 --- /dev/null +++ b/addons/survey/static/src/js/libs/chartjs-plugin-datalabels.min.js @@ -0,0 +1,7 @@ +/*! + * chartjs-plugin-datalabels v0.7.0 + * https://chartjs-plugin-datalabels.netlify.com + * (c) 2019 Chart.js Contributors + * Released under the MIT license + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e(require("chart.js")):"function"==typeof define&&define.amd?define(["chart.js"],e):(t=t||self).ChartDataLabels=e(t.Chart)}(this,function(t){"use strict";var e=(t=t&&t.hasOwnProperty("default")?t.default:t).helpers,r=function(){if("undefined"!=typeof window){if(window.devicePixelRatio)return window.devicePixelRatio;var t=window.screen;if(t)return(t.deviceXDPI||1)/(t.logicalXDPI||1)}return 1}(),n={toTextLines:function(t){var r,n=[];for(t=[].concat(t);t.length;)"string"==typeof(r=t.pop())?n.unshift.apply(n,r.split("\n")):Array.isArray(r)?t.push.apply(t,r):e.isNullOrUndef(t)||n.unshift(""+r);return n},toFontString:function(t){return!t||e.isNullOrUndef(t.size)||e.isNullOrUndef(t.family)?null:(t.style?t.style+" ":"")+(t.weight?t.weight+" ":"")+t.size+"px "+t.family},textSize:function(t,e,r){var n,i=[].concat(e),o=i.length,a=t.font,l=0;for(t.font=r.string,n=0;n<o;++n)l=Math.max(t.measureText(i[n]).width,l);return t.font=a,{height:o*r.lineHeight,width:l}},parseFont:function(r){var i=t.defaults.global,o=e.valueOrDefault(r.size,i.defaultFontSize),a={family:e.valueOrDefault(r.family,i.defaultFontFamily),lineHeight:e.options.toLineHeight(r.lineHeight,o),size:o,style:e.valueOrDefault(r.style,i.defaultFontStyle),weight:e.valueOrDefault(r.weight,null),string:""};return a.string=n.toFontString(a),a},bound:function(t,e,r){return Math.max(t,Math.min(e,r))},arrayDiff:function(t,e){var r,n,i,o,a=t.slice(),l=[];for(r=0,i=e.length;r<i;++r)o=e[r],-1===(n=a.indexOf(o))?l.push([o,1]):a.splice(n,1);for(r=0,i=a.length;r<i;++r)l.push([a[r],-1]);return l},rasterize:function(t){return Math.round(t*r)/r}};function i(t,e){var r=e.x,n=e.y;if(null===r)return{x:0,y:-1};if(null===n)return{x:1,y:0};var i=t.x-r,o=t.y-n,a=Math.sqrt(i*i+o*o);return{x:a?i/a:0,y:a?o/a:-1}}var o=0,a=1,l=2,s=4,u=8;function f(t,e,r){var n=o;return t<r.left?n|=a:t>r.right&&(n|=l),e<r.top?n|=u:e>r.bottom&&(n|=s),n}function d(t,e){var r,n,i=e.anchor,o=t;return e.clamp&&(o=function(t,e){for(var r,n,i,o=t.x0,d=t.y0,c=t.x1,h=t.y1,x=f(o,d,e),y=f(c,h,e);x|y&&!(x&y);)(r=x||y)&u?(n=o+(c-o)*(e.top-d)/(h-d),i=e.top):r&s?(n=o+(c-o)*(e.bottom-d)/(h-d),i=e.bottom):r&l?(i=d+(h-d)*(e.right-o)/(c-o),n=e.right):r&a&&(i=d+(h-d)*(e.left-o)/(c-o),n=e.left),r===x?x=f(o=n,d=i,e):y=f(c=n,h=i,e);return{x0:o,x1:c,y0:d,y1:h}}(o,e.area)),"start"===i?(r=o.x0,n=o.y0):"end"===i?(r=o.x1,n=o.y1):(r=(o.x0+o.x1)/2,n=(o.y0+o.y1)/2),function(t,e,r,n,i){switch(i){case"center":r=n=0;break;case"bottom":r=0,n=1;break;case"right":r=1,n=0;break;case"left":r=-1,n=0;break;case"top":r=0,n=-1;break;case"start":r=-r,n=-n;break;case"end":break;default:i*=Math.PI/180,r=Math.cos(i),n=Math.sin(i)}return{x:t,y:e,vx:r,vy:n}}(r,n,t.vx,t.vy,e.align)}var c={arc:function(t,e){var r=(t.startAngle+t.endAngle)/2,n=Math.cos(r),i=Math.sin(r),o=t.innerRadius,a=t.outerRadius;return d({x0:t.x+n*o,y0:t.y+i*o,x1:t.x+n*a,y1:t.y+i*a,vx:n,vy:i},e)},point:function(t,e){var r=i(t,e.origin),n=r.x*t.radius,o=r.y*t.radius;return d({x0:t.x-n,y0:t.y-o,x1:t.x+n,y1:t.y+o,vx:r.x,vy:r.y},e)},rect:function(t,e){var r=i(t,e.origin),n=t.x,o=t.y,a=0,l=0;return t.horizontal?(n=Math.min(t.x,t.base),a=Math.abs(t.base-t.x)):(o=Math.min(t.y,t.base),l=Math.abs(t.base-t.y)),d({x0:n,y0:o+l,x1:n+a,y1:o,vx:r.x,vy:r.y},e)},fallback:function(t,e){var r=i(t,e.origin);return d({x0:t.x,y0:t.y,x1:t.x,y1:t.y,vx:r.x,vy:r.y},e)}},h=t.helpers,x=n.rasterize;function y(t){var e=t._model.horizontal,r=t._scale||e&&t._xScale||t._yScale;if(!r)return null;if(void 0!==r.xCenter&&void 0!==r.yCenter)return{x:r.xCenter,y:r.yCenter};var n=r.getBasePixel();return e?{x:n,y:null}:{x:null,y:n}}function v(t,e,r){var n=t.shadowBlur,i=r.stroked,o=x(r.x),a=x(r.y),l=x(r.w);i&&t.strokeText(e,o,a,l),r.filled&&(n&&i&&(t.shadowBlur=0),t.fillText(e,o,a,l),n&&i&&(t.shadowBlur=n))}var _=function(t,e,r,n){var i=this;i._config=t,i._index=n,i._model=null,i._rects=null,i._ctx=e,i._el=r};h.extend(_.prototype,{_modelize:function(e,r,i,o){var a,l=this._index,s=h.options.resolve,u=n.parseFont(s([i.font,{}],o,l)),f=s([i.color,t.defaults.global.defaultFontColor],o,l);return{align:s([i.align,"center"],o,l),anchor:s([i.anchor,"center"],o,l),area:o.chart.chartArea,backgroundColor:s([i.backgroundColor,null],o,l),borderColor:s([i.borderColor,null],o,l),borderRadius:s([i.borderRadius,0],o,l),borderWidth:s([i.borderWidth,0],o,l),clamp:s([i.clamp,!1],o,l),clip:s([i.clip,!1],o,l),color:f,display:e,font:u,lines:r,offset:s([i.offset,0],o,l),opacity:s([i.opacity,1],o,l),origin:y(this._el),padding:h.options.toPadding(s([i.padding,0],o,l)),positioner:(a=this._el,a instanceof t.elements.Arc?c.arc:a instanceof t.elements.Point?c.point:a instanceof t.elements.Rectangle?c.rect:c.fallback),rotation:s([i.rotation,0],o,l)*(Math.PI/180),size:n.textSize(this._ctx,r,u),textAlign:s([i.textAlign,"start"],o,l),textShadowBlur:s([i.textShadowBlur,0],o,l),textShadowColor:s([i.textShadowColor,f],o,l),textStrokeColor:s([i.textStrokeColor,f],o,l),textStrokeWidth:s([i.textStrokeWidth,0],o,l)}},update:function(t){var e,r,i,o=this,a=null,l=null,s=o._index,u=o._config,f=h.options.resolve([u.display,!0],t,s);f&&(e=t.dataset.data[s],r=h.valueOrDefault(h.callback(u.formatter,[e,t]),e),(i=h.isNullOrUndef(r)?[]:n.toTextLines(r)).length&&(l=function(t){var e=t.borderWidth||0,r=t.padding,n=t.size.height,i=t.size.width,o=-i/2,a=-n/2;return{frame:{x:o-r.left-e,y:a-r.top-e,w:i+r.width+2*e,h:n+r.height+2*e},text:{x:o,y:a,w:i,h:n}}}(a=o._modelize(f,i,u,t)))),o._model=a,o._rects=l},geometry:function(){return this._rects?this._rects.frame:{}},rotation:function(){return this._model?this._model.rotation:0},visible:function(){return this._model&&this._model.opacity},model:function(){return this._model},draw:function(t,e){var r,i=t.ctx,o=this._model,a=this._rects;this.visible()&&(i.save(),o.clip&&(r=o.area,i.beginPath(),i.rect(r.left,r.top,r.right-r.left,r.bottom-r.top),i.clip()),i.globalAlpha=n.bound(0,o.opacity,1),i.translate(x(e.x),x(e.y)),i.rotate(o.rotation),function(t,e,r){var n=r.backgroundColor,i=r.borderColor,o=r.borderWidth;(n||i&&o)&&(t.beginPath(),h.canvas.roundedRect(t,x(e.x)+o/2,x(e.y)+o/2,x(e.w)-o,x(e.h)-o,r.borderRadius),t.closePath(),n&&(t.fillStyle=n,t.fill()),i&&o&&(t.strokeStyle=i,t.lineWidth=o,t.lineJoin="miter",t.stroke()))}(i,a.frame,o),function(t,e,r,n){var i,o=n.textAlign,a=n.color,l=!!a,s=n.font,u=e.length,f=n.textStrokeColor,d=n.textStrokeWidth,c=f&&d;if(u&&(l||c))for(r=function(t,e,r){var n=r.lineHeight,i=t.w,o=t.x;return"center"===e?o+=i/2:"end"!==e&&"right"!==e||(o+=i),{h:n,w:i,x:o,y:t.y+n/2}}(r,o,s),t.font=s.string,t.textAlign=o,t.textBaseline="middle",t.shadowBlur=n.textShadowBlur,t.shadowColor=n.textShadowColor,l&&(t.fillStyle=a),c&&(t.lineJoin="round",t.lineWidth=d,t.strokeStyle=f),i=0,u=e.length;i<u;++i)v(t,e[i],{stroked:c,filled:l,w:r.w,x:r.x,y:r.y+r.h*i})}(i,o.lines,a.text,o),i.restore())}});var b=t.helpers,p=Number.MIN_SAFE_INTEGER||-9007199254740991,g=Number.MAX_SAFE_INTEGER||9007199254740991;function m(t,e,r){var n=Math.cos(r),i=Math.sin(r),o=e.x,a=e.y;return{x:o+n*(t.x-o)-i*(t.y-a),y:a+i*(t.x-o)+n*(t.y-a)}}function w(t,e){var r,n,i,o,a,l=g,s=p,u=e.origin;for(r=0;r<t.length;++r)i=(n=t[r]).x-u.x,o=n.y-u.y,a=e.vx*i+e.vy*o,l=Math.min(l,a),s=Math.max(s,a);return{min:l,max:s}}function k(t,e){var r=e.x-t.x,n=e.y-t.y,i=Math.sqrt(r*r+n*n);return{vx:(e.x-t.x)/i,vy:(e.y-t.y)/i,origin:t,ln:i}}var M=function(){this._rotation=0,this._rect={x:0,y:0,w:0,h:0}};function S(t,e,r){var n=e.positioner(t,e),i=n.vx,o=n.vy;if(!i&&!o)return{x:n.x,y:n.y};var a=r.w,l=r.h,s=e.rotation,u=Math.abs(a/2*Math.cos(s))+Math.abs(l/2*Math.sin(s)),f=Math.abs(a/2*Math.sin(s))+Math.abs(l/2*Math.cos(s)),d=1/Math.max(Math.abs(i),Math.abs(o));return u*=i*d,f*=o*d,u+=e.offset*i,f+=e.offset*o,{x:n.x+u,y:n.y+f}}b.extend(M.prototype,{center:function(){var t=this._rect;return{x:t.x+t.w/2,y:t.y+t.h/2}},update:function(t,e,r){this._rotation=r,this._rect={x:e.x+t.x,y:e.y+t.y,w:e.w,h:e.h}},contains:function(t){var e=this._rect;return!((t=m(t,this.center(),-this._rotation)).x<e.x-1||t.y<e.y-1||t.x>e.x+e.w+2||t.y>e.y+e.h+2)},intersects:function(t){var e,r,n,i=this._points(),o=t._points(),a=[k(i[0],i[1]),k(i[0],i[3])];for(this._rotation!==t._rotation&&a.push(k(o[0],o[1]),k(o[0],o[3])),e=0;e<a.length;++e)if(r=w(i,a[e]),n=w(o,a[e]),r.max<n.min||n.max<r.min)return!1;return!0},_points:function(){var t=this._rect,e=this._rotation,r=this.center();return[m({x:t.x,y:t.y},r,e),m({x:t.x+t.w,y:t.y},r,e),m({x:t.x+t.w,y:t.y+t.h},r,e),m({x:t.x,y:t.y+t.h},r,e)]}});var C={prepare:function(t){var e,r,n,i,o,a=[];for(e=0,n=t.length;e<n;++e)for(r=0,i=t[e].length;r<i;++r)o=t[e][r],a.push(o),o.$layout={_box:new M,_hidable:!1,_visible:!0,_set:e,_idx:r};return a.sort(function(t,e){var r=t.$layout,n=e.$layout;return r._idx===n._idx?n._set-r._set:n._idx-r._idx}),this.update(a),a},update:function(t){var e,r,n,i,o,a=!1;for(e=0,r=t.length;e<r;++e)i=(n=t[e]).model(),(o=n.$layout)._hidable=i&&"auto"===i.display,o._visible=n.visible(),a|=o._hidable;a&&function(t){var e,r,n,i,o,a;for(e=0,r=t.length;e<r;++e)(i=(n=t[e]).$layout)._visible&&(o=n.geometry(),a=S(n._el._model,n.model(),o),i._box.update(a,o,n.rotation()));(function(t,e){var r,n,i,o;for(r=t.length-1;r>=0;--r)for(i=t[r].$layout,n=r-1;n>=0&&i._visible;--n)(o=t[n].$layout)._visible&&i._box.intersects(o._box)&&e(i,o)})(t,function(t,e){var r=t._hidable,n=e._hidable;r&&n||n?e._visible=!1:r&&(t._visible=!1)})}(t)},lookup:function(t,e){var r,n;for(r=t.length-1;r>=0;--r)if((n=t[r].$layout)&&n._visible&&n._box.contains(e))return t[r];return null},draw:function(t,e){var r,n,i,o,a,l;for(r=0,n=e.length;r<n;++r)(o=(i=e[r]).$layout)._visible&&(a=i.geometry(),l=S(i._el._view,i.model(),a),o._box.update(l,a,i.rotation()),i.draw(t,l))}},z=t.helpers,$={align:"center",anchor:"center",backgroundColor:null,borderColor:null,borderRadius:0,borderWidth:0,clamp:!1,clip:!1,color:void 0,display:!0,font:{family:void 0,lineHeight:1.2,size:void 0,style:void 0,weight:null},formatter:function(t){if(z.isNullOrUndef(t))return null;var e,r,n,i=t;if(z.isObject(t))if(z.isNullOrUndef(t.label))if(z.isNullOrUndef(t.r))for(i="",n=0,r=(e=Object.keys(t)).length;n<r;++n)i+=(0!==n?", ":"")+e[n]+": "+t[e[n]];else i=t.r;else i=t.label;return""+i},labels:void 0,listeners:{},offset:4,opacity:1,padding:{top:4,right:4,bottom:4,left:4},rotation:0,textAlign:"start",textStrokeColor:void 0,textStrokeWidth:0,textShadowBlur:0,textShadowColor:void 0},O=t.helpers,A="$datalabels",D="$default";function P(t,e,r){if(e){var n,i=r.$context,o=r.$groups;e[o._set]&&(n=e[o._set][o._key])&&!0===O.callback(n,[i])&&(t[A]._dirty=!0,r.update(i))}}function N(t,e){var r,n,i=t[A],o=i._listeners;if(o.enter||o.leave){if("mousemove"===e.type)n=C.lookup(i._labels,e);else if("mouseout"!==e.type)return;r=i._hovered,i._hovered=n,function(t,e,r,n){var i,o;(r||n)&&(r?n?r!==n&&(o=i=!0):o=!0:i=!0,o&&P(t,e.leave,r),i&&P(t,e.enter,n))}(t,o,r,n)}}t.defaults.global.plugins.datalabels=$;var R={id:"datalabels",beforeInit:function(t){t[A]={_actives:[]}},beforeUpdate:function(t){var e=t[A];e._listened=!1,e._listeners={},e._datasets=[],e._labels=[]},afterDatasetUpdate:function(t,e,r){var n,i,o,a,l,s,u,f,d=e.index,c=t[A],h=c._datasets[d]=[],x=t.isDatasetVisible(d),y=t.data.datasets[d],v=function(t,e){var r,n,i,o=t.datalabels,a=[];return!1===o?null:(!0===o&&(o={}),e=O.merge({},[e,o]),n=e.labels||{},i=Object.keys(n),delete e.labels,i.length?i.forEach(function(t){n[t]&&a.push(O.merge({},[e,n[t],{_key:t}]))}):a.push(e),r=a.reduce(function(t,e){return O.each(e.listeners||{},function(r,n){t[n]=t[n]||{},t[n][e._key||D]=r}),delete e.listeners,t},{}),{labels:a,listeners:r})}(y,r),b=e.meta.data||[],p=t.ctx;for(p.save(),n=0,o=b.length;n<o;++n)if((u=b[n])[A]=[],x&&u&&!u.hidden&&!u._model.skip)for(i=0,a=v.labels.length;i<a;++i)s=(l=v.labels[i])._key,(f=new _(l,p,u,n)).$groups={_set:d,_key:s||D},f.$context={active:!1,chart:t,dataIndex:n,dataset:y,datasetIndex:d},f.update(f.$context),u[A].push(f),h.push(f);p.restore(),O.merge(c._listeners,v.listeners,{merger:function(t,r,n){r[t]=r[t]||{},r[t][e.index]=n[t],c._listened=!0}})},afterUpdate:function(t,e){t[A]._labels=C.prepare(t[A]._datasets,e)},afterDatasetsDraw:function(t){C.draw(t,t[A]._labels)},beforeEvent:function(t,e){if(t[A]._listened)switch(e.type){case"mousemove":case"mouseout":N(t,e);break;case"click":!function(t,e){var r=t[A],n=r._listeners.click,i=n&&C.lookup(r._labels,e);i&&P(t,n,i)}(t,e)}},afterEvent:function(e){var r,i,o,a,l,s,u,f=e[A],d=f._actives,c=f._actives=e.lastActive||[],h=n.arrayDiff(d,c);for(r=0,i=h.length;r<i;++r)if((l=h[r])[1])for(o=0,a=(u=l[0][A]||[]).length;o<a;++o)(s=u[o]).$context.active=1===l[1],s.update(s.$context);(f._dirty||h.length)&&(C.update(f._labels),function(e){if(!e.animating){for(var r=t.animationService.animations,n=0,i=r.length;n<i;++n)if(r[n].chart===e)return;e.render({duration:1,lazy:!0})}}(e)),delete f._dirty}};return t.plugins.register(R),R}); diff --git a/addons/survey/static/src/js/survey_breadcrumb.js b/addons/survey/static/src/js/survey_breadcrumb.js new file mode 100644 index 00000000..4cc3796f --- /dev/null +++ b/addons/survey/static/src/js/survey_breadcrumb.js @@ -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; + +}); diff --git a/addons/survey/static/src/js/survey_form.js b/addons/survey/static/src/js/survey_form.js new file mode 100644 index 00000000..e0e12c1c --- /dev/null +++ b/addons/survey/static/src/js/survey_form.js @@ -0,0 +1,1094 @@ +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 Dialog = require('web.Dialog'); +var dom = require('web.dom'); +var utils = require('web.utils'); + +var _t = core._t; + +publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({ + selector: '.o_survey_form', + events: { + 'change .o_survey_form_choice_item': '_onChangeChoiceItem', + 'click .o_survey_matrix_btn': '_onMatrixBtnClick', + 'click button[type="submit"]': '_onSubmit', + }, + custom_events: { + 'breadcrumb_click': '_onBreadcrumbClick', + }, + + //-------------------------------------------------------------------------- + // Widget + //-------------------------------------------------------------------------- + + /** + * @override + */ + start: function () { + var self = this; + this.fadeInOutDelay = 400; + return this._super.apply(this, arguments).then(function () { + self.options = self.$target.find('form').data(); + self.readonly = self.options.readonly; + self.selectedAnswers = self.options.selectedAnswers; + + // Add Survey cookie to retrieve the survey if you quit the page and restart the survey. + if (!utils.get_cookie('survey_' + self.options.surveyToken)) { + utils.set_cookie('survey_' + self.options.surveyToken, self.options.answerToken, 60*60*24); + } + + // Init fields + if (!self.options.isStartScreen && !self.readonly) { + self._initTimer(); + self._initBreadcrumb(); + } + self.$('div.o_survey_form_date').each(function () { + self._initDateTimePicker($(this)); + }); + self._initChoiceItems(); + self._initTextArea(); + self._focusOnFirstInput(); + // Init event listener + if (!self.readonly) { + $(document).on('keydown', self._onKeyDown.bind(self)); + } + if (self.options.sessionInProgress && + (self.options.isStartScreen || self.options.hasAnswered || self.options.isPageDescription)) { + self.preventEnterSubmit = true; + } + self._initSessionManagement(); + + // Needs global selector as progress/navigation are not within the survey form, but need + //to be updated at the same time + self.$surveyProgress = $('.o_survey_progress_wrapper'); + self.$surveyNavigation = $('.o_survey_navigation_wrapper'); + self.$surveyNavigation.find('.o_survey_navigation_submit').on('click', self._onSubmit.bind(self)); + }); + }, + + // ------------------------------------------------------------------------- + // Private + // ------------------------------------------------------------------------- + + // Handlers + // ------------------------------------------------------------------------- + + /** + * Handle keyboard navigation: + * - 'enter' or 'arrow-right' => submit form + * - 'arrow-left' => submit form (but go back backwards) + * - other alphabetical character ('a', 'b', ...) + * Select the related option in the form (if available) + * + * @param {Event} event + */ + _onKeyDown: function (event) { + // If user is answering a text input, do not handle keydown + if (this.$("textarea").is(":focus") || this.$('input').is(':focus')) { + return; + } + // If in session mode and question already answered, do not handle keydown + if (this.$('fieldset[disabled="disabled"]').length !== 0) { + return; + } + + var self = this; + var keyCode = event.keyCode; + var letter = String.fromCharCode(keyCode).toUpperCase(); + + // Handle Start / Next / Submit + if (keyCode === 13 || keyCode === 39) { // Enter or arrow-right: go Next + event.preventDefault(); + if (!this.preventEnterSubmit) { + var isFinish = this.$('button[value="finish"]').length !== 0; + this._submitForm({isFinish: isFinish}); + } + } else if (keyCode === 37) { // arrow-left: previous (if available) + // It's easier to actually click on the button (if in the DOM) as it contains necessary + // data that are used in the event handler. + // Again, global selector necessary since the navigation is outside of the form. + $('.o_survey_navigation_submit[value="previous"]').click(); + } else if (self.options.questionsLayout === 'page_per_question' + && letter.match(/[a-z]/i)) { + var $choiceInput = this.$(`input[data-selection-key=${letter}]`); + if ($choiceInput.length === 1) { + $choiceInput.prop("checked", !$choiceInput.prop("checked")).trigger('change'); + + // Avoid selection key to be typed into the textbox if 'other' is selected by key + event.preventDefault(); + } + } + }, + + /** + * Checks, if the 'other' choice is checked. Applies only if the comment count as answer. + * If not checked : Clear the comment textarea, hide and disable it + * If checked : enable the comment textarea, show and focus on it + * + * @private + * @param {Event} event + */ + _onChangeChoiceItem: function (event) { + var self = this; + var $target = $(event.currentTarget); + var $choiceItemGroup = $target.closest('.o_survey_form_choice'); + var $otherItem = $choiceItemGroup.find('.o_survey_js_form_other_comment'); + var $commentInput = $choiceItemGroup.find('textarea[type="text"]'); + + if ($otherItem.prop('checked') || $commentInput.hasClass('o_survey_comment')) { + $commentInput.enable(); + $commentInput.closest('.o_survey_comment_container').removeClass('d-none'); + if ($otherItem.prop('checked')) { + $commentInput.focus(); + } + } else { + $commentInput.val(''); + $commentInput.closest('.o_survey_comment_container').addClass('d-none'); + $commentInput.enable(false); + } + + var $matrixBtn = $target.closest('.o_survey_matrix_btn'); + if ($target.attr('type') === 'radio') { + var isQuestionComplete = false; + if ($matrixBtn.length > 0) { + $matrixBtn.closest('tr').find('td').removeClass('o_survey_selected'); + $matrixBtn.addClass('o_survey_selected'); + if (this.options.questionsLayout === 'page_per_question') { + var subQuestionsIds = $matrixBtn.closest('table').data('subQuestions'); + var completedQuestions = []; + subQuestionsIds.forEach(function (id) { + if (self.$('tr#' + id).find('input:checked').length !== 0) { + completedQuestions.push(id); + } + }); + isQuestionComplete = completedQuestions.length === subQuestionsIds.length; + } + } else { + var previouslySelectedAnswer = $choiceItemGroup.find('label.o_survey_selected'); + previouslySelectedAnswer.removeClass('o_survey_selected'); + + var newlySelectedAnswer = $target.closest('label'); + if (newlySelectedAnswer.find('input').val() !== previouslySelectedAnswer.find('input').val()) { + newlySelectedAnswer.addClass('o_survey_selected'); + isQuestionComplete = this.options.questionsLayout === 'page_per_question'; + } + + // Conditional display + if (this.options.questionsLayout !== 'page_per_question') { + var treatedQuestionIds = []; // Needed to avoid show (1st 'if') then immediately hide (2nd 'if') question during conditional propagation cascade + if (Object.keys(this.options.triggeredQuestionsByAnswer).includes(previouslySelectedAnswer.find('input').val())) { + // Hide and clear depending question + this.options.triggeredQuestionsByAnswer[previouslySelectedAnswer.find('input').val()].forEach(function (questionId) { + var dependingQuestion = $('.js_question-wrapper#' + questionId); + + dependingQuestion.addClass('d-none'); + self._clearQuestionInputs(dependingQuestion); + + treatedQuestionIds.push(questionId); + }); + // Remove answer from selected answer + self.selectedAnswers.splice(self.selectedAnswers.indexOf(parseInt($target.val())), 1); + } + if (Object.keys(this.options.triggeredQuestionsByAnswer).includes($target.val())) { + // Display depending question + this.options.triggeredQuestionsByAnswer[$target.val()].forEach(function (questionId) { + if (!treatedQuestionIds.includes(questionId)) { + var dependingQuestion = $('.js_question-wrapper#' + questionId); + dependingQuestion.removeClass('d-none'); + } + }); + // Add answer to selected answer + this.selectedAnswers.push(parseInt($target.val())); + } + } + } + // Auto Submit Form + var isLastQuestion = this.$('button[value="finish"]').length !== 0; + var questionHasComment = $target.closest('.o_survey_form_choice').find('.o_survey_comment').length !== 0 + || $target.hasClass('o_survey_js_form_other_comment'); + if (!isLastQuestion && this.options.usersCanGoBack && isQuestionComplete && !questionHasComment) { + this._submitForm({}); + } + } else { // $target.attr('type') === 'checkbox' + if ($matrixBtn.length > 0) { + $matrixBtn.toggleClass('o_survey_selected', !$matrixBtn.hasClass('o_survey_selected')); + } else { + var $label = $target.closest('label'); + $label.toggleClass('o_survey_selected', !$label.hasClass('o_survey_selected')); + + // Conditional display + if (this.options.questionsLayout !== 'page_per_question' && Object.keys(this.options.triggeredQuestionsByAnswer).includes($target.val())) { + var isInputSelected = $label.hasClass('o_survey_selected'); + // Hide and clear or display depending question + this.options.triggeredQuestionsByAnswer[$target.val()].forEach(function (questionId) { + var dependingQuestion = $('.js_question-wrapper#' + questionId); + dependingQuestion.toggleClass('d-none', !isInputSelected); + if (!isInputSelected) { + self._clearQuestionInputs(dependingQuestion); + } + }); + // Add/remove answer to/from selected answer + if (!isInputSelected) { + self.selectedAnswers.splice(self.selectedAnswers.indexOf(parseInt($target.val())), 1); + } else { + self.selectedAnswers.push(parseInt($target.val())); + } + } + } + } + }, + + _onMatrixBtnClick: function (event) { + if (this.readonly) { + return; + } + + var $target = $(event.currentTarget); + var $input = $target.find('input'); + if ($input.attr('type') === 'radio') { + $input.prop("checked", true).trigger('change'); + } else { + $input.prop("checked", !$input.prop("checked")).trigger('change'); + } + }, + + _onSubmit: function (event) { + event.preventDefault(); + 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); + }, + + // Custom Events + // ------------------------------------------------------------------------- + + _onBreadcrumbClick: function (event) { + this._submitForm({'previousPageId': event.data.previousPageId}); + }, + + /** + * We listen to 'next_question' and 'end_session' events to load the next + * page of the survey automatically, based on the host pacing. + * + * If the trigger is 'next_question', we handle some extra computation to find + * a suitable "fadeInOutDelay" based on the delay between the time of the question + * change by the host and the time of reception of the event. + * This will allow us to account for a little bit of server lag (up to 1 second) + * while giving everyone a fair experience on the quiz. + * + * e.g 1: + * - The host switches the question + * - We receive the event 200 ms later due to server lag + * - -> The fadeInOutDelay will be 400 ms (200ms delay + 400ms * 2 fade in fade out) + * + * e.g 2: + * - The host switches the question + * - We receive the event 600 ms later due to bigger server lag + * - -> The fadeInOutDelay will be 200ms (600ms delay + 200ms * 2 fade in fade out) + * + * @private + * @param {Array[]} notifications structured as specified by the bus feature + */ + _onNotification: function (notifications) { + var nextPageEvent = false; + if (notifications && notifications.length !== 0) { + notifications.forEach(function (notification) { + if (notification.length >= 2) { + var event = notification[1]; + if (event.type === 'next_question' || + event.type === 'end_session') { + nextPageEvent = event; + } + } + }); + } + + if (this.options.isStartScreen && nextPageEvent.type === 'end_session') { + // can happen when triggering the same survey session multiple times + // we received an "old" end_session event that needs to be ignored + return; + } + + if (nextPageEvent) { + if (nextPageEvent.type === 'next_question') { + var serverDelayMS = moment.utc().valueOf() - moment.unix(nextPageEvent.question_start).utc().valueOf(); + if (serverDelayMS < 0) { + serverDelayMS = 0; + } else if (serverDelayMS > 1000) { + serverDelayMS = 1000; + } + this.fadeInOutDelay = (1000 - serverDelayMS) / 2; + } else { + this.fadeInOutDelay = 400; + } + + this.$('.o_survey_main_title:visible').fadeOut(400); + + this.preventEnterSubmit = false; + this.readonly = false; + this._nextScreen( + this._rpc({ + route: `/survey/next_question/${this.options.surveyToken}/${this.options.answerToken}`, + }), { + initTimer: true, + isFinish: nextPageEvent.type === 'end_session' + } + ); + } + }, + + // 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.initTime] will force the re-init of the timer after next + * screen transition + * @param {Boolean} [options.isFinish] fades out breadcrumb and timer + * @private + */ + _submitForm: function (options) { + var self = this; + var params = {}; + if (options.previousPageId) { + params.previous_page_id = options.previousPageId; + } + var route = "/survey/submit"; + + if (this.options.isStartScreen) { + route = "/survey/begin"; + // Hide survey title in 'page_per_question' layout: it takes too much space + if (this.options.questionsLayout === 'page_per_question') { + this.$('.o_survey_main_title').fadeOut(400); + } + } else { + var $form = this.$('form'); + var formData = new FormData($form[0]); + + if (!options.skipValidation) { + // Validation pre submit + if (!this._validateForm($form, formData)) { + return; + } + } + + this._prepareSubmitValues(formData, params); + } + + // prevent user from submitting more times using enter key + this.preventEnterSubmit = true; + + if (this.options.sessionInProgress) { + // reset the fadeInOutDelay when attendee is submitting form + this.fadeInOutDelay = 400; + // prevent user from clicking on matrix options when form is submitted + this.readonly = true; + } + + var submitPromise = self._rpc({ + route: _.str.sprintf('%s/%s/%s', route, self.options.surveyToken, self.options.answerToken), + params: params, + }); + this._nextScreen(submitPromise, options); + }, + + /** + * Will fade out / fade in the next screen based on passed promise and options. + * + * @param {Promise} nextScreenPromise + * @param {Object} options see '_submitForm' for details + */ + _nextScreen: function (nextScreenPromise, options) { + var self = this; + + 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'); + utils.set_cookie('survey_' + self.options.surveyToken, '', -1); // delete cookie + } + self.$(selectorsToFadeout.join(',')).fadeOut(this.fadeInOutDelay, function () { + resolveFadeOut(); + }); + + Promise.all([fadeOutPromise, nextScreenPromise]).then(function (results) { + return self._onNextScreenDone(results[1], 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) { + var self = this; + + if (!(options && options.isFinish) + && !this.options.sessionInProgress) { + this.preventEnterSubmit = false; + } + + if (result && !result.error) { + this.$(".o_survey_form_content").empty(); + this.$(".o_survey_form_content").html(result.survey_content); + + if (result.survey_progress && this.$surveyProgress.length !== 0) { + this.$surveyProgress.html(result.survey_progress); + } else if (options.isFinish && this.$surveyProgress.length !== 0) { + this.$surveyProgress.remove(); + } + + if (result.survey_navigation && this.$surveyNavigation.length !== 0) { + this.$surveyNavigation.html(result.survey_navigation); + this.$surveyNavigation.find('.o_survey_navigation_submit').on('click', self._onSubmit.bind(self)); + } + + // Hide timer if end screen (if page_per_question in case of conditional questions) + if (self.options.questionsLayout === 'page_per_question' && this.$('.o_survey_finished').length > 0) { + options.isFinish = true; + } + + this.$('div.o_survey_form_date').each(function () { + self._initDateTimePicker($(this)); + }); + if (this.options.isStartScreen || (options && options.initTimer)) { + this._initTimer(); + this.options.isStartScreen = false; + } else { + if (this.options.sessionInProgress && this.surveyTimerWidget) { + this.surveyTimerWidget.destroy(); + } + } + if (options && options.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(); + } + self._initChoiceItems(); + self._initTextArea(); + + if (this.options.sessionInProgress && this.$('.o_survey_form_content_data').data('isPageDescription')) { + // prevent enter submit if we're on a page description (there is nothing to submit) + this.preventEnterSubmit = true; + } + + this.$('.o_survey_form_content').fadeIn(this.fadeInOutDelay); + $("html, body").animate({ scrollTop: 0 }, this.fadeInOutDelay); + self._focusOnFirstInput(); + } + else if (result && result.fields && result.error === 'validation') { + 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; + }); + + var inactiveQuestionIds = this.options.sessionInProgress ? [] : this._getInactiveConditionalQuestionIds(); + + $form.find('[data-question-type]').each(function () { + var $input = $(this); + var $questionWrapper = $input.closest(".js_question-wrapper"); + var questionId = $questionWrapper.attr('id'); + + // If question is inactive, skip validation. + if (inactiveQuestionIds.includes(parseInt(questionId))) { + return; + } + + var questionRequired = $questionWrapper.data('required'); + var constrErrorMsg = $questionWrapper.data('constrErrorMsg'); + var validationErrorMsg = $questionWrapper.data('validationErrorMsg'); + 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 datetimepickerFormat = $input.data('questionType') === 'datetime' ? time.getLangDatetimeFormat() : time.getLangDateFormat(); + var momentDate = moment($input.val(), datetimepickerFormat); + 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) + * + * + * @private + * @param {Event} event + */ + _prepareSubmitValues: function (formData, params) { + var self = this; + formData.forEach(function (value, key) { + switch (key) { + case 'csrf_token': + case 'token': + case 'page_id': + case 'question_id': + params[key] = value; + break; + } + }); + + // Get all question answers by question type + this.$('[data-question-type]').each(function () { + switch ($(this).data('questionType')) { + case 'text_box': + case 'char_box': + case 'numerical_box': + params[this.name] = this.value; + break; + case 'date': + params = self._prepareSubmitDates(params, this.name, this.value, false); + break; + case 'datetime': + params = self._prepareSubmitDates(params, this.name, this.value, true); + break; + case 'simple_choice_radio': + case 'multiple_choice': + params = self._prepareSubmitChoices(params, $(this), $(this).data('name')); + break; + case 'matrix': + params = self._prepareSubmitAnswersMatrix(params, $(this)); + break; + } + }); + }, + + /** + * Prepare date answer before submitting form. + * 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) { + var momentDate = isDateTime ? field_utils.parse.datetime(value, null, {timezone: true}) : field_utils.parse.date(value); + var formattedDate = momentDate ? momentDate.toJSON() : ''; + params[questionId] = formattedDate; + return params; + }, + + /** + * Prepare choice answer before submitting form. + * 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) { + var self = this; + $parent.find('input:checked').each(function () { + if (this.value !== '-1') { + params = self._prepareSubmitAnswer(params, questionId, this.value); + } + }); + params = self._prepareSubmitComment(params, $parent, questionId, false); + return params; + }, + + + /** + * Prepare matrix answers before submitting form. + * This method adds matrix answers one by one and add comment if any to a params key,value like : + * params = { 'matrixQuestionId' : {'rowId1': [colId1, colId2,...], 'rowId2': [colId1, colId3, ...], 'comment': comment }} + */ + _prepareSubmitAnswersMatrix: function (params, $matrixTable) { + var self = this; + $matrixTable.find('input:checked').each(function () { + params = self._prepareSubmitAnswerMatrix(params, $matrixTable.data('name'), $(this).data('rowId'), this.value); + }); + params = self._prepareSubmitComment(params, $matrixTable.closest('.js_question-wrapper'), $matrixTable.data('name'), true); + return params; + }, + + /** + * Prepare answer before submitting form if question type is matrix. + * This method regroups answers by question and by row to make an object like : + * params = { 'matrixQuestionId' : { 'rowId1' : [colId1, colId2,...], 'rowId2' : [colId1, colId3, ...] } } + */ + _prepareSubmitAnswerMatrix: function (params, questionId, rowId, colId, isComment) { + var value = questionId in params ? params[questionId] : {}; + if (isComment) { + value['comment'] = colId; + } else { + if (rowId in value) { + value[rowId].push(colId); + } else { + value[rowId] = [colId]; + } + } + params[questionId] = value; + return params; + }, + + /** + * Prepare answer before submitting form (any kind of answer - except Matrix -). + * This method regroups answers by question. + * 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) { + if (questionId in params) { + if (params[questionId].constructor === Array) { + params[questionId].push(value); + } else { + params[questionId] = [params[questionId], value]; + } + } else { + params[questionId] = value; + } + return params; + }, + + /** + * Prepare comment before submitting form. + * This method extract the comment, encapsulate it in a dict and calls the _prepareSubmitAnswer methods + * 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) { + var self = this; + $parent.find('textarea').each(function () { + if (this.value) { + var value = {'comment': this.value}; + if (isMatrix) { + params = self._prepareSubmitAnswerMatrix(params, questionId, this.name, this.value, true); + } else { + params = self._prepareSubmitAnswer(params, questionId, value); + } + } + }); + return params; + }, + + // INIT FIELDS TOOLS + // ------------------------------------------------------------------------- + + /** + * Will allow the textarea to resize on carriage return instead of showing scrollbar. + */ + _initTextArea: function () { + this.$('textarea').each(function () { + dom.autoresize($(this)); + }); + }, + + _initChoiceItems: function () { + this.$("input[type='radio'],input[type='checkbox']").each(function () { + var matrixBtn = $(this).parents('.o_survey_matrix_btn'); + if ($(this).prop("checked")) { + var $target = matrixBtn.length > 0 ? matrixBtn : $(this).closest('label'); + $target.addClass('o_survey_selected'); + } + }); + }, + + /** + * 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(); + } + }, + + /** + * Will handle bus specific behavior for survey 'sessions' + * + * @private + */ + _initSessionManagement: function () { + var self = this; + if (this.options.surveyToken && this.options.sessionInProgress) { + this.call('bus_service', 'addChannel', this.options.surveyToken); + this.call('bus_service', 'startPolling'); + + if (!this._checkIsMasterTab()) { + this.shouldReloadMasterTab = true; + this.masterTabCheckInterval = setInterval(function() { + if (self._checkIsMasterTab()) { + clearInterval(self.masterTabCheckInterval); + } + }, 2000); + } + + this.call('bus_service', 'onNotification', this, this._onNotification); + } + }, + + _initTimer: function () { + if (this.surveyTimerWidget) { + this.surveyTimerWidget.destroy(); + } + + var self = this; + var $timerData = this.$('.o_survey_form_content_data'); + var questionTimeLimitReached = $timerData.data('questionTimeLimitReached'); + var timeLimitMinutes = $timerData.data('timeLimitMinutes'); + var hasAnswered = $timerData.data('hasAnswered'); + + if (!questionTimeLimitReached && !hasAnswered && timeLimitMinutes) { + var timer = $timerData.data('timer'); + var $timer = $('<span>', { + class: 'o_survey_timer' + }); + this.$('.o_survey_timer_container').append($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': !this.options.sessionInProgress + }); + }); + } + }, + + /** + * Initialize datetimepicker in correct format and with constraints + */ + _initDateTimePicker: function ($dateGroup) { + var disabledDates = []; + var questionType = $dateGroup.find('input').data('questionType'); + var minDateData = $dateGroup.data('mindate'); + var maxDateData = $dateGroup.data('maxdate'); + + var datetimepickerFormat = questionType === 'datetime' ? time.getLangDatetimeFormat() : time.getLangDateFormat(); + + var minDate = minDateData + ? this._formatDateTime(minDateData, datetimepickerFormat) + : moment({ y: 1000 }); + + var maxDate = maxDateData + ? this._formatDateTime(maxDateData, datetimepickerFormat) + : moment().add(200, "y"); + + if (questionType === 'date') { + // Include min and max date in selectable values + maxDate = moment(maxDate).add(1, "d"); + minDate = moment(minDate).subtract(1, "d"); + disabledDates = [minDate, maxDate]; + } + + $dateGroup.datetimepicker({ + format : datetimepickerFormat, + minDate: minDate, + maxDate: maxDate, + disabledDates: disabledDates, + useCurrent: false, + viewDate: moment(new Date()).hours(minDate.hours()).minutes(minDate.minutes()).seconds(minDate.seconds()).milliseconds(minDate.milliseconds()), + calendarWeeks: true, + icons: { + time: 'fa fa-clock-o', + date: 'fa fa-calendar', + next: 'fa fa-chevron-right', + previous: 'fa fa-chevron-left', + up: 'fa fa-chevron-up', + down: 'fa fa-chevron-down', + }, + locale : moment.locale(), + allowInputToggle: true, + }); + $dateGroup.on('error.datetimepicker', function (err) { + if (err.date) { + if (err.date < minDate) { + Dialog.alert(this, _t('The date you selected is lower than the minimum date: ') + minDate.format(datetimepickerFormat)); + } + + if (err.date > maxDate) { + Dialog.alert(this, _t('The date you selected is greater than the maximum date: ') + maxDate.format(datetimepickerFormat)); + } + } + return false; + }); + }, + + _formatDateTime: function (datetimeValue, format){ + return moment(field_utils.format.datetime(moment(datetimeValue), null, {timezone: true}), format); + }, + + _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); + } + }, + + /** + * 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 -) + */ + _focusOnFirstInput: function () { + var $firstTextInput = this.$('.js_question-wrapper').first() // Take first question + .find("input[type='text'],input[type='number'],textarea") // get 'text' inputs + .filter('.form-control') // needed for the auto-resize + .not('.o_survey_comment'); // remove inputs for comments that does not count as answers + if ($firstTextInput.length > 0) { + $firstTextInput.focus(); + } + }, + + /** + * This method check if the current tab is the master tab at the bus level. + * If not, the survey could not receive next question notification anymore from session manager. + * We then ask the participant to close all other tabs on the same hostname before letting them continue. + * + * @private + */ + _checkIsMasterTab: function () { + var isMasterTab = this.call('bus_service', 'isMasterTab'); + var $errorModal = this.$('#MasterTabErrorModal'); + if (isMasterTab) { + // Force reload the page when survey is ready to be followed, to force restart long polling + if (this.shouldReloadMasterTab) { + window.location.reload(); + } + return true; + } else if (!$errorModal.modal._isShown){ + $errorModal.find('.text-danger').text(window.location.hostname); + $errorModal.modal('show'); + } + return false; + }, + + // CONDITIONAL QUESTIONS MANAGEMENT TOOLS + // ------------------------------------------------------------------------- + + /** + * Clear / Un-select all the input from the given question + * + propagate conditional hierarchy by triggering change on choice inputs. + * + * @private + */ + _clearQuestionInputs: function (question) { + question.find('input').each(function () { + if ($(this).attr('type') === 'text' || $(this).attr('type') === 'number') { + $(this).val(''); + } else if ($(this).prop('checked')) { + $(this).prop('checked', false).change(); + } + }); + question.find('textarea').val(''); + }, + + /** + * Get questions that are not supposed to be answered by the user. + * Those are the ones triggered by answers that the user did not selected. + * + * @private + */ + _getInactiveConditionalQuestionIds: function () { + var self = this; + var inactiveQuestionIds = []; + if (this.options.triggeredQuestionsByAnswer) { + Object.keys(this.options.triggeredQuestionsByAnswer).forEach(function (answerId) { + if (!self.selectedAnswers.includes(parseInt(answerId))) { + self.options.triggeredQuestionsByAnswer[answerId].forEach(function (questionId) { + inactiveQuestionIds.push(questionId); + }); + } + }); + } + return inactiveQuestionIds; + }, + + // 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(); + if (navbarHeight) { + // In overflow auto, scrollLocation of target can be negative if target is out of screen (up side) + scrollLocation = scrollLocation >= 0 ? scrollLocation - navbarHeight : scrollLocation + navbarHeight; + } + var scrollinside = $("#wrapwrap").scrollTop(); + $('#wrapwrap').animate({ + scrollTop: scrollinside + scrollLocation + }, 500); + }, + + /** + * Clean all form errors in order to clean DOM before a new validation + */ + _resetErrors: function () { + this.$('.o_survey_question_error').empty().removeClass('slide_in'); + this.$('.o_survey_error').addClass('d-none'); + }, + +}); + +return publicWidget.registry.SurveyFormWidget; + +}); diff --git a/addons/survey/static/src/js/survey_print.js b/addons/survey/static/src/js/survey_print.js new file mode 100644 index 00000000..5e4b8118 --- /dev/null +++ b/addons/survey/static/src/js/survey_print.js @@ -0,0 +1,31 @@ +odoo.define('survey.print', function (require) { +'use strict'; + +var publicWidget = require('web.public.widget'); +var dom = require('web.dom'); + +publicWidget.registry.SurveyPrintWidget = publicWidget.Widget.extend({ + selector: '.o_survey_print', + + //-------------------------------------------------------------------------- + // Widget + //-------------------------------------------------------------------------- + + /** + * @override + */ + start: function () { + var self = this; + return this._super.apply(this, arguments).then(function () { + // Will allow the textarea to resize if any carriage return instead of showing scrollbar. + self.$('textarea').each(function () { + dom.autoresize($(this)); + }); + }); + }, + +}); + +return publicWidget.registry.SurveyPrintWidget; + +}); diff --git a/addons/survey/static/src/js/survey_quick_access.js b/addons/survey/static/src/js/survey_quick_access.js new file mode 100644 index 00000000..ff0f3e88 --- /dev/null +++ b/addons/survey/static/src/js/survey_quick_access.js @@ -0,0 +1,68 @@ +odoo.define('survey.quick.access', function (require) { +'use strict'; + +var publicWidget = require('web.public.widget'); + +publicWidget.registry.SurveyQuickAccessWidget = publicWidget.Widget.extend({ + selector: '.o_survey_quick_access', + events: { + 'click button[type="submit"]': '_onSubmit', + }, + + //-------------------------------------------------------------------------- + // Widget + //-------------------------------------------------------------------------- + + /** + * @override + */ + start: function () { + var self = this; + return this._super.apply(this, arguments).then(function () { + // Init event listener + if (!self.readonly) { + $(document).on('keypress', self._onKeyPress.bind(self)); + } + + self.$('input').focus(); + }); + }, + + // ------------------------------------------------------------------------- + // Private + // ------------------------------------------------------------------------- + + // Handlers + // ------------------------------------------------------------------------- + + _onKeyPress: function (event) { + if (event.keyCode === 13) { // Enter + event.preventDefault(); + this._submitCode(); + } + }, + + _onSubmit: function (event) { + event.preventDefault(); + this._submitCode(); + }, + + _submitCode: function () { + var self = this; + this.$('.o_survey_error').addClass("d-none"); + var $sessionCodeInput = this.$('input#session_code'); + this._rpc({ + route: `/survey/check_session_code/${$sessionCodeInput.val()}`, + }).then(function (response) { + if (response.survey_url) { + window.location = response.survey_url; + } else { + self.$('.o_survey_error').removeClass("d-none"); + } + }); + }, +}); + +return publicWidget.registry.SurveyQuickAccessWidget; + +}); diff --git a/addons/survey/static/src/js/survey_result.js b/addons/survey/static/src/js/survey_result.js new file mode 100644 index 00000000..c27fc343 --- /dev/null +++ b/addons/survey/static/src/js/survey_result.js @@ -0,0 +1,408 @@ +odoo.define('survey.result', function (require) { +'use strict'; + +var _t = require('web.core')._t; +var ajax = require('web.ajax'); +var publicWidget = require('web.public.widget'); + +// The given colors are the same as those used by D3 +var D3_COLORS = ["#1f77b4","#ff7f0e","#aec7e8","#ffbb78","#2ca02c","#98df8a","#d62728", + "#ff9896","#9467bd","#c5b0d5","#8c564b","#c49c94","#e377c2","#f7b6d2", + "#7f7f7f","#c7c7c7","#bcbd22","#dbdb8d","#17becf","#9edae5"]; + +// TODO awa: this widget loads all records and only hides some based on page +// -> this is ugly / not efficient, needs to be refactored +publicWidget.registry.SurveyResultPagination = publicWidget.Widget.extend({ + events: { + 'click li.o_survey_js_results_pagination a': '_onPageClick', + }, + + //-------------------------------------------------------------------------- + // Widget + //-------------------------------------------------------------------------- + + /** + * @override + * @param {$.Element} params.questionsEl The element containing the actual questions + * to be able to hide / show them based on the page number + */ + init: function (parent, params) { + this._super.apply(this, arguments); + this.$questionsEl = params.questionsEl; + }, + + /** + * @override + */ + start: function () { + var self = this; + return this._super.apply(this, arguments).then(function () { + self.limit = self.$el.data("record_limit"); + }); + }, + + // ------------------------------------------------------------------------- + // Handlers + // ------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} ev + */ + _onPageClick: function (ev) { + ev.preventDefault(); + this.$('li.o_survey_js_results_pagination').removeClass('active'); + + var $target = $(ev.currentTarget); + $target.closest('li').addClass('active'); + this.$questionsEl.find('tbody tr').addClass('d-none'); + + var num = $target.text(); + var min = (this.limit * (num-1))-1; + if (min === -1){ + this.$questionsEl.find('tbody tr:lt('+ this.limit * num +')') + .removeClass('d-none'); + } else { + this.$questionsEl.find('tbody tr:lt('+ this.limit * num +'):gt(' + min + ')') + .removeClass('d-none'); + } + + }, +}); + +/** + * Widget responsible for the initialization and the drawing of the various charts. + * + */ +publicWidget.registry.SurveyResultChart = publicWidget.Widget.extend({ + jsLibs: [ + '/web/static/lib/Chart/Chart.js', + ], + + //-------------------------------------------------------------------------- + // Widget + //-------------------------------------------------------------------------- + + /** + * Initializes the widget based on its defined graph_type and loads the chart. + * + * @override + */ + start: function () { + var self = this; + + return this._super.apply(this, arguments).then(function () { + self.graphData = self.$el.data("graphData"); + + switch (self.$el.data("graphType")) { + case 'multi_bar': + self.chartConfig = self._getMultibarChartConfig(); + break; + case 'bar': + self.chartConfig = self._getBarChartConfig(); + break; + case 'pie': + self.chartConfig = self._getPieChartConfig(); + break; + case 'doughnut': + self.chartConfig = self._getDoughnutChartConfig(); + break; + } + + self._loadChart(); + }); + }, + + // ------------------------------------------------------------------------- + // Private + // ------------------------------------------------------------------------- + + /** + * Returns a standard multi bar chart configuration. + * + * @private + */ + _getMultibarChartConfig: function () { + return { + type: 'bar', + data: { + labels: this.graphData[0].values.map(function (value) { + return value.text; + }), + datasets: this.graphData.map(function (group, index) { + var data = group.values.map(function (value) { + return value.count; + }); + return { + label: group.key, + data: data, + backgroundColor: D3_COLORS[index % 20], + }; + }) + }, + options: { + scales: { + xAxes: [{ + ticks: { + callback: this._customTick(25), + }, + }], + yAxes: [{ + ticks: { + precision: 0, + }, + }], + }, + tooltips: { + callbacks: { + title: function (tooltipItem, data) { + return data.labels[tooltipItem[0].index]; + } + } + }, + }, + }; + }, + + /** + * Returns a standard bar chart configuration. + * + * @private + */ + _getBarChartConfig: function () { + return { + type: 'bar', + data: { + labels: this.graphData[0].values.map(function (value) { + return value.text; + }), + datasets: this.graphData.map(function (group) { + var data = group.values.map(function (value) { + return value.count; + }); + return { + label: group.key, + data: data, + backgroundColor: data.map(function (val, index) { + return D3_COLORS[index % 20]; + }), + }; + }) + }, + options: { + legend: { + display: false, + }, + scales: { + xAxes: [{ + ticks: { + callback: this._customTick(35), + }, + }], + yAxes: [{ + ticks: { + precision: 0, + }, + }], + }, + tooltips: { + enabled: false, + } + }, + }; + }, + + /** + * Returns a standard pie chart configuration. + * + * @private + */ + _getPieChartConfig: function () { + var counts = this.graphData.map(function (point) { + return point.count; + }); + + return { + type: 'pie', + data: { + labels: this.graphData.map(function (point) { + return point.text; + }), + datasets: [{ + label: '', + data: counts, + backgroundColor: counts.map(function (val, index) { + return D3_COLORS[index % 20]; + }), + }] + } + }; + }, + + _getDoughnutChartConfig: function () { + var scoring_percentage = this.$el.data("scoring_percentage") || 0.0; + var counts = this.graphData.map(function (point) { + return point.count; + }); + + return { + type: 'doughnut', + data: { + labels: this.graphData.map(function (point) { + return point.text; + }), + datasets: [{ + label: '', + data: counts, + backgroundColor: counts.map(function (val, index) { + return D3_COLORS[index % 20]; + }), + }] + }, + options: { + title: { + display: true, + text: _.str.sprintf(_t("Overall Performance %.2f%s"), parseFloat(scoring_percentage), '%'), + }, + } + }; + }, + + /** + * Custom Tick function to replace overflowing text with '...' + * + * @private + * @param {Integer} tickLimit + */ + _customTick: function (tickLimit) { + return function (label) { + if (label.length <= tickLimit) { + return label; + } else { + return label.slice(0, tickLimit) + '...'; + } + }; + }, + + /** + * Loads the chart using the provided Chart library. + * + * @private + */ + _loadChart: function () { + this.$el.css({position: 'relative'}); + var $canvas = this.$('canvas'); + var ctx = $canvas.get(0).getContext('2d'); + return new Chart(ctx, this.chartConfig); + } +}); + +publicWidget.registry.SurveyResultWidget = publicWidget.Widget.extend({ + selector: '.o_survey_result', + events: { + 'click td.survey_answer i.fa-filter': '_onSurveyAnswerFilterClick', + 'click .clear_survey_filter': '_onClearFilterClick', + 'click span.filter-all': '_onFilterAllClick', + 'click span.filter-finished': '_onFilterFinishedClick', + }, + + //-------------------------------------------------------------------------- + // Widget + //-------------------------------------------------------------------------- + + /** + * @override + */ + willStart: function () { + var url = '/web/webclient/locale/' + (document.documentElement.getAttribute('lang') || 'en_US').replace('-', '_'); + var localeReady = ajax.loadJS(url); + return Promise.all([this._super.apply(this, arguments), localeReady]); + }, + + /** + * @override + */ + start: function () { + var self = this; + return this._super.apply(this, arguments).then(function () { + var allPromises = []; + + self.$('.pagination').each(function (){ + var questionId = $(this).data("question_id"); + allPromises.push(new publicWidget.registry.SurveyResultPagination(self, { + 'questionsEl': self.$('#survey_table_question_'+ questionId) + }).attachTo($(this))); + }); + + self.$('.survey_graph').each(function () { + allPromises.push(new publicWidget.registry.SurveyResultChart(self) + .attachTo($(this))); + }); + + if (allPromises.length !== 0) { + return Promise.all(allPromises); + } else { + return Promise.resolve(); + } + }); + }, + + // ------------------------------------------------------------------------- + // Handlers + // ------------------------------------------------------------------------- + + /** + * @private + * @param {Event} ev + */ + _onSurveyAnswerFilterClick: function (ev) { + var cell = $(ev.target); + var row_id = cell.data('row_id') | 0; + var answer_id = cell.data('answer_id'); + + var params = new URLSearchParams(window.location.search); + var filters = params.get('filters') ? params.get('filters') + "|" + row_id + ',' + answer_id : row_id + ',' + answer_id; + params.set('filters', filters); + + window.location.href = window.location.pathname + '?' + params.toString(); + }, + + /** + * @private + * @param {Event} ev + */ + _onClearFilterClick: function (ev) { + var params = new URLSearchParams(window.location.search); + params.delete('filters'); + params.delete('finished'); + window.location.href = window.location.pathname + '?' + params.toString(); + }, + + /** + * @private + * @param {Event} ev + */ + _onFilterAllClick: function (ev) { + var params = new URLSearchParams(window.location.search); + params.delete('finished'); + window.location.href = window.location.pathname + '?' + params.toString(); + }, + + /** + * @private + * @param {Event} ev + */ + _onFilterFinishedClick: function (ev) { + var params = new URLSearchParams(window.location.search); + params.set('finished', true); + window.location.href = window.location.pathname + '?' + params.toString(); + }, +}); + +return { + resultWidget: publicWidget.registry.SurveyResultWidget, + chartWidget: publicWidget.registry.SurveyResultChart, + paginationWidget: publicWidget.registry.SurveyResultPagination +}; + +}); diff --git a/addons/survey/static/src/js/survey_session_chart.js b/addons/survey/static/src/js/survey_session_chart.js new file mode 100644 index 00000000..a4e74555 --- /dev/null +++ b/addons/survey/static/src/js/survey_session_chart.js @@ -0,0 +1,363 @@ +odoo.define('survey.session_chart', function (require) { +'use strict'; + +var publicWidget = require('web.public.widget'); +var SESSION_CHART_COLORS = require('survey.session_colors'); + +publicWidget.registry.SurveySessionChart = publicWidget.Widget.extend({ + init: function (parent, options) { + this._super.apply(this, arguments); + + this.questionType = options.questionType; + this.answersValidity = options.answersValidity; + this.hasCorrectAnswers = options.hasCorrectAnswers; + this.questionStatistics = this._processQuestionStatistics(options.questionStatistics); + this.showInputs = options.showInputs; + this.showAnswers = false; + }, + + start: function () { + var self = this; + return this._super.apply(this, arguments).then(function () { + self._setupChart(); + }); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Updates the chart data using the latest received question user inputs. + * + * By updating the numbers in the dataset, we take advantage of the Chartjs API + * that will automatically add animations to show the new number. + * + * @param {Object} questionStatistics object containing chart data (counts / labels / ...) + * @param {Integer} newAttendeesCount: max height of chart, not used anymore (deprecated) + */ + updateChart: function (questionStatistics, newAttendeesCount) { + if (questionStatistics) { + this.questionStatistics = this._processQuestionStatistics(questionStatistics); + } + + if (this.chart) { + // only a single dataset for our bar charts + var chartData = this.chart.data.datasets[0].data; + for (var i = 0; i < chartData.length; i++){ + var value = 0; + if (this.showInputs) { + value = this.questionStatistics[i].count; + } + this.chart.data.datasets[0].data[i] = value; + } + + this.chart.update(); + } + }, + + /** + * Toggling this parameter will display or hide the correct and incorrect answers of the current + * question directly on the chart. + * + * @param {Boolean} showAnswers + */ + setShowAnswers: function (showAnswers) { + this.showAnswers = showAnswers; + }, + + /** + * Toggling this parameter will display or hide the user inputs of the current question directly + * on the chart. + * + * @param {Boolean} showInputs + */ + setShowInputs: function (showInputs) { + this.showInputs = showInputs; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _setupChart: function () { + var $canvas = this.$('canvas'); + var ctx = $canvas.get(0).getContext('2d'); + + this.chart = new Chart(ctx, this._buildChartConfiguration()); + }, + + /** + * Custom bar chart configuration for our survey session use case. + * + * Quick summary of enabled features: + * - background_color is one of the 10 custom colors from SESSION_CHART_COLORS + * (see _getBackgroundColor for details) + * - The ticks are bigger and bolded to be able to see them better on a big screen (projector) + * - We don't use tooltips to keep it as simple as possible + * - We don't set a suggestedMin or Max so that Chart will adapt automatically himself based on the given data + * The '+1' part is a small trick to avoid the datalabels to be clipped in height + * - We use a custom 'datalabels' plugin to be able to display the number value on top of the + * associated bar of the chart. + * This allows the host to discuss results with attendees in a more interactive way. + * + * @private + */ + _buildChartConfiguration: function () { + return { + type: 'bar', + data: { + labels: this._extractChartLabels(), + datasets: [{ + backgroundColor: this._getBackgroundColor.bind(this), + data: this._extractChartData(), + }] + }, + options: { + maintainAspectRatio: false, + plugins: { + datalabels: { + color: this._getLabelColor.bind(this), + font: { + size: '50', + weight: 'bold', + }, + anchor: 'end', + align: 'top', + } + }, + legend: { + display: false, + }, + scales: { + yAxes: [{ + ticks: { + display: false, + }, + gridLines: { + display: false + } + }], + xAxes: [{ + ticks: { + maxRotation: 0, + fontSize: '35', + fontStyle: 'bold', + fontColor: '#212529' + }, + gridLines: { + drawOnChartArea: false, + color: 'rgba(0, 0, 0, 0.2)' + } + }] + }, + tooltips: { + enabled: false, + }, + layout: { + padding: { + left: 0, + right: 0, + top: 70, + bottom: 0 + } + } + }, + plugins: [{ + /** + * The way it works is each label is an array of words. + * eg.: if we have a chart label: "this is an example of a label" + * The library will split it as: ["this is an example", "of a label"] + * Each value of the array represents a line of the label. + * So for this example above: it will be displayed as: + * "this is an examble<br/>of a label", breaking the label in 2 parts and put on 2 lines visually. + * + * What we do here is rework the labels with our own algorithm to make them fit better in screen space + * based on breakpoints based on number of columns to display. + * So this example will become: ["this is an", "example of", "a label"] if we have a lot of labels to put in the chart. + * Which will be displayed as "this is an<br/>example of<br/>a label" + * Obviously, the more labels you have, the more columns, and less screen space is available. + * + * We also adapt the font size based on the width available in the chart. + * + * So we counterbalance multiple times: + * - Based on number of columns (i.e. number of survey.question.answer of your current survey.question), + * we split the words of every labels to make them display on more rows. + * - Based on the width of the chart (which is equivalent to screen width), + * we reduce the chart font to be able to fit more characters. + * - Based on the longest word present in the labels, we apply a certain ratio with the width of the chart + * to get a more accurate font size for the space available. + * + * @param {Object} chart + */ + beforeInit: function (chart) { + const nbrCol = chart.data.labels.length; + const minRatio = 0.4; + // Numbers of maximum characters per line to print based on the number of columns and default ratio for the font size + // Between 1 and 2 -> 25, 3 and 4 -> 20, 5 and 6 -> 15, ... + const charPerLineBreakpoints = [ + [1, 2, 25, minRatio], + [3, 4, 20, minRatio], + [5, 6, 15, 0.45], + [7, 8, 10, 0.65], + [9, null, 7, 0.7], + ]; + + let charPerLine; + let fontRatio; + charPerLineBreakpoints.forEach(([lowerBound, upperBound, value, ratio]) => { + if (nbrCol >= lowerBound && (upperBound === null || nbrCol <= upperBound)) { + charPerLine = value; + fontRatio = ratio; + } + }); + + // Adapt font size if the number of characters per line is under the maximum + if (charPerLine < 25) { + const allWords = chart.data.labels.reduce((accumulator, words) => accumulator.concat(' '.concat(words))); + const maxWordLength = Math.max(...allWords.split(' ').map((word) => word.length)); + fontRatio = maxWordLength > charPerLine ? minRatio : fontRatio; + chart.options.scales.xAxes[0].ticks.fontSize = Math.min(parseInt(chart.options.scales.xAxes[0].ticks.fontSize), chart.width * fontRatio / (nbrCol)); + } + + chart.data.labels.forEach(function (label, index, labelsList) { + // Split all the words of the label + const words = label.split(" "); + let resultLines = []; + let currentLine = []; + for (let i = 0; i < words.length; i++) { + // If the word we are adding exceed already the number of characters for the line, we add it anyway before passing to a new line + currentLine.push(words[i]); + + // Continue to add words in the line if there is enough space and if there is at least one more word to add + const nextWord = i+1 < words.length ? words[i+1] : null; + if (nextWord) { + const nextLength = currentLine.join(' ').length + nextWord.length; + if (nextLength <= charPerLine) { + continue; + } + } + // Add the constructed line and reset the variable for the next line + const newLabelLine = currentLine.join(' '); + resultLines.push(newLabelLine); + currentLine = []; + } + labelsList[index] = resultLines; + }); + }, + }], + }; + }, + + /** + * Returns the label of the associated survey.question.answer. + * + * @private + */ + _extractChartLabels: function () { + return this.questionStatistics.map(function (point) { + return point.text; + }); + }, + + /** + * We simply return an array of zeros as initial value. + * The chart will update afterwards as attendees add their user inputs. + * + * @private + */ + _extractChartData: function () { + return this.questionStatistics.map(function () { + return 0; + }); + }, + + /** + * Custom method that returns a color from SESSION_CHART_COLORS. + * It loops through the ten values and assign them sequentially. + * + * We have a special mechanic when the host shows the answers of a question. + * Wrong answers are "faded out" using a 0.3 opacity. + * + * @param {Object} metaData + * @param {Integer} metaData.dataIndex the index of the label, matching the index of the answer + * in 'this.answersValidity' + * @private + */ + _getBackgroundColor: function (metaData) { + var opacity = '0.8'; + if (this.showAnswers && this.hasCorrectAnswers) { + if (!this._isValidAnswer(metaData.dataIndex)){ + opacity = '0.2'; + } + } + var rgb = SESSION_CHART_COLORS[metaData.dataIndex]; + return `rgba(${rgb},${opacity})`; + }, + + /** + * Custom method that returns the survey.question.answer label color. + * + * Break-down of use cases: + * - Red if the host is showing answer, and the associated answer is not correct + * - Green if the host is showing answer, and the associated answer is correct + * - Black in all other cases + * + * @param {Object} metaData + * @param {Integer} metaData.dataIndex the index of the label, matching the index of the answer + * in 'this.answersValidity' + * @private + */ + _getLabelColor: function (metaData) { + if (this.showAnswers && this.hasCorrectAnswers) { + if (this._isValidAnswer(metaData.dataIndex)){ + return '#2CBB70'; + } else { + return '#D9534F'; + } + } + return '#212529'; + }, + + /** + * Small helper method that returns the validity of the answer based on its index. + * + * We need this special handling because of Chartjs data structure. + * The library determines the parameters (color/label/...) by only passing the answer 'index' + * (and not the id or anything else we can identify). + * + * @param {Integer} answerIndex + * @private + */ + _isValidAnswer: function (answerIndex) { + return this.answersValidity[answerIndex]; + }, + + /** + * Special utility method that will process the statistics we receive from the + * survey.question#_prepare_statistics method. + * + * For multiple choice questions, the values we need are stored in a different place. + * We simply return the values to make the use of the statistics common for both simple and + * multiple choice questions. + * + * See survey.question#_get_stats_data for more details + * + * @param {Object} rawStatistics + * @private + */ + _processQuestionStatistics: function (rawStatistics) { + if (this.questionType === 'multiple_choice') { + return rawStatistics[0].values; + } + + return rawStatistics; + } +}); + +return publicWidget.registry.SurveySessionChart; + +}); diff --git a/addons/survey/static/src/js/survey_session_colors.js b/addons/survey/static/src/js/survey_session_colors.js new file mode 100644 index 00000000..6b5ca344 --- /dev/null +++ b/addons/survey/static/src/js/survey_session_colors.js @@ -0,0 +1,21 @@ +odoo.define('survey.session_colors', function (require) { +'use strict'; + +/** + * Small tool that returns common colors for survey session widgets. + * Source: https://www.materialui.co/colors (500) + */ +return [ + '33,150,243', + '63,81,181', + '205,220,57', + '0,150,136', + '76,175,80', + '121,85,72', + '158,158,158', + '156,39,176', + '96,125,139', + '244,67,54', +]; + +}); diff --git a/addons/survey/static/src/js/survey_session_leaderboard.js b/addons/survey/static/src/js/survey_session_leaderboard.js new file mode 100644 index 00000000..7d4ad6b8 --- /dev/null +++ b/addons/survey/static/src/js/survey_session_leaderboard.js @@ -0,0 +1,335 @@ +odoo.define('survey.session_leaderboard', function (require) { +'use strict'; + +var publicWidget = require('web.public.widget'); +var SESSION_CHART_COLORS = require('survey.session_colors'); + +publicWidget.registry.SurveySessionLeaderboard = publicWidget.Widget.extend({ + init: function (parent, options) { + this._super.apply(this, arguments); + + this.surveyAccessToken = options.surveyAccessToken; + this.$sessionResults = options.sessionResults; + + this.BAR_MIN_WIDTH = '3rem'; + this.BAR_WIDTH = '24rem'; + this.BAR_HEIGHT = '3.8rem'; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Shows the question leaderboard on screen. + * It's based on the attendees score (descending). + * + * We fade out the $sessionResults to fade in our rendered template. + * + * The width of the progress bars is set after the rendering to enable a width css animation. + */ + showLeaderboard: function (fadeOut, isScoredQuestion) { + var self = this; + + var resolveFadeOut; + var fadeOutPromise; + if (fadeOut) { + fadeOutPromise = new Promise(function (resolve, reject) { resolveFadeOut = resolve; }); + self.$sessionResults.fadeOut(400, function () { + resolveFadeOut(); + }); + } else { + fadeOutPromise = Promise.resolve(); + self.$sessionResults.hide(); + self.$('.o_survey_session_leaderboard_container').empty(); + } + + var leaderboardPromise = this._rpc({ + route: _.str.sprintf('/survey/session/leaderboard/%s', this.surveyAccessToken) + }); + + Promise.all([fadeOutPromise, leaderboardPromise]).then(function (results) { + var leaderboardResults = results[1]; + var $renderedTemplate = $(leaderboardResults); + self.$('.o_survey_session_leaderboard_container').append($renderedTemplate); + + self.$('.o_survey_session_leaderboard_item').each(function (index) { + var rgb = SESSION_CHART_COLORS[index % 10]; + $(this) + .find('.o_survey_session_leaderboard_bar') + .css('background-color', `rgba(${rgb},1)`); + $(this) + .find('.o_survey_session_leaderboard_bar_question') + .css('background-color', `rgba(${rgb},${0.4})`); + }); + + self.$el.fadeIn(400, async function () { + if (isScoredQuestion) { + await self._prepareScores(); + await self._showQuestionScores(); + await self._sumScores(); + await self._reorderScores(); + } + }); + }); + }, + + /** + * Inverse the process, fading out our template to fade int the $sessionResults. + */ + hideLeaderboard: function () { + var self = this; + this.$el.fadeOut(400, function () { + self.$('.o_survey_session_leaderboard_container').empty(); + self.$sessionResults.fadeIn(400); + }); + }, + + /** + * This method animates the passed jQuery element from 0 points to {totalScore} points. + * It will create a nice "animated" effect of a counter increasing by {increment} until it + * reaches the actual score. + * + * @param {$.Element} $scoreEl the element to animate + * @param {Integer} currentScore the currently displayed score + * @param {Integer} totalScore to total score to animate to + * @param {Integer} increment the base increment of each animation iteration + * @param {Boolean} plusSign wether or not we add a "+" before the score + * @private + */ + _animateScoreCounter: function ($scoreEl, currentScore, totalScore, increment, plusSign) { + var self = this; + setTimeout(function () { + var nextScore = currentScore + increment; + if (nextScore > totalScore) { + nextScore = totalScore; + } + $scoreEl.text(`${plusSign ? '+ ' : ''}${Math.round(nextScore)} p`); + + if (nextScore < totalScore) { + self._animateScoreCounter($scoreEl, nextScore, totalScore, increment, plusSign); + } + }, 25); + }, + + /** + * Helper to move a score bar from its current position in the leaderboard + * to a new position. + * + * @param {$.Element} $score the score bar to move + * @param {Integer} position the new position in the leaderboard + * @param {Integer} offset an offset in 'rem' + * @param {Integer} timeout time to wait while moving before resolving the promise + */ + _animateMoveTo: function ($score, position, offset, timeout) { + var animationDone; + var animationPromise = new Promise(function (resolve) { + animationDone = resolve; + }); + $score.css('top', `calc(calc(${this.BAR_HEIGHT} * ${position}) + ${offset}rem)`); + setTimeout(animationDone, timeout); + return animationPromise; + }, + + /** + * Takes the leaderboard prior to the current question results + * and reduce all scores bars to a small width (3rem). + * We keep the small score bars on screen for 1s. + * + * This visually prepares the display of points for the current question. + * + * @private + */ + _prepareScores: function () { + var self = this; + var animationDone; + var animationPromise = new Promise(function (resolve) { + animationDone = resolve; + }); + setTimeout(function () { + this.$('.o_survey_session_leaderboard_bar').each(function () { + var currentScore = parseInt($(this) + .closest('.o_survey_session_leaderboard_item') + .data('currentScore')) + if (currentScore && currentScore !== 0) { + $(this).css('transition', `width 1s cubic-bezier(.4,0,.4,1)`); + $(this).css('width', self.BAR_MIN_WIDTH); + } + }); + setTimeout(animationDone, 1000); + }, 300); + + return animationPromise; + }, + + /** + * Now that we have summed the score for the current question to the total score + * of the user and re-weighted the bars accordingly, we need to re-order everything + * to match the new ranking. + * + * In addition to moving the bars to their new position, we create a "bounce" effect + * by moving the bar a little bit more to the top or bottom (depending on if it's moving up + * the ranking or down), the moving it the other way around, then moving it to its final + * position. + * + * (Feels complicated when explained but it's fairly simple once you see what it does). + * + * @private + */ + _reorderScores: function () { + var self = this; + var animationDone; + var animationPromise = new Promise(function (resolve) { + animationDone = resolve; + }); + setTimeout(function () { + self.$('.o_survey_session_leaderboard_item').each(async function () { + var $score = $(this); + var currentPosition = parseInt($(this).data('currentPosition')); + var newPosition = parseInt($(this).data('newPosition')); + if (currentPosition !== newPosition) { + var offset = newPosition > currentPosition ? 2 : -2; + await self._animateMoveTo($score, newPosition, offset, 300); + $score.css('transition', 'top ease-in-out .1s'); + await self._animateMoveTo($score, newPosition, offset * -0.3, 100); + await self._animateMoveTo($score, newPosition, 0, 0); + animationDone(); + } + }); + }, 1800); + + return animationPromise; + }, + + /** + * Will display the score for the current question. + * We simultaneously: + * - increase the width of "question bar" + * (faded out bar right next to the global score one) + * - animate the score for the question (ex: from + 0 p to + 40 p) + * + * (We keep a minimum width of 3rem to be able to display '+30 p' within the bar). + * + * @private + */ + _showQuestionScores: function () { + var self = this; + var animationDone; + var animationPromise = new Promise(function (resolve) { + animationDone = resolve; + }); + setTimeout(function () { + this.$('.o_survey_session_leaderboard_bar_question').each(function () { + var $barEl = $(this); + var width = `calc(calc(100% - ${self.BAR_WIDTH}) * ${$barEl.data('widthRatio')} + ${self.BAR_MIN_WIDTH})`; + $barEl.css('transition', 'width 1s ease-out'); + $barEl.css('width', width); + + var $scoreEl = $barEl + .find('.o_survey_session_leaderboard_bar_question_score') + .text('0 p'); + var questionScore = parseInt($barEl.data('questionScore')); + if (questionScore && questionScore > 0) { + var increment = parseInt($barEl.data('maxQuestionScore') / 40); + if (!increment || increment === 0){ + increment = 1; + } + $scoreEl.text('+ 0 p'); + console.log($barEl.data('maxQuestionScore')); + setTimeout(function () { + self._animateScoreCounter( + $scoreEl, + 0, + questionScore, + increment, + true); + }, 400); + } + setTimeout(animationDone, 1400); + }); + }, 300); + + return animationPromise; + }, + + /** + * After displaying the score for the current question, we sum the total score + * of the user so far with the score of the current question. + * + * Ex: + * We have ('#' for total score before question and '=' for current question score): + * 210 p ####=================================== +30 p John + * We want: + * 240 p ###################################==== +30 p John + * + * Of course, we also have to weight the bars based on the maximum score. + * So if John here has 50% of the points of the leader user, both the question score bar + * and the total score bar need to have their width divided by 2: + * 240 p ##################== +30 p John + * + * The width of both bars move at the same time to reach their new position, + * with an animation on the width property. + * The new width of the "question bar" should represent the ratio of won points + * when compared to the total points. + * (We keep a minimum width of 3rem to be able to display '+30 p' within the bar). + * + * The updated total score is animated towards the new value. + * we keep this on screen for 500ms before reordering the bars. + * + * @private + */ + _sumScores: function () { + var self = this; + var animationDone; + var animationPromise = new Promise(function (resolve) { + animationDone = resolve; + }); + // values that felt the best after a lot of testing + var growthAnimation = 'cubic-bezier(.5,0,.66,1.11)'; + setTimeout(function () { + this.$('.o_survey_session_leaderboard_item').each(function () { + var currentScore = parseInt($(this).data('currentScore')); + var updatedScore = parseInt($(this).data('updatedScore')); + var increment = parseInt($(this).data('maxQuestionScore') / 40); + if (!increment || increment === 0){ + increment = 1; + } + self._animateScoreCounter( + $(this).find('.o_survey_session_leaderboard_score'), + currentScore, + updatedScore, + increment, + false); + + var maxUpdatedScore = parseInt($(this).data('maxUpdatedScore')); + var baseRatio = updatedScore / maxUpdatedScore; + var questionScore = parseInt($(this).data('questionScore')); + var questionRatio = questionScore / + (updatedScore && updatedScore !== 0 ? updatedScore : 1); + // we keep a min fixed with of 3rem to be able to display "+ 5 p" + // even if the user already has 1.000.000 points + var questionWith = `calc(calc(calc(100% - ${self.BAR_WIDTH}) * ${questionRatio * baseRatio}) + ${self.BAR_MIN_WIDTH})`; + $(this) + .find('.o_survey_session_leaderboard_bar_question') + .css('transition', `width ease .5s ${growthAnimation}`) + .css('width', questionWith); + + var updatedScoreRatio = 1 - questionRatio; + var updatedScoreWidth = `calc(calc(100% - ${self.BAR_WIDTH}) * ${updatedScoreRatio * baseRatio})`; + $(this) + .find('.o_survey_session_leaderboard_bar') + .css('min-width', '0px') + .css('transition', `width ease .5s ${growthAnimation}`) + .css('width', updatedScoreWidth); + + setTimeout(animationDone, 500); + }); + }, 1400); + + return animationPromise; + } +}); + +return publicWidget.registry.SurveySessionLeaderboard; + +}); diff --git a/addons/survey/static/src/js/survey_session_manage.js b/addons/survey/static/src/js/survey_session_manage.js new file mode 100644 index 00000000..dc9898e7 --- /dev/null +++ b/addons/survey/static/src/js/survey_session_manage.js @@ -0,0 +1,588 @@ +odoo.define('survey.session_manage', function (require) { +'use strict'; + +var publicWidget = require('web.public.widget'); +var SurveySessionChart = require('survey.session_chart'); +var SurveySessionTextAnswers = require('survey.session_text_answers'); +var SurveySessionLeaderBoard = require('survey.session_leaderboard'); +var core = require('web.core'); +var _t = core._t; + +publicWidget.registry.SurveySessionManage = publicWidget.Widget.extend({ + selector: '.o_survey_session_manage', + events: { + 'click .o_survey_session_copy': '_onCopySessionLink', + 'click .o_survey_session_navigation_next, .o_survey_session_start': '_onNext', + 'click .o_survey_session_navigation_previous': '_onBack', + 'click .o_survey_session_close': '_onEndSessionClick', + }, + + /** + * Overridden to set a few properties that come from the python template rendering. + * + * We also handle the timer IF we're not "transitioning", meaning a fade out of the previous + * $el to the next question (the fact that we're transitioning is in the isRpcCall data). + * If we're transitioning, the timer is handled manually at the end of the transition. + */ + start: function () { + var self = this; + this.fadeInOutTime = 500; + return this._super.apply(this, arguments).then(function () { + // general survey props + self.surveyId = self.$el.data('surveyId'); + self.surveyAccessToken = self.$el.data('surveyAccessToken'); + self.isStartScreen = self.$el.data('isStartScreen'); + self.isLastQuestion = self.$el.data('isLastQuestion'); + // scoring props + self.isScoredQuestion = self.$el.data('isScoredQuestion'); + self.sessionShowLeaderboard = self.$el.data('sessionShowLeaderboard'); + self.hasCorrectAnswers = self.$el.data('hasCorrectAnswers'); + // display props + self.showBarChart = self.$el.data('showBarChart'); + self.showTextAnswers = self.$el.data('showTextAnswers'); + + var isRpcCall = self.$el.data('isRpcCall'); + if (!isRpcCall) { + self._startTimer(); + $(document).on('keydown', self._onKeyDown.bind(self)); + } + + self._setupIntervals(); + self._setupCurrentScreen(); + var setupPromises = []; + setupPromises.push(self._setupTextAnswers()); + setupPromises.push(self._setupChart()); + setupPromises.push(self._setupLeaderboard()); + + return Promise.all(setupPromises); + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Copies the survey URL link to the clipboard. + * We use 'ClipboardJS' to avoid having to print the URL in a standard text input + * + * @param {MouseEvent} ev + */ + _onCopySessionLink: function (ev) { + var self = this; + ev.preventDefault(); + + var $clipboardBtn = this.$('.o_survey_session_copy'); + + $clipboardBtn.popover({ + placement: 'right', + container: 'body', + offset: '0, 3', + content: function () { + return _t("Copied !"); + } + }); + + var clipboard = new ClipboardJS('.o_survey_session_copy', { + text: function () { + return self.$('.o_survey_session_copy_url').val(); + }, + container: this.el + }); + + clipboard.on('success', function () { + clipboard.destroy(); + $clipboardBtn.popover('show'); + _.delay(function () { + $clipboardBtn.popover('hide'); + }, 800); + }); + + clipboard.on('error', function (e) { + clipboard.destroy(); + }); + }, + + /** + * Listeners for keyboard arrow / spacebar keys. + * + * - 39 = arrow-right + * - 32 = spacebar + * - 37 = arrow-left + * + * @param {KeyboardEvent} ev + */ + _onKeyDown: function (ev) { + var keyCode = ev.keyCode; + + if (keyCode === 39 || keyCode === 32) { + this._onNext(ev); + } else if (keyCode === 37) { + this._onBack(ev); + } + }, + + /** + * Handles the "next screen" behavior. + * It happens when the host uses the keyboard key / button to go to the next screen. + * The result depends on the current screen we're on. + * + * Possible values of the "next screen" to display are: + * - 'userInputs' when going from a question to the display of attendees' survey.user_input.line + * for that question. + * - 'results' when going from the inputs to the actual correct / incorrect answers of that + * question. Only used for scored simple / multiple choice questions. + * - 'leaderboard' (or 'leaderboardFinal') when going from the correct answers of a question to + * the leaderboard of attendees. Only used for scored simple / multiple choice questions. + * - If it's not one of the above: we go to the next question, or end the session if we're on + * the last question of this session. + * + * See '_getNextScreen' for a detailed logic. + * + * @param {Event} ev + */ + _onNext: function (ev) { + ev.preventDefault(); + + var screenToDisplay = this._getNextScreen(); + + if (screenToDisplay === 'userInputs') { + this._setShowInputs(true); + this.$('.o_survey_session_navigation_previous').removeClass('d-none'); + } else if (screenToDisplay === 'results') { + this._setShowAnswers(true); + // when showing results, stop refreshing answers + clearInterval(this.resultsRefreshInterval); + delete this.resultsRefreshInterval; + this.$('.o_survey_session_navigation_previous').removeClass('d-none'); + } else if (['leaderboard', 'leaderboardFinal'].includes(screenToDisplay) + && !['leaderboard', 'leaderboardFinal'].includes(this.currentScreen)) { + if (this.isLastQuestion) { + this.$('.o_survey_session_navigation_next').addClass('d-none'); + } + this.leaderBoard.showLeaderboard(true, this.isScoredQuestion); + } else { + if (!this.isLastQuestion) { + this._nextQuestion(); + } else if (!this.sessionShowLeaderboard) { + // If we have no leaderboard to show, directly end the session + this.$('.o_survey_session_close').click(); + } + } + + this.currentScreen = screenToDisplay; + }, + + /** + * Reverse behavior of '_onNext'. + * + * @param {Event} ev + */ + _onBack: function (ev) { + ev.preventDefault(); + + var screenToDisplay = this._getPreviousScreen(); + + if (screenToDisplay === 'question') { + this._setShowInputs(false); + this.$('.o_survey_session_navigation_previous').addClass('d-none'); + } else if (screenToDisplay === 'userInputs') { + this._setShowAnswers(false); + // resume refreshing answers if necessary + if (!this.resultsRefreshInterval) { + this.resultsRefreshInterval = setInterval(this._refreshResults.bind(this), 2000); + } + } else if (screenToDisplay === 'results') { + this.leaderBoard.hideLeaderboard(); + // when showing results, stop refreshing answers + clearInterval(this.resultsRefreshInterval); + delete this.resultsRefreshInterval; + } + + this.currentScreen = screenToDisplay; + }, + + /** + * Marks this session as 'done' and redirects the user to the results based on the clicked link. + * + * @param {MouseEvent} ev + * @private + */ + _onEndSessionClick: function (ev) { + var self = this; + ev.preventDefault(); + + this._rpc({ + model: 'survey.survey', + method: 'action_end_session', + args: [[this.surveyId]], + }).then(function () { + if ($(ev.currentTarget).data('showResults')) { + document.location = _.str.sprintf( + '/survey/results/%s', + self.surveyId + ); + } else { + window.history.back(); + } + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Business logic that determines the 'next screen' based on the current screen and the question + * configuration. + * + * Breakdown of use cases: + * - If we're on the 'question' screen, and the question is scored, we move to the 'userInputs' + * - If we're on the 'question' screen and it's NOT scored, then we move to + * - 'results' if the question has correct / incorrect answers + * (but not scored, which is kind of a corner case) + * - 'nextQuestion' otherwise + * - If we're on the 'userInputs' screen and the question has answers, we move to the 'results' + * - If we're on the 'results' and the question is scored, we move to the 'leaderboard' + * - In all other cases, we show the next question + * - (Small exception for the last question: we show the "final leaderboard") + * + * (For details about which screen shows what, see '_onNext') + */ + _getNextScreen: function () { + if (this.currentScreen === 'question' && this.isScoredQuestion) { + return 'userInputs'; + } else if (this.hasCorrectAnswers && ['question', 'userInputs'].includes(this.currentScreen)) { + return 'results'; + } else if (this.sessionShowLeaderboard) { + if (['question', 'userInputs', 'results'].includes(this.currentScreen) && this.isScoredQuestion) { + return 'leaderboard'; + } else if (this.isLastQuestion) { + return 'leaderboardFinal'; + } + } + return 'nextQuestion'; + }, + + /** + * Reverse behavior of '_getNextScreen'. + * + * @param {Event} ev + */ + _getPreviousScreen: function () { + if (this.currentScreen === 'userInputs' && this.isScoredQuestion) { + return 'question'; + } else if (this.currentScreen === 'results' || + (this.currentScreen === 'leaderboard' && !this.isScoredQuestion)) { + return 'userInputs'; + } else if (this.currentScreen === 'leaderboard' && this.isScoredQuestion) { + return 'results'; + } + + return this.currentScreen; + }, + + /** + * We use a fade in/out mechanism to display the next question of the session. + * + * The fade out happens at the same moment as the _rpc to get the new question template. + * When they're both finished, we update the HTML of this widget with the new template and then + * fade in the updated question to the user. + * + * The timer (if configured) starts at the end of the fade in animation. + * + * @param {MouseEvent} ev + * @private + */ + _nextQuestion: function () { + var self = this; + + this.isStartScreen = false; + if (this.surveyTimerWidget) { + this.surveyTimerWidget.destroy(); + } + + var resolveFadeOut; + var fadeOutPromise = new Promise(function (resolve, reject) { resolveFadeOut = resolve; }); + this.$el.fadeOut(this.fadeInOutTime, function () { + resolveFadeOut(); + }); + + var nextQuestionPromise = this._rpc({ + route: _.str.sprintf('/survey/session/next_question/%s', self.surveyAccessToken) + }); + + // avoid refreshing results while transitioning + if (this.resultsRefreshInterval) { + clearInterval(this.resultsRefreshInterval); + delete this.resultsRefreshInterval; + } + + Promise.all([fadeOutPromise, nextQuestionPromise]).then(function (results) { + if (results[1]) { + var $renderedTemplate = $(results[1]); + self.$el.replaceWith($renderedTemplate); + self.attachTo($renderedTemplate); + self.$el.fadeIn(self.fadeInOutTime, function () { + self._startTimer(); + }); + } 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); + }); + } else { + self.$('.o_survey_session_close').click(); + } + }); + }, + + /** + * Will start the question timer so that the host may know when the question is done to display + * the results and the leaderboard. + * + * If the question is scored, the timer ending triggers the display of attendees inputs. + */ + _startTimer: function () { + var self = this; + var $timer = this.$('.o_survey_timer'); + + if ($timer.length) { + var timeLimitMinutes = this.$el.data('timeLimitMinutes'); + var timer = this.$el.data('timer'); + this.surveyTimerWidget = new publicWidget.registry.SurveyTimerWidget(this, { + 'timer': timer, + 'timeLimitMinutes': timeLimitMinutes + }); + this.surveyTimerWidget.attachTo($timer); + this.surveyTimerWidget.on('time_up', this, function () { + if (self.currentScreen === 'question' && this.isScoredQuestion) { + self.$('.o_survey_session_navigation_next').click(); + } + }); + } + }, + + /** + * Refreshes the question results. + * + * What we get from this call: + * - The 'question statistics' used to display the bar chart when appropriate + * - The 'user input lines' that are used to display text/date/datetime answers on the screen + * - The number of answers, useful for refreshing the progress bar + */ + _refreshResults: function () { + var self = this; + + return this._rpc({ + route: _.str.sprintf('/survey/session/results/%s', self.surveyAccessToken) + }).then(function (questionResults) { + if (questionResults) { + self.attendeesCount = questionResults.attendees_count; + + if (self.resultsChart && questionResults.question_statistics_graph) { + self.resultsChart.updateChart(JSON.parse(questionResults.question_statistics_graph)); + } else if (self.textAnswers) { + self.textAnswers.updateTextAnswers(questionResults.input_line_values); + } + + var max = self.attendeesCount > 0 ? self.attendeesCount : 1; + var percentage = Math.min(Math.round((questionResults.answer_count / max) * 100), 100); + self.$('.progress-bar').css('width', `${percentage}%`); + + if (self.attendeesCount && self.attendeesCount > 0) { + var answerCount = Math.min(questionResults.answer_count, self.attendeesCount); + self.$('.o_survey_session_answer_count').text(answerCount); + self.$('.progress-bar.o_survey_session_progress_small span').text( + `${answerCount} / ${self.attendeesCount}` + ); + } + } + + return Promise.resolve(); + }, function () { + // on failure, stop refreshing + clearInterval(self.resultsRefreshInterval); + delete self.resultsRefreshInterval; + }); + }, + + /** + * We refresh the attendees count every 2 seconds while the user is on the start screen. + * + */ + _refreshAttendeesCount: function () { + var self = this; + + return self._rpc({ + model: 'survey.survey', + method: 'read', + args: [[self.surveyId], ['session_answer_count']], + }).then(function (result) { + if (result && result.length === 1){ + self.$('.o_survey_session_attendees_count').text( + result[0].session_answer_count + ); + } + }, function () { + // on failure, stop refreshing + clearInterval(self.attendeesRefreshInterval); + }); + }, + + /** + * For simple/multiple choice questions, we display a bar chart with: + * + * - answers of attendees + * - correct / incorrect answers when relevant + * + * see SurveySessionChart widget doc for more information. + * + */ + _setupChart: function () { + if (this.resultsChart) { + this.resultsChart.setElement(null); + this.resultsChart.destroy(); + delete this.resultsChart; + } + + if (!this.isStartScreen && this.showBarChart) { + this.resultsChart = new SurveySessionChart(this, { + questionType: this.$el.data('questionType'), + answersValidity: this.$el.data('answersValidity'), + hasCorrectAnswers: this.hasCorrectAnswers, + questionStatistics: this.$el.data('questionStatistics'), + showInputs: this.showInputs + }); + + return this.resultsChart.attachTo(this.$('.o_survey_session_chart')); + } else { + return Promise.resolve(); + } + }, + + /** + * Leaderboard of all the attendees based on their score. + * see SurveySessionLeaderBoard widget doc for more information. + * + */ + _setupLeaderboard: function () { + if (this.leaderBoard) { + this.leaderBoard.setElement(null); + this.leaderBoard.destroy(); + delete this.leaderBoard; + } + + if (this.isScoredQuestion || this.isLastQuestion) { + this.leaderBoard = new SurveySessionLeaderBoard(this, { + surveyAccessToken: this.surveyAccessToken, + sessionResults: this.$('.o_survey_session_results') + }); + + return this.leaderBoard.attachTo(this.$('.o_survey_session_leaderboard')); + } else { + return Promise.resolve(); + } + }, + + /** + * Shows attendees answers for char_box/date and datetime questions. + * see SurveySessionTextAnswers widget doc for more information. + * + */ + _setupTextAnswers: function () { + if (this.textAnswers) { + this.textAnswers.setElement(null); + this.textAnswers.destroy(); + delete this.textAnswers; + } + + if (!this.isStartScreen && this.showTextAnswers) { + this.textAnswers = new SurveySessionTextAnswers(this, { + questionType: this.$el.data('questionType') + }); + + return this.textAnswers.attachTo(this.$('.o_survey_session_text_answers_container')); + } else { + return Promise.resolve(); + } + }, + + /** + * Setup the 2 refresh intervals of 2 seconds for our widget: + * - The refresh of attendees count (only on the start screen) + * - The refresh of results (used for chart/text answers/progress bar) + */ + _setupIntervals: function () { + this.attendeesCount = this.$el.data('attendeesCount') ? this.$el.data('attendeesCount') : 0; + + if (this.isStartScreen) { + this.attendeesRefreshInterval = setInterval(this._refreshAttendeesCount.bind(this), 2000); + } else { + if (this.attendeesRefreshInterval) { + clearInterval(this.attendeesRefreshInterval); + } + + if (!this.resultsRefreshInterval) { + this.resultsRefreshInterval = setInterval(this._refreshResults.bind(this), 2000); + } + } + }, + + /** + * Setup current screen based on question properties. + * If it's a non-scored question with a chart, we directly display the user inputs. + */ + _setupCurrentScreen: function () { + if (this.isStartScreen) { + this.currentScreen = 'startScreen'; + } else if (!this.isScoredQuestion && this.showBarChart) { + this.currentScreen = 'userInputs'; + } else { + this.currentScreen = 'question'; + } + + this._setShowInputs(this.currentScreen === 'userInputs'); + }, + + /** + * When we go from the 'question' screen to the 'userInputs' screen, we toggle this boolean + * and send the information to the chart. + * The chart will show attendees survey.user_input.lines. + * + * @param {Boolean} showInputs + */ + _setShowInputs(showInputs) { + this.showInputs = showInputs; + + if (this.resultsChart) { + this.resultsChart.setShowInputs(showInputs); + this.resultsChart.updateChart(); + } + }, + + /** + * When we go from the 'userInputs' screen to the 'results' screen, we toggle this boolean + * and send the information to the chart. + * The chart will show the question survey.question.answers. + * (Only used for simple / multiple choice questions). + * + * @param {Boolean} showAnswers + */ + _setShowAnswers(showAnswers) { + this.showAnswers = showAnswers; + + if (this.resultsChart) { + this.resultsChart.setShowAnswers(showAnswers); + this.resultsChart.updateChart(); + } + } +}); + +return publicWidget.registry.SurveySessionManage; + +}); diff --git a/addons/survey/static/src/js/survey_session_text_answers.js b/addons/survey/static/src/js/survey_session_text_answers.js new file mode 100644 index 00000000..4e669c17 --- /dev/null +++ b/addons/survey/static/src/js/survey_session_text_answers.js @@ -0,0 +1,74 @@ +odoo.define('survey.session_text_answers', function (require) { +'use strict'; + +var publicWidget = require('web.public.widget'); +var core = require('web.core'); +var time = require('web.time'); +var SESSION_CHART_COLORS = require('survey.session_colors'); + +var QWeb = core.qweb; + +publicWidget.registry.SurveySessionTextAnswers = publicWidget.Widget.extend({ + xmlDependencies: ['/survey/static/src/xml/survey_session_text_answer_template.xml'], + init: function (parent, options) { + this._super.apply(this, arguments); + + this.answerIds = []; + this.questionType = options.questionType; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Adds the attendees answers on the screen. + * This is used for char_box/date and datetime questions. + * + * We use some tricks with jQuery for wow effect: + * - force a width on the external div container, to reserve space for that answer + * - set the actual width of the answer, and enable a css width animation + * - set the opacity to 1, and enable a css opacity animation + * + * @param {Array} inputLineValues array of survey.user_input.line records in the form + * {id: line.id, value: line.[value_char_box/value_date/value_datetime]} + */ + updateTextAnswers: function (inputLineValues) { + var self = this; + + inputLineValues.forEach(function (inputLineValue) { + if (!self.answerIds.includes(inputLineValue.id) && inputLineValue.value) { + var textValue = inputLineValue.value; + if (self.questionType === 'char_box') { + textValue = textValue.length > 25 ? + textValue.substring(0, 22) + '...' : + textValue; + } else if (self.questionType === 'date') { + textValue = moment(textValue).format(time.getLangDateFormat()); + } else if (self.questionType === 'datetime') { + textValue = moment(textValue).format(time.getLangDatetimeFormat()); + } + + var $textAnswer = $(QWeb.render('survey.survey_session_text_answer', { + value: textValue, + borderColor: `rgb(${SESSION_CHART_COLORS[self.answerIds.length % 10]})` + })); + self.$el.append($textAnswer); + var spanWidth = $textAnswer.find('span').width(); + var calculatedWidth = `calc(${spanWidth}px + 1.2rem)`; + $textAnswer.css('width', calculatedWidth); + setTimeout(function () { + // setTimeout to force jQuery rendering + $textAnswer.find('.o_survey_session_text_answer_container') + .css('width', calculatedWidth) + .css('opacity', '1'); + }, 1); + self.answerIds.push(inputLineValue.id); + } + }); + }, +}); + +return publicWidget.registry.SurveySessionTextAnswers; + +}); diff --git a/addons/survey/static/src/js/survey_timer.js b/addons/survey/static/src/js/survey_timer.js new file mode 100644 index 00000000..2bfd78fe --- /dev/null +++ b/addons/survey/static/src/js/survey_timer.js @@ -0,0 +1,72 @@ +odoo.define('survey.timer', function (require) { +'use strict'; + +var publicWidget = require('web.public.widget'); + +publicWidget.registry.SurveyTimerWidget = publicWidget.Widget.extend({ + //-------------------------------------------------------------------------- + // Widget + //-------------------------------------------------------------------------- + + /** + * @override + */ + init: function (parent, params) { + this._super.apply(this, arguments); + this.timer = params.timer; + this.timeLimitMinutes = params.timeLimitMinutes; + this.surveyTimerInterval = null; + }, + + + /** + * Two responsabilities : Validate that time limit is not exceeded and Run timer otherwise. + * + * @override + */ + start: function () { + var self = this; + return this._super.apply(this, arguments).then(function () { + self.countDownDate = moment.utc(self.timer).add(self.timeLimitMinutes, 'minutes'); + if (self.timeLimitMinutes <= 0 || self.countDownDate.diff(moment.utc(), 'seconds') < 0) { + self.trigger_up('time_up'); + } else { + self._updateTimer(); + self.surveyTimerInterval = setInterval(self._updateTimer.bind(self), 1000); + } + }); + }, + + // ------------------------------------------------------------------------- + // Private + // ------------------------------------------------------------------------- + + _formatTime: function (time) { + return time > 9 ? time : '0' + time; + }, + + /** + * This function is responsible for the visual update of the timer DOM every second. + * When the time runs out, it triggers a 'time_up' event to notify the parent widget. + * + * We use a diff in millis and not a second, that we round to the nearest second. + * Indeed, a difference of 999 millis is interpreted as 0 second by moment, which is problematic + * for our use case. + */ + _updateTimer: function () { + var timeLeft = Math.round(this.countDownDate.diff(moment.utc(), 'milliseconds') / 1000); + + if (timeLeft >= 0) { + var timeLeftMinutes = parseInt(timeLeft / 60); + var timeLeftSeconds = timeLeft - (timeLeftMinutes * 60); + this.$el.text(this._formatTime(timeLeftMinutes) + ':' + this._formatTime(timeLeftSeconds)); + } else { + clearInterval(this.surveyTimerInterval); + this.trigger_up('time_up'); + } + }, +}); + +return publicWidget.registry.SurveyTimerWidget; + +}); diff --git a/addons/survey/static/src/scss/survey_form.scss b/addons/survey/static/src/scss/survey_form.scss new file mode 100644 index 00000000..270b0e61 --- /dev/null +++ b/addons/survey/static/src/scss/survey_form.scss @@ -0,0 +1,422 @@ +/********************************************************** + Remove website navbar : Should be done in website survey + but we won't do a bridge module only for this. + TODO: SmartPeople Fixme - cleaner solution? be my guest! + **********************************************************/ +body.o_connected_user { + padding-top: 0px !important; +} + +nav#oe_main_menu_navbar { + display: none; +} + +/********************************************************** + Common Style + **********************************************************/ +.o_survey_wrap { + min-height: 100%; +} + +.o_survey_progress_wrapper { + min-width: 11rem; + max-width: 15rem; + + .o_survey_progress { + height:0.5em; + } +} + +.o_survey_navigation_wrapper .o_survey_navigation_submit { + cursor: pointer; +} + +.o_survey_timer { + min-height: 1.2rem; +} + +.o_survey_brand_message { + background-color: rgba(255,255,255,0.7); +} + +.o_survey_form, .o_survey_print, .o_survey_session_manage, .o_survey_quick_access { + .o_survey_question_error { + height: 0px; + transition: height .5s ease; + line-height: 4rem; + &.slide_in { + height: 4rem; + } + } + + fieldset[disabled] { + .o_survey_question_text_box, + .o_survey_question_date, + .o_survey_question_datetime, + .o_survey_question_numerical_box { + padding-left: 0px; + } + } + + .o_survey_question_text_box, + .o_survey_question_date, + .o_survey_question_datetime, + .o_survey_question_numerical_box { + border: 0px; + border-bottom: 1px solid $primary; + &:disabled { + color: black !important; + border-color: $gray-600; + border-bottom: 1px solid $gray-600; + } + &:focus { + box-shadow: none; + } + } + + .o_survey_form_date .input-group-append { + right: 0; + bottom: 5px; + top: auto; + } + + .o_survey_choice_btn { + transition: background-color 0.3s ease; + flex: 1 0 300px; + color: $primary; + + span { + line-height: 25px; + } + i { + top: 0px; + font-size: large; + &.fa-check-circle,&.fa-check-square { + display: none; + } + } + + &.o_survey_selected i { + display: none; + &.fa-check-circle,&.fa-check-square { + display: inline; + } + } + } + + input::placeholder, textarea::placeholder { + font-weight: 300; + } + + .o_survey_page_per_question.o_survey_simple_choice.o_survey_minimized_display, + .o_survey_page_per_question.o_survey_multiple_choice.o_survey_minimized_display, + .o_survey_page_per_question.o_survey_numerical_box, + .o_survey_page_per_question.o_survey_date, + .o_survey_page_per_question.o_survey_datetime { + // 'pixel perfect' layouting for choice questions having less than 5 choices in page_per_question mode + // we use media queries instead of bootstrap classes because they don't provide everything needed here + @media (min-width: 768px) { + width: 50%; + position: relative; + left: 25%; + } + } + + .o_survey_question_matrix { + td { + min-width: 100px; + i { + font-size: 22px; + display: none; + &.o_survey_matrix_empty_checkbox { + display: inline; + } + } + .o_survey_choice_key { + left: 10px; + right: auto; + top: 12px; + > span > span { + top: 0px; + } + } + + &.o_survey_selected { + i { + display: inline; + &.o_survey_matrix_empty_checkbox { + display: none; + } + } + } + } + thead { + th:first-child { + border-top-left-radius: .25rem; + } + th:last-child { + border-top-right-radius: .25rem; + } + } + tbody tr:last-child { + th { + border-bottom-left-radius: .25rem; + } + td:last-child { + border-bottom-right-radius: .25rem; + } + } + } +} + +.o_survey_form, .o_survey_session_manage { + .o_survey_question_matrix { + th { + background-color: $primary; + } + td { + background-color: rgba($primary, 0.2); + } + } +} + +/********************************************************** + Form Specific Style + **********************************************************/ + +.o_survey_form { + min-height: 25rem; + + .o_survey_choice_btn { + cursor: pointer; + background-color: rgba($primary, 0.1); + box-shadow: $primary 0px 0px 0px 1px; + + &.o_survey_selected { + box-shadow: $primary 0px 0px 0px 2px; + } + + &:hover { + background-color: rgba($primary, 0.3); + .o_survey_choice_key span.o_survey_key { + opacity: 1; + } + } + } + + .o_survey_choice_key { + width: 25px; + height: 25px; + border: 1px solid $primary; + span { + font-size: smaller; + top: -1px; + &.o_survey_key { + right: 21px; + border: 1px solid $primary; + border-right: 0px; + height: 25px; + transition: opacity 0.4s ease; + white-space: nowrap; + opacity: 0; + span { + top: -2px; + } + } + } + } + + .o_survey_question_matrix td:hover { + background-color: rgba($primary, 0.5); + cursor: pointer; + .o_survey_choice_key span.o_survey_key { + opacity: 1; + } + } +} + +/********************************************************** + Survey Session Specific Style + **********************************************************/ + +.o_survey_session_manage { + h1 { + font-size: 3rem; + } + + h2 { + font-size: 2.5rem; + } + + .o_survey_session_navigation { + position: fixed; + padding: 1rem; + top: calc(50% - 0.5rem); + cursor: pointer; + + &.o_survey_session_navigation_next { + right: 2rem; + } + + &.o_survey_session_navigation_previous { + left: 2rem; + } + } + + .o_survey_manage_fontsize_14 { + font-size: 1.4rem; + } + + .o_survey_question_header { + top: 1em; + > div { + width: 400px; + } + .progress { + height: 2rem; + border-radius: 0.6rem; + font-size: 1.2rem; + background-color: #cfcfcf; + .progress-bar { + width: 0%; + transition: width 1s ease; + } + } + } + + .o_survey_session_manage_container { + .o_survey_choice_key { + display: none; + } + + &.pt-6 { + padding-top: 5rem !important; + } + + .o_survey_session_results { + display: flex; // here and not d-flex because we need to be able to fade-out + + .mb-6 { + margin-bottom: 6rem; + } + + .o_survey_session_text_answer { + .o_survey_session_text_answer_container { + border: solid 1.6px; + border-radius: 0.6rem; + font-size: 1.4rem; + width: 2rem; + opacity: .1; + transition: width .4s ease, opacity .4s ease; + overflow: hidden; + } + + span { + white-space: nowrap; + } + } + } + + .o_survey_session_leaderboard { + display: flex; // here and not d-flex because we need to be able to fade-out + .o_survey_leaderboard_buttons { + line-height: 4rem; + font-variant: small-caps; + } + } + } + + .o_survey_session_copy { + cursor: pointer; + opacity: .75; + transition: opacity .3s ease; + &:hover { + opacity: 1; + } + } +} + +.o_survey_session_leaderboard { + font-size: 1.4rem; + + .o_survey_session_leaderboard_container { + height: calc(2.8rem * 15); + } + + .o_survey_session_leaderboard_item { + line-height: 2.4rem; + width: 100%; + transition: top ease-in-out .3s; + + .o_survey_session_leaderboard_score { + width: 6.5rem; + padding-top: .2rem; + height: 2.8rem; + } + + .o_survey_session_leaderboard_bar, .o_survey_session_leaderboard_bar_question { + height: 2.8rem; + } + + .o_survey_session_leaderboard_bar { + min-width: 3rem; + background-color: #007A77; + z-index: 2; + } + + .o_survey_session_leaderboard_bar_question_score { + top: .2rem; + right: .5rem; + width: 20rem; + z-index: 1; + } + + .o_survey_session_leaderboard_name { + padding-top: .2rem; + width: 7.5rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } +} + +/********************************************************** + Print Specific Style + **********************************************************/ + +.o_survey_print { + .o_survey_choice_btn { + background-color: $gray-500; + border-color: transparent; + cursor: default; + color: white; // not bootstrap to customize for survey_print only + font-weight: bold; // not bootstrap to customize for survey_print only + + &.bg-success, &.bg-danger { + opacity: 0.6; + } + &.o_survey_selected { + background-color: $gray-600; + opacity: 1; + } + i.fa-square-o, i.fa-circle-thin { + display: none; + } + } + .o_survey_question_matrix { + th { + /* important needed to force override bg-primary set on th in the template */ + background-color: $gray-600 !important; + } + td { + background-color: $gray-200; + &:hover { + cursor: default; + } + } + i.fa-check-square, i.fa-check-circle, i.o_survey_matrix_empty_checkbox { + color: $gray-600; + } + } +} diff --git a/addons/survey/static/src/scss/survey_reports.scss b/addons/survey/static/src/scss/survey_reports.scss new file mode 100644 index 00000000..2cdb5a0a --- /dev/null +++ b/addons/survey/static/src/scss/survey_reports.scss @@ -0,0 +1,196 @@ +@font-face { + font-family: certification-cursive; + src: url(/survey/static/src/fonts/MrDeHaviland-Regular.ttf); +} +@font-face { + font-family: certification-serif; + src: url(/survey/static/src/fonts/ETHOS-REGULAR.OTF); +} +@font-face { + font-family: certification-modern; + src: url(/survey/static/src/fonts/Oswald-Light.ttf); +} + +#o_survey_certification.certification-wrapper { + width: 250mm; + height: 280mm; + display: flex; + background-size: 100% 100%; + background-color: #875A7B; + + &.blue { + background-color: #4761e2; + } + + &.gold { + background-color: #af881a; + } + + .certification { + position: relative; + width: 100%; + height: 100%; + + .test-entry { + top: 0px; + left: 0px; + position: absolute; + content: ''; + height: 610px; + width: 860px; + background: url(/survey/static/src/img/watermark.png) no-repeat; + background-size: 100% 100%; + background-position: center; + } + } + + &.classic { + padding: 5mm; + background-image: url(/survey/static/src/img/certification_bg_classic_default.jpg); + color: #3e2e3a; + + &.blue { + background-image: url(/survey/static/src/img/certification_bg_classic_blue.jpg); + color: #272d4f; + } + + &.gold { + background-image: url(/survey/static/src/img/certification_bg_classic_gold.jpg); + color: #5A4721; + } + + .certification { + padding: 20mm; + font-family: certification-serif, serif; + font-family: Times; + text-align: center; + color: inherit; + + .certification-name-label { + padding: 5mm 0 8mm; + } + + .certification-name { + font-size: 45pt; + line-height: 0.9; + text-transform: uppercase; + letter-spacing: 0.2mm; + } + + .user-name { + padding-bottom: 6mm; + padding-top: 3mm; + font-family: certification-cursive, cursive; + letter-spacing: -0.2mm; + word-spacing: 0.2mm; + font-size: 60pt; + } + + hr { + border-color: inherit; + margin: 15mm auto; + opacity: 0.6; + width: 50mm; + + &.small { + margin: 2mm auto; + } + } + + .certification-description, .certification-date { + padding: 5mm 40mm 10mm; + } + + .certificate-signature { + position: absolute; + bottom: 10mm; + right: 10mm; + left: 10mm; + font-size: 14pt; + + img { + max-width: 40mm; + max-height: 40mm; + } + } + } + } + + &.modern { + background-image: url(/survey/static/src/img/certification_bg_modern.png); + + .certification { + padding-left: 40mm; + padding-right: 40mm; + font-family: certification-modern, sans-serif; + color: #223541; + + .certification-bg-dark { + display: inline-block; + background-color: #223541; + background: rgba(34, 53, 65, 0.1); + } + + .certification-top { + .certification-bg-dark { + margin-bottom: 10mm; + padding: 20mm 6mm 5mm; + text-align: center; + width: 56mm; + } + + .certification-company-wrapper { + padding: 1mm 2mm; + + img { + max-width: 40mm; + max-height: 30mm; + } + } + + .certification-name { + text-transform: uppercase; + font-size: 40pt; + line-height: 0.9; + } + } + + .certification-bottom { + padding: 60mm 0 10mm; + font-size: 16pt; + + .user-name { + margin-bottom: 20mm; + font-family: certification-cursive, cursive; + font-size: 50pt; + } + + .certification-bottom-group { + position: absolute; + bottom: 0; + right: 40mm; + left: 40mm; + top: 220mm; + + > div { + display: inline-block; + height: 100%; + float: left; + bottom: 0; + top: 0; + } + + .certification-bg-dark { + width: 56mm; + max-height: 100%; + } + + .certification-date { + padding-right: 30mm; + padding-left: 15mm; + } + } + } + } + } +} diff --git a/addons/survey/static/src/scss/survey_views.scss b/addons/survey/static/src/scss/survey_views.scss new file mode 100644 index 00000000..6a0f4ddb --- /dev/null +++ b/addons/survey/static/src/scss/survey_views.scss @@ -0,0 +1,26 @@ +.o_kanban_card_survey { + & > .row { + min-height: 60px; + } +} + +.o_kanban_card_survey_successed{ + background-image: + linear-gradient(rgba(255,255,255,.9), + rgba(255,255,255,.9)), + url(/survey/static/src/img/trophy-solid.svg); + background-repeat: no-repeat; + background-position: bottom 6px left -45px; + background-size: 100%, 100px; +} + +table.o_section_list_view tr.o_data_row.o_is_section { + font-weight: bold; + background-color: #DDD; + border-top: 1px solid #BBB; + border-bottom: 1px solid #BBB; +} + +.icon_rotates { + transform: rotate(180deg); +} diff --git a/addons/survey/static/src/xml/survey_breadcrumb_templates.xml b/addons/survey/static/src/xml/survey_breadcrumb_templates.xml new file mode 100644 index 00000000..1df7f54a --- /dev/null +++ b/addons/survey/static/src/xml/survey_breadcrumb_templates.xml @@ -0,0 +1,31 @@ +<?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 bg-transparent"> + <t t-set="canGoBack" t-value="widget.canGoBack"/> + <t t-foreach="widget.pages" t-as="page"> + <t t-set="isActivePage" t-value="page.id === widget.currentPageId"/> + <li t-att-class="'breadcrumb-item' + (isActivePage ? ' active font-weight-bold' : '')" + 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 class="text-primary" href="#"> + <span t-esc="page.title" /> + </a> + </t> + <t t-else=""> + <span t-att-class="(isActivePage ? 'text-black' : 'text-muted')" + t-esc="page.title" /> + </t> + </li> + </t> + </ol> +</t> + +</templates> diff --git a/addons/survey/static/src/xml/survey_session_text_answer_template.xml b/addons/survey/static/src/xml/survey_session_text_answer_template.xml new file mode 100644 index 00000000..d37e0864 --- /dev/null +++ b/addons/survey/static/src/xml/survey_session_text_answer_template.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + +<div t-name="survey.survey_session_text_answer" class="o_survey_session_text_answer d-inline-block m-1"> + <div class="o_survey_session_text_answer_container d-inline-block p-2 font-weight-bold" + t-attf-style="border-color: #{borderColor}"> + <span class="d-inline-block" t-esc="value" /> + </div> +</div> + +</templates> |
