Files
web/web_responsive/static/src/js/web_responsive.js
Alexandre Díaz 1325b260c8 [FIX] web_responsive: modify action handling
Previous this commit, AppMenu doesn't hide in some situations.
Steps to reproduce (sometimes works, sometimes doesn't):
From Discuss App (refresh) open AppMenu and search some menu,
select the second in the list and press enter.
The action is launched correctly, but the AppMenu doesn't hide.

Now uses 'defer' to ensure that all has done to do the task
2019-11-20 18:03:54 +01:00

529 lines
17 KiB
JavaScript

/* Copyright 2018 Tecnativa - Jairo Llopis
* License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). */
odoo.define('web_responsive', function (require) {
'use strict';
var ActionManager = require('web.ActionManager');
var AbstractWebClient = require("web.AbstractWebClient");
var AppsMenu = require("web.AppsMenu");
var BasicController = require('web.BasicController');
var config = require("web.config");
var core = require("web.core");
var FormRenderer = require('web.FormRenderer');
var Menu = require("web.Menu");
var RelationalFields = require('web.relational_fields');
var Chatter = require('mail.Chatter');
/*
* Helper function to know if are waiting
*
*/
function isWaiting () {
return $('.oe_wait').length !== 0;
}
/**
* 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 (var 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 = $.Deferred();
},
/**
* @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();
},
/**
* 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) {
var 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.$search_input.focus();
}
},
/**
* 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.reject();
this._search_def = $.Deferred();
setTimeout(this._search_def.resolve.bind(this._search_def), 50);
this._search_def.done(this._searchMenus.bind(this));
},
/**
* Search among available menu items, and render that search.
*/
_searchMenus: function () {
var 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();
var $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
var 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)
var all = this.$search_results.find(".o-menu-search-result"),
pre_focused = all.filter(".active") || $(all[0]),
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
var 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 !isWaiting() && !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)) {
$('.o_menu_apps .dropdown:has(.dropdown-menu.show) > a').dropdown('toggle');
$('.o_menu_sections li.show .dropdown-toggle').dropdown('toggle');
}
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]": "_hideMobileSubmenus",
// Opening any dropdown in the navbar should hide the hamburger
"show.bs.dropdown .o_menu_systray, .o_menu_apps":
"_hideMobileSubmenus",
// Prevent close section menu
"hide.bs.dropdown .o_menu_sections": "_hideMenuSection",
}, 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 (
this.$menu_toggle.is(":visible") &&
this.$section_placeholder.is(":visible") &&
!isWaiting()
) {
this.$section_placeholder.collapse("hide");
}
},
/**
* Hide Menu Section
*
* @returns {Boolean}
*/
_hideMenuSection: function () {
return !isWaiting();
},
/**
* No menu brand in mobiles
*
* @override
*/
_updateMenuBrand: function () {
if (!config.device.isMobile) {
return this._super.apply(this, arguments);
}
},
/**
* Don't display the menu if are waiting for an action to end
*
* @override
*/
_onMouseOverMenu: function () {
if (!isWaiting()) {
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, function (value) {
value.fold = true;
});
}
},
});
// Responsive view "action" buttons
FormRenderer.include({
/**
* In mobiles, put all statusbar buttons in a dropdown.
*
* @override
*/
_renderHeaderButtons: function () {
var $buttons = this._super.apply(this, arguments);
if (
!config.device.isMobile ||
!$buttons.is(":has(>:not(.o_invisible_modifier))")
) {
return $buttons;
}
// $buttons must be appended by JS because all events are bound
$buttons.addClass("dropdown-menu");
var $dropdown = $(core.qweb.render(
'web_responsive.MenuStatusbarButtons'
));
$buttons.addClass("dropdown-menu").appendTo($dropdown);
return $dropdown;
},
});
// Chatter Hide Composer
Chatter.include({
_openComposer: function (options) {
if (this._composer &&
options.isLog === this._composer.options.isLog &&
this._composer.$el.is(':visible')) {
this._closeComposer(false);
} else {
this._super.apply(this, arguments);
}
},
});
// Hide AppDrawer or Menu when the action has been completed
ActionManager.include({
/**
* Because the menu aren't closed when click, this method
* searchs for the menu with the action executed to close it.
* To avoid delays in pages with a lot of DOM nodes we make
* 'sub-groups' with 'querySelector' to improve the performance.
*
* @param {action} action
* The executed action
*/
_hideMenusByAction: function (action) {
_.defer(function () {
var uniq_sel = '[data-action-id='+action.id+']';
// Need close AppDrawer?
var menu_apps_dropdown = document.querySelector(
'.o_menu_apps .dropdown');
$(menu_apps_dropdown).has('.dropdown-menu.show')
.has(uniq_sel).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');
$(menu_sections).has(uniq_sel).find('.dropdown-toggle')
.dropdown('toggle');
// Need close Mobile?
var menu_sections_mobile = document.querySelector(
'.o_menu_sections.show');
$(menu_sections_mobile).has(uniq_sel).collapse('hide');
});
},
_handleAction: function (action) {
return this._super.apply(this, arguments).always(
$.proxy(this, '_hideMenusByAction', action));
},
});
/**
* 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) {
var 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);
});