summaryrefslogtreecommitdiff
path: root/web_responsive/static/src/js
diff options
context:
space:
mode:
Diffstat (limited to 'web_responsive/static/src/js')
-rw-r--r--web_responsive/static/src/js/kanban_renderer_mobile.js525
-rw-r--r--web_responsive/static/src/js/web_responsive.js629
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,
+ };
+});