/** @odoo-module **/ import { _t } from "@web/core/l10n/translation"; import options from "@web_editor/js/editor/snippets.options"; import weUtils from "@web_editor/js/common/utils"; import { isCSSColor } from '@web/core/utils/colors'; options.registry.InnerChart = options.Class.extend({ custom_events: Object.assign({}, options.Class.prototype.custom_events, { 'get_custom_colors': '_onGetCustomColors', }), events: Object.assign({}, options.Class.prototype.events, { 'click we-button.add_column': '_onAddColumnClick', 'click we-button.add_row': '_onAddRowClick', 'click we-button.o_we_matrix_remove_col': '_onRemoveColumnClick', 'click we-button.o_we_matrix_remove_row': '_onRemoveRowClick', 'input we-matrix input': '_onMatrixInputInput', 'focus we-matrix input': '_onMatrixInputFocus', }), /** * @override */ init: function () { this._super.apply(this, arguments); this.themeArray = ['o-color-1', 'o-color-2', 'o-color-3', 'o-color-4', 'o-color-5']; this.style = window.getComputedStyle(this.$target[0].ownerDocument.documentElement); }, /** * @override */ start: function () { this.backSelectEl = this.el.querySelector('[data-name="chart_bg_color_opt"]'); this.borderSelectEl = this.el.querySelector('[data-name="chart_border_color_opt"]'); // Build matrix content this.tableEl = this.el.querySelector('we-matrix table'); const data = JSON.parse(this.$target[0].dataset.data); data.labels.forEach(el => { this._addRow(el); }); data.datasets.forEach((el, i) => { if (this._isPieChart()) { // Add header colors in case the user changes the type of graph const headerBackgroundColor = this.themeArray[i] || this._randomColor(); const headerBorderColor = this.themeArray[i] || this._randomColor(); this._addColumn(el.label, el.data, headerBackgroundColor, headerBorderColor, el.backgroundColor, el.borderColor); } else { this._addColumn(el.label, el.data, el.backgroundColor, el.borderColor); } }); this._displayRemoveColButton(); this._displayRemoveRowButton(); this._setDefaultSelectedInput(); return this._super(...arguments); }, /** * @override */ updateUI: async function () { // Selected input might not be in dom anymore if col/row removed // Done before _super because _computeWidgetState of colorChange if (!this.lastEditableSelectedInput.closest('table') || this.colorPaletteSelectedInput && !this.colorPaletteSelectedInput.closest('table')) { this._setDefaultSelectedInput(); } await this._super(...arguments); this.backSelectEl.querySelector('we-title').textContent = this._isPieChart() ? _t("Data Color") : _t("Dataset Color"); this.borderSelectEl.querySelector('we-title').textContent = this._isPieChart() ? _t("Data Border") : _t("Dataset Border"); // Dataset/Cell color this.tableEl.querySelectorAll('input').forEach(el => el.style.border = ''); const selector = this._isPieChart() ? 'td input' : 'tr:first-child input'; this.tableEl.querySelectorAll(selector).forEach(el => { const color = el.dataset.backgroundColor || el.dataset.borderColor; if (color) { el.style.border = '2px solid'; el.style.borderColor = isCSSColor(color) ? color : weUtils.getCSSVariableValue(color, this.style); } }); }, //-------------------------------------------------------------------------- // Options //-------------------------------------------------------------------------- /** * Set the color on the selected input. */ colorChange: async function (previewMode, widgetValue, params) { if (widgetValue) { this.colorPaletteSelectedInput.dataset[params.attributeName] = widgetValue; } else { delete this.colorPaletteSelectedInput.dataset[params.attributeName]; } await this._reloadGraph(); // To focus back the input that is edited we have to wait for the color // picker to be fully reloaded. await new Promise(resolve => setTimeout(() => { this.lastEditableSelectedInput.focus(); resolve(); })); }, /** * @override */ selectDataAttribute: async function (previewMode, widgetValue, params) { await this._super(...arguments); // Data might change if going from or to a pieChart. if (params.attributeName === 'type') { this._setDefaultSelectedInput(); await this._reloadGraph(); } if (params.attributeName === 'minValue' || params.attributeName === 'maxValue') { this._computeTicksMinMaxValue(); } }, //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- /** * @override */ _computeWidgetState: function (methodName, params) { if (methodName === 'colorChange') { return this.colorPaletteSelectedInput && this.colorPaletteSelectedInput.dataset[params.attributeName] || ''; } return this._super(...arguments); }, /** * @override */ _computeWidgetVisibility: function (widgetName, params) { switch (widgetName) { case 'stacked_chart_opt': { return this._getColumnCount() > 1; } case 'chart_bg_color_opt': case 'chart_border_color_opt': { return !!this.colorPaletteSelectedInput; } } return this._super(...arguments); }, /** * Maintains the gap between the scale axis for the auto fit behavior if we * used min/max config. * * @private */ _computeTicksMinMaxValue() { const dataset = this.$target[0].dataset; let minValue = parseInt(dataset.minValue); let maxValue = parseInt(dataset.maxValue); if (!isNaN(maxValue)) { // Reverse min max values when min value is greater than max value if (maxValue < minValue) { maxValue = minValue; minValue = parseInt(dataset.maxValue); } else if (maxValue === minValue) { // If min value and max value are same for positive and negative // number minValue < 0 ? (maxValue = 0, minValue = 2 * minValue) : (minValue = 0, maxValue = 2 * maxValue); } } else { // Find max value from each row/column data const datasets = JSON.parse(dataset.data).datasets || []; const dataValue = datasets .map((el) => { return el.data.map((data) => { return !isNaN(parseInt(data)) ? parseInt(data) : 0; }); }) .flat(); // When max value is not given and min value is greater than chart // data values if (minValue >= Math.max(...dataValue)) { maxValue = minValue; minValue = 0; } } this.$target.attr({ 'data-ticks-min': minValue, 'data-ticks-max': maxValue, }); }, /** * Sets and reloads the data on the canvas if it has changed. * Used in matrix related method. * * @private */ _reloadGraph: async function () { const jsonValue = this._matrixToChartData(); if (this.$target[0].dataset.data !== jsonValue) { this.$target[0].dataset.data = jsonValue; await this._refreshPublicWidgets(); } }, /** * Return a stringifyed chart.js data object from the matrix * Pie charts have one color per data while other charts have one color per dataset. * * @private */ _matrixToChartData: function () { const data = { labels: [], datasets: [], }; this.tableEl.querySelectorAll('tr:first-child input').forEach(el => { data.datasets.push({ label: el.value || '', data: [], backgroundColor: this._isPieChart() ? [] : el.dataset.backgroundColor || '', borderColor: this._isPieChart() ? [] : el.dataset.borderColor || '', }); }); this.tableEl.querySelectorAll('tr:not(:first-child):not(:last-child)').forEach((el) => { const title = el.querySelector('th input').value || ''; data.labels.push(title); el.querySelectorAll('td input').forEach((el, i) => { data.datasets[i].data.push(el.value || 0); if (this._isPieChart()) { data.datasets[i].backgroundColor.push(el.dataset.backgroundColor || ''); data.datasets[i].borderColor.push(el.dataset.borderColor || ''); } }); }); return JSON.stringify(data); }, /** * Return a td containing a we-button with minus icon * * @param {...string} classes Classes to add to the we-button * @returns {HTMLElement} */ _makeDeleteButton: function (...classes) { const rmbuttonEl = options.buildElement('we-button', null, { classes: ['o_we_text_danger', 'o_we_link', 'fa', 'fa-fw', 'fa-minus', ...classes], }); rmbuttonEl.title = classes.includes('o_we_matrix_remove_col') ? _t("Remove Serie") : _t("Remove Row"); const newEl = document.createElement('td'); newEl.appendChild(rmbuttonEl); return newEl; }, /** * Add a column to the matrix * The th (dataset label) of a column hold the colors for the entire dataset if the graph is not a pie chart * If the graph is a pie chart the color of the td (data) are used. * * @private * @param {String} title The title of the column * @param {Array} values The values of the column input * @param {String} heardeBackgroundColor The background color of the dataset * @param {String} headerBorderColor The border color of the dataset * @param {string[]} cellBackgroundColors The background colors of the datas inputs, random color if missing * @param {string[]} cellBorderColors The border color of the datas inputs, no color if missing */ _addColumn: function (title, values, heardeBackgroundColor, headerBorderColor, cellBackgroundColors = [], cellBorderColors = []) { const firstRow = this.tableEl.querySelector('tr:first-child'); const headerInput = this._makeCell('th', title, heardeBackgroundColor, headerBorderColor); firstRow.insertBefore(headerInput, firstRow.lastElementChild); this.tableEl.querySelectorAll('tr:not(:first-child):not(:last-child)').forEach((el, i) => { const newCell = this._makeCell('td', values ? values[i] : null, cellBackgroundColors[i] || this._randomColor(), cellBorderColors[i - 1]); el.insertBefore(newCell, el.lastElementChild); }); const lastRow = this.tableEl.querySelector('tr:last-child'); const removeButton = this._makeDeleteButton('o_we_matrix_remove_col'); lastRow.appendChild(removeButton); }, /** * Add a row to the matrix * The background color of the datas are random * * @private * @param {String} tilte The title of the row */ _addRow: function (tilte) { const trEl = document.createElement('tr'); trEl.appendChild(this._makeCell('th', tilte)); this.tableEl.querySelectorAll('tr:first-child input').forEach(() => { trEl.appendChild(this._makeCell('td', null, this._randomColor())); }); trEl.appendChild(this._makeDeleteButton('o_we_matrix_remove_row')); const tbody = this.tableEl.querySelector('tbody'); tbody.insertBefore(trEl, tbody.lastElementChild); }, /** * @private * @param {string} tag tag of the HTML Element (td/th) * @param {string} value The current value of the cell input * @param {string} backgroundColor The background Color of the data on the graph * @param {string} borderColor The border Color of the data on the graph * @returns {HTMLElement} */ _makeCell: function (tag, value, backgroundColor, borderColor) { const newEl = document.createElement(tag); const contentEl = document.createElement('input'); contentEl.type = 'text'; if (tag === 'td') { contentEl.type = 'number'; } contentEl.value = value || ''; if (backgroundColor) { contentEl.dataset.backgroundColor = backgroundColor; } if (borderColor) { contentEl.dataset.borderColor = borderColor; } newEl.appendChild(contentEl); return newEl; }, /** * Display the remove button coresponding to the colIndex * * @private * @param {Int} colIndex Can be undefined, if so the last remove button of the column will be shown */ _displayRemoveColButton: function (colIndex) { if (this._getColumnCount() > 1) { this._displayRemoveButton(colIndex, 'o_we_matrix_remove_col'); } }, /** * Display the remove button coresponding to the rowIndex * * @private * @param {Int} rowIndex Can be undefined, if so the last remove button of the row will be shown */ _displayRemoveRowButton: function (rowIndex) { //Nbr of row minus header and button const rowCount = this.tableEl.rows.length - 2; if (rowCount > 1) { this._displayRemoveButton(rowIndex, 'o_we_matrix_remove_row'); } }, /** * @private * @param {Int} tdIndex Can be undefined, if so the last remove button will be shown * @param {String} btnClass Either o_we_matrix_remove_col or o_we_matrix_remove_row */ _displayRemoveButton: function (tdIndex, btnClass) { const removeBtn = this.tableEl.querySelectorAll(`td we-button.${btnClass}`); removeBtn.forEach(el => el.style.display = ''); //hide all const index = tdIndex < removeBtn.length ? tdIndex : removeBtn.length - 1; removeBtn[index].style.display = 'inline-block'; }, /** * @private * @return {boolean} */ _isPieChart: function () { return ['pie', 'doughnut'].includes(this.$target[0].dataset.type); }, /** * Return the number of column minus header and button * @private * @return {integer} */ _getColumnCount: function () { return this.tableEl.rows[0].cells.length - 2; }, /** * Select the first data input * * @private */ _setDefaultSelectedInput: function () { this.lastEditableSelectedInput = this.tableEl.querySelector('td input'); if (this._isPieChart()) { this.colorPaletteSelectedInput = this.lastEditableSelectedInput; } else { this.colorPaletteSelectedInput = this.tableEl.querySelector('th input'); } }, /** * Return a random hexadecimal color. * * @private * @return {string} */ _randomColor: function () { return '#' + ('00000' + (Math.random() * (1 << 24) | 0).toString(16)).slice(-6).toUpperCase(); }, //-------------------------------------------------------------------------- // Handlers //-------------------------------------------------------------------------- /** * Used by colorPalette to retrieve the custom colors used on the chart * Make an array with all the custom colors used on the chart * and apply it to the onSuccess method provided by the trigger_up. * * @private */ _onGetCustomColors: function (ev) { const data = JSON.parse(this.$target[0].dataset.data || ''); let customColors = []; data.datasets.forEach(el => { if (this._isPieChart()) { customColors = customColors.concat(el.backgroundColor).concat(el.borderColor); } else { customColors.push(el.backgroundColor); customColors.push(el.borderColor); } }); customColors = customColors.filter((el, i, array) => { return !weUtils.getCSSVariableValue(el, this.style) && array.indexOf(el) === i && el !== ''; // unique non class not transparent }); ev.data.onSuccess(customColors); }, /** * Add a row at the end of the matrix and display it's remove button * Choose the color of the column from the theme array or a random color if they are already used * * @private */ _onAddColumnClick: function () { const usedColor = Array.from(this.tableEl.querySelectorAll('tr:first-child input')).map(el => el.dataset.backgroundColor); const color = this.themeArray.filter(el => !usedColor.includes(el))[0] || this._randomColor(); this._addColumn(null, null, color, color); this._reloadGraph().then(() => { this._displayRemoveColButton(); this.updateUI(); }); }, /** * Add a column at the end of the matrix and display it's remove button * * @private */ _onAddRowClick: function () { this._addRow(); this._reloadGraph().then(() => { this._displayRemoveRowButton(); this.updateUI(); }); }, /** * Remove the column and show the remove button of the next column or the last if no next. * * @private * @param {Event} ev */ _onRemoveColumnClick: function (ev) { const cell = ev.currentTarget.parentElement; const cellIndex = cell.cellIndex; this.tableEl.querySelectorAll('tr').forEach((el) => { el.children[cellIndex].remove(); }); this._displayRemoveColButton(cellIndex - 1); this._reloadGraph().then(() => { this.updateUI(); }); }, /** * Remove the row and show the remove button of the next row or the last if no next. * * @private * @param {Event} ev */ _onRemoveRowClick: function (ev) { const row = ev.currentTarget.parentElement.parentElement; const rowIndex = row.rowIndex; row.remove(); this._displayRemoveRowButton(rowIndex - 1); this._reloadGraph().then(() => { this.updateUI(); }); }, /** * @private */ _onMatrixInputInput() { this._reloadGraph(); }, /** * Set the selected cell/header and display the related remove button * * @private * @param {Event} ev */ _onMatrixInputFocus: function (ev) { this.lastEditableSelectedInput = ev.target; const col = ev.target.parentElement.cellIndex; const row = ev.target.parentElement.parentElement.rowIndex; if (this._isPieChart()) { this.colorPaletteSelectedInput = ev.target.parentNode.tagName === 'TD' ? ev.target : null; } else { this.colorPaletteSelectedInput = this.tableEl.querySelector(`tr:first-child th:nth-of-type(${col + 1}) input`); } if (col > 0) { this._displayRemoveColButton(col - 1); } if (row > 0) { this._displayRemoveRowButton(row - 1); } this.updateUI(); }, });