667 lines
23 KiB
JavaScript
667 lines
23 KiB
JavaScript
/** @odoo-module **/
|
|
|
|
import { _t } from "@web/core/l10n/translation";
|
|
import { loadBundle } from "@web/core/assets";
|
|
import { SurveyImageZoomer } from "@survey/js/survey_image_zoomer";
|
|
import publicWidget from "@web/legacy/js/public/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',
|
|
"click .o_survey_question_answers_show_btn": "_onShowAllAnswers",
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// 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");
|
|
}
|
|
},
|
|
|
|
/**
|
|
* @private
|
|
* @param {MouseEvent} ev
|
|
*/
|
|
_onShowAllAnswers: function (ev) {
|
|
const btnEl = ev.currentTarget;
|
|
const pager = btnEl.previousElementSibling;
|
|
btnEl.classList.add("d-none");
|
|
this.$questionsEl.find("tbody tr").removeClass("d-none");
|
|
pager.classList.add("d-none");
|
|
this.$questionsEl.parent().addClass("h-auto");
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Widget responsible for the initialization and the drawing of the various charts.
|
|
*
|
|
*/
|
|
publicWidget.registry.SurveyResultChart = publicWidget.Widget.extend({
|
|
|
|
//--------------------------------------------------------------------------
|
|
// 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.chart = self._loadChart();
|
|
}
|
|
});
|
|
},
|
|
|
|
willStart: async function () {
|
|
await loadBundle("web.chartjs_lib");
|
|
},
|
|
|
|
// -------------------------------------------------------------------------
|
|
// 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: {
|
|
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.
|
|
*
|
|
* @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: {
|
|
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.
|
|
*
|
|
* @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];
|
|
}),
|
|
}]
|
|
},
|
|
options: {
|
|
aspectRatio: 2,
|
|
},
|
|
};
|
|
},
|
|
|
|
_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: {
|
|
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%
|
|
*
|
|
*
|
|
* @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: {
|
|
plugins: {
|
|
title: {
|
|
display: true,
|
|
text: _t("Performance by Section"),
|
|
},
|
|
legend: {
|
|
display: true,
|
|
},
|
|
tooltip: {
|
|
callbacks: {
|
|
label: (tooltipItem) => {
|
|
const xLabel = tooltipItem.label;
|
|
var 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,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
},
|
|
|
|
/**
|
|
* 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 .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',
|
|
'click .o_survey_answer_image': '_onAnswerImgClick',
|
|
"click .o_survey_results_print": "_onPrintResultsClick",
|
|
"click .o_survey_results_data_tab": "_onDataViewChange",
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Widget
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* @override
|
|
*/
|
|
start: function () {
|
|
var self = this;
|
|
return this._super.apply(this, arguments).then(function () {
|
|
var allPromises = [];
|
|
self.$('.pagination_wrapper').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)));
|
|
});
|
|
|
|
// Set the size of results tables so that they do not resize when switching pages.
|
|
document.querySelectorAll('.o_survey_results_table_wrapper').forEach((table) => {
|
|
table.style.height = table.clientHeight + 'px';
|
|
})
|
|
|
|
if (allPromises.length !== 0) {
|
|
return Promise.all(allPromises);
|
|
} else {
|
|
return Promise.resolve();
|
|
}
|
|
});
|
|
},
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Handlers
|
|
// -------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Recompute the table height as the table could have been hidden when its height was initially computed (see 'start').
|
|
* @private
|
|
* @param {Event} ev
|
|
*/
|
|
_onDataViewChange: function (ev) {
|
|
const tableWrapper = document.querySelector(`div[id="${ev.currentTarget.getAttribute('aria-controls')}"] .o_survey_results_table_wrapper`);
|
|
if (tableWrapper) {
|
|
tableWrapper.style.height = 'auto';
|
|
tableWrapper.style.height = tableWrapper.clientHeight + 'px';
|
|
}
|
|
},
|
|
|
|
/**
|
|
* 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();
|
|
},
|
|
|
|
/**
|
|
* Called when an image on an answer in multi-answers question is clicked.
|
|
* Starts a widget opening a dialog to display the now zoomable image.
|
|
* this.imgZoomer is the zoomer widget linked to the survey result widget, if any.
|
|
*
|
|
* @private
|
|
* @param {Event} ev
|
|
*/
|
|
_onAnswerImgClick: function (ev) {
|
|
ev.preventDefault();
|
|
new SurveyImageZoomer({
|
|
sourceImage: $(ev.currentTarget).attr('src')
|
|
}).appendTo(document.body);
|
|
},
|
|
|
|
/**
|
|
* Call print dialog
|
|
* @private
|
|
*/
|
|
_onPrintResultsClick: function () {
|
|
window.print();
|
|
},
|
|
|
|
/**
|
|
* Returns the modified pathname string for filters after adding or removing an
|
|
* answer filter (from click event).
|
|
* @private
|
|
* @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;
|
|
}
|
|
});
|
|
|
|
export default {
|
|
resultWidget: publicWidget.registry.SurveyResultWidget,
|
|
chartWidget: publicWidget.registry.SurveyResultChart,
|
|
paginationWidget: publicWidget.registry.SurveyResultPagination
|
|
};
|