19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:30:27 +01:00
parent d1963a3c3a
commit 2d3ee4855a
7430 changed files with 2687981 additions and 2965473 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 356 KiB

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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",
];

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because one or more lines are too long

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -5,3 +5,7 @@
.o_survey_form {
--SurveyForm__section-background-color: #{$o-gray-300};
}
.o_survey_sample_card {
--SurveySampleCard-background-hover: #{$o-gray-300};
}

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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