Initial commit: Core packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:45 +02:00
commit 12c29a983b
9512 changed files with 8379910 additions and 0 deletions

View file

@ -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.

View file

@ -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.

View file

@ -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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 27 KiB

View file

@ -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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.5 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.5 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

View file

@ -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

View file

@ -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

View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

View file

@ -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);
});

View file

@ -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);
});

File diff suppressed because one or more lines are too long

View 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;
});

File diff suppressed because it is too large Load diff

View 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;
},
});

View file

@ -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;
}
};
});

View file

@ -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;
});

View file

@ -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;
});

View file

@ -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
};
});

View file

@ -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;
});

View file

@ -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',
];
});

View file

@ -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;
});

View file

@ -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;
});

View file

@ -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;
});

View file

@ -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;
});

View file

@ -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',
}
]);

View file

@ -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);

View file

@ -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>

View file

@ -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);
}
}

View file

@ -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);

View file

@ -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;
}
}

View file

@ -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;
}
}
}
}

View file

@ -0,0 +1,7 @@
// = Survey view
// ============================================================================
// No CSS hacks, variables overrides only
.o_survey_form {
--SurveyForm__section-background-color: #{$o-gray-300};
}

View file

@ -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;
}
}
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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,
});

View file

@ -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>

View file

@ -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>

View file

@ -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>