diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/color_palette.js b/addons/web_editor/static/src/js/wysiwyg/widgets/color_palette.js index 04a21c79378..a4881e1b139 100644 --- a/addons/web_editor/static/src/js/wysiwyg/widgets/color_palette.js +++ b/addons/web_editor/static/src/js/wysiwyg/widgets/color_palette.js @@ -158,16 +158,32 @@ const ColorPaletteWidget = Widget.extend({ return ColorpickerDialog.normalizeCSSColor(el.style.backgroundColor); }) )); + this.trigger_up('get_custom_colors', { + onSuccess: (colors) => { + colors.forEach(color => { + this._addCustomColor(existingColors, color); + }); + }, + }); _.each(this.options.$editable.find('[style*="color"]'), el => { for (const colorProp of ['color', 'backgroundColor']) { - const color = ColorpickerDialog.normalizeCSSColor(el.style[colorProp]); - if (color && !existingColors.has(color)) { - this._addCustomColorButton(color); - existingColors.add(color); - } + this._addCustomColor(existingColors, el.style[colorProp]); } }); }, + /** + * Add the color to the custom color section if it is not in the existingColors. + * + * @param {string[]} existingColors Colors currently in the colorpicker + * @param {string} color Color to add to the cuustom colors + */ + _addCustomColor: function (existingColors, color) { + const normColor = ColorpickerDialog.normalizeCSSColor(color); + if (color && !existingColors.has(normColor)) { + this._addCustomColorButton(normColor); + existingColors.add(normColor); + } + }, /** * Add a custom button in the coresponding section. * diff --git a/addons/web_editor/static/src/scss/wysiwyg_snippets.scss b/addons/web_editor/static/src/scss/wysiwyg_snippets.scss index 019fb32fd3f..6f76811a704 100644 --- a/addons/web_editor/static/src/scss/wysiwyg_snippets.scss +++ b/addons/web_editor/static/src/scss/wysiwyg_snippets.scss @@ -408,6 +408,47 @@ body.editor_enable.editor_has_snippets { } } + // Chart Snippet + we-matrix { + overflow-y: auto; + + table { + table-layout: fixed; + width: 100%; + + td, th { + text-align: center; + we-button { + display: inline-block; + color: white; + height: 100%; + + &.o_we_matrix_remove_col, &.o_we_matrix_remove_row { + display: none; + } + } + input { + border: 1px solid var(--o-we-bg-color-dark); + background-color: var(--o-we-bg-color-dark); + color: var(--o-we-color); + font-size: 12px; + width: 100%; + } + &:last-child { + width: 28px; + } + } + &.o_we_matrix_five_col { + width: auto; + td, th { + input { + width: 43px; + } + } + } + } + } + //---------------------------------------------------------------------- // Layout Utils //---------------------------------------------------------------------- diff --git a/addons/website/static/src/img/snippets_thumbs/s_chart.png b/addons/website/static/src/img/snippets_thumbs/s_chart.png new file mode 100644 index 00000000000..aa1d88d85d8 Binary files /dev/null and b/addons/website/static/src/img/snippets_thumbs/s_chart.png differ diff --git a/addons/website/static/src/js/content/snippets.animation.js b/addons/website/static/src/js/content/snippets.animation.js index 8cb4da6efe9..ff90fa478e8 100644 --- a/addons/website/static/src/js/content/snippets.animation.js +++ b/addons/website/static/src/js/content/snippets.animation.js @@ -1561,6 +1561,130 @@ registry.countdown = publicWidget.Widget.extend({ }, }); +registry.chart = publicWidget.Widget.extend({ + selector: '.s_chart', + disabledInEditableMode: false, + jsLibs: [ + '/web/static/lib/Chart/Chart.js', + ], + + /** + * @override + * @param {Object} parent + * @param {Object} options The default value of the chartbar. + */ + init: function (parent, options) { + this._super.apply(this, arguments); + this.style = window.getComputedStyle(document.documentElement); + }, + /** + * @override + */ + start: function () { + // Convert Theme colors to css color + const data = JSON.parse(this.el.dataset.data); + data.datasets.forEach(el => { + if (Array.isArray(el.backgroundColor)) { + el.backgroundColor = el.backgroundColor.map(el => this._convertToCssColor(el)); + el.borderColor = el.borderColor.map(el => this._convertToCssColor(el)); + } else { + el.backgroundColor = this._convertToCssColor(el.backgroundColor); + el.borderColor = this._convertToCssColor(el.borderColor); + } + el.borderWidth = this.el.dataset.borderWidth; + }); + + // Make chart data + const chartData = { + type: this.el.dataset.type, + data: data, + options: { + legend: { + display: this.el.dataset.legendPosition !== 'none', + position: this.el.dataset.legendPosition, + }, + tooltips: { + enabled: this.el.dataset.tooltipDisplay === 'true', + }, + title: { + display: !!this.el.dataset.title, + text: this.el.dataset.title, + }, + }, + }; + + // Add type specific options + if (this.el.dataset.type === 'radar') { + chartData.options.scale = { + ticks: { + beginAtZero: true, + } + }; + } else if (['pie', 'doughnut'].includes(this.el.dataset.type)) { + chartData.options.tooltips.callbacks = { + label: (tooltipItem, data) => { + const label = data.datasets[tooltipItem.datasetIndex].label; + const secondLabel = data.labels[tooltipItem.index]; + let final = label; + if (label) { + if (secondLabel) { + final = label + ' - ' + secondLabel; + } + } else if (secondLabel) { + final = secondLabel; + } + return final + ':' + data.datasets[tooltipItem.datasetIndex].data[tooltipItem.index]; + }, + }; + } else { + chartData.options.scales = { + xAxes: [{ + stacked: this.el.dataset.stacked === 'true', + ticks: { + beginAtZero: true + }, + }], + yAxes: [{ + stacked: this.el.dataset.stacked === 'true', + ticks: { + beginAtZero: true + }, + }], + }; + } + const canvas = this.el.querySelector('canvas'); + this.chart = new window.Chart(canvas, chartData); + return this._super.apply(this, arguments); + }, + /** + * @override + * Discard all library changes to reset the state of the Html. + */ + destroy: function () { + if (this.chart) { // The widget can be destroyed before start has completed + this.chart.destroy(); + this.el.querySelectorAll('.chartjs-size-monitor').forEach(el => el.remove()); + } + this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @param {string} color A css color or theme color string + * @returns {string} Css color + */ + _convertToCssColor: function (color) { + if (!color) { + return 'transparent'; + } + return this.style.getPropertyValue(`--${color}`).trim() || color; + }, +}); + return { Widget: publicWidget.Widget, Animation: Animation, diff --git a/addons/website/static/src/js/editor/snippets.options.js b/addons/website/static/src/js/editor/snippets.options.js index 75a83a1ae15..3a0cfa72c6d 100644 --- a/addons/website/static/src/js/editor/snippets.options.js +++ b/addons/website/static/src/js/editor/snippets.options.js @@ -2,6 +2,7 @@ odoo.define('website.editor.snippets.options', function (require) { 'use strict'; var core = require('web.core'); +const ColorpickerDialog = require('web.ColorpickerDialog'); var Dialog = require('web.Dialog'); var weWidgets = require('wysiwyg.widgets'); const wUtils = require('website.utils'); @@ -2072,6 +2073,459 @@ options.registry.SnippetMove = options.Class.extend({ }, }); +options.registry.InnerChart = options.Class.extend({ + custom_events: _.extend({}, options.Class.prototype.custom_events, { + 'get_custom_colors': '_onGetCustomColors', + }), + events: _.extend({}, 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', + 'blur we-matrix input': '_onMatrixInputFocusOut', + 'focus we-matrix input': '_onMatrixInputFocus', + }), + + /** + * @override + */ + init: function () { + this._super.apply(this, arguments); + this.themeArray = ['alpha', 'beta', 'gamma', 'delta', 'epsilon'], + this.style = window.getComputedStyle(document.documentElement); + }, + /** + * @override + */ + start: function () { + // Get the 2 color pickers to hide in updateUI + this.backSelectEl = this.el.querySelector('we-select[data-attribute-name="backgroundColor"]'); + this.borderSelectEl = this.el.querySelector('we-select[data-attribute-name="borderColor"]'); + + // 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: 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(); + } + this._super(...arguments); + // prevent the columns from becoming too small. + this.tableEl.classList.toggle('o_we_matrix_five_col', this.tableEl.querySelectorAll('tr:first-child th').length > 5); + this.el.querySelector('[data-attribute-name="stacked"]').classList.toggle('d-none', !this._isStackableChart() || this._getColumnCount() === 1); + // Disable color on inputs that don't use them + const notDisplayed = !this.colorPaletteSelectedInput; + this.backSelectEl.classList.toggle('d-none', notDisplayed); + this.borderSelectEl.classList.toggle('d-none', notDisplayed); + this.backSelectEl.querySelector('we-title').textContent = this._isPieChart() ? _t("Data Background Color") : _t("Dataset Background Color"); + this.borderSelectEl.querySelector('we-title').textContent = this._isPieChart() ? _t("Data Border Color") : _t("Dataset Border Color"); + // 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 = ColorpickerDialog.isCSSColor(color) ? color : this.style.getPropertyValue(`--${color}`).trim(); + } + }); + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Set the color on the selected input. + */ + colorChange: function (previewMode, widgetValue, params) { + if (widgetValue) { + this.colorPaletteSelectedInput.dataset[params.attributeName] = widgetValue; + } else { + delete this.colorPaletteSelectedInput.dataset[params.attributeName]; + } + this._reloadGraph(); + // To focus back the input that is edited we have to wait for the color picker to be fully reloaded. + setTimeout(() => this.lastEditableSelectedInput.focus()); + }, + /** + * @override + */ + selectDataAttribute: function (previewMode, widgetValue, params) { + this._super(...arguments); + // Data might change if going from or to a pieChart. + if (params.attributeName === 'type') { + this._setDefaultSelectedInput(); + this._reloadGraph(); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _computeWidgetState: function (methodName, params) { + if (methodName === 'colorChange') { + return this.colorPaletteSelectedInput && this.colorPaletteSelectedInput.dataset[params.attributeName] || ''; + } + return this._super(...arguments); + }, + /** + * Sets and reloads the data on the canvas if it has changed. + * Used in matrix related method. + * + * @private + */ + _reloadGraph: function () { + const jsonValue = this._matrixToChartData(); + if (this.$target[0].dataset.data !== jsonValue) { + this.$target[0].dataset.data = jsonValue; + 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: ['fa', 'fa-fw', 'fa-minus', ...classes], + }); + 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 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'; + 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); + }, + /** + * @private + * @return {boolean} + */ + _isStackableChart: function () { + return ['bar', 'horizontalBar'].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 !this.style.getPropertyValue(`--${el}`) && 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(); + 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(); + 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(); + 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(); + this.updateUI(); + }, + /** + * @private + * @param {Event} ev + */ + _onMatrixInputFocusOut: function (ev) { + // Sometimes, an input is focusout for internal reason (like an undo + // recording) then focused again manually in the same JS stack + // execution. In that case, the blur should not trigger an option + // selection as the user did not leave the input. We thus defer the blur + // handling to then check that the target is indeed still blurred before + // executing the actual option selection. + setTimeout(() => { + if (ev.currentTarget === document.activeElement) { + return; + } + 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(); + }, +}); + const InputUserValueWidget = options.userValueWidgetsRegistry['we-input']; const UrlPickerUserValueWidget = InputUserValueWidget.extend({ custom_events: _.extend({}, InputUserValueWidget.prototype.custom_events || {}, { diff --git a/addons/website/views/snippets.xml b/addons/website/views/snippets.xml index 9fc9117e499..c29dcbcf877 100644 --- a/addons/website/views/snippets.xml +++ b/addons/website/views/snippets.xml @@ -747,6 +747,24 @@ + +