Initial commit: Core packages
|
|
@ -0,0 +1,93 @@
|
|||
Copyright 2011 The Alex Brush Project Authors (https://github.com/googlefonts/alex-brush)
|
||||
|
||||
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.
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
Copyright 2007 The Ibarra Real Nova Project Authors (https://github.com/googlefonts/ibarrareal)
|
||||
|
||||
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.
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
Copyright (c) 2011, The Montserrat Project Authors (https://github.com/JulietaUla/Montserrat).
|
||||
Copyright (c) 2015, Jasper @ Cannot Into Space Fonts.
|
||||
|
||||
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.
|
||||
|
After Width: | Height: | Size: 356 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.89 595.28"><polygon points="0 0 0 111.72 644.97 595.28 841.89 595.28 841.89 0 0 0" style="fill:#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 165 B |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
|
@ -0,0 +1,14 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 62.01 117.54">
|
||||
<defs>
|
||||
<clipPath id="a" transform="translate(0 0)">
|
||||
<rect width="62.01" height="117.54" style="fill:none" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g style="clip-path:url(#a)">
|
||||
<path d="M31,62A31,31,0,1,0,0,31,31,31,0,0,0,31,62" transform="translate(0 0)" style="fill:#263e86" />
|
||||
<path d="M14,59.92v57.62l17-13.9,17,13.9V59.92a31,31,0,0,1-34,0" transform="translate(0 0)"
|
||||
style="fill:#263e86" />
|
||||
<circle cx="31.01" cy="31.01" r="23.06" style="fill:none;stroke:#f1f2f2;stroke-width:0.16699999570846558px" />
|
||||
<circle cx="31.01" cy="31.01" r="22.23" style="fill:none;stroke:#f1f2f2;stroke-width:0.16699999570846558px" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 807 B |
|
|
@ -0,0 +1,14 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 62.01 117.54">
|
||||
<defs>
|
||||
<clipPath id="a" transform="translate(0 0)">
|
||||
<rect width="62.01" height="117.54" style="fill:none" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g style="clip-path:url(#a)">
|
||||
<path d="M31,62A31,31,0,1,0,0,31,31,31,0,0,0,31,62" transform="translate(0 0)" style="fill:#d7a520" />
|
||||
<path d="M14,59.92v57.62l17-13.9,17,13.9V59.92a31,31,0,0,1-34,0" transform="translate(0 0)"
|
||||
style="fill:#d7a520" />
|
||||
<circle cx="31.01" cy="31.01" r="23.06" style="fill:none;stroke:#f1f2f2;stroke-width:0.16699999570846558px" />
|
||||
<circle cx="31.01" cy="31.01" r="22.23" style="fill:none;stroke:#f1f2f2;stroke-width:0.16699999570846558px" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 807 B |
|
|
@ -0,0 +1,14 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 62.01 117.54">
|
||||
<defs>
|
||||
<clipPath id="a" transform="translate(0 0)">
|
||||
<rect width="62.01" height="117.54" style="fill:none" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g style="clip-path:url(#a)">
|
||||
<path d="M31,62A31,31,0,1,0,0,31,31,31,0,0,0,31,62" transform="translate(0 0)" style="fill:#875A7B" />
|
||||
<path d="M14,59.92v57.62l17-13.9,17,13.9V59.92a31,31,0,0,1-34,0" transform="translate(0 0)"
|
||||
style="fill:#875A7B" />
|
||||
<circle cx="31.01" cy="31.01" r="23.06" style="fill:none;stroke:#f1f2f2;stroke-width:0.16699999570846558px" />
|
||||
<circle cx="31.01" cy="31.01" r="22.23" style="fill:none;stroke:#f1f2f2;stroke-width:0.16699999570846558px" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 807 B |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 8 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
|
@ -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="#9DA1AA" 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>
|
||||
|
After Width: | Height: | Size: 808 B |
BIN
odoo-bringout-oca-ocb-survey/survey/static/src/img/watermark.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
|
|
@ -0,0 +1,51 @@
|
|||
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 = $(`
|
||||
<button type="button" title="Open section" class="btn oe_edit_only o_icon_button">
|
||||
<i class="fa fa-fw o_button_icon fa-external-link"/>
|
||||
</button>
|
||||
`);
|
||||
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);
|
||||
|
||||
});
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
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--;
|
||||
}
|
||||
// Render empty cells for buttons to avoid having unaligned elements
|
||||
nbrColumns -= this.columns.filter(elem => elem.tag === "button_group").length;
|
||||
$cell.attr('colspan', nbrColumns);
|
||||
} else if (node.tag === "button_group") {
|
||||
$cell.addClass('o_invisible_modifier');
|
||||
} 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);
|
||||
});
|
||||
7
odoo-bringout-oca-ocb-survey/survey/static/src/js/libs/chartjs-plugin-datalabels.min.js
vendored
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
odoo.define('survey.breadcrumb', function (require) {
|
||||
'use strict';
|
||||
|
||||
var publicWidget = require('web.public.widget');
|
||||
|
||||
publicWidget.registry.SurveyBreadcrumbWidget = publicWidget.Widget.extend({
|
||||
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;
|
||||
|
||||
});
|
||||
1207
odoo-bringout-oca-ocb-survey/survey/static/src/js/survey_form.js
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import publicWidget from 'web.public.widget';
|
||||
|
||||
export const SurveyImageZoomer = publicWidget.Widget.extend({
|
||||
template: 'survey.survey_image_zoomer',
|
||||
events: {
|
||||
'wheel .o_survey_img_zoom_image': '_onImgScroll',
|
||||
'click': '_onZoomerClick',
|
||||
'click .o_survey_img_zoom_in_btn': '_onZoomInClick',
|
||||
'click .o_survey_img_zoom_out_btn': '_onZoomOutClick',
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
init(params) {
|
||||
this.zoomImageScale = 1;
|
||||
// The image is needed to render the template survey_image_zoom.
|
||||
this.sourceImage = params.sourceImage;
|
||||
this._super(... arguments);
|
||||
},
|
||||
/**
|
||||
* Open a transparent modal displaying the survey choice image.
|
||||
* @override
|
||||
*/
|
||||
async start() {
|
||||
const superResult = await this._super(...arguments);
|
||||
// Prevent having hidden modal in the view.
|
||||
this.$el.on('hidden.bs.modal', () => this.destroy());
|
||||
this.$el.modal('show');
|
||||
return superResult;
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Zoom in/out image on scrolling
|
||||
*
|
||||
* @private
|
||||
* @param {WheelEvent} e
|
||||
*/
|
||||
_onImgScroll(e) {
|
||||
e.preventDefault();
|
||||
if (e.originalEvent.wheelDelta > 0 || e.originalEvent.detail < 0) {
|
||||
this._addZoomSteps(1);
|
||||
} else {
|
||||
this._addZoomSteps(-1);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Allow user to close by clicking anywhere (mobile...). Destroying the modal
|
||||
* without using 'hide' would leave a modal-open in the view.
|
||||
* @private
|
||||
* @param {Event} e
|
||||
*/
|
||||
_onZoomerClick(e) {
|
||||
e.preventDefault();
|
||||
this.$el.modal('hide');
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {Event} e
|
||||
*/
|
||||
_onZoomInClick(e) {
|
||||
e.stopPropagation();
|
||||
this._addZoomSteps(1);
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {Event} e
|
||||
*/
|
||||
_onZoomOutClick(e) {
|
||||
e.stopPropagation();
|
||||
this._addZoomSteps(-1);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Zoom in / out the image by changing the scale by the given number of steps.
|
||||
*
|
||||
* @private
|
||||
* @param {integer} zoomStepNumber - Number of zoom steps applied to the scale of
|
||||
* the image. It can be negative, in order to zoom out. Step is set to 0.1.
|
||||
*/
|
||||
_addZoomSteps(zoomStepNumber) {
|
||||
const image = this.el.querySelector('.o_survey_img_zoom_image');
|
||||
const body = this.el.querySelector('.o_survey_img_zoom_body');
|
||||
const imageWidth = image.clientWidth;
|
||||
const imageHeight = image.clientHeight;
|
||||
const bodyWidth = body.clientWidth;
|
||||
const bodyHeight = body.clientHeight;
|
||||
const newZoomImageScale = this.zoomImageScale + zoomStepNumber * 0.1;
|
||||
if (newZoomImageScale <= 0.2) {
|
||||
// Prevent the user from de-zooming too much
|
||||
return;
|
||||
}
|
||||
if (zoomStepNumber > 0 && (imageWidth * newZoomImageScale > bodyWidth || imageHeight * newZoomImageScale > bodyHeight)) {
|
||||
// Prevent to user to further zoom in as the new image would becomes too large or too high for the screen.
|
||||
// Dezooming is still allowed to bring back image into frame (use case: resizing screen).
|
||||
return;
|
||||
}
|
||||
// !important is needed to prevent default 'no-transform' on smaller screens.
|
||||
image.setAttribute('style', 'transform: scale(' + newZoomImageScale + ') !important');
|
||||
this.zoomImageScale = newZoomImageScale;
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
odoo.define('survey.preload_image_mixin', function (require) {
|
||||
"use strict";
|
||||
|
||||
|
||||
return {
|
||||
/**
|
||||
* Load the target section background and render it when loaded.
|
||||
*
|
||||
* This method is used to pre-load the image during the questions transitions (fade out) in order
|
||||
* to be sure the image is fully loaded when setting it as background of the next question and
|
||||
* finally display it (fade in)
|
||||
*
|
||||
* This idea is to wait until new background is loaded before changing the background
|
||||
* (to avoid flickering or loading latency)
|
||||
*
|
||||
* @param {string} imageUrl
|
||||
* @private
|
||||
*/
|
||||
_preloadBackground: async function (imageUrl) {
|
||||
var resolvePreload;
|
||||
|
||||
// We have to manually create a promise here because the "onload" API does not provide one.
|
||||
var preloadPromise = new Promise(function (resolve, reject) {resolvePreload = resolve;});
|
||||
var background = new Image();
|
||||
background.onload = function () {
|
||||
resolvePreload(imageUrl);
|
||||
};
|
||||
background.src = imageUrl;
|
||||
|
||||
return preloadPromise;
|
||||
}
|
||||
};
|
||||
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
||||
});
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
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',
|
||||
'input #session_code': '_onSessionCodeInput',
|
||||
'click .o_survey_launch_session': '_onLaunchSessionClick',
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// 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));
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Private
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// Handlers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
_onLaunchSessionClick: async function () {
|
||||
const sessionResult = await this._rpc({
|
||||
'model': 'survey.survey',
|
||||
'method': 'action_start_session',
|
||||
'args': [[this.$('.o_survey_launch_session').data('surveyId')]],
|
||||
});
|
||||
window.location = sessionResult.url;
|
||||
},
|
||||
|
||||
_onSessionCodeInput: function () {
|
||||
this.el.querySelectorAll('.o_survey_error > span').forEach((elem) => elem.classList.add('d-none'));
|
||||
this.$('.o_survey_launch_session').addClass('d-none');
|
||||
this.$('button[type="submit"]').removeClass('d-none');
|
||||
},
|
||||
|
||||
_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 > span').addClass('d-none');
|
||||
const sessionCodeInputVal = this.$('input#session_code').val().trim();
|
||||
if (!sessionCodeInputVal) {
|
||||
self.$('.o_survey_session_error_invalid_code').removeClass('d-none');
|
||||
return;
|
||||
}
|
||||
this._rpc({
|
||||
route: `/survey/check_session_code/${sessionCodeInputVal}`,
|
||||
}).then(function (response) {
|
||||
if (response.survey_url) {
|
||||
window.location = response.survey_url;
|
||||
} else {
|
||||
if (response.error && response.error === 'survey_session_not_launched') {
|
||||
self.$('.o_survey_session_error_not_launched').removeClass('d-none');
|
||||
if ("survey_id" in response) {
|
||||
self.$('button[type="submit"]').addClass('d-none');
|
||||
self.$('.o_survey_launch_session').removeClass('d-none');
|
||||
self.$('.o_survey_launch_session').data('surveyId', response.survey_id);
|
||||
}
|
||||
} else {
|
||||
self.$('.o_survey_session_error_invalid_code').removeClass('d-none');
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return publicWidget.registry.SurveyQuickAccessWidget;
|
||||
|
||||
});
|
||||
|
|
@ -0,0 +1,596 @@
|
|||
odoo.define('survey.result', function (require) {
|
||||
'use strict';
|
||||
|
||||
var _t = require('web.core')._t;
|
||||
const { loadJS } = require('@web/core/assets');
|
||||
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");
|
||||
self.rightAnswers = self.$el.data("rightAnswers") || [];
|
||||
|
||||
if (self.graphData && self.graphData.length !== 0) {
|
||||
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;
|
||||
case 'by_section':
|
||||
self.chartConfig = self._getSectionResultsChartConfig();
|
||||
break;
|
||||
}
|
||||
|
||||
self._loadChart();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Private
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns a standard multi bar chart configuration.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_getMultibarChartConfig: function () {
|
||||
return {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: this.graphData[0].values.map(this._markIfCorrect, this),
|
||||
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: {
|
||||
beginAtZero: true,
|
||||
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(this._markIfCorrect, this),
|
||||
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: {
|
||||
beginAtZero: true,
|
||||
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(this._markIfCorrect, this),
|
||||
datasets: [{
|
||||
label: '',
|
||||
data: counts,
|
||||
backgroundColor: counts.map(function (val, index) {
|
||||
return D3_COLORS[index % 20];
|
||||
}),
|
||||
}]
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
_getDoughnutChartConfig: function () {
|
||||
var totalsGraphData = this.graphData.totals;
|
||||
var counts = totalsGraphData.map(function (point) {
|
||||
return point.count;
|
||||
});
|
||||
|
||||
return {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: totalsGraphData.map(this._markIfCorrect, this),
|
||||
datasets: [{
|
||||
label: '',
|
||||
data: counts,
|
||||
backgroundColor: counts.map(function (val, index) {
|
||||
return D3_COLORS[index % 20];
|
||||
}),
|
||||
borderColor: 'rgba(0, 0, 0, 0.1)'
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
title: {
|
||||
display: true,
|
||||
text: _t("Overall Performance"),
|
||||
},
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Displays the survey results grouped by section.
|
||||
* For each section, user can see the percentage of answers
|
||||
* - Correct
|
||||
* - Partially correct (multiple choices and not all correct answers ticked)
|
||||
* - Incorrect
|
||||
* - Unanswered
|
||||
*
|
||||
* e.g:
|
||||
*
|
||||
* Mathematics:
|
||||
* - Correct 75%
|
||||
* - Incorrect 25%
|
||||
* - Partially correct 0%
|
||||
* - Unanswered 0%
|
||||
*
|
||||
* Geography:
|
||||
* - Correct 0%
|
||||
* - Incorrect 0%
|
||||
* - Partially correct 50%
|
||||
* - Unanswered 50%
|
||||
*
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_getSectionResultsChartConfig: function () {
|
||||
var sectionGraphData = this.graphData.by_section;
|
||||
|
||||
var resultKeys = {
|
||||
'correct': _t('Correct'),
|
||||
'partial': _t('Partially'),
|
||||
'incorrect': _t('Incorrect'),
|
||||
'skipped': _t('Unanswered'),
|
||||
};
|
||||
var resultColorIndex = 0;
|
||||
var datasets = [];
|
||||
for (var resultKey in resultKeys) {
|
||||
var data = [];
|
||||
for (var section in sectionGraphData) {
|
||||
data.push((sectionGraphData[section][resultKey]) / sectionGraphData[section]['question_count'] * 100);
|
||||
}
|
||||
datasets.push({
|
||||
label: resultKeys[resultKey],
|
||||
data: data,
|
||||
backgroundColor: D3_COLORS[resultColorIndex % 20],
|
||||
});
|
||||
resultColorIndex++;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: Object.keys(sectionGraphData),
|
||||
datasets: datasets
|
||||
},
|
||||
options: {
|
||||
title: {
|
||||
display: true,
|
||||
text: _t("Performance by Section"),
|
||||
},
|
||||
legend: {
|
||||
display: true,
|
||||
},
|
||||
scales: {
|
||||
xAxes: [{
|
||||
ticks: {
|
||||
callback: this._customTick(20),
|
||||
},
|
||||
}],
|
||||
yAxes: [{
|
||||
gridLines: {
|
||||
display: false,
|
||||
},
|
||||
ticks: {
|
||||
beginAtZero: true,
|
||||
precision: 0,
|
||||
callback: function (label) {
|
||||
return label + '%';
|
||||
},
|
||||
suggestedMin: 0,
|
||||
suggestedMax: 100,
|
||||
maxTicksLimit: 5,
|
||||
stepSize: 25,
|
||||
},
|
||||
}],
|
||||
},
|
||||
tooltips: {
|
||||
callbacks: {
|
||||
label: function (tooltipItem, data) {
|
||||
var datasetLabel = data.datasets[tooltipItem.datasetIndex].label || '';
|
||||
var roundedValue = Math.round(tooltipItem.yLabel * 100) / 100;
|
||||
return `${datasetLabel}: ${roundedValue}%`;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 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);
|
||||
},
|
||||
|
||||
/**
|
||||
* Adds a unicode 'check' mark if the answer's text is among the question's right answers.
|
||||
* @private
|
||||
* @param {Object} value
|
||||
* @param {String} value.text The original text of the answer
|
||||
*/
|
||||
_markIfCorrect: function (value) {
|
||||
return `${value.text}${this.rightAnswers.indexOf(value.text) >= 0 ? " \u2713": ''}`;
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
publicWidget.registry.SurveyResultWidget = publicWidget.Widget.extend({
|
||||
selector: '.o_survey_result',
|
||||
events: {
|
||||
'click .o_survey_results_topbar_clear_filters': '_onClearFiltersClick',
|
||||
'click i.filter-add-answer': '_onFilterAddAnswerClick',
|
||||
'click i.filter-remove-answer': '_onFilterRemoveAnswerClick',
|
||||
'click a.filter-finished-or-not': '_onFilterFinishedOrNotClick',
|
||||
'click a.filter-finished': '_onFilterFinishedClick',
|
||||
'click a.filter-failed': '_onFilterFailedClick',
|
||||
'click a.filter-passed': '_onFilterPassedClick',
|
||||
'click a.filter-passed-and-failed': '_onFilterPassedAndFailedClick',
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Widget
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
willStart: function () {
|
||||
var url = '/web/webclient/locale/' + (document.documentElement.getAttribute('lang') || 'en_US').replace('-', '_');
|
||||
var localeReady = 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
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Add an answer filter by updating the URL and redirecting.
|
||||
* @private
|
||||
* @param {Event} ev
|
||||
*/
|
||||
_onFilterAddAnswerClick: function (ev) {
|
||||
let params = new URLSearchParams(window.location.search);
|
||||
params.set('filters', this._prepareAnswersFilters(params.get('filters'), 'add', ev));
|
||||
window.location.href = window.location.pathname + '?' + params.toString();
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove an answer filter by updating the URL and redirecting.
|
||||
* @private
|
||||
* @param {Event} ev
|
||||
*/
|
||||
_onFilterRemoveAnswerClick: function (ev) {
|
||||
let params = new URLSearchParams(window.location.search);
|
||||
let filters = this._prepareAnswersFilters(params.get('filters'), 'remove', ev);
|
||||
if (filters) {
|
||||
params.set('filters', filters);
|
||||
} else {
|
||||
params.delete('filters')
|
||||
}
|
||||
window.location.href = window.location.pathname + '?' + params.toString();
|
||||
},
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Event} ev
|
||||
*/
|
||||
_onClearFiltersClick: function (ev) {
|
||||
let params = new URLSearchParams(window.location.search);
|
||||
params.delete('filters');
|
||||
params.delete('finished');
|
||||
params.delete('failed');
|
||||
params.delete('passed');
|
||||
window.location.href = window.location.pathname + '?' + params.toString();
|
||||
},
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Event} ev
|
||||
*/
|
||||
_onFilterFinishedOrNotClick: function (ev) {
|
||||
let params = new URLSearchParams(window.location.search);
|
||||
params.delete('finished');
|
||||
window.location.href = window.location.pathname + '?' + params.toString();
|
||||
},
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Event} ev
|
||||
*/
|
||||
_onFilterFinishedClick: function (ev) {
|
||||
let params = new URLSearchParams(window.location.search);
|
||||
params.set('finished', 'true');
|
||||
window.location.href = window.location.pathname + '?' + params.toString();
|
||||
},
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Event} ev
|
||||
*/
|
||||
_onFilterFailedClick: function (ev) {
|
||||
let params = new URLSearchParams(window.location.search);
|
||||
params.set('failed', 'true');
|
||||
params.delete('passed');
|
||||
window.location.href = window.location.pathname + '?' + params.toString();
|
||||
},
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Event} ev
|
||||
*/
|
||||
_onFilterPassedClick: function (ev) {
|
||||
let params = new URLSearchParams(window.location.search);
|
||||
params.set('passed', 'true');
|
||||
params.delete('failed');
|
||||
window.location.href = window.location.pathname + '?' + params.toString();
|
||||
},
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Event} ev
|
||||
*/
|
||||
_onFilterPassedAndFailedClick: function (ev) {
|
||||
let params = new URLSearchParams(window.location.search);
|
||||
params.delete('failed');
|
||||
params.delete('passed');
|
||||
window.location.href = window.location.pathname + '?' + params.toString();
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the modified pathname string for filters after adding or removing an
|
||||
* answer filter (from click event). Filters are formatted as `"rowX,ansX", where
|
||||
* the row is used for matrix-type questions and set to 0 otherwise.
|
||||
* @private
|
||||
* @param {String} filters Existing answer filters, formatted as `rowX,ansX|rowY,ansY...`.
|
||||
* @param {"add" | "remove"} operation Whether to add or remove the filter.
|
||||
* @param {Event} ev Event defining the filter.
|
||||
* @returns {String} Updated filters.
|
||||
*/
|
||||
_prepareAnswersFilters(filters, operation, ev) {
|
||||
const cell = $(ev.target);
|
||||
const eventFilter = `${cell.data('rowId') || 0},${cell.data('answerId')}`;
|
||||
|
||||
if (operation === 'add') {
|
||||
filters = filters ? filters + `|${eventFilter}` : eventFilter;
|
||||
} else if (operation === 'remove') {
|
||||
filters = filters
|
||||
.split("|")
|
||||
.filter(filterItem => filterItem !== eventFilter)
|
||||
.join("|");
|
||||
} else {
|
||||
throw new Error('`operation` parameter for `_prepareAnswersFilters` must be either "add" or "remove".')
|
||||
}
|
||||
return filters;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
resultWidget: publicWidget.registry.SurveyResultWidget,
|
||||
chartWidget: publicWidget.registry.SurveyResultChart,
|
||||
paginationWidget: publicWidget.registry.SurveyResultPagination
|
||||
};
|
||||
|
||||
});
|
||||
|
|
@ -0,0 +1,372 @@
|
|||
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 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',
|
||||
autoSkip: false,
|
||||
},
|
||||
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.
|
||||
* When the screen space is too small for long words, those long words are split over multiple rows.
|
||||
* At 6 chars per row, the above example becomes ["this", "is an", "examp-", "le of", "a label"]
|
||||
* Which is displayed as "this<br/>is an<br/>examp-<br/>le of<br/>a label"
|
||||
*
|
||||
* 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++) {
|
||||
// Chop down words that do not fit on a single line, add each part on its own line.
|
||||
let word = words[i];
|
||||
while (word.length > charPerLine) {
|
||||
resultLines.push(word.slice(0, charPerLine - 1) + '-');
|
||||
word = word.slice(charPerLine - 1);
|
||||
}
|
||||
currentLine.push(word);
|
||||
|
||||
// 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;
|
||||
|
||||
});
|
||||
|
|
@ -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',
|
||||
];
|
||||
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
||||
});
|
||||
|
|
@ -0,0 +1,665 @@
|
|||
odoo.define('survey.session_manage', function (require) {
|
||||
'use strict';
|
||||
|
||||
var publicWidget = require('web.public.widget');
|
||||
var SurveyPreloadImageMixin = require('survey.preload_image_mixin');
|
||||
var SurveySessionChart = require('survey.session_chart');
|
||||
var SurveySessionTextAnswers = require('survey.session_text_answers');
|
||||
var SurveySessionLeaderBoard = require('survey.session_leaderboard');
|
||||
var core = require('web.core');
|
||||
var _t = core._t;
|
||||
|
||||
publicWidget.registry.SurveySessionManage = publicWidget.Widget.extend(SurveyPreloadImageMixin, {
|
||||
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 () {
|
||||
if (self.$el.data('isSessionClosed')) {
|
||||
self._displaySessionClosedPage();
|
||||
self.$el.removeClass('invisible');
|
||||
return;
|
||||
}
|
||||
// general survey props
|
||||
self.surveyId = self.$el.data('surveyId');
|
||||
self.surveyAccessToken = self.$el.data('surveyAccessToken');
|
||||
self.isStartScreen = self.$el.data('isStartScreen');
|
||||
self.isFirstQuestion = self.$el.data('isFirstQuestion');
|
||||
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');
|
||||
// Question transition
|
||||
self.stopNextQuestion = false;
|
||||
// Background Management
|
||||
self.refreshBackground = self.$el.data('refreshBackground');
|
||||
// Copy link tooltip
|
||||
self.$('.o_survey_session_copy').tooltip({delay: 0, title: 'Click to copy link', placement: 'right'});
|
||||
|
||||
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());
|
||||
|
||||
self.$el.removeClass('invisible');
|
||||
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.tooltip('dispose');
|
||||
|
||||
$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('dispose');
|
||||
}, 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);
|
||||
} else if (screenToDisplay === 'results') {
|
||||
this._setShowAnswers(true);
|
||||
// when showing results, stop refreshing answers
|
||||
clearInterval(this.resultsRefreshInterval);
|
||||
delete this.resultsRefreshInterval;
|
||||
} 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.sessionShowLeaderboard) {
|
||||
this._nextQuestion();
|
||||
}
|
||||
|
||||
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);
|
||||
} 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') {
|
||||
if (this.leaderBoard) {
|
||||
this.leaderBoard.hideLeaderboard();
|
||||
}
|
||||
// when showing results, stop refreshing answers
|
||||
clearInterval(this.resultsRefreshInterval);
|
||||
delete this.resultsRefreshInterval;
|
||||
} else if (screenToDisplay === 'previousQuestion') {
|
||||
if (this.isFirstQuestion) {
|
||||
return; // nothing to go back to, we're on the first question
|
||||
}
|
||||
this._nextQuestion(true);
|
||||
}
|
||||
|
||||
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.isScoredQuestion) ||
|
||||
(this.currentScreen === 'leaderboard' && !this.isScoredQuestion) ||
|
||||
(this.currentScreen === 'leaderboardFinal' && this.isScoredQuestion)) {
|
||||
return 'userInputs';
|
||||
} else if ((this.currentScreen === 'leaderboard' && this.isScoredQuestion) ||
|
||||
(this.currentScreen === 'leaderboardFinal' && !this.isScoredQuestion)){
|
||||
return 'results';
|
||||
}
|
||||
|
||||
return 'previousQuestion';
|
||||
},
|
||||
|
||||
/**
|
||||
* 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 (goBack) {
|
||||
var self = this;
|
||||
|
||||
// stop calling multiple times "get next question" process until next question is fully loaded.
|
||||
if (this.stopNextQuestion) {
|
||||
return;
|
||||
}
|
||||
this.stopNextQuestion = true;
|
||||
|
||||
this.isStartScreen = false;
|
||||
if (this.surveyTimerWidget) {
|
||||
this.surveyTimerWidget.destroy();
|
||||
}
|
||||
|
||||
var resolveFadeOut;
|
||||
var fadeOutPromise = new Promise(function (resolve, reject) { resolveFadeOut = resolve; });
|
||||
this.$el.fadeOut(this.fadeInOutTime, function () {
|
||||
resolveFadeOut();
|
||||
});
|
||||
|
||||
if (this.refreshBackground) {
|
||||
$('div.o_survey_background').addClass('o_survey_background_transition');
|
||||
}
|
||||
|
||||
// avoid refreshing results while transitioning
|
||||
if (this.resultsRefreshInterval) {
|
||||
clearInterval(this.resultsRefreshInterval);
|
||||
delete this.resultsRefreshInterval;
|
||||
}
|
||||
|
||||
var nextQuestionPromise = this._rpc({
|
||||
route: _.str.sprintf('/survey/session/next_question/%s', self.surveyAccessToken),
|
||||
params: {
|
||||
'go_back': goBack,
|
||||
}
|
||||
}).then(function (result) {
|
||||
self.nextQuestion = result;
|
||||
if (self.refreshBackground && result.background_image_url) {
|
||||
return self._preloadBackground(result.background_image_url);
|
||||
} else {
|
||||
return Promise.resolve();
|
||||
}
|
||||
});
|
||||
|
||||
Promise.all([fadeOutPromise, nextQuestionPromise]).then(function () {
|
||||
return self._onNextQuestionDone(goBack);
|
||||
});
|
||||
},
|
||||
|
||||
_displaySessionClosedPage:function () {
|
||||
this.$('.o_survey_question_header').addClass('invisible');
|
||||
this.$('.o_survey_session_results, .o_survey_session_navigation_previous, .o_survey_session_navigation_next')
|
||||
.addClass('d-none');
|
||||
this.$('.o_survey_session_description_done').removeClass('d-none');
|
||||
},
|
||||
|
||||
/**
|
||||
* Refresh the screen with the next question's rendered template.
|
||||
*
|
||||
* @param {boolean} goBack Whether we are going back to the previous question or not
|
||||
*/
|
||||
_onNextQuestionDone: async function (goBack) {
|
||||
var self = this;
|
||||
|
||||
if (this.nextQuestion.question_html) {
|
||||
var $renderedTemplate = $(this.nextQuestion.question_html);
|
||||
this.$el.replaceWith($renderedTemplate);
|
||||
|
||||
// Ensure new question is fully loaded before force loading previous question screen.
|
||||
await this.attachTo($renderedTemplate);
|
||||
if (goBack) {
|
||||
// As we arrive on "question" screen, simulate going to the results screen or leaderboard.
|
||||
this._setShowInputs(true);
|
||||
this._setShowAnswers(true);
|
||||
if (this.sessionShowLeaderboard && this.isScoredQuestion) {
|
||||
this.currentScreen = 'leaderboard';
|
||||
this.leaderBoard.showLeaderboard(false, this.isScoredQuestion);
|
||||
} else {
|
||||
this.currentScreen = 'results';
|
||||
this._refreshResults();
|
||||
}
|
||||
} else {
|
||||
this._startTimer();
|
||||
}
|
||||
this.$el.fadeIn(this.fadeInOutTime);
|
||||
} else if (this.sessionShowLeaderboard) {
|
||||
// Display last screen if leaderboard activated
|
||||
this.isLastQuestion = true;
|
||||
this._setupLeaderboard().then(function () {
|
||||
self.$('.o_survey_session_leaderboard_title').text(_t('Final Leaderboard'));
|
||||
self.$('.o_survey_session_navigation_next').addClass('d-none');
|
||||
self.$('.o_survey_leaderboard_buttons').removeClass('d-none');
|
||||
self.leaderBoard.showLeaderboard(false, false);
|
||||
});
|
||||
} else {
|
||||
self.$('.o_survey_session_close').first().click();
|
||||
self._displaySessionClosedPage();
|
||||
}
|
||||
|
||||
// Background Management
|
||||
if (this.refreshBackground) {
|
||||
$('div.o_survey_background').css("background-image", "url(" + this.nextQuestion.background_image_url + ")");
|
||||
$('div.o_survey_background').removeClass('o_survey_background_transition');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 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.$('.o_survey_session_navigation_previous').toggleClass('d-none', !!this.isFirstQuestion);
|
||||
|
||||
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;
|
||||
|
||||
});
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
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({
|
||||
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;
|
||||
|
||||
});
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
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;
|
||||
this.timeDifference = null;
|
||||
if (params.serverTime) {
|
||||
this.timeDifference = moment.utc().diff(moment.utc(params.serverTime), 'milliseconds');
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
/**
|
||||
* Two responsibilities: Validate that the time limit is not exceeded and Run timer otherwise.
|
||||
* If the end-user's clock OR the system clock is desynchronized,
|
||||
* we apply the difference in the clocks (if the time difference is more than 500 ms).
|
||||
* This makes the timer fair across users and helps avoid early submissions to the server.
|
||||
*
|
||||
* @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 (Math.abs(self.timeDifference) >= 500) {
|
||||
self.countDownDate = self.countDownDate.add(self.timeDifference, 'milliseconds');
|
||||
}
|
||||
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;
|
||||
|
||||
});
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { _t } from 'web.core';
|
||||
import { Markup } from 'web.utils';
|
||||
import tour from 'web_tour.tour';
|
||||
|
||||
tour.register('survey_tour', {
|
||||
url: "/web",
|
||||
rainbowManMessage: _t("Congratulations! You are now ready to collect feedback like a pro :-)"),
|
||||
sequence: 225,
|
||||
}, [
|
||||
...tour.stepUtils.goToAppSteps('survey.menu_surveys', Markup(_t("Ready to change the way you <b>gather data</b>?"))),
|
||||
{
|
||||
trigger: '.btn-outline-primary.o_survey_load_sample',
|
||||
content: Markup(_t("Load a <b>sample Survey</b> to get started quickly.")),
|
||||
position: 'left',
|
||||
}, {
|
||||
trigger: 'button[name=action_test_survey]',
|
||||
content: _t("Let's give it a spin!"),
|
||||
position: 'bottom',
|
||||
}, {
|
||||
trigger: '.o_survey_start button[type=submit]',
|
||||
content: _t("Let's get started!"),
|
||||
position: 'bottom',
|
||||
}, {
|
||||
trigger: '.o_survey_simple_choice button[type=submit]',
|
||||
extra_trigger: '.js_question-wrapper span:contains("How frequently")',
|
||||
content: _t("Whenever you pick an answer, Odoo saves it for you."),
|
||||
position: 'bottom',
|
||||
}, {
|
||||
trigger: '.o_survey_numerical_box button[type=submit]',
|
||||
extra_trigger: '.js_question-wrapper span:contains("How many")',
|
||||
content: _t("Only a single question left!"),
|
||||
position: 'bottom',
|
||||
}, {
|
||||
trigger: '.o_survey_matrix button[value=finish]',
|
||||
extra_trigger: '.js_question-wrapper span:contains("How likely")',
|
||||
content: _t("Now that you are done, submit your form."),
|
||||
position: 'bottom',
|
||||
}, {
|
||||
trigger: '.o_survey_review a',
|
||||
content: _t("Let's have a look at your answers!"),
|
||||
position: 'bottom',
|
||||
}, {
|
||||
trigger: '.alert-info a:contains("This is a Test Survey")',
|
||||
content: _t("Now, use this shortcut to go back to the survey."),
|
||||
position: 'bottom',
|
||||
}, {
|
||||
trigger: 'button[name=action_survey_user_input_completed]',
|
||||
content: _t("Here, you can overview all the participations."),
|
||||
position: 'bottom',
|
||||
}, {
|
||||
trigger: 'td[name=survey_id]',
|
||||
content: _t("Let's open the survey you just submitted."),
|
||||
position: 'bottom',
|
||||
}, {
|
||||
trigger: '.breadcrumb a:contains("Feedback Form")',
|
||||
content: _t("Use the breadcrumbs to quickly go back to the dashboard."),
|
||||
position: 'bottom',
|
||||
}
|
||||
]);
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { CharField } from "@web/views/fields/char/char_field";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
const { useEffect, useRef } = owl;
|
||||
|
||||
class DescriptionPageField extends CharField {
|
||||
setup() {
|
||||
super.setup();
|
||||
const inputRef = useRef("input");
|
||||
useEffect(
|
||||
(input) => {
|
||||
if (input) {
|
||||
input.classList.add("col");
|
||||
}
|
||||
},
|
||||
() => [inputRef.el]
|
||||
);
|
||||
}
|
||||
onExternalBtnClick() {
|
||||
this.env.openRecord(this.props.record);
|
||||
}
|
||||
}
|
||||
DescriptionPageField.template = "survey.DescriptionPageField";
|
||||
|
||||
registry.category("fields").add("survey_description_page", DescriptionPageField);
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="survey.DescriptionPageField" t-inherit="web.CharField" owl="1">
|
||||
<xpath expr="//t[@t-else='']" position="replace">
|
||||
<t t-else="">
|
||||
<div class="input-group">
|
||||
<t t-call="web.CharField"/>
|
||||
<button type="button" title="Open section" class="btn oe_edit_only o_icon_button" t-on-click.stop="onExternalBtnClick">
|
||||
<i class="fa fa-fw o_button_icon fa-external-link"/>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { makeContext } from "@web/core/context";
|
||||
import { ListRenderer } from "@web/views/list/list_renderer";
|
||||
|
||||
const { useEffect } = owl;
|
||||
|
||||
export class QuestionPageListRenderer extends ListRenderer {
|
||||
setup() {
|
||||
super.setup();
|
||||
|
||||
this.discriminant = "is_page";
|
||||
this.fieldsToShow = ["random_questions_count"];
|
||||
this.titleField = "title";
|
||||
|
||||
useEffect(
|
||||
(table) => {
|
||||
if (table) {
|
||||
table.classList.add("o_section_list_view");
|
||||
}
|
||||
},
|
||||
() => [this.tableRef.el]
|
||||
);
|
||||
}
|
||||
|
||||
add(params) {
|
||||
let editable = false;
|
||||
if (params.context && !this.env.isSmall) {
|
||||
const evaluatedContext = makeContext([params.context]);
|
||||
if (evaluatedContext[`default_${this.discriminant}`]) {
|
||||
editable = this.props.editable;
|
||||
}
|
||||
}
|
||||
super.add({ ...params, editable });
|
||||
}
|
||||
|
||||
getColumns(record) {
|
||||
const columns = super.getColumns(record);
|
||||
if (this.isSection(record)) {
|
||||
return this.getSectionColumns(columns);
|
||||
}
|
||||
return columns;
|
||||
}
|
||||
|
||||
getRowClass(record) {
|
||||
const classNames = super.getRowClass(record).split(" ");
|
||||
if (this.isSection(record)) {
|
||||
classNames.push(`o_is_section`, `fw-bold`);
|
||||
}
|
||||
return classNames.join(" ");
|
||||
}
|
||||
|
||||
getSectionColumns(columns) {
|
||||
let titleColumnIndex = 0;
|
||||
let found = false;
|
||||
let colspan = 1
|
||||
for (let index = 0; index < columns.length; index++) {
|
||||
const col = columns[index];
|
||||
if (!found && col.name !== this.titleField) {
|
||||
continue;
|
||||
}
|
||||
if (!found) {
|
||||
found = true;
|
||||
titleColumnIndex = index;
|
||||
continue;
|
||||
}
|
||||
if (col.type !== "field" || this.fieldsToShow.includes(col.name)) {
|
||||
break;
|
||||
}
|
||||
colspan += 1;
|
||||
}
|
||||
|
||||
const sectionColumns = columns.slice(0, titleColumnIndex + 1).concat(columns.slice(titleColumnIndex + colspan));
|
||||
|
||||
sectionColumns[titleColumnIndex] = {...sectionColumns[titleColumnIndex], colspan};
|
||||
|
||||
return sectionColumns;
|
||||
}
|
||||
|
||||
isInlineEditable(record) {
|
||||
return this.isSection(record) && this.props.editable;
|
||||
}
|
||||
|
||||
isSection(record) {
|
||||
return record.data[this.discriminant];
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Overriding the method in order to identify the requested column based on its `name`
|
||||
* instead of the exact object passed. This is necessary for section rows because the
|
||||
* column object could have been replaced in `getSectionColumns` to add a `colspan`
|
||||
* attribute.
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
focusCell(column, forward = true) {
|
||||
const actualColumn = column.name ? this.state.columns.find(
|
||||
(col) => col.name === column.name
|
||||
) : column;
|
||||
super.focusCell(actualColumn, forward);
|
||||
}
|
||||
|
||||
onCellKeydownEditMode(hotkey) {
|
||||
switch (hotkey) {
|
||||
case "enter":
|
||||
case "tab":
|
||||
case "shift+tab": {
|
||||
this.props.list.unselectRecord(true);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return super.onCellKeydownEditMode(...arguments);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { QuestionPageListRenderer } from "./question_page_list_renderer";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { X2ManyField } from "@web/views/fields/x2many/x2many_field";
|
||||
|
||||
const { useSubEnv } = owl;
|
||||
|
||||
class QuestionPageOneToManyField extends X2ManyField {
|
||||
setup() {
|
||||
super.setup();
|
||||
useSubEnv({
|
||||
openRecord: (record) => this.openRecord(record),
|
||||
});
|
||||
}
|
||||
}
|
||||
QuestionPageOneToManyField.components = {
|
||||
...X2ManyField.components,
|
||||
ListRenderer: QuestionPageListRenderer,
|
||||
};
|
||||
QuestionPageOneToManyField.defaultProps = {
|
||||
...X2ManyField.defaultProps,
|
||||
editable: "bottom",
|
||||
};
|
||||
QuestionPageOneToManyField.additionalClasses = ['o_field_one2many'];
|
||||
registry.category("fields").add("question_page_one2many", QuestionPageOneToManyField);
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
.o_survey_question_view_form {
|
||||
|
||||
.o_preview_questions {
|
||||
border: 3px solid $o-gray-500;
|
||||
width: auto;
|
||||
padding: 10px 20px 10px;
|
||||
margin-top: 15px;
|
||||
color: $o-gray-500;
|
||||
}
|
||||
|
||||
.o_preview_questions .o_datetime {
|
||||
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;
|
||||
}
|
||||
|
||||
.o_preview_questions_choice {
|
||||
line-height: 1rem;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,321 @@
|
|||
@font-face {
|
||||
font-family: "certification-cursive";
|
||||
src: url("/survey/static/src/fonts/AlexBrush-Regular.ttf") format("truetype");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "certification-serif";
|
||||
src: url("/survey/static/src/fonts/IbarraRealNova-Regular.ttf") format("truetype");
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "certification-serif";
|
||||
src: url("/survey/static/src/fonts/IbarraRealNova-Bold.ttf") format("truetype");
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "certification-modern";
|
||||
src: url("/survey/static/src/fonts/Trueno-wml2.otf") format("opentype");
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "certification-modern";
|
||||
src: url("/survey/static/src/fonts/TruenoBd.otf") format("opentype");
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#o_survey_certification.certification-wrapper {
|
||||
background-color: #875A7B;
|
||||
position: relative;
|
||||
display: flex;
|
||||
margin-left: -4mm;
|
||||
margin-right: -4mm;
|
||||
|
||||
&.blue {
|
||||
background-color: #263e86;
|
||||
}
|
||||
|
||||
&.gold {
|
||||
background-color: #d7a520;
|
||||
}
|
||||
|
||||
.test-entry {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: url(/survey/static/src/img/watermark.png);
|
||||
background-size: 20%;
|
||||
background-position: center;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.certification {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
|
||||
.certification-failed p{
|
||||
margin-top: 30mm;
|
||||
}
|
||||
|
||||
.certification-seal {
|
||||
position: absolute;
|
||||
background-size: 100%;
|
||||
}
|
||||
|
||||
.certification-top {
|
||||
h1 {
|
||||
font-size: 60pt;
|
||||
text-transform: uppercase;
|
||||
line-height: 24pt;
|
||||
|
||||
span {
|
||||
font-size: 20pt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.certification-content {
|
||||
p {
|
||||
font-size: 16pt;
|
||||
color: #6b6d70;
|
||||
|
||||
.user-name {
|
||||
font-family: certification-cursive, cursive;
|
||||
font-size: 50pt;
|
||||
line-height: 2;
|
||||
color: #282f33;
|
||||
border-bottom: 1pt solid #282f33;
|
||||
}
|
||||
|
||||
.certification-name {
|
||||
font-size: 18pt;
|
||||
line-height: 1.5;
|
||||
text-transform: uppercase;
|
||||
color: #282f33;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.certification-bottom {
|
||||
position: absolute;
|
||||
bottom: 15mm;
|
||||
|
||||
.certification-date-wrapper {
|
||||
width: 80mm;
|
||||
padding: 10mm;
|
||||
text-align: center;
|
||||
|
||||
.certification-date {
|
||||
border-bottom: 1pt solid #282f33;
|
||||
}
|
||||
}
|
||||
|
||||
.certification-company {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 33%;
|
||||
text-align: center;
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
.certification-number {
|
||||
color: #6b6d70;
|
||||
position: absolute;
|
||||
font-size: 10pt;
|
||||
}
|
||||
}
|
||||
|
||||
// Classic Template
|
||||
&.classic {
|
||||
|
||||
&.blue .certification {
|
||||
color: #263e86;
|
||||
|
||||
.certification-top {
|
||||
&:before, &:after {
|
||||
background-image: url("/survey/static/src/img/classic-ornament-blue.svg");
|
||||
}
|
||||
}
|
||||
|
||||
.certification-content p .certification-name {
|
||||
color: #263e86;
|
||||
}
|
||||
}
|
||||
|
||||
&.gold .certification {
|
||||
color: #d7a520;
|
||||
|
||||
.certification-top {
|
||||
&:before, &:after{
|
||||
background-image: url("/survey/static/src/img/classic-ornament-gold.svg");
|
||||
}
|
||||
}
|
||||
.certification-content p .certification-name {
|
||||
color: #d7a520;
|
||||
}
|
||||
}
|
||||
|
||||
.certification {
|
||||
background-image: url("/survey/static/src/img/certification_bg_classic.svg");
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
font-family: "certification-serif", serif;
|
||||
width: 295mm;
|
||||
height: 208mm;
|
||||
margin: 1mm;
|
||||
color: #875A7B;
|
||||
|
||||
.certification-top {
|
||||
padding-top: 10mm;
|
||||
|
||||
&:before, &:after {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 108mm;
|
||||
height: 16mm;
|
||||
margin: 0 auto;
|
||||
background-image: url("/survey/static/src/img/classic-ornament-purple.svg");
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
&:before {
|
||||
margin-bottom: 10mm;
|
||||
}
|
||||
|
||||
&:after {
|
||||
-webkit-transform: scaleY(-1);
|
||||
transform: scaleY(-1);
|
||||
margin-bottom: 5mm;
|
||||
}
|
||||
}
|
||||
.certification-content {
|
||||
margin-bottom: 10mm;
|
||||
|
||||
p .certification-name {
|
||||
color: #875A7B;
|
||||
}
|
||||
}
|
||||
|
||||
.certification-bottom {
|
||||
width: 295mm;
|
||||
bottom: 20mm;
|
||||
|
||||
.certification-date-wrapper {
|
||||
margin-left: 40mm;
|
||||
font-size: 18pt;
|
||||
color: #6b6d70;
|
||||
|
||||
span {
|
||||
font-size: 18pt;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.certification-seal {
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
-webkit-transform: translate(-50% , -50%);
|
||||
transform: translate(-50% , -50%);
|
||||
width: 24mm;
|
||||
height: 24mm;
|
||||
background: url(/survey/static/src/img/classic-seal.png) no-repeat;
|
||||
}
|
||||
|
||||
.certification-company {
|
||||
padding-left: 10mm;
|
||||
right: 35mm;
|
||||
}
|
||||
|
||||
.certification-number {
|
||||
bottom: -15mm;
|
||||
left: 50%;
|
||||
-webkit-transform: translateX(-50%);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Modern Template
|
||||
&.modern {
|
||||
background-image: url("/survey/static/src/img/certification_bg_modern.svg");
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
|
||||
&.blue .certification .certification-seal {
|
||||
background-image: url("/survey/static/src/img/modern-seal-blue.svg");
|
||||
}
|
||||
|
||||
&.gold .certification .certification-seal {
|
||||
background-image: url("/survey/static/src/img/modern-seal-gold.svg");
|
||||
}
|
||||
|
||||
.certification {
|
||||
font-family: "certification-modern", sans-serif;
|
||||
width: 272mm;
|
||||
height: 185mm;
|
||||
margin: 12.5mm;
|
||||
background-color: #f2f2f2;
|
||||
text-align: center;
|
||||
|
||||
.certification-seal {
|
||||
top: -7mm;
|
||||
right: 20mm;
|
||||
width: 22mm;
|
||||
height: 42mm;
|
||||
background-image: url("/survey/static/src/img/modern-seal-purple.svg");
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.certification-top {
|
||||
padding-top: 28mm;
|
||||
padding-bottom: 10mm;
|
||||
color: #282f33;
|
||||
|
||||
h1 b {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.certification-bottom {
|
||||
width: 272mm;
|
||||
|
||||
.certification-date-wrapper {
|
||||
margin-left: 40mm;
|
||||
text-transform: uppercase;
|
||||
|
||||
.certification-date {
|
||||
font-size: 18pt;
|
||||
color: #282f33;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 14pt;
|
||||
color: #6b6d70;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.certification-company {
|
||||
right: 30mm;
|
||||
}
|
||||
}
|
||||
|
||||
.certification-number {
|
||||
bottom: -6mm;
|
||||
right:0;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
// = Survey view
|
||||
// ============================================================================
|
||||
// No CSS hacks, variables overrides only
|
||||
|
||||
.o_survey_form {
|
||||
--SurveyForm__section-background-color: #{$o-gray-300};
|
||||
}
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
// KANBAN VIEW
|
||||
.o_survey_survey_view_kanban {
|
||||
// Common: left semi-trophy icon for certifications
|
||||
.o_survey_kanban_card_certification {
|
||||
background-image:
|
||||
linear-gradient(rgba($o-view-background-color,.75),
|
||||
rgba($o-view-background-color,.75)),
|
||||
url(/survey/static/src/img/trophy-solid.svg);
|
||||
background-repeat: no-repeat;
|
||||
background-position: bottom 6px left -45px;
|
||||
background-size: 100%, 100px;
|
||||
}
|
||||
|
||||
// Grouped / Ungrouped sections hidding
|
||||
&.o_kanban_grouped {
|
||||
.o_survey_kanban_card_ungrouped {
|
||||
display:none !important;
|
||||
}
|
||||
}
|
||||
&.o_kanban_ungrouped {
|
||||
.o_survey_kanban_card_grouped {
|
||||
display:none !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Ungrouped display: whole length (kanban-list)
|
||||
&.o_kanban_ungrouped {
|
||||
padding: 0px;
|
||||
|
||||
.o_kanban_record {
|
||||
width: 100%;
|
||||
margin: 0px;
|
||||
border-top: 0px !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Grouped specific
|
||||
&.o_kanban_grouped {
|
||||
// Set a minimal height otherwise display may have different card sized
|
||||
.o_survey_kanban_card_grouped {
|
||||
& > .row {
|
||||
min-height: 60px;
|
||||
}
|
||||
}
|
||||
|
||||
// Due to activity widget crashing if present twice, have to set absolute and tweak
|
||||
.o_survey_kanban_card_bottom {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
right: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
// Ungrouped specific
|
||||
&.o_kanban_ungrouped {
|
||||
// Set a minimal height otherwise display may have different card sized
|
||||
.o_survey_kanban_card_ungrouped {
|
||||
&.row {
|
||||
min-height: 60px;
|
||||
}
|
||||
}
|
||||
|
||||
// Left semi-trophy icon for certifications: tweak display for list view
|
||||
.o_survey_kanban_card_certification {
|
||||
background-position: center left -35px;
|
||||
background-size: auto 75%;
|
||||
}
|
||||
|
||||
// Due to activity widget crashing if present twice, have to set absolute and tweak
|
||||
.o_survey_kanban_card_bottom {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
right: 19px;
|
||||
}
|
||||
}
|
||||
|
||||
// RIBBON: Kanban specific
|
||||
// Ungrouped specific
|
||||
.o_survey_kanban_card_ungrouped {
|
||||
.ribbon {
|
||||
// Desktop: finishes on next kanban card line
|
||||
height: 100px;
|
||||
width: 125px;
|
||||
|
||||
// Mobile: is in a corner, takes more place
|
||||
@include media-breakpoint-down(md) {
|
||||
height: 100px;
|
||||
width: 125px;
|
||||
}
|
||||
|
||||
&-top-right {
|
||||
top: 25px;
|
||||
|
||||
&:after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
& span {
|
||||
left: 26px;
|
||||
top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
& span {
|
||||
font-size: 0.9rem;
|
||||
width: 130px;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Grouped specific
|
||||
.o_survey_kanban_card_grouped {
|
||||
.ribbon {
|
||||
height: 90px;
|
||||
width: 98px;
|
||||
|
||||
&-top-right {
|
||||
margin-top: -$o-kanban-inside-vgutter;
|
||||
right: 0;
|
||||
|
||||
& span {
|
||||
left: -8px;
|
||||
top: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
& span {
|
||||
font-size: 1rem;
|
||||
width: 150px;
|
||||
height: 32px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FORM view
|
||||
.o_survey_form table.o_section_list_view tr.o_data_row.o_is_section {
|
||||
font-weight: bold;
|
||||
background-color: var(--SurveyForm__section-background-color, #DDD);
|
||||
border-top: 1px solid #BBB;
|
||||
border-bottom: 1px solid #BBB;
|
||||
|
||||
> td {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
// TOOLS
|
||||
.icon_rotates {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Style of the tiles allowing the user to load a sample survey. */
|
||||
.o_survey_sample_tile {
|
||||
max-width: 150px;
|
||||
height: 150px;
|
||||
.o_survey_sample_tile_cover {
|
||||
display: none;
|
||||
overflow-y: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
&:hover {
|
||||
.o_survey_sample_tile_cover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
&::before, &::after {
|
||||
content: '';
|
||||
}
|
||||
&::before {
|
||||
margin-top: auto;
|
||||
}
|
||||
&::after {
|
||||
margin-bottom: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,556 @@
|
|||
/**********************************************************
|
||||
Remove website backend redirection button : 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!
|
||||
**********************************************************/
|
||||
div.o_frontend_to_backend_nav {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/**********************************************************
|
||||
Common Style
|
||||
**********************************************************/
|
||||
// dynamic color is used to ensure enough contrast between the text and the background color
|
||||
$dynamic-text-color: if(lightness($body-bg) > 50%, $gray-900, $gray-100);
|
||||
|
||||
// the survey background image takes all the page background, with a translucent white overlay (box-shadow)
|
||||
// When changing the background from one section to another, the overlay will become opaque to simulate a fade out of
|
||||
// the background image. This ensure a smooth transition from one background to another, likewise the question
|
||||
// transition.
|
||||
.o_survey_background {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
transition: box-shadow 0.3s ease-in-out;
|
||||
background: no-repeat fixed center;
|
||||
background-size: cover;
|
||||
color: $dynamic-text-color !important;
|
||||
.text-muted {
|
||||
opacity: 0.7;
|
||||
color: $dynamic-text-color !important;
|
||||
}
|
||||
&.o_survey_background_shadow {
|
||||
box-shadow: inset 0 0 0 10000px rgba(255,255,255,.7);
|
||||
color: $gray-900 !important;
|
||||
.text-muted {
|
||||
color: $gray-900 !important;
|
||||
}
|
||||
}
|
||||
&.o_survey_background_transition {
|
||||
box-shadow: inset 0 0 0 10000px rgba(255,255,255,1);
|
||||
}
|
||||
}
|
||||
|
||||
.o_survey_wrap {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
// Safari(v 7.1+) specific fix (min-height given above doesn't work with safari)
|
||||
// see https://stackoverflow.com/a/25975282 for info on safari specific css
|
||||
_::-webkit-full-page-media, _:future, :root .o_survey_wrap {
|
||||
min-height: 90vh;
|
||||
}
|
||||
|
||||
.o_survey_progress_wrapper {
|
||||
min-width: 7rem;
|
||||
max-width: 11rem;
|
||||
|
||||
.o_survey_progress {
|
||||
height:0.5em;
|
||||
}
|
||||
}
|
||||
|
||||
.o_survey_navigation_wrapper .o_survey_navigation_submit {
|
||||
cursor: pointer;
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
opacity: 1;
|
||||
i {
|
||||
opacity: .3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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_background_shadow & {
|
||||
color: $gray-900 !important;
|
||||
}
|
||||
}
|
||||
|
||||
div.bg-danger, div.bg-success, div.o_survey_question_skipped {
|
||||
.o_survey_question_char_box,
|
||||
.o_survey_question_date,
|
||||
.o_survey_question_datetime,
|
||||
.o_survey_question_numerical_box,
|
||||
.o_survey_question_text_box {
|
||||
border: 0;
|
||||
color: $white !important;
|
||||
font-weight: $font-weight-bold;
|
||||
height: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.o_survey_form_date [data-toggle="datetimepicker"] {
|
||||
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 {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.o_survey_selected i {
|
||||
display: none;
|
||||
&.fa-check-circle {
|
||||
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_quick_access {
|
||||
.o_survey_error {
|
||||
min-height: 2rem;
|
||||
}
|
||||
#session_code {
|
||||
font-size: 4rem;
|
||||
}
|
||||
}
|
||||
|
||||
.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_img img {
|
||||
max-width: 95%;
|
||||
max-height: 60vh;
|
||||
cursor: zoom-in;
|
||||
&:hover {
|
||||
box-sizing: border-box;
|
||||
box-shadow: 0 0 5px 2px grey;
|
||||
}
|
||||
}
|
||||
|
||||
.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: 1rem;
|
||||
}
|
||||
|
||||
&.o_survey_session_navigation_previous {
|
||||
left: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
.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-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;
|
||||
}
|
||||
}
|
||||
|
||||
.o_survey_question_skipped {
|
||||
background-color: darken($warning, 10%);
|
||||
}
|
||||
|
||||
.o_survey_choice_question_skipped {
|
||||
color: darken($warning, 10%);
|
||||
}
|
||||
|
||||
.o_survey_choice_img img {
|
||||
cursor: default;
|
||||
&:hover {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**********************************************************
|
||||
Zoomer Specific Style (SurveyImageZoomer widget)
|
||||
When the width is small (mobile), let space above and below
|
||||
to indicate that the user can close it by clicking out.
|
||||
**********************************************************/
|
||||
|
||||
.o_survey_img_zoom_modal {
|
||||
cursor: pointer;
|
||||
.o_survey_img_zoom_dialog {
|
||||
background-color: rgba(0,0,0,0.65);
|
||||
@include media-breakpoint-down(sm) {
|
||||
height: 80% !important;
|
||||
}
|
||||
.o_survey_img_zoom_body {
|
||||
font-size: 1.5rem;
|
||||
img {
|
||||
max-width: 90%;
|
||||
min-width: clamp(250px, 60%, 450px);
|
||||
max-height: 90%;
|
||||
object-fit: contain;
|
||||
}
|
||||
.o_survey_img_zoom_close_btn {
|
||||
right: 12px;
|
||||
top: 12px;
|
||||
z-index: 1;
|
||||
}
|
||||
.o_survey_img_zoom_controls_wrapper {
|
||||
bottom: 5%;
|
||||
.o_survey_img_zoom_in_btn, .o_survey_img_zoom_out_btn {
|
||||
background-color: rgba(0,0,0,0.65);
|
||||
&:hover .fa {
|
||||
color: grey;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Avoid shifting (due to scroll bar) when opening the image zoom widget
|
||||
.modal-open {
|
||||
.o_survey_background {
|
||||
overflow: auto !important;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
@media print {
|
||||
.chartjs-size-monitor {
|
||||
display: none;
|
||||
}
|
||||
.chartjs-render-monitor {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
.tab-content > .tab-pane {
|
||||
display: block;
|
||||
}
|
||||
html {
|
||||
height: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.o_survey_results_topbar {
|
||||
border: 1px solid rgba(0, 0, 0, 0.125);
|
||||
|
||||
.nav-item.dropdown a {
|
||||
min-width: 13em;
|
||||
}
|
||||
.o_survey_results_topbar_dropdown_filters {
|
||||
// Dropdown adapted from event templates to get a coherent styling
|
||||
.dropdown-toggle {
|
||||
text-align: left;
|
||||
&:hover, &:focus {
|
||||
text-decoration: none;
|
||||
}
|
||||
&:after {
|
||||
float:right;
|
||||
margin-top: .5em;
|
||||
}
|
||||
.fa {
|
||||
margin-right: .4em;
|
||||
}
|
||||
}
|
||||
.dropdown-menu {
|
||||
margin-top: $navbar-padding-y;
|
||||
min-width: 12rem;
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.dropdown-item {
|
||||
&.active .badge { // Invert badge display when the item is active
|
||||
background-color: color-contrast(map-get($theme-colors, 'primary'));
|
||||
color: map-get($theme-colors, 'primary');
|
||||
}
|
||||
}
|
||||
}
|
||||
.o_survey_results_topbar_answer_filters {
|
||||
.btn.filter-remove-answer {
|
||||
border-color: #DEE2E6;
|
||||
background-color: transparent;
|
||||
white-space: normal;
|
||||
text-align: left;
|
||||
i.fa-times {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
.o_survey_results_topbar_clear_filters {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_survey_results_question {
|
||||
.o_survey_results_question_pill {
|
||||
.only_right_radius {
|
||||
border-radius: 0 2em 2em 0;
|
||||
}
|
||||
.only_left_radius {
|
||||
border-radius: 2em 0 0 2em;
|
||||
}
|
||||
}
|
||||
.o_survey_answer i {
|
||||
padding:3px;
|
||||
cursor:pointer;
|
||||
|
||||
&.o_survey_answer_matrix_whitespace {
|
||||
padding-right:18px;
|
||||
cursor:default;
|
||||
}
|
||||
}
|
||||
.nav-tabs .nav-link.active {
|
||||
background-color: transparent;
|
||||
border-color: #DEE2E6;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registry } from '@web/core/registry';
|
||||
import { ListRenderer } from "@web/views/list/list_renderer";
|
||||
import { KanbanRenderer } from "@web/views/kanban/kanban_renderer";
|
||||
import { listView } from '@web/views/list/list_view';
|
||||
import { kanbanView } from '@web/views/kanban/kanban_view';
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
const { useEffect, useRef } = owl;
|
||||
|
||||
export function useSurveyLoadSampleHook(selector) {
|
||||
const rootRef = useRef("root");
|
||||
const actionService = useService("action");
|
||||
const orm = useService('orm');
|
||||
let isLoadingSample = false;
|
||||
/**
|
||||
* Load and show the sample survey related to the clicked element,
|
||||
* when there is no survey to display.
|
||||
* We currently have 3 different samples to load:
|
||||
* - Sample Feedback Form
|
||||
* - Sample Certification
|
||||
* - Sample Live Presentation
|
||||
*/
|
||||
const loadSample = async (method) => {
|
||||
// Prevent loading multiple samples if double clicked
|
||||
isLoadingSample = true;
|
||||
const action = await orm.call('survey.survey', method);
|
||||
actionService.doAction(action);
|
||||
};
|
||||
useEffect(
|
||||
(elems) => {
|
||||
if (!elems || !elems.length) {
|
||||
return;
|
||||
}
|
||||
const handler = (ev) => {
|
||||
if (!isLoadingSample) {
|
||||
const surveyMethod = ev.currentTarget.closest('.o_survey_sample_container').getAttribute('action');
|
||||
loadSample(surveyMethod);
|
||||
}
|
||||
}
|
||||
for (const elem of elems) {
|
||||
elem.addEventListener('click', handler);
|
||||
}
|
||||
return () => {
|
||||
for (const elem of elems) {
|
||||
elem.removeEventListener('click', handler);
|
||||
}
|
||||
};
|
||||
},
|
||||
() => [rootRef.el && rootRef.el.querySelectorAll(selector)]
|
||||
);
|
||||
};
|
||||
|
||||
export class SurveyListRenderer extends ListRenderer {
|
||||
setup() {
|
||||
super.setup();
|
||||
|
||||
if (this.canCreate) {
|
||||
useSurveyLoadSampleHook('.o_survey_load_sample');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
registry.category('views').add('survey_view_tree', {
|
||||
...listView,
|
||||
Renderer: SurveyListRenderer,
|
||||
});
|
||||
|
||||
export class SurveyKanbanRenderer extends KanbanRenderer {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.canCreate = this.props.archInfo.activeActions.create;
|
||||
if (this.canCreate) {
|
||||
useSurveyLoadSampleHook('.o_survey_load_sample');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
registry.category('views').add('survey_view_kanban', {
|
||||
...kanbanView,
|
||||
Renderer: SurveyKanbanRenderer,
|
||||
});
|
||||
|
|
@ -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 fw-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 text-break" href="#">
|
||||
<span t-esc="page.title" />
|
||||
</a>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span t-att-class="'text-break ' + (isActivePage ? 'text-black' : 'text-muted')"
|
||||
t-esc="page.title" />
|
||||
</t>
|
||||
</li>
|
||||
</t>
|
||||
</ol>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
|
||||
<t t-name="survey.survey_image_zoomer">
|
||||
<div role="dialog" class="o_survey_img_zoom_modal modal fade d-flex align-items-center p-0"
|
||||
data-bs-backdrop="false" aria-label="Image Zoom Dialog" tabindex="-1">
|
||||
<div class="o_survey_img_zoom_dialog modal-dialog h-100 w-100 mw-100 py-0" role="Picture Enlarged">
|
||||
<div class="modal-content h-100 bg-transparent">
|
||||
<div class="o_survey_img_zoom_body modal-body h-100 bg-transparent d-flex justify-content-center">
|
||||
<button type="button" data-bs-dismiss="modal" aria-label="close"
|
||||
class="o_survey_img_zoom_close_btn close text-white btn-close-white btn-close position-absolute"/>
|
||||
<img class="o_survey_img_zoom_image img img-fluid d-block m-auto" t-att-src="widget.sourceImage" alt="Zoomed Image"/>
|
||||
<div class="o_survey_img_zoom_controls_wrapper position-absolute">
|
||||
<button type="button" class="o_survey_img_zoom_in_btn text-white me-1" aria-label="Zoom in">
|
||||
<span class="fa fa-plus"/>
|
||||
</button>
|
||||
<button type="button" class="o_survey_img_zoom_out_btn text-white ms-1" aria-label="Zoom out">
|
||||
<span class="fa fa-minus"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -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 fw-bold"
|
||||
t-attf-style="border-color: #{borderColor}">
|
||||
<span class="d-inline-block" t-esc="value" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</templates>
|
||||