19.0 vanilla
|
Before Width: | Height: | Size: 356 KiB |
|
|
@ -0,0 +1,10 @@
|
|||
Picture by Haseeb Jamil published on Unsplash
|
||||
|
||||
Unsplash grants an irrevocable, nonexclusive, worldwide copyright license to
|
||||
download, copy, modify, distribute, perform, and use photos from Unsplash for
|
||||
free, including for commercial purposes, without permission from or attributing
|
||||
the photographer or Unsplash. This license does not include the right to
|
||||
compile photos from Unsplash to replicate a similar or competing service.
|
||||
|
||||
https://unsplash.com/photos/bread-with-patty-and-sliced-tomato-and-lettuce-burger-OvNsLemoVEw
|
||||
https://unsplash.com/license
|
||||
|
After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 163 KiB |
|
|
@ -0,0 +1,4 @@
|
|||
<svg width="1440" height="1024" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#714B67" d="M0 0h1440v1024H0z"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M1137.67 1024A1038.007 1038.007 0 0 1 436.85 0h429.64a608.96 608.96 0 0 0 176.91 472.6c106.16 106.162 247.49 169.079 396.6 177.411V1024h-302.33ZM296 1024A370 370 0 0 0 0 537.962v155.796a217.059 217.059 0 0 1 158.896 167.894A217.075 217.075 0 0 1 126.881 1024H296Z" fill="#7D5372"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 467 B |
|
After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 8 KiB |
|
Before Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
|
@ -3,13 +3,13 @@
|
|||
|
||||
<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"/>
|
||||
<t t-set="canGoBack" t-value="surveyCanGoBack"/>
|
||||
<t t-foreach="pages" t-as="page" t-key="page_index">
|
||||
<t t-set="isActivePage" t-value="page.id === 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">
|
||||
<t t-if="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" />
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import { isMacOS } from "@web/core/browser/feature_detection";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { Interaction } from "@web/public/interaction";
|
||||
|
||||
export class SurveyEnterTooltip extends Interaction {
|
||||
static selector = ".o_survey_form #enter-tooltip";
|
||||
|
||||
dynamicSelectors = {
|
||||
...this.dynamicSelectors,
|
||||
_inputs: () => document.querySelectorAll(".o_survey_form .form-control"),
|
||||
};
|
||||
|
||||
dynamicContent = {
|
||||
_inputs: {
|
||||
"t-on-focusin": this.updateEnterButtonText,
|
||||
"t-on-focusout": this.updateEnterButtonText,
|
||||
},
|
||||
_root: {
|
||||
"t-out": () => this.enterTooltipText,
|
||||
},
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.isMac = isMacOS();
|
||||
this.defaultText = _t("or press Enter");
|
||||
this.otherText = isMacOS() ? _t("or press ⌘+Enter") : _t("or press CTRL+Enter");
|
||||
this.enterTooltipText = this.defaultText;
|
||||
const { questionsLayout } =
|
||||
document.querySelector("form.o_survey-fill-form")?.dataset || {};
|
||||
this.surveyQuestionsLayout = questionsLayout;
|
||||
}
|
||||
|
||||
start() {
|
||||
const activeEl = document.activeElement;
|
||||
this.updateTooltip(
|
||||
document.hasFocus() &&
|
||||
activeEl.tagName.toLowerCase() === "textarea" &&
|
||||
activeEl.classList.contains("form-control")
|
||||
);
|
||||
this.updateContent();
|
||||
}
|
||||
|
||||
updateEnterButtonText(ev) {
|
||||
const targetEl = ev.target;
|
||||
const isTextbox = ev.type === "focusin" && targetEl.tagName.toLowerCase() === "textarea";
|
||||
this.updateTooltip(isTextbox);
|
||||
}
|
||||
|
||||
updateTooltip(isTextbox) {
|
||||
this.enterTooltipText =
|
||||
isTextbox || ["one_page", "page_per_section"].includes(this.surveyQuestionsLayout)
|
||||
? this.otherText
|
||||
: this.defaultText;
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("public.interactions").add("survey.SurveyEnterTooltip", SurveyEnterTooltip);
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
import { Interaction } from "@web/public/interaction";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { fadeIn, fadeOut } from "@survey/utils";
|
||||
|
||||
export class SurveyImageZoomer extends Interaction {
|
||||
static selector = ".o_survey_img_zoom_modal";
|
||||
dynamicContent = {
|
||||
_root: {
|
||||
"t-on-click.prevent": this.onZoomerClick,
|
||||
},
|
||||
".o_survey_img_zoom_image": {
|
||||
"t-on-wheel": this.onImageScroll,
|
||||
"t-att-style": () => ({
|
||||
// !important is needed to prevent default 'no-transform' on smaller screens.
|
||||
transform: `scale(${this.zoomImageScale}) !important`,
|
||||
}),
|
||||
},
|
||||
".o_survey_img_zoom_in_btn": {
|
||||
"t-on-click.stop": () => this.addZoomSteps(1),
|
||||
},
|
||||
".o_survey_img_zoom_out_btn": {
|
||||
"t-on-click.stop": () => this.addZoomSteps(-1),
|
||||
},
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.fadeInOutDelay = 200;
|
||||
this.zoomImageScale = 1;
|
||||
}
|
||||
|
||||
async willStart() {
|
||||
await fadeOut(this.el, 0);
|
||||
fadeIn(this.el, this.fadeInOutDelay);
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Zoom in/out image on scrolling
|
||||
* @param {WheelEvent} ev
|
||||
*/
|
||||
onImageScroll(ev) {
|
||||
ev.preventDefault();
|
||||
if (ev.wheelDelta > 0 || ev.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.
|
||||
*/
|
||||
async onZoomerClick() {
|
||||
await this.waitFor(fadeOut(this.el, this.fadeInOutDelay));
|
||||
this.el.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Zoom in / out the image by changing the scale by the given number of steps.
|
||||
* @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;
|
||||
}
|
||||
this.zoomImageScale = newZoomImageScale;
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("public.interactions").add("survey.SurveyImageZoomer", SurveyImageZoomer);
|
||||
|
|
@ -2,14 +2,14 @@
|
|||
<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"
|
||||
<div role="dialog" class="o_survey_img_zoom_modal show 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"/>
|
||||
<img class="o_survey_img_zoom_image img img-fluid d-block m-auto" t-att-src="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"/>
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import { Interaction } from "@web/public/interaction";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { resizeTextArea } from "@web/core/utils/autoresize";
|
||||
|
||||
export class SurveyPrint extends Interaction {
|
||||
static selector = ".o_survey_print";
|
||||
|
||||
dynamicContent = {
|
||||
".o_survey_user_results_print": { "t-on-click": this.onPrintUserResultsClick },
|
||||
};
|
||||
|
||||
start() {
|
||||
// Will allow the textarea to resize if any carriage return instead of showing scrollbar.
|
||||
document.querySelectorAll("textarea").forEach((textarea) => {
|
||||
resizeTextArea(textarea);
|
||||
});
|
||||
}
|
||||
|
||||
onPrintUserResultsClick() {
|
||||
window.print();
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("public.interactions").add("survey.survey_print", SurveyPrint);
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
import { Interaction } from "@web/public/interaction";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
export class SurveyQuickAccess extends Interaction {
|
||||
static selector = ".o_survey_quick_access";
|
||||
|
||||
dynamicContent = {
|
||||
_document: { "t-on-keypress": this.onKeyPress },
|
||||
"button[type='submit']": {
|
||||
"t-on-click.prevent": this.submitCode,
|
||||
"t-att-class": () => ({ "d-none": this.isLaunchShown })
|
||||
},
|
||||
"#session_code": { "t-on-input": this.onSessionCodeInput },
|
||||
".o_survey_launch_session": {
|
||||
"t-on-click": this.onLaunchSessionClick,
|
||||
"t-att-class": () => ({ "d-none": !this.isLaunchShown }),
|
||||
},
|
||||
".o_survey_session_error_not_launched": { "t-att-class": () => ({ "d-none": this.errorCode !== "not_launched" }) },
|
||||
".o_survey_session_error_invalid_code": { "t-att-class": () => ({ "d-none": this.errorCode !== "invalid_code" }) },
|
||||
|
||||
};
|
||||
|
||||
async onLaunchSessionClick() {
|
||||
const sessionResult = await this.waitFor(this.services.orm.call(
|
||||
"survey.survey",
|
||||
"action_start_session",
|
||||
[[parseInt(this.el.querySelector(".o_survey_launch_session").dataset.surveyId)]]
|
||||
));
|
||||
window.location = sessionResult.url;
|
||||
}
|
||||
|
||||
onSessionCodeInput() {
|
||||
this.errorCode = "";
|
||||
this.isLaunchShown = false;
|
||||
}
|
||||
|
||||
onKeyPress(event) {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
this.submitCode();
|
||||
}
|
||||
}
|
||||
|
||||
async submitCode() {
|
||||
this.errorCode = "";
|
||||
const sessionCodeInputVal = encodeURIComponent(this.el.querySelector("input#session_code").value.trim());
|
||||
if (!sessionCodeInputVal) {
|
||||
this.errorCode = "invalid_code";
|
||||
return;
|
||||
}
|
||||
const response = await this.waitFor(rpc(`/survey/check_session_code/${sessionCodeInputVal}`));
|
||||
this.protectSyncAfterAsync(() => {
|
||||
if (response.survey_url) {
|
||||
window.location = response.survey_url;
|
||||
} else {
|
||||
if (response.error && response.error === "survey_session_not_launched") {
|
||||
this.errorCode = "not_launched";
|
||||
if ("survey_id" in response) {
|
||||
this.isLaunchShown = true;
|
||||
this.el.querySelector(".o_survey_launch_session").dataset.surveyId = response.survey_id;
|
||||
}
|
||||
} else {
|
||||
this.errorCode = "invalid_code";
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
registry
|
||||
.category("public.interactions")
|
||||
.add("survey.survey_quick_access", SurveyQuickAccess);
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
import { Interaction } from "@web/public/interaction";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { redirect } from "@web/core/utils/urls";
|
||||
import { getActiveHotkey } from "@web/core/hotkeys/hotkey_service";
|
||||
|
||||
export class SurveyResult extends Interaction {
|
||||
static selector = ".o_survey_result";
|
||||
|
||||
dynamicContent = {
|
||||
".o_survey_results_topbar_clear_filters": { "t-on-click": this.onClearFiltersClick },
|
||||
".o_survey_results_data_tab:not(.active)": { "t-on-click": this.updateContent },
|
||||
".filter-add-answer": { "t-on-click": this.onFilterAddAnswerClick },
|
||||
"i.filter-remove-answer": { "t-on-click": this.onFilterRemoveAnswerClick },
|
||||
"a.filter-finished-or-not": { "t-on-click": this.onFilterFinishedOrNotClick },
|
||||
"a.filter-finished": { "t-on-click": this.onFilterFinishedClick },
|
||||
"a.filter-failed": { "t-on-click": this.onFilterFailedClick },
|
||||
"a.filter-passed": { "t-on-click": this.onFilterPassedClick },
|
||||
"a.filter-passed-and-failed": { "t-on-click": this.onFilterPassedAndFailedClick },
|
||||
".o_survey_answer_image": { "t-on-click.prevent": this.onAnswerImageClick },
|
||||
".o_survey_results_print": { "t-on-click": this.onPrintResultsClick },
|
||||
_document: {
|
||||
"t-on-keydown": this.onKeydown,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Add an answer filter by updating the URL and redirecting.
|
||||
* @param {Event} ev
|
||||
*/
|
||||
onFilterAddAnswerClick(ev) {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set("filters", this.prepareAnswersFilters(params.get("filters"), "add", ev));
|
||||
redirect(window.location.pathname + "?" + params.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an answer filter by updating the URL and redirecting.
|
||||
* @param {Event} ev
|
||||
*/
|
||||
onFilterRemoveAnswerClick(ev) {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const filters = this.prepareAnswersFilters(params.get("filters"), "remove", ev);
|
||||
if (filters) {
|
||||
params.set("filters", filters);
|
||||
} else {
|
||||
params.delete("filters");
|
||||
}
|
||||
redirect(window.location.pathname + "?" + params.toString());
|
||||
}
|
||||
|
||||
onClearFiltersClick() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.delete("filters");
|
||||
params.delete("finished");
|
||||
params.delete("failed");
|
||||
params.delete("passed");
|
||||
redirect(window.location.pathname + "?" + params.toString());
|
||||
}
|
||||
|
||||
onFilterFinishedOrNotClick() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.delete("finished");
|
||||
redirect(window.location.pathname + "?" + params.toString());
|
||||
}
|
||||
|
||||
onFilterFinishedClick() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set("finished", "true");
|
||||
redirect(window.location.pathname + "?" + params.toString());
|
||||
}
|
||||
|
||||
onFilterFailedClick() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set("failed", "true");
|
||||
params.delete("passed");
|
||||
redirect(window.location.pathname + "?" + params.toString());
|
||||
}
|
||||
|
||||
onFilterPassedClick() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set("passed", "true");
|
||||
params.delete("failed");
|
||||
redirect(window.location.pathname + "?" + params.toString());
|
||||
}
|
||||
|
||||
onFilterPassedAndFailedClick() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.delete("failed");
|
||||
params.delete("passed");
|
||||
redirect(window.location.pathname + "?" + params.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when an image on an answer in multi-answers question is clicked.
|
||||
* @param {Event} ev
|
||||
*/
|
||||
onAnswerImageClick(ev) {
|
||||
this.renderAt(
|
||||
"survey.survey_image_zoomer",
|
||||
{ sourceImage: ev.currentTarget.src },
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Call print dialog
|
||||
*/
|
||||
onPrintResultsClick() {
|
||||
// For each paginator, save the current state and uncollapse the table.
|
||||
for (const paginatorEl of document.querySelectorAll(".survey_table_with_pagination")) {
|
||||
paginatorEl.dispatchEvent(new Event("save_state_and_show_all"));
|
||||
}
|
||||
window.print();
|
||||
// Restore the original state of each paginator after the print.
|
||||
for (const paginatorEl of document.querySelectorAll(".survey_table_with_pagination")) {
|
||||
paginatorEl.dispatchEvent(new Event("restore_state"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a key is pressed on the survey result page.
|
||||
* If the user pressed CTRL+P, the print procedure is started.
|
||||
* @param {Event} ev Keydown event
|
||||
*/
|
||||
onKeydown(ev) {
|
||||
if (getActiveHotkey(ev) === "control+p") {
|
||||
ev.preventDefault();
|
||||
ev.stopImmediatePropagation();
|
||||
this.onPrintResultsClick();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the modified pathname string for filters after adding or removing an
|
||||
* answer filter (from click event).
|
||||
* @param {String} filters Existing answer filters, formatted as
|
||||
* `modelX,rowX,ansX|modelY,rowY,ansY...` - row is used for matrix-type questions row id, 0 for others
|
||||
* "model" specifying the model to query depending on the question type we filter on.
|
||||
- 'A': 'survey.question.answer' ids: simple_choice, multiple_choice, matrix
|
||||
- 'L': 'survey.user_input.line' ids: char_box, text_box, numerical_box, date, datetime
|
||||
* @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 cellDataset = ev.currentTarget.dataset;
|
||||
const filter = `${cellDataset.modelShortKey},${cellDataset.rowId || 0},${cellDataset.recordId}`;
|
||||
|
||||
if (operation === "add") {
|
||||
if (filters) {
|
||||
filters = !filters.split("|").includes(filter) ? (filters += `|${filter}`) : filters;
|
||||
} else {
|
||||
filters = filter;
|
||||
}
|
||||
} else if (operation === "remove") {
|
||||
filters = filters
|
||||
.split("|")
|
||||
.filter((filterItem) => filterItem !== filter)
|
||||
.join("|");
|
||||
} else {
|
||||
throw new Error('`operation` parameter for `prepareAnswersFilters` must be either "add" or "remove".');
|
||||
}
|
||||
return filters;
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("public.interactions").add("survey.SurveyResult", SurveyResult);
|
||||
|
|
@ -0,0 +1,378 @@
|
|||
import { _t } from "@web/core/l10n/translation";
|
||||
import { loadBundle } from "@web/core/assets";
|
||||
import { Interaction } from "@web/public/interaction";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
// The given colors are the same as those used by D3
|
||||
const D3_COLORS = [
|
||||
"#1f77b4",
|
||||
"#ff7f0e",
|
||||
"#aec7e8",
|
||||
"#ffbb78",
|
||||
"#2ca02c",
|
||||
"#98df8a",
|
||||
"#d62728",
|
||||
"#ff9896",
|
||||
"#9467bd",
|
||||
"#c5b0d5",
|
||||
"#8c564b",
|
||||
"#c49c94",
|
||||
"#e377c2",
|
||||
"#f7b6d2",
|
||||
"#7f7f7f",
|
||||
"#c7c7c7",
|
||||
"#bcbd22",
|
||||
"#dbdb8d",
|
||||
"#17becf",
|
||||
"#9edae5",
|
||||
];
|
||||
|
||||
/**
|
||||
* Interaction responsible for the initialization and the drawing of the various charts.
|
||||
*
|
||||
*/
|
||||
export class SurveyResultChart extends Interaction {
|
||||
static selector = ".survey_graph";
|
||||
|
||||
/**
|
||||
* Initializes the interaction based on its defined graph_type and loads the chart.
|
||||
*
|
||||
*/
|
||||
start() {
|
||||
this.graphData = JSON.parse(this.el.dataset.graphData);
|
||||
this.rightAnswers = this.el.dataset.rightAnswers || [];
|
||||
if (this.graphData && this.graphData.length !== 0) {
|
||||
switch (this.el.dataset.graphType) {
|
||||
case "multi_bar":
|
||||
this.chartConfig = this.getMultibarChartConfig();
|
||||
break;
|
||||
case "bar":
|
||||
this.chartConfig = this.getBarChartConfig();
|
||||
break;
|
||||
case "pie":
|
||||
this.chartConfig = this.getPieChartConfig();
|
||||
break;
|
||||
case "doughnut":
|
||||
this.chartConfig = this.getDoughnutChartConfig();
|
||||
break;
|
||||
case "by_section":
|
||||
this.chartConfig = this.getSectionResultsChartConfig();
|
||||
break;
|
||||
}
|
||||
this.chart = this.loadChart();
|
||||
this.registerCleanup(() => this.chart?.destroy());
|
||||
}
|
||||
}
|
||||
|
||||
async willStart() {
|
||||
await loadBundle("web.chartjs_lib");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a standard multi bar chart configuration.
|
||||
*
|
||||
*/
|
||||
getMultibarChartConfig() {
|
||||
return {
|
||||
type: "bar",
|
||||
data: {
|
||||
labels: this.graphData[0].values.map(this.markIfCorrect, this),
|
||||
datasets: this.graphData.map(function (group, index) {
|
||||
const data = group.values.map(function (value) {
|
||||
return value.count;
|
||||
});
|
||||
return {
|
||||
label: group.key,
|
||||
data: data,
|
||||
backgroundColor: D3_COLORS[index % 20],
|
||||
};
|
||||
}),
|
||||
},
|
||||
options: {
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
callback: function (val, index) {
|
||||
// For a category axis, the val is the index so the lookup via getLabelForValue is needed
|
||||
const value = this.getLabelForValue(val);
|
||||
const tickLimit = 25;
|
||||
return value?.length > tickLimit
|
||||
? `${value.slice(0, tickLimit)}...`
|
||||
: value;
|
||||
},
|
||||
},
|
||||
},
|
||||
y: {
|
||||
ticks: {
|
||||
precision: 0,
|
||||
},
|
||||
beginAtZero: true,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
title: function (tooltipItem) {
|
||||
return tooltipItem.label;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a standard bar chart configuration.
|
||||
*
|
||||
*/
|
||||
getBarChartConfig() {
|
||||
return {
|
||||
type: "bar",
|
||||
data: {
|
||||
labels: this.graphData[0].values.map(this.markIfCorrect, this),
|
||||
datasets: this.graphData.map(function (group) {
|
||||
const 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: {
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
callback: function (val, index) {
|
||||
// For a category axis, the val is the index so the lookup via getLabelForValue is needed
|
||||
const value = this.getLabelForValue(val);
|
||||
const tickLimit = 35;
|
||||
return value?.length > tickLimit
|
||||
? `${value.slice(0, tickLimit)}...`
|
||||
: value;
|
||||
},
|
||||
},
|
||||
},
|
||||
y: {
|
||||
ticks: {
|
||||
precision: 0,
|
||||
},
|
||||
beginAtZero: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a standard pie chart configuration.
|
||||
*
|
||||
*/
|
||||
getPieChartConfig() {
|
||||
const 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];
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
aspectRatio: 2,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a standard doughnut chart configuration.
|
||||
*
|
||||
*/
|
||||
getDoughnutChartConfig() {
|
||||
const totalsGraphData = this.graphData.totals;
|
||||
const 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: {
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: _t("Overall Performance"),
|
||||
},
|
||||
},
|
||||
aspectRatio: 2,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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%
|
||||
*
|
||||
*/
|
||||
getSectionResultsChartConfig() {
|
||||
const sectionGraphData = this.graphData.by_section;
|
||||
|
||||
const resultKeys = {
|
||||
correct: _t("Correct"),
|
||||
partial: _t("Partially"),
|
||||
incorrect: _t("Incorrect"),
|
||||
skipped: _t("Unanswered"),
|
||||
};
|
||||
let resultColorIndex = 0;
|
||||
const datasets = [];
|
||||
for (const resultKey in resultKeys) {
|
||||
const data = [];
|
||||
for (const 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: {
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: _t("Performance by Section"),
|
||||
},
|
||||
legend: {
|
||||
display: true,
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (tooltipItem) => {
|
||||
const xLabel = tooltipItem.label;
|
||||
const roundedValue = Math.round(tooltipItem.parsed.y * 100) / 100;
|
||||
return `${xLabel}: ${roundedValue}%`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
callback: function (val, index) {
|
||||
// For a category axis, the val is the index so the lookup via getLabelForValue is needed
|
||||
const value = this.getLabelForValue(val);
|
||||
const tickLimit = 20;
|
||||
return value?.length > tickLimit
|
||||
? `${value.slice(0, tickLimit)}...`
|
||||
: value;
|
||||
},
|
||||
},
|
||||
},
|
||||
y: {
|
||||
gridLines: {
|
||||
display: false,
|
||||
},
|
||||
ticks: {
|
||||
precision: 0,
|
||||
callback: function (label) {
|
||||
return label + "%";
|
||||
},
|
||||
maxTicksLimit: 5,
|
||||
stepSize: 25,
|
||||
},
|
||||
beginAtZero: true,
|
||||
suggestedMin: 0,
|
||||
suggestedMax: 100,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a unicode 'check' mark if the answer's text is among the question's right answers.
|
||||
*
|
||||
* @param value
|
||||
* @param value.text The original text of the answer
|
||||
*/
|
||||
markIfCorrect(value) {
|
||||
return value.text + (this.rightAnswers.indexOf(value.text) >= 0 ? " \u2713" : "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the chart using the provided Chart library.
|
||||
*
|
||||
*/
|
||||
loadChart() {
|
||||
this.el.style.position = "relative";
|
||||
const canvas = this.el.querySelector("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
return new Chart(ctx, this.chartConfig);
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("public.interactions").add("survey.survey_result_chart", SurveyResultChart);
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
import { Interaction } from "@web/public/interaction";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { renderToMarkup } from "@web/core/utils/render";
|
||||
|
||||
export class SurveyResultPagination extends Interaction {
|
||||
static selector = ".survey_table_with_pagination";
|
||||
dynamicContent = {
|
||||
"li.o_survey_js_results_pagination a": {
|
||||
"t-on-click.prevent": this.onPageClick,
|
||||
},
|
||||
".o_survey_question_answers_show_btn": {
|
||||
"t-on-click": this.onShowAllAnswers,
|
||||
"t-att-class": () => ({
|
||||
"d-none": this.paginationState.showAll,
|
||||
}),
|
||||
},
|
||||
".pagination": {
|
||||
"t-att-class": () => ({
|
||||
"d-none": this.paginationState.showAll,
|
||||
}),
|
||||
},
|
||||
".o_survey_results_table_wrapper": {
|
||||
"t-att-class": () => ({
|
||||
"h-auto": this.paginationState.showAll,
|
||||
}),
|
||||
},
|
||||
tbody: {
|
||||
"t-out": () => this.tableContent,
|
||||
},
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.limit = this.el.dataset.record_limit;
|
||||
this.questionData = this.parseAnswersJSON();
|
||||
this.elCount = this.questionData.length;
|
||||
this.paginationState = {
|
||||
currentPage: 1,
|
||||
minIdx: 0,
|
||||
maxIdx: Math.min(this.elCount, this.limit),
|
||||
showAll: false,
|
||||
hideFilter: this.el.dataset.hideFilter,
|
||||
};
|
||||
|
||||
// The following two events are dispatched by survey_result when user
|
||||
// clicks on the "print" button.
|
||||
this.el.addEventListener("save_state_and_show_all", () => {
|
||||
this.paginationStateBackup = Object.assign({}, this.paginationState);
|
||||
this.onShowAllAnswers();
|
||||
this.updateContent();
|
||||
});
|
||||
this.el.addEventListener("restore_state", () => {
|
||||
if (this.paginationStateBackup) {
|
||||
this.paginationState = this.paginationStateBackup;
|
||||
}
|
||||
this.updateContent();
|
||||
});
|
||||
}
|
||||
|
||||
parseAnswersJSON() {
|
||||
const keys = ["id", "value", "url"];
|
||||
return JSON.parse(this.el.dataset.answersJson).map((entry, index) => {
|
||||
const content = Object.fromEntries(entry.map((value, index) => [keys[index], value]));
|
||||
return { index: index, ...content };
|
||||
});
|
||||
}
|
||||
|
||||
get tableContent() {
|
||||
return renderToMarkup("survey.paginated_results_rows", {
|
||||
records: this.questionData.slice(
|
||||
this.paginationState.minIdx,
|
||||
this.paginationState.maxIdx
|
||||
),
|
||||
hide_filter: this.paginationState.hideFilter,
|
||||
});
|
||||
}
|
||||
|
||||
onPageClick(ev) {
|
||||
this.pageBtnsEl = this.el.querySelector("ul.pagination");
|
||||
this.pageBtnsEl
|
||||
.querySelector(`li:nth-child(${this.paginationState.currentPage})`)
|
||||
.classList.remove("active");
|
||||
this.paginationState.currentPage = ev.currentTarget.text;
|
||||
this.pageBtnsEl
|
||||
.querySelector(`li:nth-child(${this.paginationState.currentPage})`)
|
||||
.classList.add("active");
|
||||
this.paginationState.minIdx = this.limit * (this.paginationState.currentPage - 1);
|
||||
this.paginationState.maxIdx = Math.min(
|
||||
this.elCount,
|
||||
this.limit * this.paginationState.currentPage
|
||||
);
|
||||
}
|
||||
|
||||
onShowAllAnswers() {
|
||||
this.paginationState.showAll = true;
|
||||
this.paginationState.minIdx = 0;
|
||||
this.paginationState.maxIdx = this.elCount;
|
||||
}
|
||||
}
|
||||
|
||||
registry
|
||||
.category("public.interactions")
|
||||
.add("survey.survey_result_pagination", SurveyResultPagination);
|
||||
|
|
@ -0,0 +1,367 @@
|
|||
/* global ChartDataLabels */
|
||||
|
||||
import { loadJS } from "@web/core/assets";
|
||||
import SESSION_CHART_COLORS from "@survey/interactions/survey_session_colors";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { Interaction } from "@web/public/interaction";
|
||||
|
||||
export class SurveySessionChart extends Interaction {
|
||||
static selector = ".o_survey_session_chart";
|
||||
dynamicContent = {
|
||||
_root: {
|
||||
"t-on-updateState": this.updateState,
|
||||
"t-att-class": () => ({ chart_is_ready: !!this.chart }),
|
||||
},
|
||||
};
|
||||
setup() {
|
||||
const sessionManageEl = this.el.closest(".o_survey_session_manage");
|
||||
this.questionType = sessionManageEl.dataset.questionType;
|
||||
this.answersValidity = JSON.parse(sessionManageEl.dataset.answersValidity);
|
||||
this.hasCorrectAnswers = sessionManageEl.dataset.hasCorrectAnswers;
|
||||
this.questionStatistics = this.processQuestionStatistics(
|
||||
JSON.parse(sessionManageEl.dataset.questionStatistics)
|
||||
);
|
||||
this.showAnswers = false;
|
||||
this.showInputs = false;
|
||||
}
|
||||
|
||||
async willStart() {
|
||||
await loadJS("/survey/static/src/js/libs/chartjs-plugin-datalabels.js");
|
||||
}
|
||||
|
||||
start() {
|
||||
const canvas = this.el.querySelector("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
this.chart = new Chart(ctx, this.buildChartConfiguration());
|
||||
this.registerCleanup(() => this.chart.destroy());
|
||||
// survey_session_manage waits for us to start.
|
||||
// If we are ready before survey_session_manage, this is signaled
|
||||
// by the presence of the class `chart_is_ready` (see dynamicContent).
|
||||
// If survey_session_manage is ready before us, it will wait for this
|
||||
// signal on the bus.
|
||||
this.env.bus.trigger("SURVEY:CHART_INTERACTION_STARTED");
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the chart state based on the CustomEvent received.
|
||||
* Possible options, passed in detail, are:
|
||||
* - showInputs: boolean, show the user inputs on the chart
|
||||
* - showAnswers: boolean, show the correct and incorrect answers on the chart
|
||||
* - questionStatistics: object, the statistics of the current question
|
||||
*
|
||||
* @param {CustomEvent} ev
|
||||
*/
|
||||
updateState(ev) {
|
||||
if ("showInputs" in ev.detail) {
|
||||
this.showInputs = ev.detail.showInputs;
|
||||
}
|
||||
if ("showAnswers" in ev.detail) {
|
||||
this.showAnswers = ev.detail.showAnswers;
|
||||
}
|
||||
this.updateChart(ev.detail.questionStatistics);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 / ...)
|
||||
*/
|
||||
updateChart(questionStatistics) {
|
||||
if (questionStatistics) {
|
||||
this.questionStatistics = this.processQuestionStatistics(questionStatistics);
|
||||
}
|
||||
if (this.chart) {
|
||||
// only a single dataset for our bar charts
|
||||
const chartData = this.chart.data.datasets[0].data;
|
||||
for (let i = 0; i < chartData.length; i++) {
|
||||
const value = this.showInputs ? this.questionStatistics[i].count : 0;
|
||||
this.chart.data.datasets[0].data[i] = value;
|
||||
}
|
||||
this.chart.update();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
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,
|
||||
},
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
ticks: {
|
||||
display: false,
|
||||
},
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
x: {
|
||||
ticks: {
|
||||
minRotation: 20,
|
||||
maxRotation: 90,
|
||||
font: {
|
||||
size: "35",
|
||||
weight: "bold",
|
||||
},
|
||||
color: "#212529",
|
||||
autoSkip: false,
|
||||
},
|
||||
grid: {
|
||||
drawOnChartArea: false,
|
||||
color: "rgba(0, 0, 0, 0.2)",
|
||||
},
|
||||
},
|
||||
},
|
||||
layout: {
|
||||
padding: {
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 70,
|
||||
bottom: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
ChartDataLabels,
|
||||
{
|
||||
/**
|
||||
* 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 -> 35, 3 and 4 -> 30, 5 and 6 -> 30, ...
|
||||
const charPerLineBreakpoints = [
|
||||
[1, 2, 35, minRatio],
|
||||
[3, 4, 30, minRatio],
|
||||
[5, 6, 30, 0.45],
|
||||
[7, 8, 30, 0.65],
|
||||
[9, null, 30, 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 < 35) {
|
||||
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.x.ticks.font.size = Math.min(
|
||||
parseInt(chart.options.scales.x.ticks.font.size),
|
||||
(chart.width * fontRatio) / nbrCol
|
||||
);
|
||||
}
|
||||
|
||||
chart.data.labels.forEach(function (label, index, labelsList) {
|
||||
// Split all the words of the label
|
||||
const words = label.split(" ");
|
||||
const 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.
|
||||
*/
|
||||
extractChartLabels() {
|
||||
return this.questionStatistics.map((point) => point.text);
|
||||
}
|
||||
|
||||
/**
|
||||
* We simply return an array of zeros as initial value.
|
||||
* The chart will update afterwards as attendees add their user inputs.
|
||||
*/
|
||||
extractChartData() {
|
||||
return Array(this.questionStatistics.length).fill(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'
|
||||
*/
|
||||
getBackgroundColor(metaData) {
|
||||
const opacity =
|
||||
this.showAnswers && this.hasCorrectAnswers && !this.isValidAnswer(metaData.dataIndex)
|
||||
? "0.2"
|
||||
: "0.8";
|
||||
// If metaData.dataIndex is greater than SESSION_CHART_COLORS.length, it should start from the beginning
|
||||
const rgb = SESSION_CHART_COLORS[metaData.dataIndex % SESSION_CHART_COLORS.length];
|
||||
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'
|
||||
*/
|
||||
getLabelColor(metaData) {
|
||||
let color = "#212529";
|
||||
if (this.showAnswers && this.hasCorrectAnswers) {
|
||||
color = this.isValidAnswer(metaData.dataIndex) ? "#2CBB70" : "#D9534F";
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
isValidAnswer(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
|
||||
*/
|
||||
processQuestionStatistics(rawStatistics) {
|
||||
if (["multiple_choice", "scale"].includes(this.questionType)) {
|
||||
return rawStatistics[0].values;
|
||||
}
|
||||
return rawStatistics;
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("public.interactions").add("survey.survey_session_chart", SurveySessionChart);
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
/**
|
||||
* Small tool that returns common colors for survey session interactions.
|
||||
*/
|
||||
export default [
|
||||
// the same colors as those used in odoo reporting
|
||||
"31,119,180",
|
||||
"255,127,14",
|
||||
"174,199,232",
|
||||
"255,187,120",
|
||||
"44,160,44",
|
||||
"152,223,138",
|
||||
"214,39,40",
|
||||
"255,152,150",
|
||||
"148,103,189",
|
||||
"197,176,213",
|
||||
"140,86,75",
|
||||
"196,156,148",
|
||||
"227,119,194",
|
||||
"247,182,210",
|
||||
"127,127,127",
|
||||
"199,199,199",
|
||||
"188,189,34",
|
||||
"219,219,141",
|
||||
"23,190,207",
|
||||
"158,218,229",
|
||||
];
|
||||
|
|
@ -0,0 +1,375 @@
|
|||
import { fadeIn, fadeOut } from "@survey/utils";
|
||||
import { Interaction } from "@web/public/interaction";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import SESSION_CHART_COLORS from "@survey/interactions/survey_session_colors";
|
||||
|
||||
export class SurveySessionLeaderboard extends Interaction {
|
||||
// Note: the class `o_survey_session_leaderboard` is present in two
|
||||
// templates: `user_input_session_manage_content` and `survey_page_statistics`.
|
||||
// This interaction is not needed in the second case. The descendant
|
||||
// combinator in the selector is used to select only the first case.
|
||||
static selector = ".o_survey_session_manage .o_survey_session_leaderboard";
|
||||
dynamicContent = {
|
||||
_root: {
|
||||
"t-on-showLeaderboard": this.showLeaderboard,
|
||||
"t-on-hideLeaderboard": this.hideLeaderboard,
|
||||
},
|
||||
".o_survey_session_leaderboard_bar": {
|
||||
"t-att-style": this.getBarStyle,
|
||||
},
|
||||
".o_survey_session_leaderboard_bar_question": {
|
||||
"t-att-style": this.getBarQuestionStyle,
|
||||
},
|
||||
};
|
||||
|
||||
getBarStyle(barEl) {
|
||||
if (this.leaderboardAnimationPhase === "prepareScores") {
|
||||
// See also this.prepareScores
|
||||
const currentScore = parseInt(
|
||||
barEl.closest(".o_survey_session_leaderboard_item").dataset.currentScore
|
||||
);
|
||||
if (currentScore && currentScore !== 0) {
|
||||
return {
|
||||
transition: "width 1s cubic-bezier(.4,0,.4,1)",
|
||||
width: this.BAR_MIN_WIDTH,
|
||||
};
|
||||
}
|
||||
} else if (this.leaderboardAnimationPhase === "sumScores") {
|
||||
// See also this.sumScores
|
||||
const { baseRatio, questionRatio } = this.getBarRatios(barEl);
|
||||
const updatedScoreRatio = 1 - questionRatio;
|
||||
const updatedScoreWidth = `calc(calc(100% - ${this.BAR_WIDTH}) * ${
|
||||
updatedScoreRatio * baseRatio
|
||||
})`;
|
||||
return {
|
||||
transition: "width ease .5s cubic-bezier(.5,0,.66,1.11)",
|
||||
width: updatedScoreWidth,
|
||||
minWidth: "0px",
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
getBarQuestionStyle(barEl) {
|
||||
if (this.leaderboardAnimationPhase === "showQuestionScores") {
|
||||
// See also this.showQuestionScores
|
||||
return {
|
||||
transition: "width 1s ease-out",
|
||||
width: `calc(calc(100% - ${this.BAR_WIDTH}) * ${barEl.dataset.widthRatio} + ${this.BAR_MIN_WIDTH})`,
|
||||
};
|
||||
} else if (this.leaderboardAnimationPhase === "sumScores") {
|
||||
// See also this.sumScores
|
||||
const { baseRatio, questionRatio } = this.getBarRatios(barEl);
|
||||
// we keep a min fixed width of 3rem to be able to display "+ 5 p"
|
||||
// even if the user already has 1,000,000 points
|
||||
const questionWidth = `calc(calc(calc(100% - ${this.BAR_WIDTH}) * ${
|
||||
questionRatio * baseRatio
|
||||
}) + ${this.BAR_MIN_WIDTH})`;
|
||||
return {
|
||||
transition: "width ease .5s cubic-bezier(.5,0,.66,1.11)",
|
||||
width: questionWidth,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
getBarRatios(barEl) {
|
||||
const item = barEl.closest(".o_survey_session_leaderboard_item");
|
||||
const updatedScore = parseInt(item.dataset.updatedScore);
|
||||
const questionScore = parseInt(item.dataset.questionScore);
|
||||
const maxUpdatedScore = parseInt(item.dataset.maxUpdatedScore);
|
||||
const baseRatio = maxUpdatedScore ? updatedScore / maxUpdatedScore : 1;
|
||||
const questionRatio = questionScore / (updatedScore || 1);
|
||||
return { baseRatio, questionRatio };
|
||||
}
|
||||
|
||||
setup() {
|
||||
this.fadeInOutTime = 400;
|
||||
this.BAR_MIN_WIDTH = "3rem";
|
||||
this.BAR_WIDTH = "24rem";
|
||||
this.BAR_HEIGHT = "3.8rem";
|
||||
this.surveyAccessToken = this.el.closest(
|
||||
".o_survey_session_manage"
|
||||
).dataset.surveyAccessToken;
|
||||
this.sessionResults = this.el.parentElement.querySelector(".o_survey_session_results");
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the question leaderboard on screen.
|
||||
* It's based on the attendees score (descending).
|
||||
*
|
||||
* We fade out the .o_survey_session_results element to fade in our rendered template.
|
||||
*
|
||||
* The width of the progress bars is set after the rendering to enable a width css animation.
|
||||
*
|
||||
* @param {CustomEvent} ev CustomEvent triggering the function
|
||||
*/
|
||||
showLeaderboard(ev) {
|
||||
let resolveFadeOut;
|
||||
let fadeOutPromise;
|
||||
const resultsEl = this.el.parentElement.querySelector(".o_survey_session_results");
|
||||
if (ev.detail.fadeOut) {
|
||||
fadeOutPromise = new Promise((resolve, reject) => {
|
||||
resolveFadeOut = resolve;
|
||||
});
|
||||
fadeOut(resultsEl, this.fadeInOutTime, () => {
|
||||
resultsEl.dispatchEvent(new CustomEvent("setDisplayNone"));
|
||||
resolveFadeOut();
|
||||
});
|
||||
} else {
|
||||
fadeOutPromise = Promise.resolve();
|
||||
resultsEl.dispatchEvent(new CustomEvent("setDisplayNone"));
|
||||
this.removeChildren(this.el.querySelector(".o_survey_session_leaderboard_container"));
|
||||
}
|
||||
|
||||
const leaderboardPromise = rpc(`/survey/session/leaderboard/${this.surveyAccessToken}`);
|
||||
this.waitFor(Promise.all([fadeOutPromise, leaderboardPromise])).then(
|
||||
this.protectSyncAfterAsync((results) => {
|
||||
const leaderboardResults = results[1];
|
||||
const renderedTemplate = document.createElement("div");
|
||||
const parser = new DOMParser();
|
||||
const parsedResults = parser.parseFromString(leaderboardResults, "text/html").body
|
||||
.firstChild;
|
||||
if (parsedResults) {
|
||||
// In case of scored survey with no participants, parsedResults
|
||||
// would be null and it would break the insert below
|
||||
this.insert(parsedResults, renderedTemplate);
|
||||
}
|
||||
this.insert(
|
||||
renderedTemplate,
|
||||
this.el.querySelector(".o_survey_session_leaderboard_container")
|
||||
);
|
||||
this.el
|
||||
.querySelectorAll(".o_survey_session_leaderboard_item")
|
||||
.forEach((item, index) => {
|
||||
const rgb = SESSION_CHART_COLORS[index % 10];
|
||||
item.querySelector(
|
||||
".o_survey_session_leaderboard_bar"
|
||||
).style.backgroundColor = `rgba(${rgb},1)`;
|
||||
item.querySelector(
|
||||
".o_survey_session_leaderboard_bar_question"
|
||||
).style.backgroundColor = `rgba(${rgb},0.4)`;
|
||||
});
|
||||
fadeIn(this.el, this.fadeInOutTime, async () => {
|
||||
if (ev.detail.isScoredQuestion) {
|
||||
await this.waitFor(this.prepareScores());
|
||||
await this.waitFor(this.showQuestionScores());
|
||||
await this.waitFor(this.sumScores());
|
||||
await this.waitFor(this.reorderScores());
|
||||
this.leaderboardAnimationPhase = null;
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse the process, fading out our template to fade in sessionResults.
|
||||
*/
|
||||
hideLeaderboard() {
|
||||
fadeOut(this.el, this.fadeInOutTime, () => {
|
||||
this.removeChildren(this.el.querySelector(".o_survey_session_leaderboard_container"));
|
||||
fadeIn(this.sessionResults, this.fadeInOutTime);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* This method animates the passed 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(scoreEl, currentScore, totalScore, increment, plusSign) {
|
||||
this.waitForTimeout(() => {
|
||||
const nextScore = Math.min(totalScore, currentScore + increment);
|
||||
scoreEl.textContent = `${plusSign ? "+ " : ""}${Math.round(nextScore)} p`;
|
||||
if (nextScore < totalScore) {
|
||||
this.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} scoreEl 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(scoreEl, position, offset, timeout) {
|
||||
let animationDone;
|
||||
const animationPromise = new Promise(function (resolve) {
|
||||
animationDone = resolve;
|
||||
});
|
||||
scoreEl.style.top = `calc(calc(${this.BAR_HEIGHT} * ${position}) + ${offset}rem)`;
|
||||
this.waitForTimeout(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
|
||||
*/
|
||||
async prepareScores() {
|
||||
let animationDone;
|
||||
const animationPromise = new Promise(function (resolve) {
|
||||
animationDone = resolve;
|
||||
});
|
||||
this.waitForTimeout(() => {
|
||||
this.leaderboardAnimationPhase = "prepareScores";
|
||||
this.waitForTimeout(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
|
||||
*/
|
||||
async reorderScores() {
|
||||
let animationDone;
|
||||
const animationPromise = new Promise(function (resolve) {
|
||||
animationDone = resolve;
|
||||
});
|
||||
this.waitForTimeout(() => {
|
||||
this.leaderboardAnimationPhase = "reorderScores";
|
||||
this.el.querySelectorAll(".o_survey_session_leaderboard_item").forEach(async (item) => {
|
||||
const currentPosition = parseInt(item.dataset.currentPosition);
|
||||
const newPosition = parseInt(item.dataset.newPosition);
|
||||
if (currentPosition !== newPosition) {
|
||||
const offset = newPosition > currentPosition ? 2 : -2;
|
||||
await this.waitFor(this.animateMoveTo(item, newPosition, offset, 300));
|
||||
item.style.transition = "top ease-in-out .1s";
|
||||
await this.waitFor(this.animateMoveTo(item, newPosition, offset * -0.3, 100));
|
||||
await this.waitFor(this.animateMoveTo(item, 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
|
||||
*/
|
||||
async showQuestionScores() {
|
||||
let animationDone;
|
||||
const animationPromise = new Promise(function (resolve) {
|
||||
animationDone = resolve;
|
||||
});
|
||||
this.waitForTimeout(() => {
|
||||
this.leaderboardAnimationPhase = "showQuestionScores";
|
||||
this.el
|
||||
.querySelectorAll(".o_survey_session_leaderboard_bar_question")
|
||||
.forEach((barEl) => {
|
||||
const scoreEl = barEl.querySelector(
|
||||
".o_survey_session_leaderboard_bar_question_score"
|
||||
);
|
||||
scoreEl.textContent = "0 p";
|
||||
const questionScore = parseInt(barEl.dataset.questionScore);
|
||||
if (questionScore && questionScore > 0) {
|
||||
let increment = parseInt(barEl.dataset.maxQuestionScore / 40);
|
||||
if (!increment || increment === 0) {
|
||||
increment = 1;
|
||||
}
|
||||
scoreEl.textContent = "+ 0 p";
|
||||
this.waitForTimeout(() => {
|
||||
this.animateScoreCounter(scoreEl, 0, questionScore, increment, true);
|
||||
}, 400);
|
||||
}
|
||||
this.waitForTimeout(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
|
||||
*/
|
||||
async sumScores() {
|
||||
let animationDone;
|
||||
const animationPromise = new Promise(function (resolve) {
|
||||
animationDone = resolve;
|
||||
});
|
||||
this.waitForTimeout(() => {
|
||||
this.leaderboardAnimationPhase = "sumScores";
|
||||
this.el.querySelectorAll(".o_survey_session_leaderboard_item").forEach((item) => {
|
||||
const currentScore = parseInt(item.dataset.currentScore);
|
||||
const updatedScore = parseInt(item.dataset.updatedScore);
|
||||
let increment = parseInt(item.dataset.maxQuestionScore / 40);
|
||||
if (!increment || increment === 0) {
|
||||
increment = 1;
|
||||
}
|
||||
this.animateScoreCounter(
|
||||
item.querySelector(".o_survey_session_leaderboard_score"),
|
||||
currentScore,
|
||||
updatedScore,
|
||||
increment,
|
||||
false
|
||||
);
|
||||
this.waitForTimeout(animationDone, 500);
|
||||
});
|
||||
}, 1400);
|
||||
|
||||
return animationPromise;
|
||||
}
|
||||
}
|
||||
|
||||
registry
|
||||
.category("public.interactions")
|
||||
.add("survey.survey_session_leaderboard", SurveySessionLeaderboard);
|
||||
|
|
@ -0,0 +1,716 @@
|
|||
import { preloadBackground } from "@survey/js/survey_preload_image_mixin";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { Interaction } from "@web/public/interaction";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { fadeIn, fadeOut } from "@survey/utils";
|
||||
import { getActiveHotkey } from "@web/core/hotkeys/hotkey_service";
|
||||
|
||||
const nextPageTooltips = {
|
||||
closingWords: _t("End of Survey"),
|
||||
leaderboard: _t("Show Leaderboard"),
|
||||
leaderboardFinal: _t("Show Final Leaderboard"),
|
||||
nextQuestion: _t("Next"),
|
||||
results: _t("Show Correct Answer(s)"),
|
||||
startScreen: _t("Start"),
|
||||
userInputs: _t("Show Results"),
|
||||
};
|
||||
|
||||
export class SurveySessionManage extends Interaction {
|
||||
static selector = ".o_survey_session_manage";
|
||||
|
||||
dynamicContent = {
|
||||
_document: {
|
||||
"t-on-keydown": this.onKeyDown,
|
||||
},
|
||||
".o_survey_session_copy": {
|
||||
"t-on-click.prevent": this.onCopySessionLink,
|
||||
},
|
||||
".o_survey_session_navigation_next": {
|
||||
"t-on-click.prevent": this.onNext,
|
||||
"t-att-class": () => ({
|
||||
"d-none":
|
||||
this.isSessionClosed ||
|
||||
(this.isLastQuestion && this.currentScreen === "leaderboardFinal"),
|
||||
}),
|
||||
},
|
||||
".o_survey_session_navigation_previous": {
|
||||
"t-on-click.prevent": this.onBack,
|
||||
"t-att-class": () => ({ "d-none": this.isFirstQuestion || this.isSessionClosed }),
|
||||
},
|
||||
".o_survey_session_close": {
|
||||
"t-on-click.prevent": this.onEndSessionClick,
|
||||
},
|
||||
".o_survey_session_attendees_count": {
|
||||
"t-out": () => this.sessionAttendeesCountText,
|
||||
},
|
||||
".o_survey_session_navigation_next_label": {
|
||||
"t-out": () => this.sessionNavigationNextLabel,
|
||||
},
|
||||
".o_survey_session_results": {
|
||||
"t-att-class": () => ({
|
||||
"d-none": this.isSessionClosed || this.leaderboardIsFadingOut || this.hideResults,
|
||||
}),
|
||||
"t-on-setDisplayNone": () => {
|
||||
// The leaderboard interaction will send a setDisplayNone event
|
||||
// after finishing its fade-in animation.
|
||||
this.hideResults = true;
|
||||
},
|
||||
},
|
||||
".o_survey_session_description_done": {
|
||||
"t-att-class": () => ({ "d-none": !this.isSessionClosed }),
|
||||
},
|
||||
};
|
||||
|
||||
setup() {
|
||||
if (this.el.dataset.isSessionClosed) {
|
||||
this.isSessionClosed = true;
|
||||
this.el.classList.remove("invisible");
|
||||
return;
|
||||
}
|
||||
this.fadeInOutTime = 500;
|
||||
this.answersRefreshDelay = 2000;
|
||||
// Flags used in dynamicContent
|
||||
this.isSessionClosed = false;
|
||||
this.leaderboardIsFadingOut = false;
|
||||
this.hideResults = false;
|
||||
// Elements related to other interactions
|
||||
this.chartEl = this.el.querySelector(".o_survey_session_chart");
|
||||
this.leaderboardEl = this.el.querySelector(".o_survey_session_leaderboard");
|
||||
this.timerEl = this.el.querySelector(".o_survey_timer_container .o_survey_timer");
|
||||
this.textAnswersEl = this.el.querySelector(".o_survey_session_text_answers_container");
|
||||
// General survey props
|
||||
this.surveyId = parseInt(this.el.dataset.surveyId);
|
||||
this.attendeesCount = this.el.dataset.attendeesCount
|
||||
? parseInt(this.el.dataset.attendeesCount, 10)
|
||||
: 0;
|
||||
this.surveyHasConditionalQuestions = this.el.dataset.surveyHasConditionalQuestions;
|
||||
this.surveyAccessToken = this.el.dataset.surveyAccessToken;
|
||||
this.isStartScreen = this.el.dataset.isStartScreen;
|
||||
this.isFirstQuestion = this.el.dataset.isFirstQuestion;
|
||||
this.isLastQuestion = this.el.dataset.isLastQuestion;
|
||||
this.surveyLastTriggeringAnswers = JSON.parse(
|
||||
this.el.dataset.surveyLastTriggeringAnswers || "[]"
|
||||
);
|
||||
// Scoring props
|
||||
this.isScoredQuestion = this.el.dataset.isScoredQuestion;
|
||||
this.sessionShowLeaderboard = this.el.dataset.sessionShowLeaderboard;
|
||||
this.hasCorrectAnswers = this.el.dataset.hasCorrectAnswers;
|
||||
// Display props
|
||||
this.showBarChart = this.el.dataset.showBarChart;
|
||||
this.showTextAnswers = this.el.dataset.showTextAnswers;
|
||||
// Question transition
|
||||
this.stopNextQuestion = false;
|
||||
// Background Management
|
||||
this.refreshBackground = this.el.dataset.refreshBackground;
|
||||
// Prepare the copy link tooltip
|
||||
this.copyBtnTooltip = window.Tooltip.getOrCreateInstance(
|
||||
this.el.querySelector(".o_survey_session_copy"),
|
||||
{
|
||||
title: _t("Click to copy link"),
|
||||
placement: "right",
|
||||
container: "body",
|
||||
trigger: "hover",
|
||||
offset: "0, 3",
|
||||
delay: 0,
|
||||
}
|
||||
);
|
||||
this.registerCleanup(() => {
|
||||
this.copyBtnPopover?.dispose();
|
||||
this.copyBtnTooltip?.dispose();
|
||||
});
|
||||
// Attendees count & navigation label
|
||||
this.sessionAttendeesCountText = "";
|
||||
this.sessionNavigationNextLabel = "";
|
||||
// Show the page and start the timer
|
||||
this.el.classList.remove("invisible");
|
||||
this.setupIntervals();
|
||||
}
|
||||
|
||||
async willStart() {
|
||||
// If a chart is present, we wait for the chart interaction to be ready.
|
||||
// The presence of the class `chart_is_ready` means that the chart was
|
||||
// ready before us, so we don't need to wait (see survey_session_chart)
|
||||
if (this.chartEl && !this.chartEl.classList.contains("chart_is_ready")) {
|
||||
let resolveChartPromise;
|
||||
const chartPromise = new Promise(function (resolve) {
|
||||
resolveChartPromise = resolve;
|
||||
});
|
||||
this.env.bus.addEventListener("SURVEY:CHART_INTERACTION_STARTED", resolveChartPromise);
|
||||
await chartPromise;
|
||||
}
|
||||
}
|
||||
|
||||
start() {
|
||||
this.setupCurrentScreen();
|
||||
this.startTimer();
|
||||
// Check if we are loading this page because the user clicked on 'previous'
|
||||
if (this.el.dataset.goingBack) {
|
||||
this.chartUpdateState({ showInputs: true, showAnswers: true });
|
||||
if (this.sessionShowLeaderboard && this.isScoredQuestion) {
|
||||
this.currentScreen = "leaderboard";
|
||||
this.leaderboardEl.dispatchEvent(
|
||||
new CustomEvent("showLeaderboard", {
|
||||
detail: {
|
||||
fadeOut: false,
|
||||
isScoredQuestion: this.isScoredQuestion,
|
||||
},
|
||||
})
|
||||
);
|
||||
} else {
|
||||
this.currentScreen = "results";
|
||||
}
|
||||
this.refreshResults();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies the survey URL link to the clipboard.
|
||||
* We avoid having to print the URL in a standard text input.
|
||||
*
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
async onCopySessionLink(ev) {
|
||||
const copyBtnTooltipHideDelay = 800;
|
||||
this.copyBtnTooltip?.dispose();
|
||||
delete this.copyBtnTooltip;
|
||||
this.copyBtnPopover = window.Popover.getOrCreateInstance(ev.currentTarget, {
|
||||
content: _t("Copied!"),
|
||||
trigger: "manual",
|
||||
placement: "right",
|
||||
container: "body",
|
||||
offset: "0, 3",
|
||||
});
|
||||
await this.waitFor(
|
||||
browser.navigator.clipboard.writeText(ev.currentTarget.innerText.trim())
|
||||
);
|
||||
this.protectSyncAfterAsync(() => {
|
||||
this.copyBtnPopover.show();
|
||||
this.waitForTimeout(() => this.copyBtnPopover.hide(), copyBtnTooltipHideDelay);
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(ev) {
|
||||
const screenToDisplay = this.getNextScreen();
|
||||
switch (screenToDisplay) {
|
||||
case "userInputs":
|
||||
this.chartUpdateState({ showInputs: true });
|
||||
break;
|
||||
case "results":
|
||||
this.chartUpdateState({ showAnswers: true });
|
||||
// when showing results, stop refreshing answers
|
||||
clearInterval(this.resultsRefreshInterval);
|
||||
delete this.resultsRefreshInterval;
|
||||
break;
|
||||
case "leaderboard":
|
||||
case "leaderboardFinal":
|
||||
if (!this.currentScreen.startsWith("leaderboard")) {
|
||||
this.leaderboardEl.dispatchEvent(
|
||||
new CustomEvent("showLeaderboard", {
|
||||
detail: {
|
||||
fadeOut: true,
|
||||
isScoredQuestion: this.isScoredQuestion,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
if (!this.isLastQuestion || !this.sessionShowLeaderboard) {
|
||||
this.goToNextQuestion();
|
||||
}
|
||||
break;
|
||||
}
|
||||
this.currentScreen = screenToDisplay;
|
||||
// To avoid a flicker, we do not update the tooltip when going to the
|
||||
// next question, as it will be done anyway in "setupCurrentScreen"
|
||||
if (!["question", "nextQuestion"].includes(screenToDisplay)) {
|
||||
this.updateNextScreenTooltip();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse behavior of 'onNext'.
|
||||
*
|
||||
* @param {Event} ev
|
||||
*/
|
||||
onBack(ev) {
|
||||
const screenToDisplay = this.getPreviousScreen();
|
||||
switch (screenToDisplay) {
|
||||
case "question":
|
||||
this.chartUpdateState({ showInputs: false });
|
||||
break;
|
||||
case "userInputs":
|
||||
this.chartUpdateState({ showAnswers: false });
|
||||
// resume refreshing answers if necessary
|
||||
if (!this.resultsRefreshInterval) {
|
||||
this.resultsRefreshInterval = setInterval(
|
||||
this.refreshResults.bind(this),
|
||||
this.answersRefreshDelay
|
||||
);
|
||||
}
|
||||
break;
|
||||
case "results":
|
||||
if (this.isScoredQuestion || this.isLastQuestion) {
|
||||
this.leaderboardEl.dispatchEvent(new Event("hideLeaderboard"));
|
||||
}
|
||||
// when showing results, stop refreshing answers
|
||||
clearInterval(this.resultsRefreshInterval);
|
||||
delete this.resultsRefreshInterval;
|
||||
break;
|
||||
case "previousQuestion":
|
||||
if (this.isFirstQuestion) {
|
||||
return; // nothing to go back to, we're on the first question
|
||||
}
|
||||
this.goToNextQuestion(true);
|
||||
break;
|
||||
}
|
||||
this.currentScreen = screenToDisplay;
|
||||
// To avoid a flicker, we do not update the tooltip when going to the
|
||||
// next question, as it will be done anyway in "setupCurrentScreen"
|
||||
if (!["question", "nextQuestion"].includes(screenToDisplay)) {
|
||||
this.updateNextScreenTooltip();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
if (this.currentScreen === "question" && this.isScoredQuestion) {
|
||||
return "userInputs";
|
||||
} else if (
|
||||
this.hasCorrectAnswers &&
|
||||
["question", "userInputs"].includes(this.currentScreen)
|
||||
) {
|
||||
return "results";
|
||||
} else if (this.sessionShowLeaderboard) {
|
||||
if (this.isLastQuestion) {
|
||||
return "leaderboardFinal";
|
||||
} else if (
|
||||
["question", "userInputs", "results"].includes(this.currentScreen) &&
|
||||
this.isScoredQuestion
|
||||
) {
|
||||
return "leaderboard";
|
||||
}
|
||||
}
|
||||
return "nextQuestion";
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse behavior of 'getNextScreen'.
|
||||
*
|
||||
* @param {Event} ev
|
||||
*/
|
||||
getPreviousScreen() {
|
||||
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 interaction
|
||||
* 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
|
||||
*/
|
||||
async goToNextQuestion(goBack) {
|
||||
// The following lines prevent calling multiple times "get next question"
|
||||
// process until next question is fully loaded.
|
||||
if (this.stopNextQuestion) {
|
||||
return;
|
||||
}
|
||||
this.stopNextQuestion = true;
|
||||
// Prevent the results to appear before the leaderboard is out
|
||||
this.leaderboardIsFadingOut = true;
|
||||
this.isStartScreen = false;
|
||||
// start the fadeout animation
|
||||
let resolveFadeOut;
|
||||
const fadeOutPromise = new Promise(function (resolve) {
|
||||
resolveFadeOut = resolve;
|
||||
});
|
||||
fadeOut(this.el, this.fadeInOutTime, () => {
|
||||
this.leaderboardIsFadingOut = false;
|
||||
resolveFadeOut();
|
||||
});
|
||||
// background management
|
||||
if (this.refreshBackground) {
|
||||
document
|
||||
.querySelector("div.o_survey_background")
|
||||
.classList.add("o_survey_background_transition");
|
||||
}
|
||||
// avoid refreshing results while transitioning
|
||||
if (this.resultsRefreshInterval) {
|
||||
clearInterval(this.resultsRefreshInterval);
|
||||
delete this.resultsRefreshInterval;
|
||||
}
|
||||
// rpc call to get the next question
|
||||
this.nextQuestion = await this.waitFor(
|
||||
rpc(`/survey/session/next_question/${this.surveyAccessToken}`, {
|
||||
go_back: goBack,
|
||||
})
|
||||
);
|
||||
let preloadBgPromise;
|
||||
if (this.refreshBackground && this.nextQuestion.background_image_url) {
|
||||
preloadBgPromise = preloadBackground(this.nextQuestion.background_image_url);
|
||||
} else {
|
||||
preloadBgPromise = Promise.resolve();
|
||||
}
|
||||
// await both the fadeout and the rpc
|
||||
await this.waitFor(Promise.all([fadeOutPromise, preloadBgPromise]));
|
||||
this.protectSyncAfterAsync(() => this.onNextQuestionDone(goBack))();
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the screen with the next question's rendered template.
|
||||
*
|
||||
* @param {boolean} goBack Whether we are going back to the previous question or not
|
||||
*/
|
||||
async onNextQuestionDone(goBack) {
|
||||
if (!this.nextQuestion.question_html) {
|
||||
this.isSessionClosed = true;
|
||||
this.onEndSessionClick();
|
||||
return;
|
||||
}
|
||||
|
||||
const parser = new DOMParser();
|
||||
const newContent = parser.parseFromString(this.nextQuestion.question_html, "text/html").body
|
||||
.firstChild;
|
||||
newContent.style.opacity = 0;
|
||||
|
||||
if (goBack) {
|
||||
// If the question is loaded with goBack flag, we need to start
|
||||
// from the leaderboard screen. This is achieved by adding a
|
||||
// data-going-back attribute to the newContentChild
|
||||
newContent.dataset.goingBack = true;
|
||||
}
|
||||
|
||||
// Background Management
|
||||
if (this.refreshBackground) {
|
||||
const surveyBackground = newContent.querySelector("div.o_survey_background");
|
||||
if (surveyBackground) {
|
||||
surveyBackground.style.backgroundImage = `url(${this.nextQuestion.background_image_url})`;
|
||||
surveyBackground.classList.remove("o_survey_background_transition");
|
||||
}
|
||||
}
|
||||
|
||||
this.el.parentNode.replaceChild(newContent, this.el);
|
||||
|
||||
// Fade in the new content, wait for the interactions to be ready and then
|
||||
// stop the interaction on the old content (the one execuing this code)
|
||||
fadeIn(newContent, this.fadeInOutTime);
|
||||
await this.services["public.interactions"].startInteractions(newContent);
|
||||
this.services["public.interactions"].stopInteractions(this.el);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks this session as 'done' and redirects the user to the results based on the clicked link.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
async onEndSessionClick(ev) {
|
||||
// ev could not exist (onNextQuestionDone )
|
||||
const showResults = ev?.currentTarget?.dataset?.showResults;
|
||||
await this.waitFor(
|
||||
this.services.orm.call("survey.survey", "action_end_session", [[this.surveyId]])
|
||||
);
|
||||
this.protectSyncAfterAsync(() => {
|
||||
if (showResults) {
|
||||
window.location.href = `/survey/results/${encodeURIComponent(this.surveyId)}`;
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Listeners for keyboard arrow / spacebar keys.
|
||||
*
|
||||
* @param {KeyboardEvent} ev
|
||||
*/
|
||||
onKeyDown(ev) {
|
||||
const hotkey = getActiveHotkey(ev);
|
||||
if (hotkey === "arrowright" || hotkey === "space") {
|
||||
this.onNext(ev);
|
||||
} else if (hotkey === "arrowleft") {
|
||||
this.onBack(ev);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup current screen based on question properties.
|
||||
* If it's a non-scored question with a chart, we directly display the user inputs.
|
||||
*/
|
||||
setupCurrentScreen() {
|
||||
if (this.isStartScreen) {
|
||||
this.currentScreen = "startScreen";
|
||||
} else if (!this.isScoredQuestion && this.showBarChart) {
|
||||
this.currentScreen = "userInputs";
|
||||
} else {
|
||||
this.currentScreen = "question";
|
||||
}
|
||||
this.chartUpdateState({ showInputs: this.currentScreen === "userInputs" });
|
||||
this.updateNextScreenTooltip();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a CustomEvent to the chart interaction to update its state.
|
||||
* Possible options are:
|
||||
* - showInputs: boolean, show attendees survey.user_input.lines
|
||||
* - showAnswers: boolean, show the question survey.question.answers
|
||||
* - questionStatistics: object, chart data (counts / labels / ...)
|
||||
*/
|
||||
chartUpdateState(options) {
|
||||
this.chartEl?.dispatchEvent(
|
||||
new CustomEvent("updateState", {
|
||||
detail: options,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the tooltip for current page (on right arrow icon for 'Next' content).
|
||||
* this method will be called on Clicking of Next and Previous Arrow to show the
|
||||
* tooltip for the Next Content.
|
||||
*/
|
||||
updateNextScreenTooltip() {
|
||||
let tooltip;
|
||||
if (this.currentScreen === "startScreen") {
|
||||
tooltip = nextPageTooltips.startScreen;
|
||||
} else if (
|
||||
this.isLastQuestion &&
|
||||
!this.surveyHasConditionalQuestions &&
|
||||
!this.isScoredQuestion &&
|
||||
!this.sessionShowLeaderboard
|
||||
) {
|
||||
tooltip = nextPageTooltips.closingWords;
|
||||
} else {
|
||||
const nextScreen = this.getNextScreen();
|
||||
if (nextScreen === "nextQuestion" || this.surveyHasConditionalQuestions) {
|
||||
tooltip = nextPageTooltips.nextQuestion;
|
||||
}
|
||||
tooltip = nextPageTooltips[nextScreen];
|
||||
}
|
||||
const sessionNavigationNextEl = this.el.querySelector(
|
||||
".o_survey_session_navigation_next_label"
|
||||
);
|
||||
if (sessionNavigationNextEl && tooltip) {
|
||||
this.sessionNavigationNextLabel = tooltip;
|
||||
this.updateContent();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup the two refresh intervals of 2 seconds for our interaction:
|
||||
* - The refresh of attendees count (only on the start screen)
|
||||
* - The refresh of results (used for chart/text answers/progress bar)
|
||||
*/
|
||||
setupIntervals() {
|
||||
if (this.isStartScreen) {
|
||||
this.attendeesRefreshInterval = setInterval(
|
||||
this.refreshAttendeesCount.bind(this),
|
||||
this.answersRefreshDelay
|
||||
);
|
||||
} else {
|
||||
if (this.attendeesRefreshInterval) {
|
||||
clearInterval(this.attendeesRefreshInterval);
|
||||
}
|
||||
if (!this.resultsRefreshInterval) {
|
||||
this.resultsRefreshInterval = setInterval(
|
||||
this.refreshResults.bind(this),
|
||||
this.answersRefreshDelay
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(el) {
|
||||
const surveyManagerEl = el || this.el;
|
||||
const timerData = surveyManagerEl.dataset;
|
||||
const questionTimeLimitReached = !!timerData.questionTimeLimitReached;
|
||||
const timeLimitMinutes = Number(timerData.timeLimitMinutes);
|
||||
const hasAnswered = !!timerData.hasAnswered;
|
||||
if (this.timerEl && !questionTimeLimitReached && !hasAnswered && timeLimitMinutes) {
|
||||
this.addListener(surveyManagerEl, "time_up", async () => {
|
||||
if (this.currentScreen === "question" && this.isScoredQuestion) {
|
||||
this.onNext();
|
||||
}
|
||||
});
|
||||
this.timerEl.dispatchEvent(
|
||||
new CustomEvent("start_timer", {
|
||||
detail: {
|
||||
timeLimitMinutes: timeLimitMinutes,
|
||||
timer: timerData.timer,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
this.waitFor(rpc(`/survey/session/results/${this.surveyAccessToken}`)).then(
|
||||
this.protectSyncAfterAsync((questionResults) => {
|
||||
if (questionResults) {
|
||||
this.attendeesCount = questionResults.attendees_count;
|
||||
|
||||
if (
|
||||
!this.isStartScreen &&
|
||||
this.showBarChart &&
|
||||
questionResults.question_statistics_graph
|
||||
) {
|
||||
const parsedStatistics = JSON.parse(
|
||||
questionResults.question_statistics_graph
|
||||
);
|
||||
if (parsedStatistics.length > 0) {
|
||||
this.chartUpdateState({ questionStatistics: parsedStatistics });
|
||||
}
|
||||
} else if (!this.isStartScreen && this.showTextAnswers) {
|
||||
this.textAnswersEl.dispatchEvent(
|
||||
new CustomEvent("updateTextAnswers", {
|
||||
detail: {
|
||||
questionType: this.el.dataset.questionType,
|
||||
inputLineValues: questionResults.input_line_values,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Update the last question next screen tooltip depending on
|
||||
// the selected answers. If a selected answer triggers a
|
||||
// conditional question, the last question may no longer be
|
||||
// the last (see PR odoo/odoo#212890).
|
||||
if (this.surveyLastTriggeringAnswers.length) {
|
||||
this.isLastQuestion =
|
||||
!questionResults.answer_count ||
|
||||
!this.surveyLastTriggeringAnswers.some((answerId) =>
|
||||
questionResults.selected_answers.includes(answerId)
|
||||
);
|
||||
this.updateNextScreenTooltip();
|
||||
}
|
||||
|
||||
const progressBar = this.el.querySelector(".progress-bar");
|
||||
if (progressBar) {
|
||||
const max = this.attendeesCount > 0 ? this.attendeesCount : 1;
|
||||
const percentage = Math.min(
|
||||
Math.round((questionResults.answer_count / max) * 100),
|
||||
100
|
||||
);
|
||||
progressBar.style.width = `${percentage}%`;
|
||||
}
|
||||
|
||||
if (this.attendeesCount && this.attendeesCount > 0) {
|
||||
const answerCount = Math.min(
|
||||
questionResults.answer_count,
|
||||
this.attendeesCount
|
||||
);
|
||||
const answerCountElement = this.el.querySelector(
|
||||
".o_survey_session_answer_count"
|
||||
);
|
||||
const progressBarTextElement = this.el.querySelector(
|
||||
".progress-bar.o_survey_session_progress_small span"
|
||||
);
|
||||
if (answerCountElement) {
|
||||
answerCountElement.textContent = answerCount;
|
||||
}
|
||||
if (progressBarTextElement) {
|
||||
progressBarTextElement.textContent = `${answerCount} / ${this.attendeesCount}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
this.protectSyncAfterAsync(() => {
|
||||
// onRejected, stop refreshing
|
||||
clearInterval(this.resultsRefreshInterval);
|
||||
delete this.resultsRefreshInterval;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* We refresh the attendees count every 2 seconds while the user is on the start screen.
|
||||
*/
|
||||
refreshAttendeesCount() {
|
||||
this.waitFor(
|
||||
this.services.orm.read("survey.survey", [this.surveyId], ["session_answer_count"])
|
||||
).then(
|
||||
this.protectSyncAfterAsync((result) => {
|
||||
if (result && result.length === 1) {
|
||||
this.sessionAttendeesCountText = String(result[0].session_answer_count);
|
||||
}
|
||||
}),
|
||||
this.protectSyncAfterAsync((err) => {
|
||||
// on failure, stop refreshing
|
||||
clearInterval(this.attendeesRefreshInterval);
|
||||
console.error(err);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("public.interactions").add("survey.survey_session_manage", SurveySessionManage);
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import { formatDate, formatDateTime } from "@web/core/l10n/dates";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { renderToElement } from "@web/core/utils/render";
|
||||
import { Interaction } from "@web/public/interaction";
|
||||
import SESSION_CHART_COLORS from "@survey/interactions/survey_session_colors";
|
||||
const { DateTime } = luxon;
|
||||
|
||||
export class SurveySessionTextAnswers extends Interaction {
|
||||
static selector = ".o_survey_session_text_answers_container";
|
||||
dynamicContent = {
|
||||
_root: {
|
||||
"t-on-updateTextAnswers": this.updateTextAnswers,
|
||||
},
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.answerIds = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the attendees answers on the screen.
|
||||
* This is used for char_box/date and datetime questions.
|
||||
*
|
||||
* We use some tricks 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 {CustomEvent} ev Custom event containing the questionType and
|
||||
* the array of survey.user_input.line records in the form
|
||||
* {id: line.id, value: line.[value_char_box/value_date/value_datetime]}
|
||||
*/
|
||||
updateTextAnswers(ev) {
|
||||
const inputLineValues = ev.detail.inputLineValues;
|
||||
const questionType = ev.detail.questionType;
|
||||
|
||||
inputLineValues.forEach((inputLineValue) => {
|
||||
if (!this.answerIds.includes(inputLineValue.id) && inputLineValue.value) {
|
||||
let textValue = inputLineValue.value;
|
||||
if (questionType === "date") {
|
||||
textValue = formatDate(DateTime.fromFormat(textValue, "yyyy-MM-dd"));
|
||||
} else if (questionType === "datetime") {
|
||||
textValue = formatDateTime(
|
||||
DateTime.fromFormat(textValue, "yyyy-MM-dd HH:mm:ss")
|
||||
);
|
||||
}
|
||||
const textAnswerEl = renderToElement("survey.survey_session_text_answer", {
|
||||
value: textValue,
|
||||
borderColor: `rgb(${SESSION_CHART_COLORS[this.answerIds.length % 10]})`,
|
||||
});
|
||||
this.insert(textAnswerEl, this.el);
|
||||
const spanWidth = textAnswerEl.querySelector("span").offsetWidth;
|
||||
const containerEl = textAnswerEl.querySelector(
|
||||
".o_survey_session_text_answer_container"
|
||||
);
|
||||
textAnswerEl.style.width = `calc(${spanWidth}px + 1.2rem)`;
|
||||
containerEl.style.width = `calc(${spanWidth}px + 1.2rem)`;
|
||||
containerEl.style.opacity = "1";
|
||||
this.answerIds.push(inputLineValue.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
registry
|
||||
.category("public.interactions")
|
||||
.add("survey.survey_session_text_answers", SurveySessionTextAnswers);
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
import { Interaction } from "@web/public/interaction";
|
||||
import { deserializeDateTime } from "@web/core/l10n/dates";
|
||||
import { registry } from "@web/core/registry";
|
||||
const { DateTime } = luxon;
|
||||
|
||||
export class SurveyTimer extends Interaction {
|
||||
static selector = ".o_survey_timer_container .o_survey_timer";
|
||||
|
||||
start() {
|
||||
const surveyFormContentEl = document.querySelector(".o_survey_form_content_data");
|
||||
const surveySessionManageEl = document.querySelector(".o_survey_session_manage");
|
||||
this.timeDifference = null;
|
||||
if (surveyFormContentEl) {
|
||||
// If the interaction is starting in a survey_form, the timer is
|
||||
// ready to start.
|
||||
if (surveyFormContentEl.dataset.serverTime) {
|
||||
this.timeDifference = DateTime.utc().diff(
|
||||
deserializeDateTime(surveyFormContentEl.dataset.serverTime)
|
||||
).milliseconds;
|
||||
}
|
||||
this.startTimer(surveyFormContentEl.dataset);
|
||||
} else if (surveySessionManageEl) {
|
||||
// If the interaction is starting in a survey_session_manage, the
|
||||
// the timer will be started by dispatching an event to it.
|
||||
this.addListener(this.el, "start_timer", (ev) => {
|
||||
this.startTimer(ev.detail);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
clearInterval(this.surveyTimerInterval);
|
||||
}
|
||||
|
||||
startTimer(timerData) {
|
||||
this.timeLimitMinutes = Number(timerData.timeLimitMinutes);
|
||||
this.timer = timerData.timer;
|
||||
this.surveyTimerInterval = null;
|
||||
this.setupTimer();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
setupTimer() {
|
||||
this.countDownDate = DateTime.fromISO(this.timer, { zone: "utc" }).plus({
|
||||
minutes: this.timeLimitMinutes,
|
||||
});
|
||||
if (Math.abs(this.timeDifference) >= 500) {
|
||||
this.countDownDate = this.countDownDate.plus({ milliseconds: this.timeDifference });
|
||||
}
|
||||
if (this.timeLimitMinutes <= 0 || this.countDownDate.diff(DateTime.utc()).seconds < 0) {
|
||||
this.triggerTimeUp();
|
||||
} else {
|
||||
this.updateTimer();
|
||||
this.surveyTimerInterval = setInterval(this.updateTimer.bind(this), 1000);
|
||||
}
|
||||
}
|
||||
|
||||
formatTime(time) {
|
||||
return time > 9 ? time : "0" + time;
|
||||
}
|
||||
|
||||
triggerTimeUp() {
|
||||
this.el.dispatchEvent(new Event("time_up"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
const timeLeft = Math.round(this.countDownDate.diff(DateTime.utc()).milliseconds / 1000);
|
||||
|
||||
if (timeLeft >= 0) {
|
||||
const timeLeftMinutes = parseInt(timeLeft / 60);
|
||||
const timeLeftSeconds = timeLeft - timeLeftMinutes * 60;
|
||||
this.el.textContent =
|
||||
this.formatTime(timeLeftMinutes) + ":" + this.formatTime(timeLeftSeconds);
|
||||
} else {
|
||||
if (this.surveyTimerInterval) {
|
||||
clearInterval(this.surveyTimerInterval);
|
||||
}
|
||||
this.triggerTimeUp();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("public.interactions").add("survey.SurveyTimer", SurveyTimer);
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
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);
|
||||
|
||||
});
|
||||
|
|
@ -1,189 +0,0 @@
|
|||
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);
|
||||
});
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
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;
|
||||
|
||||
});
|
||||
|
|
@ -1,111 +0,0 @@
|
|||
/** @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;
|
||||
},
|
||||
});
|
||||
|
|
@ -1,34 +1,26 @@
|
|||
odoo.define('survey.preload_image_mixin', function (require) {
|
||||
"use strict";
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
export async function preloadBackground(imageUrl) {
|
||||
let resolvePreload;
|
||||
|
||||
// We have to manually create a promise here because the "onload" API does not provide one.
|
||||
const preloadPromise = new Promise(function (resolve, reject) {
|
||||
resolvePreload = resolve;
|
||||
});
|
||||
const background = new Image();
|
||||
background.addEventListener("load", () => resolvePreload(imageUrl), { once: true });
|
||||
background.src = imageUrl;
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
});
|
||||
return preloadPromise;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,31 +0,0 @@
|
|||
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;
|
||||
|
||||
});
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
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;
|
||||
|
||||
});
|
||||
|
|
@ -1,596 +0,0 @@
|
|||
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
|
||||
};
|
||||
|
||||
});
|
||||
|
|
@ -1,372 +0,0 @@
|
|||
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;
|
||||
|
||||
});
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
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',
|
||||
];
|
||||
|
||||
});
|
||||
|
|
@ -1,335 +0,0 @@
|
|||
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;
|
||||
|
||||
});
|
||||
|
|
@ -1,665 +0,0 @@
|
|||
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;
|
||||
|
||||
});
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
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;
|
||||
|
||||
});
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
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;
|
||||
|
||||
});
|
||||
|
|
@ -1,61 +1,79 @@
|
|||
/** @odoo-module */
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { stepUtils } from "@web_tour/tour_utils";
|
||||
|
||||
import { _t } from 'web.core';
|
||||
import { Markup } from 'web.utils';
|
||||
import tour from 'web_tour.tour';
|
||||
import { markup } from "@odoo/owl";
|
||||
|
||||
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>?"))),
|
||||
registry.category("web_tour.tours").add('survey_tour', {
|
||||
url: "/odoo",
|
||||
steps: () => [
|
||||
...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',
|
||||
content: markup(_t("Load a <b>sample Survey</b> to get started quickly.")),
|
||||
tooltipPosition: 'left',
|
||||
run: "click",
|
||||
}, {
|
||||
trigger: 'button[name=action_test_survey]',
|
||||
content: _t("Let's give it a spin!"),
|
||||
position: 'bottom',
|
||||
tooltipPosition: 'bottom',
|
||||
run: "click",
|
||||
}, {
|
||||
trigger: '.o_survey_start button[type=submit]',
|
||||
trigger: '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")',
|
||||
tooltipPosition: 'bottom',
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
trigger: '.js_question-wrapper span:contains("How frequently")',
|
||||
},
|
||||
{
|
||||
trigger: 'button[type=submit]',
|
||||
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")',
|
||||
tooltipPosition: 'bottom',
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
trigger: '.js_question-wrapper span:contains("How many")',
|
||||
},
|
||||
{
|
||||
trigger: 'button[type=submit]',
|
||||
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")',
|
||||
tooltipPosition: 'bottom',
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
trigger: '.js_question-wrapper span:contains("How likely")',
|
||||
},
|
||||
{
|
||||
trigger: 'button[value=finish]',
|
||||
content: _t("Now that you are done, submit your form."),
|
||||
position: 'bottom',
|
||||
tooltipPosition: 'bottom',
|
||||
run: "click",
|
||||
}, {
|
||||
trigger: '.o_survey_review a',
|
||||
trigger: '.o_survey_review',
|
||||
content: _t("Let's have a look at your answers!"),
|
||||
position: 'bottom',
|
||||
tooltipPosition: 'bottom',
|
||||
run: "click",
|
||||
}, {
|
||||
trigger: '.alert-info a:contains("This is a Test Survey")',
|
||||
trigger: '.survey_button_form_view_hook',
|
||||
content: _t("Now, use this shortcut to go back to the survey."),
|
||||
position: 'bottom',
|
||||
tooltipPosition: 'bottom',
|
||||
run: "click",
|
||||
}, {
|
||||
trigger: 'button[name=action_survey_user_input_completed]',
|
||||
content: _t("Here, you can overview all the participations."),
|
||||
position: 'bottom',
|
||||
content: _t("Here, you can view the participants."),
|
||||
tooltipPosition: 'bottom',
|
||||
run: "click",
|
||||
}, {
|
||||
trigger: 'td[name=survey_id]',
|
||||
content: _t("Let's open the survey you just submitted."),
|
||||
position: 'bottom',
|
||||
tooltipPosition: 'bottom',
|
||||
run: "click",
|
||||
}, {
|
||||
trigger: '.breadcrumb a:contains("Feedback Form")',
|
||||
content: _t("Use the breadcrumbs to quickly go back to the dashboard."),
|
||||
position: 'bottom',
|
||||
tooltipPosition: 'bottom',
|
||||
run: "click",
|
||||
}
|
||||
]);
|
||||
]});
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { CharField } from "@web/views/fields/char/char_field";
|
||||
import { CharField, charField } from "@web/views/fields/char/char_field";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
const { useEffect, useRef } = owl;
|
||||
import { useEffect, useRef } from "@odoo/owl";
|
||||
|
||||
class DescriptionPageField extends CharField {
|
||||
static template = "survey.DescriptionPageField";
|
||||
setup() {
|
||||
super.setup();
|
||||
const inputRef = useRef("input");
|
||||
|
|
@ -22,6 +20,8 @@ class DescriptionPageField extends CharField {
|
|||
this.env.openRecord(this.props.record);
|
||||
}
|
||||
}
|
||||
DescriptionPageField.template = "survey.DescriptionPageField";
|
||||
|
||||
registry.category("fields").add("survey_description_page", DescriptionPageField);
|
||||
registry.category("fields").add("survey_description_page", {
|
||||
...charField,
|
||||
component: DescriptionPageField,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="survey.DescriptionPageField" t-inherit="web.CharField" owl="1">
|
||||
<t t-name="survey.DescriptionPageField" t-inherit="web.CharField">
|
||||
<xpath expr="//t[@t-else='']" position="replace">
|
||||
<t t-else="">
|
||||
<div class="input-group">
|
||||
|
|
@ -12,6 +12,11 @@
|
|||
</div>
|
||||
</t>
|
||||
</xpath>
|
||||
<xpath expr="//span[@t-esc='formattedValue']" position="after">
|
||||
<t t-if="props.record.data.is_page">
|
||||
<i class="fa fa-fw o_button_icon fa-pencil"/>
|
||||
</t>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { makeContext } from "@web/core/context";
|
||||
import { ListRenderer } from "@web/views/list/list_renderer";
|
||||
|
||||
const { useEffect } = owl;
|
||||
import { useEffect } from "@odoo/owl";
|
||||
|
||||
export class QuestionPageListRenderer extends ListRenderer {
|
||||
setup() {
|
||||
|
|
@ -50,10 +47,18 @@ export class QuestionPageListRenderer extends ListRenderer {
|
|||
return classNames.join(" ");
|
||||
}
|
||||
|
||||
getCellClass(column, record) {
|
||||
const classNames = super.getCellClass(column, record);
|
||||
if (column.type === "button_group") {
|
||||
return `${classNames} text-end`;
|
||||
}
|
||||
return classNames;
|
||||
}
|
||||
|
||||
getSectionColumns(columns) {
|
||||
let titleColumnIndex = 0;
|
||||
let found = false;
|
||||
let colspan = 1
|
||||
let colspan = 1;
|
||||
for (let index = 0; index < columns.length; index++) {
|
||||
const col = columns[index];
|
||||
if (!found && col.name !== this.titleField) {
|
||||
|
|
@ -70,9 +75,11 @@ export class QuestionPageListRenderer extends ListRenderer {
|
|||
colspan += 1;
|
||||
}
|
||||
|
||||
const sectionColumns = columns.slice(0, titleColumnIndex + 1).concat(columns.slice(titleColumnIndex + colspan));
|
||||
const sectionColumns = columns
|
||||
.slice(0, titleColumnIndex + 1)
|
||||
.concat(columns.slice(titleColumnIndex + colspan));
|
||||
|
||||
sectionColumns[titleColumnIndex] = {...sectionColumns[titleColumnIndex], colspan};
|
||||
sectionColumns[titleColumnIndex] = { ...sectionColumns[titleColumnIndex], colspan };
|
||||
|
||||
return sectionColumns;
|
||||
}
|
||||
|
|
@ -95,9 +102,9 @@ export class QuestionPageListRenderer extends ListRenderer {
|
|||
* @override
|
||||
*/
|
||||
focusCell(column, forward = true) {
|
||||
const actualColumn = column.name ? this.state.columns.find(
|
||||
(col) => col.name === column.name
|
||||
) : column;
|
||||
const actualColumn = column.name
|
||||
? this.columns.find((col) => col.name === column.name)
|
||||
: column;
|
||||
super.focusCell(actualColumn, forward);
|
||||
}
|
||||
|
||||
|
|
@ -106,10 +113,32 @@ export class QuestionPageListRenderer extends ListRenderer {
|
|||
case "enter":
|
||||
case "tab":
|
||||
case "shift+tab": {
|
||||
this.props.list.unselectRecord(true);
|
||||
this.props.list.leaveEditMode();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return super.onCellKeydownEditMode(...arguments);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the survey after a question used as trigger is deleted. This allows
|
||||
* immediate feedback on the form view as the triggers will be removed
|
||||
* anyway on the records by the ORM.
|
||||
*
|
||||
* @override
|
||||
* @param record
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
async onDeleteRecord(record) {
|
||||
const triggeredRecords = this.props.list.records.filter(
|
||||
(rec) => rec.data.triggering_question_ids.currentIds.includes(record.resId)
|
||||
);
|
||||
if (triggeredRecords.length) {
|
||||
const res = await super.onDeleteRecord(record);
|
||||
await this.props.list.model.root.save();
|
||||
return res;
|
||||
} else {
|
||||
return super.onDeleteRecord(record);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +1,105 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { QuestionPageListRenderer } from "./question_page_list_renderer";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { X2ManyField } from "@web/views/fields/x2many/x2many_field";
|
||||
import { useOpenX2ManyRecord, useX2ManyCrud } from "@web/views/fields/relational_utils";
|
||||
import { X2ManyField, x2ManyField } from "@web/views/fields/x2many/x2many_field";
|
||||
import { useSubEnv } from "@odoo/owl";
|
||||
|
||||
const { useSubEnv } = owl;
|
||||
/**
|
||||
* For convenience, we'll prevent closing the question form dialog and
|
||||
* stay in edit mode to make sure only valid records are saved. Therefore,
|
||||
* in case of error occurring when saving we will replace default error
|
||||
* modal with a notification.
|
||||
*/
|
||||
|
||||
class SurveySaveError extends Error {}
|
||||
function SurveySaveErrorHandler(env, error, originalError) {
|
||||
if (originalError instanceof SurveySaveError) {
|
||||
env.services.notification.add(originalError.message, {
|
||||
title: _t("Validation Error"),
|
||||
type: "danger",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
registry
|
||||
.category("error_handlers")
|
||||
.add("surveySaveErrorHandler", SurveySaveErrorHandler, { sequence: 10 });
|
||||
|
||||
class QuestionPageOneToManyField extends X2ManyField {
|
||||
static components = {
|
||||
...X2ManyField.components,
|
||||
ListRenderer: QuestionPageListRenderer,
|
||||
};
|
||||
static defaultProps = {
|
||||
...X2ManyField.defaultProps,
|
||||
editable: "bottom",
|
||||
};
|
||||
setup() {
|
||||
super.setup();
|
||||
useSubEnv({
|
||||
openRecord: (record) => this.openRecord(record),
|
||||
});
|
||||
|
||||
// Systematically and automatically save SurveyForm at each question edit/creation
|
||||
// enables checking validation parameters consistency and using questions as triggers
|
||||
// immediately during question creation.
|
||||
// Preparing everything in order to override `this._openRecord` below.
|
||||
const { saveRecord: superSaveRecord, updateRecord: superUpdateRecord } = useX2ManyCrud(
|
||||
() => this.list,
|
||||
this.isMany2Many
|
||||
);
|
||||
|
||||
const self = this;
|
||||
const saveRecord = async (record) => {
|
||||
await superSaveRecord(record);
|
||||
try {
|
||||
await self.props.record.save();
|
||||
} catch (error) {
|
||||
// In case of error occurring when saving.
|
||||
// Remove erroneous question row added to the embedded list
|
||||
await this.list.delete(record);
|
||||
throw new SurveySaveError(error.data.message);
|
||||
}
|
||||
};
|
||||
|
||||
const updateRecord = async (record) => {
|
||||
await superUpdateRecord(record);
|
||||
try {
|
||||
await self.props.record.save();
|
||||
} catch (error) {
|
||||
throw new SurveySaveError(error.data.message);
|
||||
}
|
||||
};
|
||||
|
||||
const openRecord = useOpenX2ManyRecord({
|
||||
resModel: this.list.resModel,
|
||||
activeField: this.activeField,
|
||||
activeActions: this.activeActions,
|
||||
getList: () => this.list,
|
||||
saveRecord,
|
||||
updateRecord,
|
||||
});
|
||||
this._openRecord = async (params) => {
|
||||
const { record, name } = this.props;
|
||||
if (!await record.save()) {
|
||||
// do not open question form as it won't be savable either.
|
||||
return;
|
||||
}
|
||||
if (params.record) {
|
||||
params.record = record.data[name].records.find(r => r.resId === params.record.resId);
|
||||
}
|
||||
await openRecord(params);
|
||||
};
|
||||
this.canOpenRecord = true;
|
||||
}
|
||||
}
|
||||
QuestionPageOneToManyField.components = {
|
||||
...X2ManyField.components,
|
||||
ListRenderer: QuestionPageListRenderer,
|
||||
|
||||
export const questionPageOneToManyField = {
|
||||
...x2ManyField,
|
||||
component: QuestionPageOneToManyField,
|
||||
additionalClasses: [...x2ManyField.additionalClasses || [], "o_field_one2many"],
|
||||
|
||||
};
|
||||
QuestionPageOneToManyField.defaultProps = {
|
||||
...X2ManyField.defaultProps,
|
||||
editable: "bottom",
|
||||
};
|
||||
QuestionPageOneToManyField.additionalClasses = ['o_field_one2many'];
|
||||
registry.category("fields").add("question_page_one2many", QuestionPageOneToManyField);
|
||||
|
||||
registry.category("fields").add("question_page_one2many", questionPageOneToManyField);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
.o_survey_question_view_form {
|
||||
.o_survey_question_view_form .o_form_renderer {
|
||||
|
||||
.o_preview_questions {
|
||||
border: 3px solid $o-gray-500;
|
||||
border: 3px solid $gray-400;
|
||||
width: auto;
|
||||
padding: 10px 20px 10px;
|
||||
margin-top: 15px;
|
||||
color: $o-gray-500;
|
||||
}
|
||||
|
||||
.o_preview_questions .o_datetime {
|
||||
|
|
@ -23,4 +22,10 @@
|
|||
.o_preview_questions_choice {
|
||||
line-height: 1rem;
|
||||
}
|
||||
|
||||
.o_survey_question_validation_parameters {
|
||||
> div, input.o_survey_question_validation_inline {
|
||||
width: 45% !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -235,13 +235,14 @@
|
|||
padding-left: 10mm;
|
||||
right: 35mm;
|
||||
}
|
||||
|
||||
.certification-number {
|
||||
bottom: -15mm;
|
||||
left: 50%;
|
||||
-webkit-transform: translateX(-50%);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
.certification-number {
|
||||
width: 40mm;
|
||||
bottom: 5mm;
|
||||
left: 50%;
|
||||
-webkit-transform: translateX(-50%);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,3 +5,7 @@
|
|||
.o_survey_form {
|
||||
--SurveyForm__section-background-color: #{$o-gray-300};
|
||||
}
|
||||
|
||||
.o_survey_sample_card {
|
||||
--SurveySampleCard-background-hover: #{$o-gray-300};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// KANBAN VIEW
|
||||
.o_survey_survey_view_kanban {
|
||||
.o_survey_view_kanban_view .o_kanban_renderer {
|
||||
// Common: left semi-trophy icon for certifications
|
||||
.o_survey_kanban_card_certification {
|
||||
background-image:
|
||||
|
|
@ -13,13 +13,17 @@
|
|||
|
||||
// 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;
|
||||
.row > div {
|
||||
flex: 1 1 100% !important;
|
||||
max-width: 100% !important;
|
||||
padding-left: 0 !important;
|
||||
&.col-6 {
|
||||
flex: 1 1 50% !important;
|
||||
max-width: 50% !important;
|
||||
}
|
||||
&.d-none {
|
||||
display: none !important; //forcing the d-none to override the d-lg or d-sm classes for grouped view
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -30,19 +34,15 @@
|
|||
.o_kanban_record {
|
||||
width: 100%;
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
.o_survey_kanban_card {
|
||||
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;
|
||||
|
|
@ -54,10 +54,8 @@
|
|||
// Ungrouped specific
|
||||
&.o_kanban_ungrouped {
|
||||
// Set a minimal height otherwise display may have different card sized
|
||||
.o_survey_kanban_card_ungrouped {
|
||||
&.row {
|
||||
.o_survey_kanban_card > div.row {
|
||||
min-height: 60px;
|
||||
}
|
||||
}
|
||||
|
||||
// Left semi-trophy icon for certifications: tweak display for list view
|
||||
|
|
@ -71,64 +69,25 @@
|
|||
position: absolute;
|
||||
bottom: 4px;
|
||||
right: 19px;
|
||||
@include media-breakpoint-down(lg) {
|
||||
bottom: 19px;
|
||||
right: 0;
|
||||
}
|
||||
@include media-breakpoint-down(sm) {
|
||||
bottom: 10px;
|
||||
right: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RIBBON: Kanban specific
|
||||
// Ungrouped specific
|
||||
.o_survey_kanban_card_ungrouped {
|
||||
.o_survey_kanban_card {
|
||||
.ribbon {
|
||||
// Desktop: finishes on next kanban card line
|
||||
height: 100px;
|
||||
width: 125px;
|
||||
--Ribbon-wrapper-width: 6rem;
|
||||
|
||||
// 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;
|
||||
--Ribbon-wrapper-width: 6.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -140,11 +99,19 @@
|
|||
background-color: var(--SurveyForm__section-background-color, #DDD);
|
||||
border-top: 1px solid #BBB;
|
||||
border-bottom: 1px solid #BBB;
|
||||
|
||||
|
||||
> td {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
.o_survey_form table.o_section_list_view tr.o_data_row.o_is_section {
|
||||
&:hover i {
|
||||
visibility: visible;
|
||||
}
|
||||
&:not(:hover) i {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
// TOOLS
|
||||
.icon_rotates {
|
||||
|
|
@ -152,28 +119,16 @@
|
|||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_survey_sample_card:hover {
|
||||
background: var(--SurveySampleCard-background-hover, #{$o-gray-200});
|
||||
}
|
||||
|
||||
.o_form_label.o_form_label_readonly.o_survey_label_survey_start_url{
|
||||
opacity: 100;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.o_nocontent_help:has(.o_survey_load_sample) {
|
||||
max-width: unset !important;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -131,12 +131,6 @@ _::-webkit-full-page-media, _:future, :root .o_survey_wrap {
|
|||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
|
|
@ -148,16 +142,13 @@ _::-webkit-full-page-media, _:future, :root .o_survey_wrap {
|
|||
i {
|
||||
top: 0px;
|
||||
font-size: large;
|
||||
&.fa-check-circle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.o_survey_selected i {
|
||||
&.o_survey_selected i.fa-circle-thin,
|
||||
&.o_survey_selected i.fa-square-o,
|
||||
&:not(.o_survey_selected) i.fa-check-circle,
|
||||
&:not(.o_survey_selected) i.fa-check-square {
|
||||
display: none;
|
||||
&.fa-check-circle {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -165,20 +156,6 @@ _::-webkit-full-page-media, _:future, :root .o_survey_wrap {
|
|||
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;
|
||||
|
|
@ -309,15 +286,41 @@ _::-webkit-full-page-media, _:future, :root .o_survey_wrap {
|
|||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.o_survey_main_title_fade {
|
||||
transition: opacity 0.4s ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
/**********************************************************
|
||||
Survey Session Specific Style
|
||||
**********************************************************/
|
||||
|
||||
.o_survey_session_open {
|
||||
|
||||
@include media-breakpoint-down(md) {
|
||||
@include o-position-absolute($top:0, $bottom: 0);
|
||||
}
|
||||
|
||||
.o_survey_session_open_header {
|
||||
backdrop-filter: blur(10px);
|
||||
--o_survey_session_title_font-size: 3rem;
|
||||
--o_survey_session_qrcode_width: 200px;
|
||||
|
||||
.o_survey_session_open_description {
|
||||
max-height: calc(var(--o_survey_session_qrcode_width) - (var(--o_survey_session_title_font-size) * #{$headings-line-height}));
|
||||
}
|
||||
|
||||
.o_survey_session_qrcode {
|
||||
border: 10px solid $white;
|
||||
width: var(--o_survey_session_qrcode_width);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_survey_session_manage {
|
||||
h1 {
|
||||
font-size: 3rem;
|
||||
h1, .o_survey_session_attendees_count {
|
||||
font-size: 3rem; // --o_survey_session_title_font-size
|
||||
}
|
||||
|
||||
h2 {
|
||||
|
|
@ -329,9 +332,16 @@ _::-webkit-full-page-media, _:future, :root .o_survey_wrap {
|
|||
padding: 1rem;
|
||||
top: calc(50% - 0.5rem);
|
||||
cursor: pointer;
|
||||
max-width: 10%;
|
||||
|
||||
&.o_survey_session_navigation_next {
|
||||
right: 1rem;
|
||||
border: 2px solid #35979c;
|
||||
border-radius: 5px;
|
||||
|
||||
&:hover {
|
||||
border-color: #2a797c;
|
||||
}
|
||||
}
|
||||
|
||||
&.o_survey_session_navigation_previous {
|
||||
|
|
@ -457,6 +467,12 @@ _::-webkit-full-page-media, _:future, :root .o_survey_wrap {
|
|||
**********************************************************/
|
||||
|
||||
.o_survey_print {
|
||||
@media print {
|
||||
// force to print background-images to render the answers green/red/gray background
|
||||
-webkit-print-color-adjust: exact !important; /* Chrome, Safari */
|
||||
print-color-adjust: exact !important; /*Firefox*/
|
||||
}
|
||||
|
||||
.o_survey_choice_btn {
|
||||
background-color: $gray-500;
|
||||
border-color: transparent;
|
||||
|
|
@ -472,7 +488,7 @@ _::-webkit-full-page-media, _:future, :root .o_survey_wrap {
|
|||
background-color: $gray-600;
|
||||
opacity: 1;
|
||||
}
|
||||
i.fa-circle-thin {
|
||||
i.fa-circle-thin, i.fa-square-o {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,10 +12,53 @@
|
|||
html {
|
||||
height: unset;
|
||||
}
|
||||
@page {
|
||||
size: portrait; // force paper orientation to portrait
|
||||
margin: auto 0; // force default left/rigth margins so elements (e.g.: graphs) are centered in the page
|
||||
}
|
||||
.o_frontend_to_backend_nav {
|
||||
display: none !important;
|
||||
}
|
||||
.o_survey_brand_message {
|
||||
border: none !important;
|
||||
}
|
||||
.o_survey_result {
|
||||
// force to print background-images to render the leaderboard bar
|
||||
-webkit-print-color-adjust: exact !important; /* Chrome, Safari */
|
||||
print-color-adjust: exact !important; /*Firefox*/
|
||||
canvas {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.o_survey_question_page {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
.o_survey_results_question_wrapper {
|
||||
.o_survey_results_question_header, .o_survey_question_description {
|
||||
page-break-inside: avoid;
|
||||
page-break-after: avoid;
|
||||
}
|
||||
}
|
||||
.o_survey_results_question_wrapper:has(div.collapsed) {
|
||||
display: none !important;
|
||||
}
|
||||
.o_survey_results_table_wrapper {
|
||||
height: auto !important;
|
||||
}
|
||||
table {
|
||||
overflow: visible !important;
|
||||
thead {
|
||||
display: table-row-group;
|
||||
}
|
||||
tbody {
|
||||
tr {
|
||||
break-inside: avoid;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_survey_results_topbar {
|
||||
border: 1px solid rgba(0, 0, 0, 0.125);
|
||||
|
||||
.nav-item.dropdown a {
|
||||
min-width: 13em;
|
||||
|
|
@ -69,6 +112,17 @@
|
|||
}
|
||||
|
||||
.o_survey_results_question {
|
||||
.o_survey_results_question_header {
|
||||
.nav .btn:active {
|
||||
box-shadow: none;
|
||||
}
|
||||
div[aria-expanded="true"] i.fa-caret-right {
|
||||
display: none;
|
||||
}
|
||||
div[aria-expanded="false"] i.fa-caret-down {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.o_survey_results_question_pill {
|
||||
.only_right_radius {
|
||||
border-radius: 0 2em 2em 0;
|
||||
|
|
@ -77,18 +131,49 @@
|
|||
border-radius: 2em 0 0 2em;
|
||||
}
|
||||
}
|
||||
.o_survey_answer_image{
|
||||
cursor: zoom-in;
|
||||
&:hover {
|
||||
box-sizing: border-box;
|
||||
box-shadow: 0 0 5px 2px grey;
|
||||
}
|
||||
}
|
||||
.o_survey_answer i {
|
||||
padding:3px;
|
||||
cursor:pointer;
|
||||
|
||||
&.o_survey_answer_matrix_whitespace {
|
||||
padding-right:18px;
|
||||
padding-right: 16px;
|
||||
cursor:default;
|
||||
}
|
||||
}
|
||||
.collapse:not(.show) {
|
||||
display: none !important;
|
||||
}
|
||||
.nav-tabs .nav-link.active {
|
||||
background-color: transparent;
|
||||
border-color: #DEE2E6;
|
||||
font-weight: bold;
|
||||
}
|
||||
table {
|
||||
font-size: 1rem;
|
||||
&.o_survey_results_table_indexed {
|
||||
td:first-child {
|
||||
width: 7%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_survey_no_answers::before {
|
||||
width: 120px;
|
||||
height: 80px;
|
||||
background: transparent url(/web/static/img/empty_folder.svg) no-repeat center;
|
||||
content: "";
|
||||
display: block;
|
||||
margin-top: 50px;
|
||||
margin-bottom: 20px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
z-index: 1;
|
||||
}
|
||||
|
|
|
|||
53
odoo-bringout-oca-ocb-survey/survey/static/src/utils.js
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
async function animate(el, keyFrame, duration) {
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
const animation = el.animate(keyFrame, {
|
||||
duration,
|
||||
iterations: 1,
|
||||
fill: "forwards",
|
||||
});
|
||||
await animation.finished;
|
||||
try {
|
||||
// can fail when the element is not visible (ex: display: none)
|
||||
animation.commitStyles();
|
||||
} catch {
|
||||
// pass
|
||||
}
|
||||
animation.cancel();
|
||||
}
|
||||
|
||||
function normalizeToArray(els) {
|
||||
if (els) {
|
||||
if (els.nodeName && ["FORM", "SELECT"].includes(els.nodeName)) {
|
||||
return [els];
|
||||
}
|
||||
return els[Symbol.iterator] ? els : [els];
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function fadeOut(els, duration, afterFadeOutCallback) {
|
||||
els = normalizeToArray(els);
|
||||
const promises = [];
|
||||
for (const el of els) {
|
||||
promises.push(animate(el, [{ opacity: 0 }], duration));
|
||||
}
|
||||
await Promise.all(promises);
|
||||
for (const el of els) {
|
||||
el.classList.add("d-none");
|
||||
}
|
||||
afterFadeOutCallback?.();
|
||||
}
|
||||
|
||||
export async function fadeIn(els, duration, afterFadeInCallback) {
|
||||
els = normalizeToArray(els);
|
||||
const promises = [];
|
||||
for (const el of els) {
|
||||
el.classList.remove("d-none");
|
||||
promises.push(animate(el, [{ opacity: 1 }], duration));
|
||||
}
|
||||
await Promise.all(promises);
|
||||
afterFadeInCallback?.();
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import { useService } from '@web/core/utils/hooks';
|
||||
import { Component, onWillStart } from '@odoo/owl';
|
||||
|
||||
export class SurveySurveyActionHelper extends Component {
|
||||
static template = 'survey.SurveySurveyActionHelper';
|
||||
static props = {};
|
||||
|
||||
setup() {
|
||||
this.orm = useService('orm');
|
||||
this.action = useService('action');
|
||||
|
||||
onWillStart(async () => {
|
||||
this.surveyTemplateData = await this.orm.call(
|
||||
'survey.survey',
|
||||
'get_survey_templates_data',
|
||||
[]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async onStartFromScratchClick() {
|
||||
const action = await this.orm.call(
|
||||
'survey.survey',
|
||||
'action_load_sample_custom',
|
||||
[],
|
||||
);
|
||||
this.action.doAction(action);
|
||||
}
|
||||
|
||||
async onTemplateClick(templateInfo) {
|
||||
const action = await this.orm.call(
|
||||
'survey.survey',
|
||||
'action_load_survey_template_sample',
|
||||
[templateInfo.template_key],
|
||||
);
|
||||
this.action.doAction(action);
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<t t-name="survey.SurveySurveyActionHelper">
|
||||
<div class="o_view_nocontent">
|
||||
<div class="o_nocontent_help">
|
||||
<p>
|
||||
No Survey Found
|
||||
</p><p>
|
||||
Pick a sample or <a action="action_load_sample_custom" class="o_survey_load_sample"
|
||||
t-on-click.stop.prevent="() => this.onStartFromScratchClick()">Start from scratch</a>.
|
||||
</p>
|
||||
|
||||
<div class="row mb-4 w-100 px-5 justify-content-center">
|
||||
<t t-set="templateNames" t-value="Object.keys(surveyTemplateData)"/>
|
||||
<t t-foreach="templateNames" t-as="templateName" t-key="templateName">
|
||||
<t t-set="templateInfo" t-value="surveyTemplateData[templateName]"/>
|
||||
<div t-attf-class="flex-row o_survey_load_sample o_survey_sample_card card rounded flex-wrap cursor-pointer col-md-3 p-2 m-3"
|
||||
t-on-click.stop.prevent="() => this.onTemplateClick(templateInfo)">
|
||||
<div class="col-lg-4 p-3">
|
||||
<img class="img-fluid" t-attf-src="{{templateInfo.icon}}" t-attf-alt="{{templateInfo.title}}"/>
|
||||
</div>
|
||||
<div class="col-lg-8 p-3 text-start">
|
||||
<h3 t-out="templateInfo.title"/>
|
||||
<p t-out="templateInfo.description"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import { KanbanRenderer } from "@web/views/kanban/kanban_renderer";
|
||||
import { SurveySurveyActionHelper } from "@survey/views/components/survey_survey_action_helper/survey_survey_action_helper";
|
||||
|
||||
export class SurveyKanbanRenderer extends KanbanRenderer {
|
||||
static template = "survey.SurveyKanbanRenderer";
|
||||
static components = {
|
||||
...KanbanRenderer.components,
|
||||
SurveySurveyActionHelper,
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="survey.SurveyKanbanRenderer" t-inherit="web.KanbanRenderer" t-inherit-mode="primary">
|
||||
<ActionHelper position="replace">
|
||||
<t t-if="showNoContentHelper">
|
||||
<SurveySurveyActionHelper/>
|
||||
</t>
|
||||
</ActionHelper>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import { registry } from "@web/core/registry";
|
||||
import { kanbanView } from "@web/views/kanban/kanban_view";
|
||||
import { SurveyKanbanRenderer } from "@survey/views/kanban/kanban_renderer";
|
||||
|
||||
export const SurveyKanbanView = {
|
||||
...kanbanView,
|
||||
Renderer: SurveyKanbanRenderer,
|
||||
};
|
||||
|
||||
registry.category('views').add('survey_view_kanban', SurveyKanbanView);
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import { ListRenderer } from "@web/views/list/list_renderer";
|
||||
import { SurveySurveyActionHelper } from "@survey/views/components/survey_survey_action_helper/survey_survey_action_helper";
|
||||
|
||||
export class SurveyListRenderer extends ListRenderer {
|
||||
static template = "survey.SurveyListRenderer";
|
||||
static components = {
|
||||
...ListRenderer.components,
|
||||
SurveySurveyActionHelper,
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="survey.SurveyListRenderer" t-inherit="web.ListRenderer" t-inherit-mode="primary">
|
||||
<ActionHelper position="replace">
|
||||
<t t-if="showNoContentHelper">
|
||||
<SurveySurveyActionHelper/>
|
||||
</t>
|
||||
</ActionHelper>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import { registry } from "@web/core/registry";
|
||||
import { listView } from "@web/views/list/list_view";
|
||||
import { SurveyListRenderer } from "@survey/views/list/list_renderer";
|
||||
|
||||
export const SurveyListView = {
|
||||
...listView,
|
||||
Renderer: SurveyListRenderer,
|
||||
};
|
||||
|
||||
registry.category('views').add('survey_view_tree', SurveyListView);
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registry } from '@web/core/registry';
|
||||
import { ListRenderer } from "@web/views/list/list_renderer";
|
||||
import { KanbanRenderer } from "@web/views/kanban/kanban_renderer";
|
||||
import { listView } from '@web/views/list/list_view';
|
||||
import { kanbanView } from '@web/views/kanban/kanban_view';
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
const { useEffect, useRef } = owl;
|
||||
|
||||
export function useSurveyLoadSampleHook(selector) {
|
||||
const rootRef = useRef("root");
|
||||
const actionService = useService("action");
|
||||
const orm = useService('orm');
|
||||
let isLoadingSample = false;
|
||||
/**
|
||||
* Load and show the sample survey related to the clicked element,
|
||||
* when there is no survey to display.
|
||||
* We currently have 3 different samples to load:
|
||||
* - Sample Feedback Form
|
||||
* - Sample Certification
|
||||
* - Sample Live Presentation
|
||||
*/
|
||||
const loadSample = async (method) => {
|
||||
// Prevent loading multiple samples if double clicked
|
||||
isLoadingSample = true;
|
||||
const action = await orm.call('survey.survey', method);
|
||||
actionService.doAction(action);
|
||||
};
|
||||
useEffect(
|
||||
(elems) => {
|
||||
if (!elems || !elems.length) {
|
||||
return;
|
||||
}
|
||||
const handler = (ev) => {
|
||||
if (!isLoadingSample) {
|
||||
const surveyMethod = ev.currentTarget.closest('.o_survey_sample_container').getAttribute('action');
|
||||
loadSample(surveyMethod);
|
||||
}
|
||||
}
|
||||
for (const elem of elems) {
|
||||
elem.addEventListener('click', handler);
|
||||
}
|
||||
return () => {
|
||||
for (const elem of elems) {
|
||||
elem.removeEventListener('click', handler);
|
||||
}
|
||||
};
|
||||
},
|
||||
() => [rootRef.el && rootRef.el.querySelectorAll(selector)]
|
||||
);
|
||||
};
|
||||
|
||||
export class SurveyListRenderer extends ListRenderer {
|
||||
setup() {
|
||||
super.setup();
|
||||
|
||||
if (this.canCreate) {
|
||||
useSurveyLoadSampleHook('.o_survey_load_sample');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
registry.category('views').add('survey_view_tree', {
|
||||
...listView,
|
||||
Renderer: SurveyListRenderer,
|
||||
});
|
||||
|
||||
export class SurveyKanbanRenderer extends KanbanRenderer {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.canCreate = this.props.archInfo.activeActions.create;
|
||||
if (this.canCreate) {
|
||||
useSurveyLoadSampleHook('.o_survey_load_sample');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
registry.category('views').add('survey_view_kanban', {
|
||||
...kanbanView,
|
||||
Renderer: SurveyKanbanRenderer,
|
||||
});
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import { _t } from "@web/core/l10n/translation";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { booleanField, BooleanField } from "@web/views/fields/boolean/boolean_field";
|
||||
|
||||
|
||||
/**
|
||||
* Update a second field when the widget's own field `value` changes.
|
||||
*
|
||||
* The second field (boolean) will be set to `true` if the new `value` is
|
||||
* different from the reference value (passed in widget's `context` attribute)
|
||||
* and vice versa.
|
||||
* This is used over an onchange/compute to only enable this behavior when a
|
||||
* user directly changes the value from the client, and not as a result of
|
||||
* another onchange/compute.
|
||||
*
|
||||
* See also `IntegerUpdateFlagField`.
|
||||
*/
|
||||
export class BooleanUpdateFlagField extends BooleanField {
|
||||
static props= {
|
||||
...BooleanField.props,
|
||||
flagFieldName: { type: String },
|
||||
referenceValue: { type: Boolean },
|
||||
}
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
async onChange(newValue) {
|
||||
super.onChange(...arguments);
|
||||
await this.props.record._update({
|
||||
[this.props.flagFieldName]: newValue !== this.props.referenceValue}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const booleanUpdateFlagField = {
|
||||
...booleanField,
|
||||
component: BooleanUpdateFlagField,
|
||||
displayName: _t("Checkbox updating comparison flag"),
|
||||
extractProps ({ options }, { context: { referenceValue } }) {
|
||||
return {
|
||||
flagFieldName: options.flagFieldName,
|
||||
referenceValue: referenceValue,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
registry.category("fields").add("boolean_update_flag", booleanUpdateFlagField);
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
import { _t } from "@web/core/l10n/translation";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { integerField, IntegerField } from "@web/views/fields/integer/integer_field";
|
||||
|
||||
import { useEffect, useRef } from "@odoo/owl";
|
||||
|
||||
|
||||
/**
|
||||
* Update a second field when the widget's own field `value` changes.
|
||||
*
|
||||
* The second field (boolean) will be set to `true` if the new `value` is
|
||||
* different from the reference value (passed in widget's `context` attribute)
|
||||
* and vice versa.
|
||||
* This is used over an onchange/compute to only enable this behavior when a
|
||||
* user directly changes the value from the client, and not as a result of
|
||||
* another onchange/compute.
|
||||
*
|
||||
* See also `BooleanUpdateFlagField`.
|
||||
*/
|
||||
export class IntegerUpdateFlagField extends IntegerField {
|
||||
static props= {
|
||||
...IntegerField.props,
|
||||
flagFieldName: { type: String },
|
||||
referenceValue: { type: Number },
|
||||
}
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
setup() {
|
||||
super.setup(...arguments);
|
||||
const inputRef = useRef("numpadDecimal");
|
||||
const onChange = async () => {
|
||||
await this.props.record._update({
|
||||
[this.props.flagFieldName]: parseInt(this.formattedValue) !== this.props.referenceValue}
|
||||
);
|
||||
}
|
||||
useEffect(
|
||||
(inputEl) => {
|
||||
if (inputEl) {
|
||||
inputEl.addEventListener("change", onChange);
|
||||
return () => {
|
||||
inputEl.removeEventListener("change", onChange);
|
||||
};
|
||||
}
|
||||
},
|
||||
() => [inputRef.el]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const integerUpdateFlagField = {
|
||||
...integerField,
|
||||
component: IntegerUpdateFlagField,
|
||||
displayName: _t("Integer updating comparison flag"),
|
||||
extractProps ({ attrs, options }, { context: { referenceValue } }) {
|
||||
return {
|
||||
...integerField.extractProps(...arguments),
|
||||
flagFieldName: options.flagFieldName,
|
||||
referenceValue: referenceValue,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
registry.category("fields").add("integer_update_flag", integerUpdateFlagField)
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import { RadioField, radioField } from "@web/views/fields/radio/radio_field";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
export class RadioSelectionFieldWithFilter extends RadioField {
|
||||
static props = {
|
||||
...RadioField.props,
|
||||
allowedSelectionField: { type: String },
|
||||
};
|
||||
|
||||
get items() {
|
||||
const allowedItems = this.props.record.data[this.props.allowedSelectionField];
|
||||
return super.items.filter(([value]) => allowedItems.includes(value));
|
||||
}
|
||||
}
|
||||
|
||||
export const radioSelectionFieldWithFilter = {
|
||||
...radioField,
|
||||
component: RadioSelectionFieldWithFilter,
|
||||
displayName: _t("Radio for Selection With Filter"),
|
||||
supportedTypes: ["selection"],
|
||||
extractProps({ options }) {
|
||||
return {
|
||||
...radioField.extractProps(...arguments),
|
||||
allowedSelectionField: options.allowed_selection_field,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
registry.category("fields").add("radio_selection_with_filter", radioSelectionFieldWithFilter);
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
import { _t } from "@web/core/l10n/translation";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { standardWidgetProps } from "@web/views/widgets/standard_widget_props";
|
||||
import { Component, useEffect, useRef, useState } from "@odoo/owl";
|
||||
|
||||
export class SurveyQuestionTriggerWidget extends Component {
|
||||
static template = "survey.surveyQuestionTrigger";
|
||||
static props = {
|
||||
...standardWidgetProps,
|
||||
};
|
||||
|
||||
setup() {
|
||||
super.setup();
|
||||
this.button = useRef('survey_question_trigger');
|
||||
this.state = useState({
|
||||
surveyIconWarning: false,
|
||||
triggerTooltip: "",
|
||||
});
|
||||
useEffect(() => {
|
||||
if (this.button?.el && this.props.record.data.triggering_question_ids.records?.length !== 0) {
|
||||
const { triggerError, misplacedTriggerQuestionRecords } = this.surveyQuestionTriggerError;
|
||||
if (triggerError === "MISPLACED_TRIGGER_WARNING") {
|
||||
this.state.surveyIconWarning = true;
|
||||
this.state.triggerTooltip = '⚠ ' + _t(
|
||||
'Triggers based on the following questions will not work because they are positioned after this question:\n"%s".',
|
||||
misplacedTriggerQuestionRecords
|
||||
.map((question) => question.data.title)
|
||||
.join('", "')
|
||||
);
|
||||
} else if (triggerError === "WRONG_QUESTIONS_SELECTION_WARNING") {
|
||||
this.state.surveyIconWarning = true;
|
||||
this.state.triggerTooltip = '⚠ ' + _t(
|
||||
"Conditional display is not available when questions are randomly picked."
|
||||
);
|
||||
} else if (triggerError === "MISSING_TRIGGER_ERROR") {
|
||||
// This case must be handled to not temporarily render the "normal" icon if previously
|
||||
// on an error state, which would cause a flicker as the trigger itself will be removed
|
||||
// at next save (auto on survey form and primary list view).
|
||||
} else {
|
||||
this.state.surveyIconWarning = false;
|
||||
this.state.triggerTooltip = _t(
|
||||
'Displayed if "%s".',
|
||||
this.props.record.data.triggering_answer_ids.records
|
||||
.map((answer) => answer.data.display_name)
|
||||
.join('", "'),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.state.surveyIconWarning = false;
|
||||
this.state.triggerTooltip = "";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* `surveyQuestionTriggerError` is computed here and does not rely on
|
||||
* record data (is_placed_before_trigger) for two linked reasons:
|
||||
* 1. Performance: we avoid saving the survey each time a line is moved.
|
||||
* 2. Robustness, as sequences values do not always match between server
|
||||
* provided values when the records are not saved.
|
||||
*
|
||||
* @returns {{ triggerError: String, misplacedTriggerQuestionRecords: Record[] }}
|
||||
* * `""`: No trigger error (also if `triggering_question_id`
|
||||
* field is not set).
|
||||
* * `"MISSING_TRIGGER_ERROR"`: `triggering_questions_ids` field is set
|
||||
* but trigger record is not found. This can happen if all questions
|
||||
* used as triggers are deleted on the client but not yet saved to DB.
|
||||
* * `"MISPLACED_TRIGGER_WARNING"`: a `triggering_question_id` is set
|
||||
* but is positioned after the current record in the list. This can
|
||||
* happen if the triggering or the triggered question is moved.
|
||||
* * `"WRONG_QUESTIONS_SELECTION_WARNING"`: a `triggering_question_id`
|
||||
* is set but the survey is configured to randomize questions asked
|
||||
* mode which ignores the triggers. This can happen if the survey mode
|
||||
* is changed after triggers are set.
|
||||
*/
|
||||
get surveyQuestionTriggerError() {
|
||||
const record = this.props.record;
|
||||
if (!record.data.triggering_question_ids.records.length) {
|
||||
return { triggerError: "", misplacedTriggerQuestionRecords: [] };
|
||||
}
|
||||
if (this.props.record.data.questions_selection === 'random') {
|
||||
return { triggerError: 'WRONG_QUESTIONS_SELECTION_WARNING', misplacedTriggerQuestionRecords: [] };
|
||||
}
|
||||
|
||||
const missingTriggerQuestionsIds = [];
|
||||
let triggerQuestionsRecords = [];
|
||||
for (const triggeringQuestion of record.data.triggering_question_ids.records) {
|
||||
const triggeringQuestionRecord = record.model.root.data.question_and_page_ids.records.find(
|
||||
rec => rec.resId === triggeringQuestion.resId);
|
||||
if (triggeringQuestionRecord) {
|
||||
triggerQuestionsRecords.push(triggeringQuestionRecord);
|
||||
} else { // Trigger question was deleted from the list
|
||||
missingTriggerQuestionsIds.push(triggeringQuestion.resId);
|
||||
}
|
||||
}
|
||||
|
||||
if (missingTriggerQuestionsIds.length === this.props.record.data.triggering_question_ids.records.length) {
|
||||
return { triggerError: 'MISSING_TRIGGER_ERROR', misplacedTriggerQuestionRecords: [] }; // only if all are missing
|
||||
}
|
||||
const misplacedTriggerQuestionRecords = [];
|
||||
for (const triggerQuestionRecord of triggerQuestionsRecords) {
|
||||
if (record.data.sequence < triggerQuestionRecord.data.sequence ||
|
||||
(record.data.sequence === triggerQuestionRecord.data.sequence && record.resId < triggerQuestionRecord.resId)) {
|
||||
misplacedTriggerQuestionRecords.push(triggerQuestionRecord);
|
||||
}
|
||||
}
|
||||
return {
|
||||
triggerError: misplacedTriggerQuestionRecords.length ? "MISPLACED_TRIGGER_WARNING" : "",
|
||||
misplacedTriggerQuestionRecords: misplacedTriggerQuestionRecords,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const surveyQuestionTriggerWidget = {
|
||||
component: SurveyQuestionTriggerWidget,
|
||||
fieldDependencies: [
|
||||
{ name: "triggering_question_ids", type: "many2one" },
|
||||
{ name: "triggering_answer_ids", type: "many2one" },
|
||||
],
|
||||
};
|
||||
registry.category("view_widgets").add("survey_question_trigger", surveyQuestionTriggerWidget);
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="survey.surveyQuestionTrigger">
|
||||
<button t-if="this.props.record.data.triggering_question_ids.records.length" disabled="disabled" t-ref="survey_question_trigger"
|
||||
class="btn btn-link px-1 py-0 pe-auto" t-att-class="this.state.surveyIconWarning ? 'opacity-100' : 'icon_rotates'">
|
||||
<i class="fa fa-fw o_button_icon " t-att-class="this.state.surveyIconWarning ? 'fa-exclamation-triangle text-warning' : 'fa-code-fork'"
|
||||
t-att-data-tooltip="this.state.triggerTooltip"/>
|
||||
</button>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="survey.paginated_results_rows">
|
||||
<t t-foreach="records" t-as="input_line" t-key="input_line.id">
|
||||
<tr>
|
||||
<td>
|
||||
<a t-att-href="input_line.url">
|
||||
<t t-esc="input_line.index + 1"></t>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<!-- If answer already covered by filter or if there is only one line to display, do not allow filtering on it again -->
|
||||
<t t-if="hide_filter">
|
||||
<t t-esc="input_line.value"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="d-flex justify-content-between">
|
||||
<t t-esc="input_line.value"/>
|
||||
<a class="text-primary filter-add-answer d-print-none"
|
||||
data-model-short-key="L" t-att-data-record-id="input_line.id"
|
||||
role="button" aria-label="Filter surveys" title="Only show survey results having selected this answer">
|
||||
<i class="fa fa-filter"/>
|
||||
</a>
|
||||
</div>
|
||||
</t>
|
||||
</td>
|
||||
</tr>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
<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" />
|
||||
<span class="d-inline-block text-truncate" t-esc="value" style="max-width: 22ch;" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||