You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

497 lines
16 KiB

7 years ago
7 years ago
  1. /* Copyright 2018 Tecnativa - Jairo Llopis
  2. * License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). */
  3. odoo.define('web_responsive', function (require) {
  4. 'use strict';
  5. var ActionManager = require('web.ActionManager');
  6. var AbstractWebClient = require("web.AbstractWebClient");
  7. var AppsMenu = require("web.AppsMenu");
  8. var BasicController = require('web.BasicController');
  9. var config = require("web.config");
  10. var core = require("web.core");
  11. var FormRenderer = require('web.FormRenderer');
  12. var Menu = require("web.Menu");
  13. var RelationalFields = require('web.relational_fields');
  14. var Chatter = require('mail.Chatter');
  15. /**
  16. * Reduce menu data to a searchable format understandable by fuzzy.js
  17. *
  18. * `AppsMenu.init()` gets `menuData` in a format similar to this (only
  19. * relevant data is shown):
  20. *
  21. * ```js
  22. * {
  23. * [...],
  24. * children: [
  25. * // This is a menu entry:
  26. * {
  27. * action: "ir.actions.client,94", // Or `false`
  28. * children: [... similar to above "children" key],
  29. * name: "Actions",
  30. * parent_id: [146, "Settings/Technical/Actions"], // Or `false`
  31. * },
  32. * ...
  33. * ]
  34. * }
  35. * ```
  36. *
  37. * This format is very hard to process to search matches, and it would
  38. * slow down the search algorithm, so we reduce it with this method to be
  39. * able to later implement a simpler search.
  40. *
  41. * @param {Object} memo
  42. * Reference to current result object, passed on recursive calls.
  43. *
  44. * @param {Object} menu
  45. * A menu entry, as described above.
  46. *
  47. * @returns {Object}
  48. * Reduced object, without entries that have no action, and with a
  49. * format like this:
  50. *
  51. * ```js
  52. * {
  53. * "Discuss": {Menu entry Object},
  54. * "Settings": {Menu entry Object},
  55. * "Settings/Technical/Actions/Actions": {Menu entry Object},
  56. * ...
  57. * }
  58. * ```
  59. */
  60. function findNames (memo, menu) {
  61. if (menu.action) {
  62. var key = menu.parent_id ? menu.parent_id[1] + "/" : "";
  63. memo[key + menu.name] = menu;
  64. }
  65. if (menu.children.length) {
  66. _.reduce(menu.children, findNames, memo);
  67. }
  68. return memo;
  69. }
  70. AppsMenu.include({
  71. events: _.extend({
  72. "keydown .search-input input": "_searchResultsNavigate",
  73. "input .search-input input": "_searchMenusSchedule",
  74. "click .o-menu-search-result": "_searchResultChosen",
  75. "shown.bs.dropdown": "_searchFocus",
  76. "hidden.bs.dropdown": "_searchReset",
  77. "hide.bs.dropdown": "_hideAppsMenu",
  78. }, AppsMenu.prototype.events),
  79. /**
  80. * Rescue some menu data stripped out in original method.
  81. *
  82. * @override
  83. */
  84. init: function (parent, menuData) {
  85. this._super.apply(this, arguments);
  86. // Keep base64 icon for main menus
  87. for (var n in this._apps) {
  88. this._apps[n].web_icon_data =
  89. menuData.children[n].web_icon_data;
  90. }
  91. // Store menu data in a format searchable by fuzzy.js
  92. this._searchableMenus = _.reduce(
  93. menuData.children,
  94. findNames,
  95. {}
  96. );
  97. // Search only after timeout, for fast typers
  98. this._search_def = $.Deferred();
  99. },
  100. /**
  101. * @override
  102. */
  103. start: function () {
  104. this.$search_container = this.$(".search-container");
  105. this.$search_input = this.$(".search-input input");
  106. this.$search_results = this.$(".search-results");
  107. return this._super.apply(this, arguments);
  108. },
  109. /**
  110. * @override
  111. */
  112. _onAppsMenuItemClicked: function (ev) {
  113. this._super.apply(this, arguments);
  114. ev.preventDefault();
  115. },
  116. /**
  117. * Get all info for a given menu.
  118. *
  119. * @param {String} key
  120. * Full path to requested menu.
  121. *
  122. * @returns {Object}
  123. * Menu definition, plus extra needed keys.
  124. */
  125. _menuInfo: function (key) {
  126. var original = this._searchableMenus[key];
  127. return _.extend({
  128. action_id: parseInt(original.action.split(',')[1], 10),
  129. }, original);
  130. },
  131. /**
  132. * Autofocus on search field on big screens.
  133. */
  134. _searchFocus: function () {
  135. if (!config.device.isMobile) {
  136. this.$search_input.focus();
  137. }
  138. },
  139. /**
  140. * Reset search input and results
  141. */
  142. _searchReset: function () {
  143. this.$search_container.removeClass("has-results");
  144. this.$search_results.empty();
  145. this.$search_input.val("");
  146. },
  147. /**
  148. * Schedule a search on current menu items.
  149. */
  150. _searchMenusSchedule: function () {
  151. this._search_def.reject();
  152. this._search_def = $.Deferred();
  153. setTimeout(this._search_def.resolve.bind(this._search_def), 50);
  154. this._search_def.done(this._searchMenus.bind(this));
  155. },
  156. /**
  157. * Search among available menu items, and render that search.
  158. */
  159. _searchMenus: function () {
  160. var query = this.$search_input.val();
  161. if (query === "") {
  162. this.$search_container.removeClass("has-results");
  163. this.$search_results.empty();
  164. return;
  165. }
  166. var results = fuzzy.filter(
  167. query,
  168. _.keys(this._searchableMenus),
  169. {
  170. pre: "<b>",
  171. post: "</b>",
  172. }
  173. );
  174. this.$search_container.toggleClass(
  175. "has-results",
  176. Boolean(results.length)
  177. );
  178. this.$search_results.html(
  179. core.qweb.render(
  180. "web_responsive.MenuSearchResults",
  181. {
  182. results: results,
  183. widget: this,
  184. }
  185. )
  186. );
  187. },
  188. /**
  189. * Use chooses a search result, so we navigate to that menu
  190. *
  191. * @param {jQuery.Event} event
  192. */
  193. _searchResultChosen: function (event) {
  194. event.preventDefault();
  195. event.stopPropagation();
  196. var $result = $(event.currentTarget),
  197. text = $result.text().trim(),
  198. data = $result.data(),
  199. suffix = ~text.indexOf("/") ? "/" : "";
  200. // Load the menu view
  201. this.trigger_up("menu_clicked", {
  202. action_id: data.actionId,
  203. id: data.menuId,
  204. previous_menu_id: data.parentId,
  205. });
  206. // Find app that owns the chosen menu
  207. var app = _.find(this._apps, function (_app) {
  208. return text.indexOf(_app.name + suffix) === 0;
  209. });
  210. // Update navbar menus
  211. core.bus.trigger("change_menu_section", app.menuID);
  212. },
  213. /**
  214. * Navigate among search results
  215. *
  216. * @param {jQuery.Event} event
  217. */
  218. _searchResultsNavigate: function (event) {
  219. // Find current results and active element (1st by default)
  220. var all = this.$search_results.find(".o-menu-search-result"),
  221. pre_focused = all.filter(".active") || $(all[0]),
  222. offset = all.index(pre_focused),
  223. key = event.key;
  224. // Keyboard navigation only supports search results
  225. if (!all.length) {
  226. return;
  227. }
  228. // Transform tab presses in arrow presses
  229. if (key === "Tab") {
  230. event.preventDefault();
  231. key = event.shiftKey ? "ArrowUp" : "ArrowDown";
  232. }
  233. switch (key) {
  234. // Pressing enter is the same as clicking on the active element
  235. case "Enter":
  236. pre_focused.click();
  237. break;
  238. // Navigate up or down
  239. case "ArrowUp":
  240. offset--;
  241. break;
  242. case "ArrowDown":
  243. offset++;
  244. break;
  245. default:
  246. // Other keys are useless in this event
  247. return;
  248. }
  249. // Allow looping on results
  250. if (offset < 0) {
  251. offset = all.length + offset;
  252. } else if (offset >= all.length) {
  253. offset -= all.length;
  254. }
  255. // Switch active element
  256. var new_focused = $(all[offset]);
  257. pre_focused.removeClass("active");
  258. new_focused.addClass("active");
  259. this.$search_results.scrollTo(new_focused, {
  260. offset: {
  261. top: this.$search_results.height() * -0.5,
  262. },
  263. });
  264. },
  265. /*
  266. * Control if AppDrawer can be closed
  267. */
  268. _hideAppsMenu: function () {
  269. return $('.oe_wait').length === 0 && !this.$('input').is(':focus');
  270. },
  271. });
  272. BasicController.include({
  273. /**
  274. * @override
  275. */
  276. canBeDiscarded: function (recordID) {
  277. if (this.model.isDirty(recordID || this.handle)) {
  278. $('.o_menu_apps .dropdown:has(.dropdown-menu.show) > a').dropdown('toggle');
  279. }
  280. return this._super.apply(this, arguments);
  281. },
  282. });
  283. Menu.include({
  284. events: _.extend({
  285. // Clicking a hamburger menu item should close the hamburger
  286. "click .o_menu_sections [role=menuitem]": "_hideMobileSubmenus",
  287. // Opening any dropdown in the navbar should hide the hamburger
  288. "show.bs.dropdown .o_menu_systray, .o_menu_apps":
  289. "_hideMobileSubmenus",
  290. // Prevent close section menu
  291. "hide.bs.dropdown .o_menu_sections": "_hideMenuSection",
  292. }, Menu.prototype.events),
  293. start: function () {
  294. this.$menu_toggle = this.$(".o-menu-toggle");
  295. return this._super.apply(this, arguments);
  296. },
  297. /**
  298. * Hide menus for current app if you're in mobile
  299. */
  300. _hideMobileSubmenus: function () {
  301. if (
  302. this.$menu_toggle.is(":visible") &&
  303. this.$section_placeholder.is(":visible") &&
  304. $('.oe_wait').length === 0
  305. ) {
  306. this.$section_placeholder.collapse("hide");
  307. }
  308. },
  309. /**
  310. * Hide Menu Section
  311. *
  312. * @returns {Boolean}
  313. */
  314. _hideMenuSection: function () {
  315. return $('.oe_wait').length === 0;
  316. },
  317. /**
  318. * No menu brand in mobiles
  319. *
  320. * @override
  321. */
  322. _updateMenuBrand: function () {
  323. if (!config.device.isMobile) {
  324. return this._super.apply(this, arguments);
  325. }
  326. },
  327. });
  328. RelationalFields.FieldStatus.include({
  329. /**
  330. * Fold all on mobiles.
  331. *
  332. * @override
  333. */
  334. _setState: function () {
  335. this._super.apply(this, arguments);
  336. if (config.device.isMobile) {
  337. _.map(this.status_information, function (value) {
  338. value.fold = true;
  339. });
  340. }
  341. },
  342. });
  343. // Responsive view "action" buttons
  344. FormRenderer.include({
  345. /**
  346. * In mobiles, put all statusbar buttons in a dropdown.
  347. *
  348. * @override
  349. */
  350. _renderHeaderButtons: function () {
  351. var $buttons = this._super.apply(this, arguments);
  352. if (
  353. !config.device.isMobile ||
  354. !$buttons.is(":has(>:not(.o_invisible_modifier))")
  355. ) {
  356. return $buttons;
  357. }
  358. // $buttons must be appended by JS because all events are bound
  359. $buttons.addClass("dropdown-menu");
  360. var $dropdown = $(core.qweb.render(
  361. 'web_responsive.MenuStatusbarButtons'
  362. ));
  363. $buttons.addClass("dropdown-menu").appendTo($dropdown);
  364. return $dropdown;
  365. },
  366. });
  367. // Chatter Hide Composer
  368. Chatter.include({
  369. _openComposer: function (options) {
  370. if (this._composer &&
  371. options.isLog === this._composer.options.isLog &&
  372. this._composer.$el.is(':visible')) {
  373. this._closeComposer(false);
  374. } else {
  375. this._super.apply(this, arguments);
  376. }
  377. },
  378. });
  379. // Hide AppDrawer or Menu when the action has been completed
  380. ActionManager.include({
  381. /**
  382. * Because the menu aren't closed when click, this method
  383. * searchs for the menu with the action executed to close it.
  384. *
  385. * @param {action} action
  386. * The executed action
  387. */
  388. _hideMenusByAction: function (action) {
  389. var uniq_sel = '[data-action-id='+action.id+']';
  390. // Need close AppDrawer?
  391. $(_.str.sprintf(
  392. '.o_menu_apps .dropdown:has(.dropdown-menu.show:has(%s)) > a',
  393. uniq_sel)).dropdown('toggle');
  394. // Need close Sections Menu?
  395. // TODO: Change to 'hide' in modern Bootstrap >4.1
  396. $(_.str.sprintf(
  397. '.o_menu_sections li.show:has(%s) .dropdown-toggle', uniq_sel))
  398. .dropdown('toggle');
  399. // Need close Mobile?
  400. $(_.str.sprintf('.o_menu_sections.show:has(%s)', uniq_sel))
  401. .collapse('hide');
  402. },
  403. _handleAction: function (action) {
  404. return this._super.apply(this, arguments).always(
  405. $.proxy(this, '_hideMenusByAction', action));
  406. },
  407. });
  408. /**
  409. * Use ALT+SHIFT instead of ALT as hotkey triggerer.
  410. *
  411. * HACK https://github.com/odoo/odoo/issues/30068 - See it to know why.
  412. *
  413. * Cannot patch in `KeyboardNavigationMixin` directly because it's a mixin,
  414. * not a `Class`, and altering a mixin's `prototype` doesn't alter it where
  415. * it has already been used.
  416. *
  417. * Instead, we provide an additional mixin to be used wherever you need to
  418. * enable this behavior.
  419. */
  420. var KeyboardNavigationShiftAltMixin = {
  421. /**
  422. * Alter the key event to require pressing Shift.
  423. *
  424. * This will produce a mocked event object where it will seem that
  425. * `Alt` is not pressed if `Shift` is not pressed.
  426. *
  427. * The reason for this is that original upstream code, found in
  428. * `KeyboardNavigationMixin` is very hardcoded against the `Alt` key,
  429. * so it is more maintainable to mock its input than to rewrite it
  430. * completely.
  431. *
  432. * @param {keyEvent} keyEvent
  433. * Original event object
  434. *
  435. * @returns {keyEvent}
  436. * Altered event object
  437. */
  438. _shiftPressed: function (keyEvent) {
  439. var alt = keyEvent.altKey || keyEvent.key === "Alt",
  440. newEvent = _.extend({}, keyEvent),
  441. shift = keyEvent.shiftKey || keyEvent.key === "Shift";
  442. // Mock event to make it seem like Alt is not pressed
  443. if (alt && !shift) {
  444. newEvent.altKey = false;
  445. if (newEvent.key === "Alt") {
  446. newEvent.key = "Shift";
  447. }
  448. }
  449. return newEvent;
  450. },
  451. _onKeyDown: function (keyDownEvent) {
  452. return this._super(this._shiftPressed(keyDownEvent));
  453. },
  454. _onKeyUp: function (keyUpEvent) {
  455. return this._super(this._shiftPressed(keyUpEvent));
  456. },
  457. };
  458. // Include the SHIFT+ALT mixin wherever
  459. // `KeyboardNavigationMixin` is used upstream
  460. AbstractWebClient.include(KeyboardNavigationShiftAltMixin);
  461. });