diff options
Diffstat (limited to 'web_responsive/static/src/js')
| -rw-r--r-- | web_responsive/static/src/js/kanban_renderer_mobile.js | 525 | ||||
| -rw-r--r-- | web_responsive/static/src/js/web_responsive.js | 629 |
2 files changed, 1154 insertions, 0 deletions
diff --git a/web_responsive/static/src/js/kanban_renderer_mobile.js b/web_responsive/static/src/js/kanban_renderer_mobile.js new file mode 100644 index 0000000..c5f4364 --- /dev/null +++ b/web_responsive/static/src/js/kanban_renderer_mobile.js @@ -0,0 +1,525 @@ +odoo.define("web_responsive.KanbanRendererMobile", function (require) { + "use strict"; + + /** + * The purpose of this file is to improve the UX of grouped kanban views in + * mobile. It includes the KanbanRenderer (in mobile only) to only display one + * column full width, and enables the swipe to browse to the other columns. + * Moreover, records in columns are lazy-loaded. + */ + + var config = require("web.config"); + var core = require("web.core"); + var KanbanRenderer = require("web.KanbanRenderer"); + var KanbanView = require("web.KanbanView"); + var KanbanQuickCreate = require("web.kanban_column_quick_create"); + + var _t = core._t; + var qweb = core.qweb; + + if (!config.device.isMobile) { + return; + } + + KanbanQuickCreate.include({ + init() { + this._super.apply(this, arguments); + this.isMobile = true; + }, + /** + * KanbanRenderer will decide can we close quick create or not + * @private + * @override + */ + _cancel: function () { + this.trigger_up("close_quick_create"); + }, + /** + * Clear input when showed + * @override + */ + toggleFold: function () { + this._super.apply(this, arguments); + if (!this.folded) { + this.$input.val(""); + } + }, + }); + + KanbanView.include({ + init() { + this._super.apply(this, arguments); + this.jsLibs.push("/web/static/lib/jquery.touchSwipe/jquery.touchSwipe.js"); + }, + }); + + KanbanRenderer.include({ + custom_events: _.extend({}, KanbanRenderer.prototype.custom_events || {}, { + quick_create_column_created: "_onColumnAdded", + }), + events: _.extend({}, KanbanRenderer.prototype.events, { + "click .o_kanban_mobile_tab": "_onMobileTabClicked", + "click .o_kanban_mobile_add_column": "_onMobileQuickCreateClicked", + }), + ANIMATE: true, // Allows to disable animations for the tests + /** + * @override + */ + init: function () { + this._super.apply(this, arguments); + this.activeColumnIndex = 0; // Index of the currently displayed column + this._scrollPosition = null; + }, + /** + * As this renderer defines its own scrolling area (the column in grouped + * mode), we override this hook to restore the scroll position like it was + * when the renderer has been last detached. + * + * @override + */ + on_attach_callback: function () { + if ( + this._scrollPosition && + this.state.groupedBy.length && + this.widgets.length + ) { + var $column = this.widgets[this.activeColumnIndex].$el; + $column.scrollLeft(this._scrollPosition.left); + $column.scrollTop(this._scrollPosition.top); + } + this._computeTabPosition(); + this._super.apply(this, arguments); + }, + /** + * As this renderer defines its own scrolling area (the column in grouped + * mode), we override this hook to store the scroll position, so that we can + * restore it if the renderer is re-attached to the DOM later. + * + * @override + */ + on_detach_callback: function () { + if (this.state.groupedBy.length && this.widgets.length) { + var $column = this.widgets[this.activeColumnIndex].$el; + this._scrollPosition = { + left: $column.scrollLeft(), + top: $column.scrollTop(), + }; + } else { + this._scrollPosition = null; + } + this._super.apply(this, arguments); + }, + + // -------------------------------------------------------------------------- + // Public + // -------------------------------------------------------------------------- + + /** + * Displays the quick create record in the active column + * override to open quick create record in current active column + * + * @override + * @returns {Promise} + */ + addQuickCreate: function () { + if (this._canCreateColumn() && !this.quickCreate.folded) { + this._onMobileQuickCreateClicked(); + } + return this.widgets[this.activeColumnIndex].addQuickCreate(); + }, + + /** + * Overrides to restore the left property and the scrollTop on the updated + * column, and to enable the swipe handlers + * + * @override + */ + updateColumn: function (localID) { + var index = _.findIndex(this.widgets, {db_id: localID}); + var $column = this.widgets[index].$el; + var scrollTop = $column.scrollTop(); + return ( + this._super + .apply(this, arguments) + .then(() => this._layoutUpdate(false)) + // Required when clicking on 'Load More' + .then(() => $column.scrollTop(scrollTop)) + .then(() => this._enableSwipe()) + ); + }, + + // -------------------------------------------------------------------------- + // Private + // -------------------------------------------------------------------------- + + /** + * Check if we use the quick create on mobile + * @returns {Boolean} + * @private + */ + _canCreateColumn: function () { + return this.quickCreateEnabled && this.quickCreate && this.widgets.length; + }, + + /** + * Update the columns positions + * + * @private + * @param {Boolean} [animate=false] set to true to animate + */ + _computeColumnPosition: function (animate) { + if (this.widgets.length) { + // Check rtl to compute correct css value + const rtl = _t.database.parameters.direction === "rtl"; + + // Display all o_kanban_group + this.$(".o_kanban_group").show(); + + const $columnAfter = this._toNode( + this.widgets.filter( + (widget, index) => index > this.activeColumnIndex + ) + ); + const promiseAfter = this._updateColumnCss( + $columnAfter, + rtl ? {right: "100%"} : {left: "100%"}, + animate + ); + + const $columnBefore = this._toNode( + this.widgets.filter( + (widget, index) => index < this.activeColumnIndex + ) + ); + const promiseBefore = this._updateColumnCss( + $columnBefore, + rtl ? {right: "-100%"} : {left: "-100%"}, + animate + ); + + const $columnCurrent = this._toNode( + this.widgets.filter( + (widget, index) => index === this.activeColumnIndex + ) + ); + const promiseCurrent = this._updateColumnCss( + $columnCurrent, + rtl ? {right: "0%"} : {left: "0%"}, + animate + ); + + promiseAfter + .then(promiseBefore) + .then(promiseCurrent) + .then(() => { + $columnAfter.hide(); + $columnBefore.hide(); + }); + } + }, + + /** + * Define the o_current class to the current selected kanban (column & tab) + * + * @private + */ + _computeCurrentColumn: function () { + if (this.widgets.length) { + var column = this.widgets[this.activeColumnIndex]; + if (!column) { + return; + } + var columnID = column.id || column.db_id; + this.$( + ".o_kanban_mobile_tab.o_current, .o_kanban_group.o_current" + ).removeClass("o_current"); + this.$( + '.o_kanban_group[data-id="' + + columnID + + '"], ' + + '.o_kanban_mobile_tab[data-id="' + + columnID + + '"]' + ).addClass("o_current"); + } + }, + + /** + * Update the tabs positions + * + * @private + */ + _computeTabPosition: function () { + this._computeTabJustification(); + this._computeTabScrollPosition(); + }, + + /** + * Update the tabs positions + * + * @private + */ + _computeTabScrollPosition: function () { + if (this.widgets.length) { + var lastItemIndex = this.widgets.length - 1; + var moveToIndex = this.activeColumnIndex; + var scrollToLeft = 0; + for (var i = 0; i < moveToIndex; i++) { + var columnWidth = this._getTabWidth(this.widgets[i]); + // Apply + if (moveToIndex !== lastItemIndex && i === moveToIndex - 1) { + var partialWidth = 0.75; + scrollToLeft += columnWidth * partialWidth; + } else { + scrollToLeft += columnWidth; + } + } + // Apply the scroll x on the tabs + // XXX in case of RTL, should we use scrollRight? + this.$(".o_kanban_mobile_tabs").scrollLeft(scrollToLeft); + } + }, + + /** + * Compute the justify content of the kanban tab headers + * + * @private + */ + _computeTabJustification: function () { + if (this.widgets.length) { + var self = this; + // Use to compute the sum of the width of all tab + var widthChilds = this.widgets.reduce(function (total, column) { + return total + self._getTabWidth(column); + }, 0); + // Apply a space around between child if the parent length is higher then the sum of the child width + var $tabs = this.$(".o_kanban_mobile_tabs"); + $tabs.toggleClass( + "justify-content-between", + $tabs.outerWidth() >= widthChilds + ); + } + }, + + /** + * Enables swipe event on the current column + * + * @private + */ + _enableSwipe: function () { + var self = this; + var step = _t.database.parameters.direction === "rtl" ? -1 : 1; + this.$el.swipe({ + excludedElements: ".o_kanban_mobile_tabs", + swipeLeft: function () { + var moveToIndex = self.activeColumnIndex + step; + if (moveToIndex < self.widgets.length) { + self._moveToGroup(moveToIndex, self.ANIMATE); + } + }, + swipeRight: function () { + var moveToIndex = self.activeColumnIndex - step; + if (moveToIndex > -1) { + self._moveToGroup(moveToIndex, self.ANIMATE); + } + }, + }); + }, + + /** + * Retrieve the outerWidth of a given widget column + * + * @param {KanbanColumn} column + * @returns {integer} outerWidth of the found column + * @private + */ + _getTabWidth: function (column) { + var columnID = column.id || column.db_id; + return this.$( + '.o_kanban_mobile_tab[data-id="' + columnID + '"]' + ).outerWidth(); + }, + + /** + * Update the kanban layout + * + * @private + * @param {Boolean} [animate=false] set to true to animate + */ + _layoutUpdate: function (animate) { + this._computeCurrentColumn(); + this._computeTabPosition(); + this._computeColumnPosition(animate); + this._enableSwipe(); + }, + + /** + * Moves to the given kanban column + * + * @private + * @param {integer} moveToIndex index of the column to move to + * @param {Boolean} [animate=false] set to true to animate + * @returns {Promise} resolved when the new current group has been loaded + * and displayed + */ + _moveToGroup: function (moveToIndex, animate) { + if (this.widgets.length === 0) { + return Promise.resolve(); + } + var self = this; + if (moveToIndex >= 0 && moveToIndex < this.widgets.length) { + this.activeColumnIndex = moveToIndex; + } + var column = this.widgets[this.activeColumnIndex]; + this._enableSwipe(); + if (!column.data.isOpen) { + this.trigger_up("column_toggle_fold", { + db_id: column.db_id, + onSuccess: () => self._layoutUpdate(animate), + }); + } else { + this._layoutUpdate(animate); + } + return Promise.resolve(); + }, + /** + * @override + * @private + */ + _renderExampleBackground: function () { + // Override to avoid display of example background + }, + /** + * @override + * @private + */ + _renderGrouped: function (fragment) { + var self = this; + var newFragment = document.createDocumentFragment(); + this._super.apply(this, [newFragment]); + this.defs.push( + Promise.all(this.defs).then(function () { + var data = []; + _.each(self.state.data, function (group) { + if (!group.value) { + group = _.extend({}, group, {value: _t("Undefined")}); + data.unshift(group); + } else { + data.push(group); + } + }); + + var kanbanColumnContainer = document.createElement("div"); + kanbanColumnContainer.classList.add("o_kanban_columns_content"); + kanbanColumnContainer.appendChild(newFragment); + fragment.appendChild(kanbanColumnContainer); + $( + qweb.render("KanbanView.MobileTabs", { + data: data, + quickCreateEnabled: self._canCreateColumn(), + }) + ).prependTo(fragment); + }) + ); + }, + + /** + * @override + * @private + */ + _renderView: function () { + var self = this; + return this._super.apply(this, arguments).then(function () { + if (self.state.groupedBy.length) { + // Force first column for kanban view, because the groupedBy can be changed + return self._moveToGroup(0); + } + if (self._canCreateColumn()) { + self._onMobileQuickCreateClicked(); + } + return Promise.resolve(); + }); + }, + + /** + * Retrieve the Jquery node (.o_kanban_group) for a list of a given widgets + * + * @private + * @param widgets + * @returns {jQuery} the matching .o_kanban_group widgets + */ + _toNode: function (widgets) { + const selectorCss = widgets + .map( + (widget) => + '.o_kanban_group[data-id="' + (widget.id || widget.db_id) + '"]' + ) + .join(", "); + return this.$(selectorCss); + }, + + /** + * Update the given column to the updated positions + * + * @private + * @param $column The jquery column + * @param cssProperties Use to update column + * @param {Boolean} [animate=false] set to true to animate + * @returns {Promise} + */ + _updateColumnCss: function ($column, cssProperties, animate) { + if (animate) { + return new Promise((resolve) => + $column.animate(cssProperties, "fast", resolve) + ); + } + $column.css(cssProperties); + return Promise.resolve(); + }, + + // -------------------------------------------------------------------------- + // Handlers + // -------------------------------------------------------------------------- + + /** + * @private + */ + _onColumnAdded: function () { + this._computeTabPosition(); + if (this._canCreateColumn() && !this.quickCreate.folded) { + this.quickCreate.toggleFold(); + } + }, + + /** + * @private + */ + _onMobileQuickCreateClicked: function (event) { + if (event) { + event.stopPropagation(); + } + this.quickCreate.toggleFold(); + this.$(".o_kanban_group").toggle(this.quickCreate.folded); + }, + /** + * @private + * @param {MouseEvent} event + */ + _onMobileTabClicked: function (event) { + if (this._canCreateColumn() && !this.quickCreate.folded) { + this.quickCreate.toggleFold(); + } + this._moveToGroup($(event.currentTarget).index(), true); + }, + /** + * @private + * @override + */ + _onCloseQuickCreate: function () { + if (this.widgets.length && !this.quickCreate.folded) { + this.$(".o_kanban_group").toggle(true); + this.quickCreate.toggleFold(); + } + }, + }); +}); diff --git a/web_responsive/static/src/js/web_responsive.js b/web_responsive/static/src/js/web_responsive.js new file mode 100644 index 0000000..5151a30 --- /dev/null +++ b/web_responsive/static/src/js/web_responsive.js @@ -0,0 +1,629 @@ +/* Copyright 2018 Tecnativa - Jairo Llopis + * Copyright 2018 Tecnativa - Sergey Shebanin + * License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). */ + +odoo.define("web_responsive", function (require) { + "use strict"; + + const ActionManager = require("web.ActionManager"); + const AbstractWebClient = require("web.AbstractWebClient"); + const AppsMenu = require("web.AppsMenu"); + const BasicController = require("web.BasicController"); + const config = require("web.config"); + const core = require("web.core"); + const FormRenderer = require("web.FormRenderer"); + const Menu = require("web.Menu"); + const RelationalFields = require("web.relational_fields"); + const ListRenderer = require("web.ListRenderer"); + const CalendarRenderer = require("web.CalendarRenderer"); + const patchMixin = require("web.patchMixin"); + const AttachmentViewer = require("mail/static/src/components/attachment_viewer/attachment_viewer.js"); + const PatchableAttachmentViewer = patchMixin(AttachmentViewer); + const ControlPanel = require("web.ControlPanel"); + const SearchPanel = require("web/static/src/js/views/search_panel.js"); + /* global owl */ + const {QWeb, Context} = owl; + const {useState, useContext} = owl.hooks; + + /* Hide AppDrawer in desktop and mobile modes. + * To avoid delays in pages with a lot of DOM nodes we make + * sub-groups' with 'querySelector' to improve the performance. + */ + function closeAppDrawer() { + _.defer(function () { + // Need close AppDrawer? + var menu_apps_dropdown = document.querySelector(".o_menu_apps .dropdown"); + $(menu_apps_dropdown) + .has(".dropdown-menu.show") + .find("> a") + .dropdown("toggle"); + // Need close Sections Menu? + // TODO: Change to 'hide' in modern Bootstrap >4.1 + var menu_sections = document.querySelector( + ".o_menu_sections li.show .dropdown-toggle" + ); + $(menu_sections).dropdown("toggle"); + // Need close Mobile? + var menu_sections_mobile = document.querySelector(".o_menu_sections.show"); + $(menu_sections_mobile).collapse("hide"); + }); + } + + /** + * Reduce menu data to a searchable format understandable by fuzzy.js + * + * `AppsMenu.init()` gets `menuData` in a format similar to this (only + * relevant data is shown): + * + * ```js + * { + * [...], + * children: [ + * // This is a menu entry: + * { + * action: "ir.actions.client,94", // Or `false` + * children: [... similar to above "children" key], + * name: "Actions", + * parent_id: [146, "Settings/Technical/Actions"], // Or `false` + * }, + * ... + * ] + * } + * ``` + * + * This format is very hard to process to search matches, and it would + * slow down the search algorithm, so we reduce it with this method to be + * able to later implement a simpler search. + * + * @param {Object} memo + * Reference to current result object, passed on recursive calls. + * + * @param {Object} menu + * A menu entry, as described above. + * + * @returns {Object} + * Reduced object, without entries that have no action, and with a + * format like this: + * + * ```js + * { + * "Discuss": {Menu entry Object}, + * "Settings": {Menu entry Object}, + * "Settings/Technical/Actions/Actions": {Menu entry Object}, + * ... + * } + * ``` + */ + function findNames(memo, menu) { + if (menu.action) { + var key = menu.parent_id ? menu.parent_id[1] + "/" : ""; + memo[key + menu.name] = menu; + } + if (menu.children.length) { + _.reduce(menu.children, findNames, memo); + } + return memo; + } + + AppsMenu.include({ + events: _.extend( + { + "keydown .search-input input": "_searchResultsNavigate", + "input .search-input input": "_searchMenusSchedule", + "click .o-menu-search-result": "_searchResultChosen", + "shown.bs.dropdown": "_searchFocus", + "hidden.bs.dropdown": "_searchReset", + "hide.bs.dropdown": "_hideAppsMenu", + }, + AppsMenu.prototype.events + ), + + /** + * Rescue some menu data stripped out in original method. + * + * @override + */ + init: function (parent, menuData) { + this._super.apply(this, arguments); + // Keep base64 icon for main menus + for (const n in this._apps) { + this._apps[n].web_icon_data = menuData.children[n].web_icon_data; + } + // Store menu data in a format searchable by fuzzy.js + this._searchableMenus = _.reduce(menuData.children, findNames, {}); + // Search only after timeout, for fast typers + this._search_def = false; + }, + + /** + * @override + */ + start: function () { + this.$search_container = this.$(".search-container"); + this.$search_input = this.$(".search-input input"); + this.$search_results = this.$(".search-results"); + return this._super.apply(this, arguments); + }, + + /** + * Prevent the menu from being opened twice + * + * @override + */ + _onAppsMenuItemClicked: function (ev) { + this._super.apply(this, arguments); + ev.preventDefault(); + ev.stopPropagation(); + }, + + /** + * Get all info for a given menu. + * + * @param {String} key + * Full path to requested menu. + * + * @returns {Object} + * Menu definition, plus extra needed keys. + */ + _menuInfo: function (key) { + const original = this._searchableMenus[key]; + return _.extend( + { + action_id: parseInt(original.action.split(",")[1], 10), + }, + original + ); + }, + + /** + * Autofocus on search field on big screens. + */ + _searchFocus: function () { + if (!config.device.isMobile) { + // This timeout is necessary since the menu has a 100ms fading animation + setTimeout(() => this.$search_input.focus(), 100); + } + }, + + /** + * Reset search input and results + */ + _searchReset: function () { + this.$search_container.removeClass("has-results"); + this.$search_results.empty(); + this.$search_input.val(""); + }, + + /** + * Schedule a search on current menu items. + */ + _searchMenusSchedule: function () { + this._search_def = new Promise((resolve) => { + setTimeout(resolve, 50); + }); + this._search_def.then(this._searchMenus.bind(this)); + }, + + /** + * Search among available menu items, and render that search. + */ + _searchMenus: function () { + const query = this.$search_input.val(); + if (query === "") { + this.$search_container.removeClass("has-results"); + this.$search_results.empty(); + return; + } + var results = fuzzy.filter(query, _.keys(this._searchableMenus), { + pre: "<b>", + post: "</b>", + }); + this.$search_container.toggleClass("has-results", Boolean(results.length)); + this.$search_results.html( + core.qweb.render("web_responsive.MenuSearchResults", { + results: results, + widget: this, + }) + ); + }, + + /** + * Use chooses a search result, so we navigate to that menu + * + * @param {jQuery.Event} event + */ + _searchResultChosen: function (event) { + event.preventDefault(); + event.stopPropagation(); + const $result = $(event.currentTarget), + text = $result.text().trim(), + data = $result.data(), + suffix = ~text.indexOf("/") ? "/" : ""; + // Load the menu view + this.trigger_up("menu_clicked", { + action_id: data.actionId, + id: data.menuId, + previous_menu_id: data.parentId, + }); + // Find app that owns the chosen menu + const app = _.find(this._apps, function (_app) { + return text.indexOf(_app.name + suffix) === 0; + }); + // Update navbar menus + core.bus.trigger("change_menu_section", app.menuID); + }, + + /** + * Navigate among search results + * + * @param {jQuery.Event} event + */ + _searchResultsNavigate: function (event) { + // Find current results and active element (1st by default) + const all = this.$search_results.find(".o-menu-search-result"), + pre_focused = all.filter(".active") || $(all[0]); + let offset = all.index(pre_focused), + key = event.key; + // Keyboard navigation only supports search results + if (!all.length) { + return; + } + // Transform tab presses in arrow presses + if (key === "Tab") { + event.preventDefault(); + key = event.shiftKey ? "ArrowUp" : "ArrowDown"; + } + switch (key) { + // Pressing enter is the same as clicking on the active element + case "Enter": + pre_focused.click(); + break; + // Navigate up or down + case "ArrowUp": + offset--; + break; + case "ArrowDown": + offset++; + break; + default: + // Other keys are useless in this event + return; + } + // Allow looping on results + if (offset < 0) { + offset = all.length + offset; + } else if (offset >= all.length) { + offset -= all.length; + } + // Switch active element + const new_focused = $(all[offset]); + pre_focused.removeClass("active"); + new_focused.addClass("active"); + this.$search_results.scrollTo(new_focused, { + offset: { + top: this.$search_results.height() * -0.5, + }, + }); + }, + + /* + * Control if AppDrawer can be closed + */ + _hideAppsMenu: function () { + return !this.$("input").is(":focus"); + }, + }); + + BasicController.include({ + /** + * Close the AppDrawer if the data set is dirty and a discard dialog + * is opened + * + * @override + */ + canBeDiscarded: function (recordID) { + if (this.model.isDirty(recordID || this.handle)) { + closeAppDrawer(); + } + return this._super.apply(this, arguments); + }, + }); + + Menu.include({ + events: _.extend( + { + // Clicking a hamburger menu item should close the hamburger + "click .o_menu_sections [role=menuitem]": "_onClickMenuItem", + // Opening any dropdown in the navbar should hide the hamburger + "show.bs.dropdown .o_menu_systray, .o_menu_apps": "_hideMobileSubmenus", + }, + Menu.prototype.events + ), + + start: function () { + this.$menu_toggle = this.$(".o-menu-toggle"); + return this._super.apply(this, arguments); + }, + + /** + * Hide menus for current app if you're in mobile + */ + _hideMobileSubmenus: function () { + if ( + config.device.isMobile && + this.$menu_toggle.is(":visible") && + this.$section_placeholder.is(":visible") + ) { + this.$section_placeholder.collapse("hide"); + } + }, + + /** + * Prevent hide the menu (should be closed when action is loaded) + * + * @param {ClickEvent} ev + */ + _onClickMenuItem: function (ev) { + ev.stopPropagation(); + }, + + /** + * No menu brand in mobiles + * + * @override + */ + _updateMenuBrand: function () { + if (!config.device.isMobile) { + return this._super.apply(this, arguments); + } + }, + }); + + RelationalFields.FieldStatus.include({ + /** + * Fold all on mobiles. + * + * @override + */ + _setState: function () { + this._super.apply(this, arguments); + if (config.device.isMobile) { + _.map(this.status_information, (value) => { + value.fold = true; + }); + } + }, + }); + + // Sticky Column Selector + ListRenderer.include({ + _renderView: function () { + const self = this; + return this._super.apply(this, arguments).then(() => { + const $col_selector = self.$el.find( + ".o_optional_columns_dropdown_toggle" + ); + if ($col_selector.length !== 0) { + const $th = self.$el.find("thead>tr:first>th:last"); + $col_selector.appendTo($th); + } + }); + }, + + _onToggleOptionalColumnDropdown: function (ev) { + // FIXME: For some strange reason the 'stopPropagation' call + // in the main method don't work. Invoking here the same method + // does the expected behavior... O_O! + // This prevents the action of sorting the column from being + // launched. + ev.stopPropagation(); + this._super.apply(this, arguments); + }, + }); + + // Responsive view "action" buttons + FormRenderer.include({ + /** + * In mobiles, put all statusbar buttons in a dropdown. + * + * @override + */ + _renderHeaderButtons: function () { + const $buttons = this._super.apply(this, arguments); + if ( + !config.device.isMobile || + $buttons.children("button:not(.o_invisible_modifier)").length <= 2 + ) { + return $buttons; + } + + // $buttons must be appended by JS because all events are bound + const $dropdown = $( + core.qweb.render("web_responsive.MenuStatusbarButtons") + ); + $buttons.addClass("dropdown-menu").appendTo($dropdown); + return $dropdown; + }, + }); + + CalendarRenderer.include({ + _getFullCalendarOptions: function () { + var options = this._super.apply(this, arguments); + if (config.device.isMobile) { + options.views.dayGridMonth.columnHeaderFormat = "ddd"; + } + return options; + }, + }); + + // Hide AppDrawer or Menu when the action has been completed + ActionManager.include({ + /** + * @override + */ + _appendController: function () { + this._super.apply(this, arguments); + closeAppDrawer(); + }, + }); + + /** + * Use ALT+SHIFT instead of ALT as hotkey triggerer. + * + * HACK https://github.com/odoo/odoo/issues/30068 - See it to know why. + * + * Cannot patch in `KeyboardNavigationMixin` directly because it's a mixin, + * not a `Class`, and altering a mixin's `prototype` doesn't alter it where + * it has already been used. + * + * Instead, we provide an additional mixin to be used wherever you need to + * enable this behavior. + */ + var KeyboardNavigationShiftAltMixin = { + /** + * Alter the key event to require pressing Shift. + * + * This will produce a mocked event object where it will seem that + * `Alt` is not pressed if `Shift` is not pressed. + * + * The reason for this is that original upstream code, found in + * `KeyboardNavigationMixin` is very hardcoded against the `Alt` key, + * so it is more maintainable to mock its input than to rewrite it + * completely. + * + * @param {keyEvent} keyEvent + * Original event object + * + * @returns {keyEvent} + * Altered event object + */ + _shiftPressed: function (keyEvent) { + const alt = keyEvent.altKey || keyEvent.key === "Alt", + newEvent = _.extend({}, keyEvent), + shift = keyEvent.shiftKey || keyEvent.key === "Shift"; + // Mock event to make it seem like Alt is not pressed + if (alt && !shift) { + newEvent.altKey = false; + if (newEvent.key === "Alt") { + newEvent.key = "Shift"; + } + } + return newEvent; + }, + + _onKeyDown: function (keyDownEvent) { + return this._super(this._shiftPressed(keyDownEvent)); + }, + + _onKeyUp: function (keyUpEvent) { + return this._super(this._shiftPressed(keyUpEvent)); + }, + }; + + // Include the SHIFT+ALT mixin wherever + // `KeyboardNavigationMixin` is used upstream + AbstractWebClient.include(KeyboardNavigationShiftAltMixin); + + // TODO: use default odoo device context when it will be realized + const deviceContext = new Context({ + isMobile: config.device.isMobile, + size_class: config.device.size_class, + SIZES: config.device.SIZES, + }); + window.addEventListener( + "resize", + owl.utils.debounce(() => { + const state = deviceContext.state; + if (state.isMobile !== config.device.isMobile) { + state.isMobile = !state.isMobile; + } + if (state.size_class !== config.device.size_class) { + state.size_class = config.device.size_class; + } + }, 15) + ); + // Patch attachment viewer to add min/max buttons capability + PatchableAttachmentViewer.patch("web_responsive.AttachmentViewer", (T) => { + class AttachmentViewerPatchResponsive extends T { + constructor() { + super(...arguments); + this.state = useState({ + maximized: false, + }); + } + // Disable auto-close to allow to use form in edit mode. + isCloseable() { + return false; + } + } + return AttachmentViewerPatchResponsive; + }); + QWeb.components.AttachmentViewer = PatchableAttachmentViewer; + + // Patch control panel to add states for mobile quick search + ControlPanel.patch("web_responsive.ControlPanelMobile", (T) => { + class ControlPanelPatchResponsive extends T { + constructor() { + super(...arguments); + this.state = useState({ + mobileSearchMode: "", + }); + this.device = useContext(deviceContext); + } + } + return ControlPanelPatchResponsive; + }); + // Patch search panel to add functionality for mobile view + SearchPanel.patch("web_responsive.SearchPanelMobile", (T) => { + class SearchPanelPatchResponsive extends T { + constructor() { + super(...arguments); + this.state.mobileSearch = false; + this.device = useContext(deviceContext); + } + getActiveSummary() { + const selection = []; + for (const filter of this.model.get("sections")) { + let filterValues = []; + if (filter.type === "category") { + if (filter.activeValueId) { + const parentIds = this._getAncestorValueIds( + filter, + filter.activeValueId + ); + filterValues = [...parentIds, filter.activeValueId].map( + (valueId) => filter.values.get(valueId).display_name + ); + } + } else { + let values = []; + if (filter.groups) { + values = [ + ...filter.groups.values().map((g) => g.values), + ].flat(); + } + if (filter.values) { + values = [...filter.values.values()]; + } + filterValues = values + .filter((v) => v.checked) + .map((v) => v.display_name); + } + if (filterValues.length) { + selection.push({ + values: filterValues, + icon: filter.icon, + color: filter.color, + type: filter.type, + }); + } + } + return selection; + } + } + return SearchPanelPatchResponsive; + }); + return { + deviceContext: deviceContext, + }; +}); |
