diff options
Diffstat (limited to 'addons/board/static')
| -rw-r--r-- | addons/board/static/description/icon.png | bin | 0 -> 9168 bytes | |||
| -rw-r--r-- | addons/board/static/description/icon.svg | 23 | ||||
| -rw-r--r-- | addons/board/static/src/img/layout_1-1-1.png | bin | 0 -> 306 bytes | |||
| -rw-r--r-- | addons/board/static/src/img/layout_1-1.png | bin | 0 -> 313 bytes | |||
| -rw-r--r-- | addons/board/static/src/img/layout_1-2.png | bin | 0 -> 313 bytes | |||
| -rw-r--r-- | addons/board/static/src/img/layout_1.png | bin | 0 -> 292 bytes | |||
| -rw-r--r-- | addons/board/static/src/img/layout_2-1.png | bin | 0 -> 304 bytes | |||
| -rw-r--r-- | addons/board/static/src/img/view_todo_arrow.png | bin | 0 -> 3389 bytes | |||
| -rw-r--r-- | addons/board/static/src/js/action_manager_board_action.js | 33 | ||||
| -rw-r--r-- | addons/board/static/src/js/add_to_board_menu.js | 152 | ||||
| -rw-r--r-- | addons/board/static/src/js/board_view.js | 465 | ||||
| -rw-r--r-- | addons/board/static/src/scss/dashboard.scss | 178 | ||||
| -rw-r--r-- | addons/board/static/src/xml/board.xml | 114 | ||||
| -rw-r--r-- | addons/board/static/tests/dashboard_tests.js | 1153 |
14 files changed, 2118 insertions, 0 deletions
diff --git a/addons/board/static/description/icon.png b/addons/board/static/description/icon.png Binary files differnew file mode 100644 index 00000000..6215e5f9 --- /dev/null +++ b/addons/board/static/description/icon.png diff --git a/addons/board/static/description/icon.svg b/addons/board/static/description/icon.svg new file mode 100644 index 00000000..ebd019e6 --- /dev/null +++ b/addons/board/static/description/icon.svg @@ -0,0 +1,23 @@ +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="70" height="70" viewBox="0 0 70 70"> + <defs> + <path id="icon-a" d="M4,5.35309892e-14 C36.4160122,9.87060235e-15 58.0836068,-3.97961823e-14 65,5.07020818e-14 C69,6.733808e-14 70,1 70,5 C70,43.0488877 70,62.4235458 70,65 C70,69 69,70 65,70 C61,70 9,70 4,70 C1,70 7.10542736e-15,69 7.10542736e-15,65 C7.25721566e-15,62.4676575 3.83358709e-14,41.8005206 3.60818146e-14,5 C-1.13686838e-13,1 1,5.75716207e-14 4,5.35309892e-14 Z"/> + <linearGradient id="icon-c" x1="100%" x2="0%" y1="0%" y2="100%"> + <stop offset="0%" stop-color="#CD7690"/> + <stop offset="100%" stop-color="#CA5377"/> + </linearGradient> + <path id="icon-d" d="M18.0450069,57.225 C16.6239398,57.2249541 15.319401,56.4292666 14.6550625,55.1573449 C12.9601701,51.9125391 12,48.2137078 12,44.2875 C12,31.4261695 22.2974514,21 35,21 C47.7025486,21 58,31.4261695 58,44.2875 C58,48.2137078 57.0398299,51.9125391 55.3449375,55.1573449 C54.6806259,56.4292924 53.3760701,57.2249902 51.9549931,57.225 L18.0450069,57.225 Z M52.8888889,41.7 C51.4775035,41.7 50.3333333,42.8584723 50.3333333,44.2875 C50.3333333,45.7165277 51.4775035,46.875 52.8888889,46.875 C54.3002743,46.875 55.4444444,45.7165277 55.4444444,44.2875 C55.4444444,42.8584723 54.3002743,41.7 52.8888889,41.7 Z M35,28.7625 C36.4113854,28.7625 37.5555556,27.6040277 37.5555556,26.175 C37.5555556,24.7459723 36.4113854,23.5875 35,23.5875 C33.5886146,23.5875 32.4444444,24.7459723 32.4444444,26.175 C32.4444444,27.6040277 33.5886146,28.7625 35,28.7625 Z M17.1111111,41.7 C15.6997257,41.7 14.5555556,42.8584723 14.5555556,44.2875 C14.5555556,45.7165277 15.6997257,46.875 17.1111111,46.875 C18.5224965,46.875 19.6666667,45.7165277 19.6666667,44.2875 C19.6666667,42.8584723 18.5224965,41.7 17.1111111,41.7 Z M22.3506389,28.8925219 C20.9392535,28.8925219 19.7950833,30.0509941 19.7950833,31.4800219 C19.7950833,32.9090496 20.9392535,34.0675219 22.3506389,34.0675219 C23.7620243,34.0675219 24.9061944,32.9090496 24.9061944,31.4800219 C24.9061944,30.0509941 23.7620243,28.8925219 22.3506389,28.8925219 Z M47.6493611,28.8925219 C46.2379757,28.8925219 45.0938056,30.0509941 45.0938056,31.4800219 C45.0938056,32.9090496 46.2379757,34.0675219 47.6493611,34.0675219 C49.0607465,34.0675219 50.2049167,32.9090496 50.2049167,31.4800219 C50.2049167,30.0509941 49.0607465,28.8925219 47.6493611,28.8925219 Z M40.6952153,31.4423414 C39.686809,31.1156695 38.6082049,31.6784508 38.285566,32.6992195 L34.6181042,44.3034293 C31.9739028,44.501373 29.8888889,46.7346281 29.8888889,49.4625 C29.8888889,52.3205555 32.1772292,54.6375 35,54.6375 C37.8227708,54.6375 40.1111111,52.3205555 40.1111111,49.4625 C40.1111111,47.8636676 39.3946771,46.434559 38.269434,45.4852699 L41.9365764,33.8821113 C42.2591354,32.8612617 41.7033819,31.7690133 40.6952153,31.4423414 Z"/> + </defs> + <g fill="none" fill-rule="evenodd"> + <mask id="icon-b" fill="#fff"> + <use xlink:href="#icon-a"/> + </mask> + <g mask="url(#icon-b)"> + <rect width="70" height="70" fill="url(#icon-c)"/> + <path fill="#FFF" fill-opacity=".383" d="M4,1.8 L65,1.8 C67.6666667,1.8 69.3333333,1.13333333 70,-0.2 C70,2.46666667 70,3.46666667 70,2.8 L1.10547097e-14,2.8 C-1.65952376e-14,3.46666667 -2.9161925e-14,2.46666667 -2.66453526e-14,-0.2 C0.666666667,1.13333333 2,1.8 4,1.8 Z" transform="matrix(1 0 0 -1 0 2.8)"/> + <path fill="#393939" d="M4,50 C2,50 -7.10542736e-15,49.851312 0,45.8367347 L0,26.3942795 L16.3536575,8.86200565 C29.4512192,-0.488174988 39.6666667,-2.3877551 47,3.16326531 C54.3333333,8.71428571 58,14.9591837 58,21.8979592 C55.8677728,29.7827578 54.7719047,33.7755585 54.7123959,33.8763613 C54.6528871,33.9771642 49.9857922,39.3517104 40.7111111,50 L4,50 Z" opacity=".324" transform="translate(0 20)"/> + <path fill="#000" fill-opacity=".383" d="M4,4 L65,4 C67.6666667,4 69.3333333,3 70,1 C70,3.66666667 70,5 70,5 L1.77635684e-15,5 C1.77635684e-15,5 1.77635684e-15,3.66666667 1.77635684e-15,1 C0.666666667,3 2,4 4,4 Z" transform="translate(0 65)"/> + <use fill="#000" fill-rule="nonzero" opacity=".3" xlink:href="#icon-d"/> + <path fill="#FFF" fill-rule="nonzero" d="M18.0450069,55.225 C16.6239398,55.2249541 15.319401,54.4292666 14.6550625,53.1573449 C12.9601701,49.9125391 12,46.2137078 12,42.2875 C12,29.4261695 22.2974514,19 35,19 C47.7025486,19 58,29.4261695 58,42.2875 C58,46.2137078 57.0398299,49.9125391 55.3449375,53.1573449 C54.6806259,54.4292924 53.3760701,55.2249902 51.9549931,55.225 L18.0450069,55.225 Z M52.8888889,39.7 C51.4775035,39.7 50.3333333,40.8584723 50.3333333,42.2875 C50.3333333,43.7165277 51.4775035,44.875 52.8888889,44.875 C54.3002743,44.875 55.4444444,43.7165277 55.4444444,42.2875 C55.4444444,40.8584723 54.3002743,39.7 52.8888889,39.7 Z M35,26.7625 C36.4113854,26.7625 37.5555556,25.6040277 37.5555556,24.175 C37.5555556,22.7459723 36.4113854,21.5875 35,21.5875 C33.5886146,21.5875 32.4444444,22.7459723 32.4444444,24.175 C32.4444444,25.6040277 33.5886146,26.7625 35,26.7625 Z M17.1111111,39.7 C15.6997257,39.7 14.5555556,40.8584723 14.5555556,42.2875 C14.5555556,43.7165277 15.6997257,44.875 17.1111111,44.875 C18.5224965,44.875 19.6666667,43.7165277 19.6666667,42.2875 C19.6666667,40.8584723 18.5224965,39.7 17.1111111,39.7 Z M22.3506389,26.8925219 C20.9392535,26.8925219 19.7950833,28.0509941 19.7950833,29.4800219 C19.7950833,30.9090496 20.9392535,32.0675219 22.3506389,32.0675219 C23.7620243,32.0675219 24.9061944,30.9090496 24.9061944,29.4800219 C24.9061944,28.0509941 23.7620243,26.8925219 22.3506389,26.8925219 Z M47.6493611,26.8925219 C46.2379757,26.8925219 45.0938056,28.0509941 45.0938056,29.4800219 C45.0938056,30.9090496 46.2379757,32.0675219 47.6493611,32.0675219 C49.0607465,32.0675219 50.2049167,30.9090496 50.2049167,29.4800219 C50.2049167,28.0509941 49.0607465,26.8925219 47.6493611,26.8925219 Z M40.6952153,29.4423414 C39.686809,29.1156695 38.6082049,29.6784508 38.285566,30.6992195 L34.6181042,42.3034293 C31.9739028,42.501373 29.8888889,44.7346281 29.8888889,47.4625 C29.8888889,50.3205555 32.1772292,52.6375 35,52.6375 C37.8227708,52.6375 40.1111111,50.3205555 40.1111111,47.4625 C40.1111111,45.8636676 39.3946771,44.434559 38.269434,43.4852699 L41.9365764,31.8821113 C42.2591354,30.8612617 41.7033819,29.7690133 40.6952153,29.4423414 Z"/> + </g> + </g> +</svg> diff --git a/addons/board/static/src/img/layout_1-1-1.png b/addons/board/static/src/img/layout_1-1-1.png Binary files differnew file mode 100644 index 00000000..5eda2823 --- /dev/null +++ b/addons/board/static/src/img/layout_1-1-1.png diff --git a/addons/board/static/src/img/layout_1-1.png b/addons/board/static/src/img/layout_1-1.png Binary files differnew file mode 100644 index 00000000..e72aa3a0 --- /dev/null +++ b/addons/board/static/src/img/layout_1-1.png diff --git a/addons/board/static/src/img/layout_1-2.png b/addons/board/static/src/img/layout_1-2.png Binary files differnew file mode 100644 index 00000000..4b14d7ae --- /dev/null +++ b/addons/board/static/src/img/layout_1-2.png diff --git a/addons/board/static/src/img/layout_1.png b/addons/board/static/src/img/layout_1.png Binary files differnew file mode 100644 index 00000000..69a0e308 --- /dev/null +++ b/addons/board/static/src/img/layout_1.png diff --git a/addons/board/static/src/img/layout_2-1.png b/addons/board/static/src/img/layout_2-1.png Binary files differnew file mode 100644 index 00000000..ed866add --- /dev/null +++ b/addons/board/static/src/img/layout_2-1.png diff --git a/addons/board/static/src/img/view_todo_arrow.png b/addons/board/static/src/img/view_todo_arrow.png Binary files differnew file mode 100644 index 00000000..8633430e --- /dev/null +++ b/addons/board/static/src/img/view_todo_arrow.png diff --git a/addons/board/static/src/js/action_manager_board_action.js b/addons/board/static/src/js/action_manager_board_action.js new file mode 100644 index 00000000..05414572 --- /dev/null +++ b/addons/board/static/src/js/action_manager_board_action.js @@ -0,0 +1,33 @@ +odoo.define('board.ActionManager', function (require) { +"use strict"; + +/** + * The purpose of this file is to patch the ActionManager to properly generate + * the flags for the 'ir.actions.act_window' of model 'board.board'. + */ + +var ActionManager = require('web.ActionManager'); + +ActionManager.include({ + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + * @private + */ + _executeWindowAction: function (action) { + if (action.res_model === 'board.board' && action.view_mode === 'form') { + action.target = 'inline'; + _.extend(action.flags, { + hasActionMenus: false, + hasSearchView: false, + headless: true, + }); + } + return this._super.apply(this, arguments); + }, +}); + +}); diff --git a/addons/board/static/src/js/add_to_board_menu.js b/addons/board/static/src/js/add_to_board_menu.js new file mode 100644 index 00000000..42a4a707 --- /dev/null +++ b/addons/board/static/src/js/add_to_board_menu.js @@ -0,0 +1,152 @@ +odoo.define('board.AddToBoardMenu', function (require) { + "use strict"; + + const Context = require('web.Context'); + const Domain = require('web.Domain'); + const DropdownMenuItem = require('web.DropdownMenuItem'); + const FavoriteMenu = require('web.FavoriteMenu'); + const { sprintf } = require('web.utils'); + const { useAutofocus } = require('web.custom_hooks'); + + const { useState } = owl.hooks; + + /** + * 'Add to board' menu + * + * Component consisiting of a toggle button, a text input and an 'Add' button. + * The first button is simply used to toggle the component and will determine + * whether the other elements should be rendered. + * The input will be given the name (or title) of the view that will be added. + * Finally, the last button will send the name as well as some of the action + * properties to the server to add the current view (and its context) to the + * user's dashboard. + * This component is only available in actions of type 'ir.actions.act_window'. + * @extends DropdownMenuItem + */ + class AddToBoardMenu extends DropdownMenuItem { + constructor() { + super(...arguments); + + this.interactive = true; + this.state = useState({ + name: this.env.action.name || "", + open: false, + }); + + useAutofocus(); + } + + //--------------------------------------------------------------------- + // Private + //--------------------------------------------------------------------- + + /** + * This is the main function for actually saving the dashboard. This method + * is supposed to call the route /board/add_to_dashboard with proper + * information. + * @private + */ + async _addToBoard() { + const searchQuery = this.env.searchModel.get('query'); + const context = new Context(this.env.action.context); + context.add(searchQuery.context); + context.add({ + group_by: searchQuery.groupBy, + orderedBy: searchQuery.orderedBy, + }); + if (searchQuery.timeRanges && searchQuery.timeRanges.hasOwnProperty('fieldName')) { + context.add({ + comparison: searchQuery.timeRanges, + }); + } + let controllerQueryParams; + this.env.searchModel.trigger('get-controller-query-params', params => { + controllerQueryParams = params || {}; + }); + controllerQueryParams.context = controllerQueryParams.context || {}; + const queryContext = controllerQueryParams.context; + delete controllerQueryParams.context; + context.add(Object.assign(controllerQueryParams, queryContext)); + + const domainArray = new Domain(this.env.action.domain || []); + const domain = Domain.prototype.normalizeArray(domainArray.toArray().concat(searchQuery.domain)); + + const evalutatedContext = context.eval(); + for (const key in evalutatedContext) { + if (evalutatedContext.hasOwnProperty(key) && /^search_default_/.test(key)) { + delete evalutatedContext[key]; + } + } + evalutatedContext.dashboard_merge_domains_contexts = false; + + Object.assign(this.state, { + name: $(".o_input").val() || "", + open: false, + }); + + const result = await this.rpc({ + route: '/board/add_to_dashboard', + params: { + action_id: this.env.action.id || false, + context_to_save: evalutatedContext, + domain: domain, + view_mode: this.env.view.type, + name: this.state.name, + }, + }); + if (result) { + this.env.services.notification.notify({ + title: sprintf(this.env._t("'%s' added to dashboard"), this.state.name), + message: this.env._t("Please refresh your browser for the changes to take effect."), + type: 'warning', + }); + } else { + this.env.services.notification.notify({ + message: this.env._t("Could not add filter to dashboard"), + type: 'danger', + }); + } + } + + //--------------------------------------------------------------------- + // Handlers + //--------------------------------------------------------------------- + + /** + * @private + * @param {KeyboardEvent} ev + */ + _onInputKeydown(ev) { + switch (ev.key) { + case 'Enter': + ev.preventDefault(); + this._addToBoard(); + break; + case 'Escape': + // Gives the focus back to the component. + ev.preventDefault(); + ev.target.blur(); + break; + } + } + + //--------------------------------------------------------------------- + // Static + //--------------------------------------------------------------------- + + /** + * @param {Object} env + * @returns {boolean} + */ + static shouldBeDisplayed(env) { + return env.action.type === 'ir.actions.act_window'; + } + } + + AddToBoardMenu.props = {}; + AddToBoardMenu.template = 'AddToBoardMenu'; + + FavoriteMenu.registry.add('add-to-board-menu', AddToBoardMenu, 10); + + return AddToBoardMenu; +}); diff --git a/addons/board/static/src/js/board_view.js b/addons/board/static/src/js/board_view.js new file mode 100644 index 00000000..87b5034f --- /dev/null +++ b/addons/board/static/src/js/board_view.js @@ -0,0 +1,465 @@ +odoo.define('board.BoardView', function (require) { +"use strict"; + +var Context = require('web.Context'); +var config = require('web.config'); +var core = require('web.core'); +var dataManager = require('web.data_manager'); +var Dialog = require('web.Dialog'); +var Domain = require('web.Domain'); +var FormController = require('web.FormController'); +var FormRenderer = require('web.FormRenderer'); +var FormView = require('web.FormView'); +var pyUtils = require('web.py_utils'); +var session = require('web.session'); +var viewRegistry = require('web.view_registry'); + +var _t = core._t; +var _lt = core._lt; +var QWeb = core.qweb; + +var BoardController = FormController.extend({ + custom_events: _.extend({}, FormController.prototype.custom_events, { + change_layout: '_onChangeLayout', + enable_dashboard: '_onEnableDashboard', + save_dashboard: '_saveDashboard', + switch_view: '_onSwitchView', + }), + + /** + * @override + */ + init: function (parent, model, renderer, params) { + this._super.apply(this, arguments); + this.customViewID = params.customViewID; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + getTitle: function () { + return _t("My Dashboard"); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Actually save a dashboard + * + * @returns {Promise} + */ + _saveDashboard: function () { + var board = this.renderer.getBoard(); + var arch = QWeb.render('DashBoard.xml', _.extend({}, board)); + return this._rpc({ + route: '/web/view/edit_custom', + params: { + custom_id: this.customViewID, + arch: arch, + } + }).then(dataManager.invalidate.bind(dataManager)); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {OdooEvent} event + */ + _onChangeLayout: function (event) { + var self = this; + var dialog = new Dialog(this, { + title: _t("Edit Layout"), + $content: QWeb.render('DashBoard.layouts', _.clone(event.data)) + }); + dialog.opened().then(function () { + dialog.$('li').click(function () { + var layout = $(this).attr('data-layout'); + self.renderer.changeLayout(layout); + self._saveDashboard(); + dialog.close(); + }); + }); + dialog.open(); + }, + /** + * We need to intercept switch_view event coming from sub views, because we + * don't actually want to switch view in dashboard, we want to do a + * do_action (which will open the record in a different breadcrumb). + * + * @private + * @param {OdooEvent} event + */ + _onSwitchView: function (event) { + event.stopPropagation(); + this.do_action({ + type: 'ir.actions.act_window', + res_model: event.data.model, + views: [[event.data.formViewID || false, 'form']], + res_id: event.data.res_id, + }); + }, +}); + +var BoardRenderer = FormRenderer.extend({ + custom_events: _.extend({}, FormRenderer.prototype.custom_events, { + update_filters: '_onUpdateFilters', + switch_view: '_onSwitchView', + }), + events: _.extend({}, FormRenderer.prototype.events, { + 'click .oe_dashboard_column .oe_fold': '_onFoldClick', + 'click .oe_dashboard_link_change_layout': '_onChangeLayout', + 'click .oe_dashboard_column .oe_close': '_onCloseAction', + }), + + /** + * @override + */ + init: function (parent, state, params) { + this._super.apply(this, arguments); + this.noContentHelp = params.noContentHelp; + this.actionsDescr = {}; + this._boardSubcontrollers = []; // for board: controllers of subviews + this._boardFormViewIDs = {}; // for board: mapping subview controller to form view id + }, + /** + * Call `on_attach_callback` for each subview + * + * @override + */ + on_attach_callback: function () { + _.each(this._boardSubcontrollers, function (controller) { + if ('on_attach_callback' in controller) { + controller.on_attach_callback(); + } + }); + }, + /** + * Call `on_detach_callback` for each subview + * + * @override + */ + on_detach_callback: function () { + _.each(this._boardSubcontrollers, function (controller) { + if ('on_detach_callback' in controller) { + controller.on_detach_callback(); + } + }); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @param {string} layout + */ + changeLayout: function (layout) { + var $dashboard = this.$('.oe_dashboard'); + var current_layout = $dashboard.attr('data-layout'); + if (current_layout !== layout) { + var clayout = current_layout.split('-').length, + nlayout = layout.split('-').length, + column_diff = clayout - nlayout; + if (column_diff > 0) { + var $last_column = $(); + $dashboard.find('.oe_dashboard_column').each(function (k, v) { + if (k >= nlayout) { + $(v).find('.oe_action').appendTo($last_column); + } else { + $last_column = $(v); + } + }); + } + $dashboard.toggleClass('oe_dashboard_layout_' + current_layout + ' oe_dashboard_layout_' + layout); + $dashboard.attr('data-layout', layout); + } + }, + /** + * Returns a representation of the current dashboard + * + * @returns {Object} + */ + getBoard: function () { + var self = this; + var board = { + form_title : this.arch.attrs.string, + style : this.$('.oe_dashboard').attr('data-layout'), + columns : [], + }; + this.$('.oe_dashboard_column').each(function () { + var actions = []; + $(this).find('.oe_action').each(function () { + var actionID = $(this).attr('data-id'); + var newAttrs = _.clone(self.actionsDescr[actionID]); + + /* prepare attributes as they should be saved */ + if (newAttrs.modifiers) { + newAttrs.modifiers = JSON.stringify(newAttrs.modifiers); + } + actions.push(newAttrs); + }); + board.columns.push(actions); + }); + return board; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Object} params + * @param {jQueryElement} params.$node + * @param {integer} params.actionID + * @param {Object} params.context + * @param {any[]} params.domain + * @param {string} params.viewType + * @returns {Promise} + */ + _createController: function (params) { + var self = this; + return this._rpc({ + route: '/web/action/load', + params: {action_id: params.actionID} + }) + .then(function (action) { + if (!action) { + // the action does not exist anymore + return Promise.resolve(); + } + var evalContext = new Context(params.context).eval(); + if (evalContext.group_by && evalContext.group_by.length === 0) { + delete evalContext.group_by; + } + // tz and lang are saved in the custom view + // override the language to take the current one + var rawContext = new Context(action.context, evalContext, {lang: session.user_context.lang}); + var context = pyUtils.eval('context', rawContext, evalContext); + var domain = params.domain || pyUtils.eval('domain', action.domain || '[]', action.context); + + action.context = context; + action.domain = domain; + + // When creating a view, `action.views` is expected to be an array of dicts, while + // '/web/action/load' returns an array of arrays. + action._views = action.views; + action.views = $.map(action.views, function (view) { return {viewID: view[0], type: view[1]}}); + + var viewType = params.viewType || action._views[0][1]; + var view = _.find(action._views, function (descr) { + return descr[1] === viewType; + }) || [false, viewType]; + return self.loadViews(action.res_model, context, [view]) + .then(function (viewsInfo) { + var viewInfo = viewsInfo[viewType]; + var View = viewRegistry.get(viewType); + + const searchQuery = { + context: context, + domain: domain, + groupBy: typeof context.group_by === 'string' && context.group_by ? + [context.group_by] : + context.group_by || [], + orderedBy: context.orderedBy || [], + }; + + if (View.prototype.searchMenuTypes.includes('comparison')) { + searchQuery.timeRanges = context.comparison || {}; + } + + var view = new View(viewInfo, { + action: action, + hasSelectors: false, + modelName: action.res_model, + searchQuery, + withControlPanel: false, + withSearchPanel: false, + }); + return view.getController(self).then(function (controller) { + self._boardFormViewIDs[controller.handle] = _.first( + _.find(action._views, function (descr) { + return descr[1] === 'form'; + }) + ); + self._boardSubcontrollers.push(controller); + return controller.appendTo(params.$node); + }); + }); + }); + }, + /** + * @private + * @param {Object} node + * @returns {jQueryElement} + */ + _renderTagBoard: function (node) { + var self = this; + // we add the o_dashboard class to the renderer's $el. This means that + // this function has a side effect. This is ok because we assume that + // once we have a '<board>' tag, we are in a special dashboard mode. + this.$el.addClass('o_dashboard'); + + var hasAction = _.detect(node.children, function (column) { + return _.detect(column.children,function (element){ + return element.tag === "action"? element: false; + }); + }); + if (!hasAction) { + return $(QWeb.render('DashBoard.NoContent')); + } + + // We should start with three columns available + node = $.extend(true, {}, node); + + // no idea why master works without this, but whatever + if (!('layout' in node.attrs)) { + node.attrs.layout = node.attrs.style; + } + for (var i = node.children.length; i < 3; i++) { + node.children.push({ + tag: 'column', + attrs: {}, + children: [] + }); + } + + // register actions, alongside a generated unique ID + _.each(node.children, function (column, column_index) { + _.each(column.children, function (action, action_index) { + action.attrs.id = 'action_' + column_index + '_' + action_index; + self.actionsDescr[action.attrs.id] = action.attrs; + }); + }); + + var $html = $('<div>').append($(QWeb.render('DashBoard', {node: node, isMobile: config.device.isMobile}))); + this._boardSubcontrollers = []; // dashboard controllers are reset on re-render + + // render each view + _.each(this.actionsDescr, function (action) { + self.defs.push(self._createController({ + $node: $html.find('.oe_action[data-id=' + action.id + '] .oe_content'), + actionID: _.str.toNumber(action.name), + context: action.context, + domain: Domain.prototype.stringToArray(action.domain, {}), + viewType: action.view_mode, + })); + }); + $html.find('.oe_dashboard_column').sortable({ + connectWith: '.oe_dashboard_column', + handle: '.oe_header', + scroll: false + }).bind('sortstop', function () { + self.trigger_up('save_dashboard'); + }); + + return $html; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onChangeLayout: function () { + var currentLayout = this.$('.oe_dashboard').attr('data-layout'); + this.trigger_up('change_layout', {currentLayout: currentLayout}); + }, + /** + * @private + * @param {MouseEvent} event + */ + _onCloseAction: function (event) { + var self = this; + var $container = $(event.currentTarget).parents('.oe_action:first'); + Dialog.confirm(this, (_t("Are you sure you want to remove this item?")), { + confirm_callback: function () { + $container.remove(); + self.trigger_up('save_dashboard'); + }, + }); + }, + /** + * @private + * @param {MouseEvent} event + */ + _onFoldClick: function (event) { + var $e = $(event.currentTarget); + var $action = $e.closest('.oe_action'); + var id = $action.data('id'); + var actionAttrs = this.actionsDescr[id]; + + if ($e.is('.oe_minimize')) { + actionAttrs.fold = '1'; + } else { + delete(actionAttrs.fold); + } + $e.toggleClass('oe_minimize oe_maximize'); + $action.find('.oe_content').toggle(); + this.trigger_up('save_dashboard'); + }, + /** + * Let FormController know which form view it should display based on the + * window action of the sub controller that is switching view + * + * @private + * @param {OdooEvent} event + */ + _onSwitchView: function (event) { + event.data.formViewID = this._boardFormViewIDs[event.target.handle]; + }, + /** + * Stops the propagation of 'update_filters' events triggered by the + * controllers instantiated by the dashboard to prevent them from + * interfering with the ActionManager. + * + * @private + * @param {OdooEvent} event + */ + _onUpdateFilters: function (event) { + event.stopPropagation(); + }, +}); + +var BoardView = FormView.extend({ + config: _.extend({}, FormView.prototype.config, { + Controller: BoardController, + Renderer: BoardRenderer, + }), + display_name: _lt('Board'), + + /** + * @override + */ + init: function (viewInfo) { + this._super.apply(this, arguments); + this.controllerParams.customViewID = viewInfo.custom_view_id; + }, +}); + +return BoardView; + +}); + + +odoo.define('board.viewRegistry', function (require) { +"use strict"; + +var BoardView = require('board.BoardView'); + +var viewRegistry = require('web.view_registry'); + +viewRegistry.add('board', BoardView); + +}); diff --git a/addons/board/static/src/scss/dashboard.scss b/addons/board/static/src/scss/dashboard.scss new file mode 100644 index 00000000..b11aa5ff --- /dev/null +++ b/addons/board/static/src/scss/dashboard.scss @@ -0,0 +1,178 @@ +.o_dashboard { + + // Dashboard layout + .oe_dashboard_layout_1 .oe_dashboard_column { + &.index_0 { + width: 100%; + } + &.index_1, &.index_2 { + display: none; + } + } + .oe_dashboard_layout_1-1 .oe_dashboard_column { + width: 50%; + &.index_2 { + display: none; + } + } + .oe_dashboard_layout_1-1-1 .oe_dashboard_column { + width: 33%; + } + .oe_dashboard_layout_2-1 .oe_dashboard_column { + &.index_0 { + width: 70%; + } + &.index_1 { + width: 30%; + } + &.index_2 { + display: none; + } + } + .oe_dashboard_layout_1-2 .oe_dashboard_column { + &.index_0 { + width: 30%; + } + &.index_1 { + width: 70%; + } + &.index_2 { + display: none; + } + } + .oe_dashboard_column { + vertical-align: top; + } + + // Layout selector + .oe_dashboard_links { + text-align: right; + margin: 0 4px 6px 0; + } + + // Dashboard content + .oe_dashboard { + width: 100%; + .oe_action { + margin: 0 8px 8px 0; + background-color: white; + border: 1px solid; + border-color: #e5e5e5 #dbdbdb #d2d2d2; + margin: 0 8px 8px 0; + .oe_header { + font-size: 16px; + vertical-align: middle; + margin: 0; + padding: 12px; + &:hover { + cursor: move; + } + .oe_icon { + float: right; + cursor: pointer; + color: #b3b3b3; + &:hover { + color: #666; + text-decoration: none; + } + } + .oe_close:after { + content: "×"; + margin-left: 4px; + } + .oe_minimize:after { + content: "-"; + margin-left: 4px; + } + .oe_maximize:after { + content: "+"; + margin-left: 4px; + } + .oe_header_text { + width: auto; + visibility: hidden; + display: inline-block; + cursor: text; + } + span { + cursor: pointer; + } + } + .oe_header_empty { + padding-top: 0; + padding-bottom: 2px; + } + .oe_button_create { + margin-left: 4px; + padding: 0 4px 0 4px; + height: 16px !important; + } + .oe_content { + padding: 0 12px 12px 12px; + &.oe_folded { + display: none; + } + .o_view_nocontent { + display: none; // we don't have a create button on the dashboard, so no need to display that + } + } + + // Override border of many2manytags defined for form_views + .o_kanban_view .o_kanban_record .oe_kanban_list_many2many .o_field_many2manytags { + border: none; + } + + // Override height of graph. min-height doesn't do the trick + .o_graph_controller canvas { + height: 300px; + } + + // Override height for calendar view to be displayed properly + .o_calendar_view { + height: 100vh; + } + + // Override height for map view to be displayed properly + .o_map_view { + .o_map_container, .o_pin_list_container { + height: calc(100vh - #{$o-navbar-height}); + } + } + } + } +} + +// Layout selector modal +.oe_dashboard_layout_selector { + ul { + white-space: nowrap; + } + li { + margin: 0; + padding: 0; + list-style-type: none; + float: left; + } + .oe_dashboard_selected_layout { + margin-left: -30px; + vertical-align: bottom; + margin-bottom: 10px; + } +} + +// Favorites menu in control panel +.o_add_to_dashboard { + display: none; // hidden by default + max-width: 250px; + width: auto; +} + +@include media-breakpoint-down(sm) { + .o_dashboard .oe_dashboard { + table-layout: fixed; + + .oe_action .oe_header .oe_header_text { + display: none; + } + } +} diff --git a/addons/board/static/src/xml/board.xml b/addons/board/static/src/xml/board.xml new file mode 100644 index 00000000..127c9b3a --- /dev/null +++ b/addons/board/static/src/xml/board.xml @@ -0,0 +1,114 @@ +<?xml version="1.0" encoding="utf-8"?> +<template> +<t t-name="DashBoard"> + <t t-if="isMobile"> + <t t-set="node.attrs.layout" t-value="1"/> + </t> + <t t-if="!isMobile"> + <div class="oe_dashboard_links"> + <button type="button" class="button oe_dashboard_link_change_layout btn btn-secondary" + title="Change Layout.."> + <img src="/board/static/src/img/layout_1-1-1.png" width="16" height="16" alt=""/> + <span> Change Layout </span> + </button> + </div> + </t> + <table t-att-data-layout="node.attrs.layout" t-attf-class="oe_dashboard oe_dashboard_layout_#{node.attrs.layout}" cellspacing="0" cellpadding="0" border="0"> + <tr> + <td t-foreach="node.children" t-as="column" t-if="column.tag == 'column'" + t-att-id="'column_' + column_index" t-attf-class="oe_dashboard_column index_#{column_index}"> + + <t t-foreach="column.children" t-as="action" t-if="action.tag == 'action'" t-call="DashBoard.action"/> + </td> + </tr> + </table> +</t> +<t t-name="DashBoard.action"> + <div t-att-data-id="action.attrs.id" class="oe_action"> + <h2 t-attf-class="oe_header #{action.attrs.string ? '' : 'oe_header_empty'}"> + <span class="oe_header_txt"> <t t-esc="action.attrs.string"/> </span> + <input class = "oe_header_text" type="text"/> + <t t-if="!action.attrs.string">&nbsp;</t> + <span class='oe_icon oe_close'></span> + <span class='oe_icon oe_minimize oe_fold' t-if="!action.attrs.fold"></span> + <span class='oe_icon oe_maximize oe_fold' t-if="action.attrs.fold"></span> + </h2> + <div t-att-class="'oe_content' + (action.attrs.fold ? ' oe_folded' : '')"/> + </div> +</t> +<t t-name="DashBoard.layouts"> + <div class="oe_dashboard_layout_selector"> + <p> + <strong>Choose dashboard layout</strong> + </p> + <ul> + <li t-foreach="'1 1-1 1-1-1 1-2 2-1'.split(' ')" t-as="layout" t-att-data-layout="layout"> + <img t-attf-src="/board/static/src/img/layout_#{layout}.png" alt=""/> + <i t-if="layout == currentLayout" class="oe_dashboard_selected_layout fa fa-check fa-lg text-success" aria-label='Layout' role="img" title="Layout"/> + </li> + </ul> + </div> +</t> +<t t-name="DashBoard.NoContent"> + <div class="o_view_nocontent"> + <div class="o_nocontent_help"> + <p class="o_view_nocontent_neutral_face"> + Your personal dashboard is empty + </p><p> + To add your first report into this dashboard, go to any + menu, switch to list or graph view, and click <i>"Add to + Dashboard"</i> in the extended search options. + </p><p> + You can filter and group data before inserting into the + dashboard using the search options. + </p> + </div> + </div> +</t> +<t t-name="DashBoard.xml"> + <form t-att-string="form_title"> + <board t-att-style="style"> + <column t-foreach="columns" t-as="column"> + <action t-foreach="column" t-as="action" t-att="action"/> + </column> + </board> + </form> +</t> +<div t-name="HomeWidget" class="oe_dashboard_home_widget"/> +<t t-name="HomeWidget.content"> + <h3><t t-esc="widget.title"/></h3> + <iframe width="100%" frameborder="0" t-att-src="url"/> +</t> + +<t t-name="AddToBoardMenu" owl="1"> + <li class="o_menu_item o_add_to_board" role="menuitem"> + <button type="button" class="dropdown-item" + t-ref="fallback-focus" + t-on-click="state.open = !state.open" + > + <t>Add to my dashboard</t> + </button> + <t t-if="state.open"> + <div class="dropdown-item-text"> + <input type="text" class="o_input" autofocus="" + t-model.trim="state.name" + t-on-keydown="_onInputKeydown" + /> + </div> + <div class="dropdown-item-text"> + <button type="button" class="btn btn-primary" t-on-click="_addToBoard">Add</button> + </div> + </t> + </li> +</t> + +<t t-name="SearchView.addtodashboard"> + <a href="#" class="dropdown-item o_add_to_dashboard_link o_closed_menu">Add to my Dashboard</a> + <div class="dropdown-item-text o_add_to_dashboard"> + <input class="o_input o_add_to_dashboard_input" type="text"/> + </div> + <div class="dropdown-item-text o_add_to_dashboard"> + <button type="button" class="btn btn-primary o_add_to_dashboard_button">Add</button> + </div> +</t> +</template> diff --git a/addons/board/static/tests/dashboard_tests.js b/addons/board/static/tests/dashboard_tests.js new file mode 100644 index 00000000..8ba4be76 --- /dev/null +++ b/addons/board/static/tests/dashboard_tests.js @@ -0,0 +1,1153 @@ +odoo.define('board.dashboard_tests', function (require) { +"use strict"; + +var BoardView = require('board.BoardView'); + +var ListController = require('web.ListController'); +var testUtils = require('web.test_utils'); +var ListRenderer = require('web.ListRenderer'); +var pyUtils = require('web.py_utils'); + +const cpHelpers = testUtils.controlPanel; +var createActionManager = testUtils.createActionManager; +var createView = testUtils.createView; + +const patchDate = testUtils.mock.patchDate; + +QUnit.module('Dashboard', { + beforeEach: function () { + this.data = { + board: { + fields: { + }, + records: [ + ] + }, + partner: { + fields: { + display_name: {string: "Displayed name", type: "char", searchable: true}, + foo: {string: "Foo", type: "char", default: "My little Foo Value", searchable: true}, + bar: {string: "Bar", type: "boolean"}, + int_field: {string: "Integer field", type: "integer", group_operator: 'sum'}, + }, + records: [{ + id: 1, + display_name: "first record", + foo: "yop", + int_field: 3, + }, { + id: 2, + display_name: "second record", + foo: "lalala", + int_field: 5, + }, { + id: 4, + display_name: "aaa", + foo: "abc", + int_field: 2, + }], + }, + }; + }, +}); + +QUnit.test('dashboard basic rendering', async function (assert) { + assert.expect(4); + + var form = await createView({ + View: BoardView, + model: 'board', + data: this.data, + arch: '<form string="My Dashboard">' + + '</form>', + }); + + assert.doesNotHaveClass(form.renderer.$el, 'o_dashboard', + "should not have the o_dashboard css class"); + + form.destroy(); + + form = await createView({ + View: BoardView, + model: 'board', + data: this.data, + arch: '<form string="My Dashboard">' + + '<board style="2-1">' + + '<column></column>' + + '</board>' + + '</form>', + }); + + assert.hasClass(form.renderer.$el,'o_dashboard', + "with a dashboard, the renderer should have the proper css class"); + assert.containsOnce(form, '.o_dashboard .o_view_nocontent', + "should have a no content helper"); + assert.strictEqual(form.$('.o_control_panel .breadcrumb-item').text(), "My Dashboard", + "should have the correct title"); + form.destroy(); +}); + +QUnit.test('display the no content helper', async function (assert) { + assert.expect(1); + + var form = await createView({ + View: BoardView, + model: 'board', + data: this.data, + arch: '<form string="My Dashboard">' + + '<board style="2-1">' + + '<column></column>' + + '</board>' + + '</form>', + viewOptions: { + action: { + help: '<p class="hello">click to add a partner</p>' + } + }, + }); + + assert.containsOnce(form, '.o_dashboard .o_view_nocontent', + "should have a no content helper with action help"); + form.destroy(); +}); + +QUnit.test('basic functionality, with one sub action', async function (assert) { + assert.expect(26); + + var form = await createView({ + View: BoardView, + model: 'board', + data: this.data, + arch: '<form string="My Dashboard">' + + '<board style="2-1">' + + '<column>' + + '<action context="{"orderedBy": [{"name": "foo", "asc": True}]}" view_mode="list" string="ABC" name="51" domain="[[\'foo\', \'!=\', \'False\']]"></action>' + + '</column>' + + '</board>' + + '</form>', + mockRPC: function (route, args) { + if (route === '/web/action/load') { + assert.step('load action'); + return Promise.resolve({ + res_model: 'partner', + views: [[4, 'list']], + }); + } + if (route === '/web/dataset/search_read') { + assert.deepEqual(args.domain, [['foo', '!=', 'False']], "the domain should be passed"); + assert.deepEqual(args.context.orderedBy, [{ + 'name': 'foo', + 'asc': true, + }], + 'orderedBy is present in the search read when specified on the custom action' + ); + } + if (route === '/web/view/edit_custom') { + assert.step('edit custom'); + return Promise.resolve(true); + } + return this._super.apply(this, arguments); + }, + archs: { + 'partner,4,list': + '<tree string="Partner"><field name="foo"/></tree>', + }, + }); + + assert.containsOnce(form, '.oe_dashboard_links', + "should have rendered a link div"); + assert.containsOnce(form, 'table.oe_dashboard[data-layout="2-1"]', + "should have rendered a table"); + assert.containsNone(form, 'td.o_list_record_selector', + "td should not have a list selector"); + assert.strictEqual(form.$('h2 span.oe_header_txt:contains(ABC)').length, 1, + "should have rendered a header with action string"); + assert.containsN(form, 'tr.o_data_row', 3, + "should have rendered 3 data rows"); + + assert.ok(form.$('.oe_content').is(':visible'), "content is visible"); + + await testUtils.dom.click(form.$('.oe_fold')); + + assert.notOk(form.$('.oe_content').is(':visible'), "content is no longer visible"); + + await testUtils.dom.click(form.$('.oe_fold')); + + assert.ok(form.$('.oe_content').is(':visible'), "content is visible again"); + assert.verifySteps(['load action', 'edit custom', 'edit custom']); + + assert.strictEqual($('.modal').length, 0, "should have no modal open"); + + await testUtils.dom.click(form.$('button.oe_dashboard_link_change_layout')); + + assert.strictEqual($('.modal').length, 1, "should have opened a modal"); + assert.strictEqual($('.modal li[data-layout="2-1"] i.oe_dashboard_selected_layout').length, 1, + "should mark currently selected layout"); + + await testUtils.dom.click($('.modal .oe_dashboard_layout_selector li[data-layout="1-1"]')); + + assert.strictEqual($('.modal').length, 0, "should have no modal open"); + assert.containsOnce(form, 'table.oe_dashboard[data-layout="1-1"]', + "should have rendered a table with correct layout"); + + + assert.containsOnce(form, '.oe_action', "should have one displayed action"); + await testUtils.dom.click(form.$('span.oe_close')); + + assert.strictEqual($('.modal').length, 1, "should have opened a modal"); + + // confirm the close operation + await testUtils.dom.click($('.modal button.btn-primary')); + + assert.strictEqual($('.modal').length, 0, "should have no modal open"); + assert.containsNone(form, '.oe_action', "should have no displayed action"); + + assert.verifySteps(['edit custom', 'edit custom']); + form.destroy(); +}); + +QUnit.test('views in the dashboard do not have a control panel', async function (assert) { + assert.expect(2); + + var form = await createView({ + View: BoardView, + model: 'board', + data: this.data, + arch: '<form>' + + '<board style="2-1">' + + '<column>' + + '<action context="{}" view_mode="list" string="ABC" name="51" domain="[]"></action>' + + '</column>' + + '</board>' + + '</form>', + mockRPC: function (route) { + if (route === '/web/action/load') { + return Promise.resolve({ + res_model: 'partner', + views: [[4, 'list'], [5, 'form']], + }); + } + return this._super.apply(this, arguments); + }, + archs: { + 'partner,4,list': + '<tree string="Partner"><field name="foo"/></tree>', + }, + }); + + assert.containsOnce(form, '.o_action .o_list_view'); + assert.containsNone(form, '.o_action .o_control_panel'); + + form.destroy(); +}); + +QUnit.test('can render an action without view_mode attribute', async function (assert) { + // The view_mode attribute is automatically set to the 'action' nodes when + // the action is added to the dashboard using the 'Add to dashboard' button + // in the searchview. However, other dashboard views can be written by hand + // (see openacademy tutorial), and in this case, we don't want hardcode + // action's params (like context or domain), as the dashboard can directly + // retrieve them from the action. Same applies for the view_type, as the + // first view of the action can be used, by default. + assert.expect(3); + + var form = await createView({ + View: BoardView, + model: 'board', + data: this.data, + arch: '<form string="My Dashboard">' + + '<board style="2-1">' + + '<column>' + + '<action string="ABC" name="51" context="{\'a\': 1}"></action>' + + '</column>' + + '</board>' + + '</form>', + archs: { + 'partner,4,list': + '<tree string="Partner"><field name="foo"/></tree>', + }, + mockRPC: function (route, args) { + if (route === '/board/static/src/img/layout_1-1-1.png') { + return Promise.resolve(); + } + if (route === '/web/action/load') { + return Promise.resolve({ + context: '{"b": 2}', + domain: '[["foo", "=", "yop"]]', + res_model: 'partner', + views: [[4, 'list'], [false, 'form']], + }); + } + if (args.method === 'load_views') { + assert.deepEqual(args.kwargs.context, {a: 1, b: 2}, + "should have mixed both contexts"); + } + if (route === '/web/dataset/search_read') { + assert.deepEqual(args.domain, [['foo', '=', 'yop']], + "should use the domain of the action"); + } + return this._super.apply(this, arguments); + }, + }); + + assert.strictEqual(form.$('.oe_action:contains(ABC) .o_list_view').length, 1, + "the list view (first view of action) should have been rendered correctly"); + + form.destroy(); +}); + +QUnit.test('can sort a sub list', async function (assert) { + assert.expect(2); + + this.data.partner.fields.foo.sortable = true; + + var form = await createView({ + View: BoardView, + model: 'board', + data: this.data, + arch: '<form string="My Dashboard">' + + '<board style="2-1">' + + '<column>' + + '<action context="{}" view_mode="list" string="ABC" name="51" domain="[]"></action>' + + '</column>' + + '</board>' + + '</form>', + mockRPC: function (route) { + if (route === '/web/action/load') { + return Promise.resolve({ + res_model: 'partner', + views: [[4, 'list']], + }); + } + return this._super.apply(this, arguments); + }, + archs: { + 'partner,4,list': + '<tree string="Partner"><field name="foo"/></tree>', + }, + }); + + assert.strictEqual($('tr.o_data_row').text(), 'yoplalalaabc', + "should have correct initial data"); + + await testUtils.dom.click(form.$('th.o_column_sortable:contains(Foo)')); + + assert.strictEqual($('tr.o_data_row').text(), 'abclalalayop', + "data should have been sorted"); + form.destroy(); +}); + +QUnit.test('can open a record', async function (assert) { + assert.expect(1); + + var form = await createView({ + View: BoardView, + model: 'board', + data: this.data, + arch: '<form string="My Dashboard">' + + '<board style="2-1">' + + '<column>' + + '<action context="{}" view_mode="list" string="ABC" name="51" domain="[]"></action>' + + '</column>' + + '</board>' + + '</form>', + mockRPC: function (route) { + if (route === '/web/action/load') { + return Promise.resolve({ + res_model: 'partner', + views: [[4, 'list']], + }); + } + return this._super.apply(this, arguments); + }, + archs: { + 'partner,4,list': + '<tree string="Partner"><field name="foo"/></tree>', + }, + intercepts: { + do_action: function (event) { + assert.deepEqual(event.data.action, { + res_id: 1, + res_model: 'partner', + type: 'ir.actions.act_window', + views: [[false, 'form']], + }, "should do a do_action with correct parameters"); + }, + }, + }); + + await testUtils.dom.click(form.$('tr.o_data_row td:contains(yop)')); + form.destroy(); +}); + +QUnit.test('can open record using action form view', async function (assert) { + assert.expect(1); + + var form = await createView({ + View: BoardView, + model: 'board', + data: this.data, + arch: '<form string="My Dashboard">' + + '<board style="2-1">' + + '<column>' + + '<action context="{}" view_mode="list" string="ABC" name="51" domain="[]"></action>' + + '</column>' + + '</board>' + + '</form>', + mockRPC: function (route) { + if (route === '/web/action/load') { + return Promise.resolve({ + res_model: 'partner', + views: [[4, 'list'], [5, 'form']], + }); + } + return this._super.apply(this, arguments); + }, + archs: { + 'partner,4,list': + '<tree string="Partner"><field name="foo"/></tree>', + 'partner,5,form': + '<form string="Partner"><field name="display_name"/></form>', + }, + intercepts: { + do_action: function (event) { + assert.deepEqual(event.data.action, { + res_id: 1, + res_model: 'partner', + type: 'ir.actions.act_window', + views: [[5, 'form']], + }, "should do a do_action with correct parameters"); + }, + }, + }); + + await testUtils.dom.click(form.$('tr.o_data_row td:contains(yop)')); + form.destroy(); +}); + +QUnit.test('can drag and drop a view', async function (assert) { + assert.expect(5); + + var form = await createView({ + View: BoardView, + model: 'board', + data: this.data, + arch: '<form string="My Dashboard">' + + '<board style="2-1">' + + '<column>' + + '<action context="{}" view_mode="list" string="ABC" name="51" domain="[]"></action>' + + '</column>' + + '</board>' + + '</form>', + mockRPC: function (route) { + if (route === '/web/action/load') { + return Promise.resolve({ + res_model: 'partner', + views: [[4, 'list']], + }); + } + if (route === '/web/view/edit_custom') { + assert.step('edit custom'); + return Promise.resolve(true); + } + return this._super.apply(this, arguments); + }, + archs: { + 'partner,4,list': + '<tree string="Partner"><field name="foo"/></tree>', + }, + }); + + assert.containsOnce(form, 'td.index_0 .oe_action', + "initial action is in column 0"); + + await testUtils.dom.dragAndDrop(form.$('.oe_dashboard_column.index_0 .oe_header'), + form.$('.oe_dashboard_column.index_1')); + assert.containsNone(form, 'td.index_0 .oe_action', + "initial action is not in column 0"); + assert.containsOnce(form, 'td.index_1 .oe_action', + "initial action is in in column 1"); + assert.verifySteps(['edit custom']); + + form.destroy(); +}); + +QUnit.test('twice the same action in a dashboard', async function (assert) { + assert.expect(2); + + var form = await createView({ + View: BoardView, + model: 'board', + data: this.data, + arch: '<form string="My Dashboard">' + + '<board style="2-1">' + + '<column>' + + '<action context="{}" view_mode="list" string="ABC" name="51" domain="[]"></action>' + + '<action context="{}" view_mode="kanban" string="DEF" name="51" domain="[]"></action>' + + '</column>' + + '</board>' + + '</form>', + mockRPC: function (route) { + if (route === '/web/action/load') { + return Promise.resolve({ + res_model: 'partner', + views: [[4, 'list'],[5, 'kanban']], + }); + } + if (route === '/web/view/edit_custom') { + assert.step('edit custom'); + return Promise.resolve(true); + } + return this._super.apply(this, arguments); + }, + archs: { + 'partner,4,list': + '<tree string="Partner"><field name="foo"/></tree>', + 'partner,5,kanban': + '<kanban><templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates></kanban>', + }, + }); + + var $firstAction = form.$('.oe_action:contains(ABC)'); + assert.strictEqual($firstAction.find('.o_list_view').length, 1, + "list view should be displayed in 'ABC' block"); + var $secondAction = form.$('.oe_action:contains(DEF)'); + assert.strictEqual($secondAction.find('.o_kanban_view').length, 1, + "kanban view should be displayed in 'DEF' block"); + + form.destroy(); +}); + +QUnit.test('non-existing action in a dashboard', async function (assert) { + assert.expect(1); + + var form = await createView({ + View: BoardView, + model: 'board', + data: this.data, + arch: '<form string="My Dashboard">' + + '<board style="2-1">' + + '<column>' + + '<action context="{}" view_mode="kanban" string="ABC" name="51" domain="[]"></action>' + + '</column>' + + '</board>' + + '</form>', + intercepts: { + load_views: function () { + throw new Error('load_views should not be called'); + } + }, + mockRPC: function (route) { + if (route === '/board/static/src/img/layout_1-1-1.png') { + return Promise.resolve(); + } + if (route === '/web/action/load') { + // server answer if the action doesn't exist anymore + return Promise.resolve(false); + } + return this._super.apply(this, arguments); + }, + }); + + assert.strictEqual(form.$('.oe_action:contains(ABC)').length, 1, + "there should be a box for the non-existing action"); + + form.destroy(); +}); + +QUnit.test('clicking on a kanban\'s button should trigger the action', async function (assert) { + assert.expect(2); + + var form = await createView({ + View: BoardView, + model: 'board', + data: this.data, + arch: '<form string="My Dashboard">' + + '<board style="2-1">' + + '<column>' + + '<action name="149" string="Partner" view_mode="kanban" id="action_0_1"></action>' + + '</column>' + + '</board>' + + '</form>', + archs: { + 'partner,false,kanban': + '<kanban class="o_kanban_test"><templates><t t-name="kanban-box">' + + '<div>' + + '<field name="foo"/>' + + '</div>' + + '<div><button name="sitting_on_a_park_bench" type="object">Eying little girls with bad intent</button>' + + '</div>' + + '</t></templates></kanban>', + }, + intercepts: { + execute_action: function (event) { + var data = event.data; + assert.strictEqual(data.env.model, 'partner', "should have correct model"); + assert.strictEqual(data.action_data.name, 'sitting_on_a_park_bench', + "should call correct method"); + } + }, + + mockRPC: function (route) { + if (route === '/board/static/src/img/layout_1-1-1.png') { + return Promise.resolve(); + } + if (route === '/web/action/load') { + return Promise.resolve({res_model: 'partner', view_mode: 'kanban', views: [[false, 'kanban']]}); + } + if (route === '/web/dataset/search_read') { + return Promise.resolve({records: [{foo: 'aqualung'}]}); + } + return this._super.apply(this, arguments); + } + }); + + await testUtils.dom.click(form.$('.o_kanban_test').find('button:first')); + + form.destroy(); +}); + +QUnit.test('subviews are aware of attach in or detach from the DOM', async function (assert) { + assert.expect(2); + + // patch list renderer `on_attach_callback` for the test only + testUtils.mock.patch(ListRenderer, { + on_attach_callback: function () { + assert.step('subview on_attach_callback'); + } + }); + + var form = await createView({ + View: BoardView, + model: 'board', + data: this.data, + arch: '<form string="My Dashboard">' + + '<board style="2-1">' + + '<column>' + + '<action context="{}" view_mode="list" string="ABC" name="51" domain="[]"></action>' + + '</column>' + + '</board>' + + '</form>', + mockRPC: function (route) { + if (route === '/web/action/load') { + return Promise.resolve({ + res_model: 'partner', + views: [[4, 'list']], + }); + } + return this._super.apply(this, arguments); + }, + archs: { + 'partner,4,list': + '<list string="Partner"><field name="foo"/></list>', + }, + }); + + assert.verifySteps(['subview on_attach_callback']); + + // restore on_attach_callback of ListRenderer + testUtils.mock.unpatch(ListRenderer); + + form.destroy(); +}); + +QUnit.test('dashboard intercepts custom events triggered by sub controllers', async function (assert) { + assert.expect(1); + + // we patch the ListController to force it to trigger the custom events that + // we want the dashboard to intercept (to stop them or to tweak their data) + testUtils.mock.patch(ListController, { + start: function () { + this.trigger_up('update_filters'); + return this._super.apply(this, arguments); + }, + }); + + var board = await createView({ + View: BoardView, + model: 'board', + data: this.data, + arch: '<form string="My Dashboard">' + + '<board style="2-1">' + + '<column>' + + '<action context="{}" view_mode="list" string="ABC" name="51" domain="[]"></action>' + + '</column>' + + '</board>' + + '</form>', + mockRPC: function (route) { + if (route === '/web/action/load') { + return Promise.resolve({res_model: 'partner', views: [[false, 'list']]}); + } + return this._super.apply(this, arguments); + }, + archs: { + 'partner,false,list': '<tree string="Partner"/>', + }, + intercepts: { + update_filters: assert.step.bind(assert, 'update_filters'), + }, + }); + + assert.verifySteps([]); + + testUtils.mock.unpatch(ListController); + board.destroy(); +}); + +QUnit.test('save actions to dashboard', async function (assert) { + assert.expect(6); + + testUtils.patch(ListController, { + getOwnedQueryParams: function () { + var result = this._super.apply(this, arguments); + result.context = { + 'fire': 'on the bayou', + }; + return result; + } + }); + + this.data['partner'].fields.foo.sortable = true; + + var actionManager = await createActionManager({ + data: this.data, + archs: { + 'partner,false,list': '<list><field name="foo"/></list>', + 'partner,false,search': '<search></search>', + }, + mockRPC: function (route, args) { + if (route === '/board/add_to_dashboard') { + assert.deepEqual(args.context_to_save.group_by, ['foo'], + 'The group_by should have been saved'); + assert.deepEqual(args.context_to_save.orderedBy, + [{ + name: 'foo', + asc: true, + }], + 'The orderedBy should have been saved'); + assert.strictEqual(args.context_to_save.fire, 'on the bayou', + 'The context of a controller should be passed and flattened'); + assert.strictEqual(args.action_id, 1, + "should save the correct action"); + assert.strictEqual(args.view_mode, 'list', + "should save the correct view type"); + return Promise.resolve(true); + } + return this._super.apply(this, arguments); + } + }); + + await actionManager.doAction({ + id: 1, + res_model: 'partner', + type: 'ir.actions.act_window', + views: [[false, 'list']], + }); + + assert.containsOnce(actionManager, '.o_list_view', + "should display the list view"); + + // Sort the list + await testUtils.dom.click($('.o_column_sortable')); + + // Group It + await cpHelpers.toggleGroupByMenu(actionManager); + await cpHelpers.toggleAddCustomGroup(actionManager); + await cpHelpers.applyGroup(actionManager); + + // add this action to dashboard + await cpHelpers.toggleFavoriteMenu(actionManager); + + await testUtils.dom.click($('.o_add_to_board > button')); + await testUtils.fields.editInput($('.o_add_to_board input'), 'a name'); + await testUtils.dom.click($('.o_add_to_board div button')); + + testUtils.unpatch(ListController); + + actionManager.destroy(); +}); + +QUnit.test('save two searches to dashboard', async function (assert) { + // the second search saved should not be influenced by the first + assert.expect(2); + + var actionManager = await createActionManager({ + data: this.data, + archs: { + 'partner,false,list': '<list><field name="foo"/></list>', + 'partner,false,search': '<search></search>', + }, + mockRPC: function (route, args) { + if (route === '/board/add_to_dashboard') { + if (filter_count === 0) { + assert.deepEqual(args.domain, [["display_name", "ilike", "a"]], + "the correct domain should be sent"); + } + if (filter_count === 1) { + assert.deepEqual(args.domain, [["display_name", "ilike", "b"]], + "the correct domain should be sent"); + } + + filter_count += 1; + return Promise.resolve(true); + } + return this._super.apply(this, arguments); + }, + }); + + await actionManager.doAction({ + id: 1, + res_model: 'partner', + type: 'ir.actions.act_window', + views: [[false, 'list']], + }); + + var filter_count = 0; + // Add a first filter + await cpHelpers.toggleFilterMenu(actionManager); + await cpHelpers.toggleAddCustomFilter(actionManager); + await testUtils.fields.editInput(actionManager.el.querySelector('.o_generator_menu_value .o_input'), 'a'); + await cpHelpers.applyFilter(actionManager); + + // Add it to dashboard + await cpHelpers.toggleFavoriteMenu(actionManager); + await testUtils.dom.click($('.o_add_to_board > button')); + await testUtils.dom.click($('.o_add_to_board div button')); + + // Remove it + await testUtils.dom.click(actionManager.el.querySelector('.o_facet_remove')); + + // Add the second filter + await cpHelpers.toggleFilterMenu(actionManager); + await cpHelpers.toggleAddCustomFilter(actionManager); + await testUtils.fields.editInput(actionManager.el.querySelector('.o_generator_menu_value .o_input'), "b"); + await cpHelpers.applyFilter(actionManager); + // Add it to dashboard + await cpHelpers.toggleFavoriteMenu(actionManager); + await testUtils.dom.click(actionManager.el.querySelector('.o_add_to_board > button')); + await testUtils.dom.click(actionManager.el.querySelector('.o_add_to_board div button')); + + actionManager.destroy(); +}); + +QUnit.test('save a action domain to dashboard', async function (assert) { + // View domains are to be added to the dashboard domain + assert.expect(1); + + var view_domain = ["display_name", "ilike", "a"]; + var filter_domain = ["display_name", "ilike", "b"]; + + // The filter domain already contains the view domain, but is always added by dashboard.., + var expected_domain = ['&', view_domain, '&', view_domain, filter_domain]; + + var actionManager = await createActionManager({ + data: this.data, + archs: { + 'partner,false,list': '<list><field name="foo"/></list>', + 'partner,false,search': '<search></search>', + }, + mockRPC: function (route, args) { + if (route === '/board/add_to_dashboard') { + assert.deepEqual(args.domain, expected_domain, + "the correct domain should be sent"); + return Promise.resolve(true); + } + return this._super.apply(this, arguments); + }, + }); + + await actionManager.doAction({ + id: 1, + res_model: 'partner', + type: 'ir.actions.act_window', + views: [[false, 'list']], + domain: [view_domain], + }); + + // Add a filter + await cpHelpers.toggleFilterMenu(actionManager); + await cpHelpers.toggleAddCustomFilter(actionManager); + await testUtils.fields.editInput( + actionManager.el.querySelector('.o_generator_menu_value .o_input'), + "b" + ); + await cpHelpers.applyFilter(actionManager); + // Add it to dashboard + await cpHelpers.toggleFavoriteMenu(actionManager); + await testUtils.dom.click(actionManager.el.querySelector('.o_add_to_board > button')); + // add + await testUtils.dom.click(actionManager.el.querySelector('.o_add_to_board div button')); + + actionManager.destroy(); +}); + +QUnit.test("Views should be loaded in the user's language", async function (assert) { + assert.expect(2); + var form = await createView({ + View: BoardView, + model: 'board', + data: this.data, + session: {user_context: {lang: 'fr_FR'}}, + arch: '<form string="My Dashboard">' + + '<board style="2-1">' + + '<column>' + + '<action context="{\'lang\': \'en_US\'}" view_mode="list" string="ABC" name="51" domain="[]"></action>' + + '</column>' + + '</board>' + + '</form>', + mockRPC: function (route, args) { + if (args.method === 'load_views') { + assert.deepEqual(pyUtils.eval('context', args.kwargs.context), {lang: 'fr_FR'}, + 'The views should be loaded with the correct context'); + } + if (route === "/web/dataset/search_read") { + assert.equal(args.context.lang, 'fr_FR', + 'The data should be loaded with the correct context'); + } + if (route === '/web/action/load') { + return Promise.resolve({ + res_model: 'partner', + views: [[4, 'list']], + }); + } + return this._super.apply(this, arguments); + }, + archs: { + 'partner,4,list': + '<list string="Partner"><field name="foo"/></list>', + }, + }); + + form.destroy(); +}); + +QUnit.test("Dashboard should use correct groupby", async function (assert) { + assert.expect(1); + var form = await createView({ + View: BoardView, + model: 'board', + data: this.data, + arch: '<form string="My Dashboard">' + + '<board style="2-1">' + + '<column>' + + '<action context="{\'group_by\': [\'bar\']}" string="ABC" name="51"></action>' + + '</column>' + + '</board>' + + '</form>', + mockRPC: function (route, args) { + if (args.method === 'web_read_group') { + assert.deepEqual(args.kwargs.groupby, ['bar'], + 'user defined groupby should have precedence on action groupby'); + } + if (route === '/web/action/load') { + return Promise.resolve({ + res_model: 'partner', + context: { + group_by: 'some_field', + }, + views: [[4, 'list']], + }); + } + return this._super.apply(this, arguments); + }, + archs: { + 'partner,4,list': + '<list string="Partner"><field name="foo"/></list>', + }, + }); + + form.destroy(); +}); + +QUnit.test("Dashboard should use correct groupby when defined as a string of one field", async function (assert) { + assert.expect(1); + var form = await createView({ + View: BoardView, + model: 'board', + data: this.data, + arch: '<form string="My Dashboard">' + + '<board style="2-1">' + + '<column>' + + '<action context="{\'group_by\': \'bar\'}" string="ABC" name="51"></action>' + + '</column>' + + '</board>' + + '</form>', + mockRPC: function (route, args) { + if (args.method === 'web_read_group') { + assert.deepEqual(args.kwargs.groupby, ['bar'], + 'user defined groupby should have precedence on action groupby'); + } + if (route === '/web/action/load') { + return Promise.resolve({ + res_model: 'partner', + context: { + group_by: 'some_field', + }, + views: [[4, 'list']], + }); + } + return this._super.apply(this, arguments); + }, + archs: { + 'partner,4,list': + '<list string="Partner"><field name="foo"/></list>', + }, + }); + + form.destroy(); +}); + +QUnit.test('click on a cell of pivot view inside dashboard', async function (assert) { + assert.expect(3); + + var form = await createView({ + View: BoardView, + model: 'board', + data: this.data, + arch: '<form>' + + '<board style="2-1">' + + '<column>' + + '<action view_mode="pivot" string="ABC" name="51"></action>' + + '</column>' + + '</board>' + + '</form>', + mockRPC: function (route) { + if (route === '/web/action/load') { + return Promise.resolve({ + res_model: 'partner', + views: [[4, 'pivot']], + }); + } + return this._super.apply(this, arguments); + }, + archs: { + 'partner,4,pivot': '<pivot><field name="int_field" type="measure"/></pivot>', + }, + intercepts: { + do_action: function () { + assert.step('do action'); + }, + }, + }); + + assert.verifySteps([]); + + await testUtils.dom.click(form.$('.o_pivot .o_pivot_cell_value')); + + assert.verifySteps(['do action']); + + form.destroy(); +}); + +QUnit.test('correctly save the time ranges of a reporting view in comparison mode', async function (assert) { + assert.expect(1); + + const unpatchDate = patchDate(2020, 6, 1, 11, 0, 0); + + this.data.partner.fields.date = { string: 'Date', type: 'date', sortable: true }; + + const actionManager = await createActionManager({ + data: this.data, + archs: { + 'partner,false,pivot': '<pivot><field name="foo"/></pivot>', + 'partner,false,search': '<search><filter name="Date" date="date"/></search>', + }, + mockRPC: function (route, args) { + if (route === '/board/add_to_dashboard') { + assert.deepEqual(args.context_to_save.comparison, { + comparisonId: "previous_period", + fieldName: "date", + fieldDescription: "Date", + rangeDescription: "July 2020", + range: ["&",["date", ">=", "2020-07-01"], ["date", "<=", "2020-07-31"]], + comparisonRange: ["&", ["date", ">=", "2020-06-01"], ["date", "<=", "2020-06-30"]], + comparisonRangeDescription: "June 2020", + }); + return Promise.resolve(true); + } + return this._super.apply(this, arguments); + }, + }); + + await actionManager.doAction({ + id: 1, + res_model: 'partner', + type: 'ir.actions.act_window', + views: [[false, 'pivot']], + }); + + // filter on July 2020 + await cpHelpers.toggleFilterMenu(actionManager); + await cpHelpers.toggleMenuItem(actionManager, 'Date'); + await cpHelpers.toggleMenuItemOption(actionManager, 'Date', 'July'); + + // compare July 2020 to June 2020 + await cpHelpers.toggleComparisonMenu(actionManager); + await cpHelpers.toggleMenuItem(actionManager, 0); + + // add the view to the dashboard + await cpHelpers.toggleFavoriteMenu(actionManager); + + await testUtils.dom.click($('.o_add_to_board > button')); + await testUtils.fields.editInput($('.o_add_to_board input'), 'a name'); + await testUtils.dom.click($('.o_add_to_board div button')); + + unpatchDate(); + actionManager.destroy(); +}); + +QUnit.test('correctly display the time range descriptions of a reporting view in comparison mode', async function (assert) { + assert.expect(1); + + this.data.partner.fields.date = { string: 'Date', type: 'date', sortable: true }; + this.data.partner.records[0].date = '2020-07-15'; + + const form = await createView({ + View: BoardView, + model: 'board', + data: this.data, + arch: `<form string="My Dashboard"> + <board style="2-1"> + <column> + <action string="ABC" name="51"></action> + </column> + </board> + </form>`, + archs: { + 'partner,1,pivot': + '<pivot string="Partner"></pivot>', + }, + mockRPC: function (route, args) { + if (route === '/board/static/src/img/layout_1-1-1.png') { + return Promise.resolve(); + } + if (route === '/web/action/load') { + return Promise.resolve({ + context: JSON.stringify({ comparison: { + comparisonId: "previous_period", + fieldName: "date", + fieldDescription: "Date", + rangeDescription: "July 2020", + range: ["&",["date", ">=", "2020-07-01"], ["date", "<=", "2020-07-31"]], + comparisonRange: ["&", ["date", ">=", "2020-06-01"], ["date", "<=", "2020-06-30"]], + comparisonRangeDescription: "June 2020", + }}), + domain: '[]', + res_model: 'partner', + views: [[1, 'pivot']], + }); + } + return this._super.apply(this, arguments); + }, + }); + + assert.deepEqual( + [...form.el.querySelectorAll('div.o_pivot th.o_pivot_origin_row')].map(el => el.innerText), + ['June 2020', 'July 2020', 'Variation'] + ); + + form.destroy(); +}); +}); |
