summaryrefslogtreecommitdiff
path: root/addons/survey/static/src
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/survey/static/src
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/survey/static/src')
-rw-r--r--addons/survey/static/src/css/survey_print.css17
-rw-r--r--addons/survey/static/src/css/survey_result.css77
-rwxr-xr-xaddons/survey/static/src/fonts/ETHOS-REGULAR.OTFbin0 -> 54772 bytes
-rw-r--r--addons/survey/static/src/fonts/MrDeHaviland-Regular.ttfbin0 -> 43260 bytes
-rw-r--r--addons/survey/static/src/fonts/MrDeHaviland-ofl.txt94
-rw-r--r--addons/survey/static/src/fonts/Oswald-Light.ttfbin0 -> 124680 bytes
-rw-r--r--addons/survey/static/src/img/burger_quiz_background.jpgbin0 -> 364528 bytes
-rw-r--r--addons/survey/static/src/img/burger_quiz_cubism_klein.jpgbin0 -> 49649 bytes
-rw-r--r--addons/survey/static/src/img/burger_quiz_don_quixote.jpgbin0 -> 42181 bytes
-rw-r--r--addons/survey/static/src/img/burger_quiz_guernica.jpgbin0 -> 33208 bytes
-rw-r--r--addons/survey/static/src/img/burger_quiz_self_portrait.jpgbin0 -> 14892 bytes
-rw-r--r--addons/survey/static/src/img/certification_bg_classic_blue.jpgbin0 -> 83580 bytes
-rw-r--r--addons/survey/static/src/img/certification_bg_classic_default.jpgbin0 -> 78982 bytes
-rw-r--r--addons/survey/static/src/img/certification_bg_classic_gold.jpgbin0 -> 83632 bytes
-rw-r--r--addons/survey/static/src/img/certification_bg_modern.pngbin0 -> 7880 bytes
-rw-r--r--addons/survey/static/src/img/certification_seal.svg1
-rw-r--r--addons/survey/static/src/img/survey_background.jpgbin0 -> 166837 bytes
-rw-r--r--addons/survey/static/src/img/survey_background_2.jpgbin0 -> 86252 bytes
-rw-r--r--addons/survey/static/src/img/trophy-solid.svg1
-rw-r--r--addons/survey/static/src/img/watermark.pngbin0 -> 61598 bytes
-rw-r--r--addons/survey/static/src/js/fields_form_page_description.js53
-rw-r--r--addons/survey/static/src/js/fields_section_one2many.js185
-rw-r--r--addons/survey/static/src/js/libs/chartjs-plugin-datalabels.min.js7
-rw-r--r--addons/survey/static/src/js/survey_breadcrumb.js50
-rw-r--r--addons/survey/static/src/js/survey_form.js1094
-rw-r--r--addons/survey/static/src/js/survey_print.js31
-rw-r--r--addons/survey/static/src/js/survey_quick_access.js68
-rw-r--r--addons/survey/static/src/js/survey_result.js408
-rw-r--r--addons/survey/static/src/js/survey_session_chart.js363
-rw-r--r--addons/survey/static/src/js/survey_session_colors.js21
-rw-r--r--addons/survey/static/src/js/survey_session_leaderboard.js335
-rw-r--r--addons/survey/static/src/js/survey_session_manage.js588
-rw-r--r--addons/survey/static/src/js/survey_session_text_answers.js74
-rw-r--r--addons/survey/static/src/js/survey_timer.js72
-rw-r--r--addons/survey/static/src/scss/survey_form.scss422
-rw-r--r--addons/survey/static/src/scss/survey_reports.scss196
-rw-r--r--addons/survey/static/src/scss/survey_views.scss26
-rw-r--r--addons/survey/static/src/xml/survey_breadcrumb_templates.xml31
-rw-r--r--addons/survey/static/src/xml/survey_session_text_answer_template.xml11
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
new file mode 100755
index 00000000..7069cc84
--- /dev/null
+++ b/addons/survey/static/src/fonts/ETHOS-REGULAR.OTF
Binary files differ
diff --git a/addons/survey/static/src/fonts/MrDeHaviland-Regular.ttf b/addons/survey/static/src/fonts/MrDeHaviland-Regular.ttf
new file mode 100644
index 00000000..20e83445
--- /dev/null
+++ b/addons/survey/static/src/fonts/MrDeHaviland-Regular.ttf
Binary files differ
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
new file mode 100644
index 00000000..b210bd7f
--- /dev/null
+++ b/addons/survey/static/src/fonts/Oswald-Light.ttf
Binary files differ
diff --git a/addons/survey/static/src/img/burger_quiz_background.jpg b/addons/survey/static/src/img/burger_quiz_background.jpg
new file mode 100644
index 00000000..b52eac68
--- /dev/null
+++ b/addons/survey/static/src/img/burger_quiz_background.jpg
Binary files differ
diff --git a/addons/survey/static/src/img/burger_quiz_cubism_klein.jpg b/addons/survey/static/src/img/burger_quiz_cubism_klein.jpg
new file mode 100644
index 00000000..95f58804
--- /dev/null
+++ b/addons/survey/static/src/img/burger_quiz_cubism_klein.jpg
Binary files differ
diff --git a/addons/survey/static/src/img/burger_quiz_don_quixote.jpg b/addons/survey/static/src/img/burger_quiz_don_quixote.jpg
new file mode 100644
index 00000000..616bc388
--- /dev/null
+++ b/addons/survey/static/src/img/burger_quiz_don_quixote.jpg
Binary files differ
diff --git a/addons/survey/static/src/img/burger_quiz_guernica.jpg b/addons/survey/static/src/img/burger_quiz_guernica.jpg
new file mode 100644
index 00000000..3301982c
--- /dev/null
+++ b/addons/survey/static/src/img/burger_quiz_guernica.jpg
Binary files differ
diff --git a/addons/survey/static/src/img/burger_quiz_self_portrait.jpg b/addons/survey/static/src/img/burger_quiz_self_portrait.jpg
new file mode 100644
index 00000000..efd7e833
--- /dev/null
+++ b/addons/survey/static/src/img/burger_quiz_self_portrait.jpg
Binary files differ
diff --git a/addons/survey/static/src/img/certification_bg_classic_blue.jpg b/addons/survey/static/src/img/certification_bg_classic_blue.jpg
new file mode 100644
index 00000000..4145476d
--- /dev/null
+++ b/addons/survey/static/src/img/certification_bg_classic_blue.jpg
Binary files differ
diff --git a/addons/survey/static/src/img/certification_bg_classic_default.jpg b/addons/survey/static/src/img/certification_bg_classic_default.jpg
new file mode 100644
index 00000000..b638bad1
--- /dev/null
+++ b/addons/survey/static/src/img/certification_bg_classic_default.jpg
Binary files differ
diff --git a/addons/survey/static/src/img/certification_bg_classic_gold.jpg b/addons/survey/static/src/img/certification_bg_classic_gold.jpg
new file mode 100644
index 00000000..6e2fdba0
--- /dev/null
+++ b/addons/survey/static/src/img/certification_bg_classic_gold.jpg
Binary files differ
diff --git a/addons/survey/static/src/img/certification_bg_modern.png b/addons/survey/static/src/img/certification_bg_modern.png
new file mode 100644
index 00000000..c0046186
--- /dev/null
+++ b/addons/survey/static/src/img/certification_bg_modern.png
Binary files differ
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
new file mode 100644
index 00000000..ba7c4f02
--- /dev/null
+++ b/addons/survey/static/src/img/survey_background.jpg
Binary files differ
diff --git a/addons/survey/static/src/img/survey_background_2.jpg b/addons/survey/static/src/img/survey_background_2.jpg
new file mode 100644
index 00000000..b2e076ed
--- /dev/null
+++ b/addons/survey/static/src/img/survey_background_2.jpg
Binary files differ
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
new file mode 100644
index 00000000..91ec26fc
--- /dev/null
+++ b/addons/survey/static/src/img/watermark.png
Binary files differ
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>