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.

502 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. * Prevent the menu from being opened twice
  111. *
  112. * @override
  113. */
  114. _onAppsMenuItemClicked: function (ev) {
  115. this._super.apply(this, arguments);
  116. ev.preventDefault();
  117. },
  118. /**
  119. * Get all info for a given menu.
  120. *
  121. * @param {String} key
  122. * Full path to requested menu.
  123. *
  124. * @returns {Object}
  125. * Menu definition, plus extra needed keys.
  126. */
  127. _menuInfo: function (key) {
  128. var original = this._searchableMenus[key];
  129. return _.extend({
  130. action_id: parseInt(original.action.split(',')[1], 10),
  131. }, original);
  132. },
  133. /**
  134. * Autofocus on search field on big screens.
  135. */
  136. _searchFocus: function () {
  137. if (!config.device.isMobile) {
  138. this.$search_input.focus();
  139. }
  140. },
  141. /**
  142. * Reset search input and results
  143. */
  144. _searchReset: function () {
  145. this.$search_container.removeClass("has-results");
  146. this.$search_results.empty();
  147. this.$search_input.val("");
  148. },
  149. /**
  150. * Schedule a search on current menu items.
  151. */
  152. _searchMenusSchedule: function () {
  153. this._search_def.reject();
  154. this._search_def = $.Deferred();
  155. setTimeout(this._search_def.resolve.bind(this._search_def), 50);
  156. this._search_def.done(this._searchMenus.bind(this));
  157. },
  158. /**
  159. * Search among available menu items, and render that search.
  160. */
  161. _searchMenus: function () {
  162. var query = this.$search_input.val();
  163. if (query === "") {
  164. this.$search_container.removeClass("has-results");
  165. this.$search_results.empty();
  166. return;
  167. }
  168. var results = fuzzy.filter(
  169. query,
  170. _.keys(this._searchableMenus),
  171. {
  172. pre: "<b>",
  173. post: "</b>",
  174. }
  175. );
  176. this.$search_container.toggleClass(
  177. "has-results",
  178. Boolean(results.length)
  179. );
  180. this.$search_results.html(
  181. core.qweb.render(
  182. "web_responsive.MenuSearchResults",
  183. {
  184. results: results,
  185. widget: this,
  186. }
  187. )
  188. );
  189. },
  190. /**
  191. * Use chooses a search result, so we navigate to that menu
  192. *
  193. * @param {jQuery.Event} event
  194. */
  195. _searchResultChosen: function (event) {
  196. event.preventDefault();
  197. event.stopPropagation();
  198. var $result = $(event.currentTarget),
  199. text = $result.text().trim(),
  200. data = $result.data(),
  201. suffix = ~text.indexOf("/") ? "/" : "";
  202. // Load the menu view
  203. this.trigger_up("menu_clicked", {
  204. action_id: data.actionId,
  205. id: data.menuId,
  206. previous_menu_id: data.parentId,
  207. });
  208. // Find app that owns the chosen menu
  209. var app = _.find(this._apps, function (_app) {
  210. return text.indexOf(_app.name + suffix) === 0;
  211. });
  212. // Update navbar menus
  213. core.bus.trigger("change_menu_section", app.menuID);
  214. },
  215. /**
  216. * Navigate among search results
  217. *
  218. * @param {jQuery.Event} event
  219. */
  220. _searchResultsNavigate: function (event) {
  221. // Find current results and active element (1st by default)
  222. var all = this.$search_results.find(".o-menu-search-result"),
  223. pre_focused = all.filter(".active") || $(all[0]),
  224. offset = all.index(pre_focused),
  225. key = event.key;
  226. // Keyboard navigation only supports search results
  227. if (!all.length) {
  228. return;
  229. }
  230. // Transform tab presses in arrow presses
  231. if (key === "Tab") {
  232. event.preventDefault();
  233. key = event.shiftKey ? "ArrowUp" : "ArrowDown";
  234. }
  235. switch (key) {
  236. // Pressing enter is the same as clicking on the active element
  237. case "Enter":
  238. pre_focused.click();
  239. break;
  240. // Navigate up or down
  241. case "ArrowUp":
  242. offset--;
  243. break;
  244. case "ArrowDown":
  245. offset++;
  246. break;
  247. default:
  248. // Other keys are useless in this event
  249. return;
  250. }
  251. // Allow looping on results
  252. if (offset < 0) {
  253. offset = all.length + offset;
  254. } else if (offset >= all.length) {
  255. offset -= all.length;
  256. }
  257. // Switch active element
  258. var new_focused = $(all[offset]);
  259. pre_focused.removeClass("active");
  260. new_focused.addClass("active");
  261. this.$search_results.scrollTo(new_focused, {
  262. offset: {
  263. top: this.$search_results.height() * -0.5,
  264. },
  265. });
  266. },
  267. /*
  268. * Control if AppDrawer can be closed
  269. */
  270. _hideAppsMenu: function () {
  271. return $('.oe_wait').length === 0 && !this.$('input').is(':focus');
  272. },
  273. });
  274. BasicController.include({
  275. /**
  276. * Close the AppDrawer if the data set is dirty and a discard dialog is opened
  277. *
  278. * @override
  279. */
  280. canBeDiscarded: function (recordID) {
  281. if (this.model.isDirty(recordID || this.handle)) {
  282. $('.o_menu_apps .dropdown:has(.dropdown-menu.show) > a').dropdown('toggle');
  283. $('.o_menu_sections li.show .dropdown-toggle').dropdown('toggle');
  284. }
  285. return this._super.apply(this, arguments);
  286. },
  287. });
  288. Menu.include({
  289. events: _.extend({
  290. // Clicking a hamburger menu item should close the hamburger
  291. "click .o_menu_sections [role=menuitem]": "_hideMobileSubmenus",
  292. // Opening any dropdown in the navbar should hide the hamburger
  293. "show.bs.dropdown .o_menu_systray, .o_menu_apps":
  294. "_hideMobileSubmenus",
  295. // Prevent close section menu
  296. "hide.bs.dropdown .o_menu_sections": "_hideMenuSection",
  297. }, Menu.prototype.events),
  298. start: function () {
  299. this.$menu_toggle = this.$(".o-menu-toggle");
  300. return this._super.apply(this, arguments);
  301. },
  302. /**
  303. * Hide menus for current app if you're in mobile
  304. */
  305. _hideMobileSubmenus: function () {
  306. if (
  307. this.$menu_toggle.is(":visible") &&
  308. this.$section_placeholder.is(":visible") &&
  309. $('.oe_wait').length === 0
  310. ) {
  311. this.$section_placeholder.collapse("hide");
  312. }
  313. },
  314. /**
  315. * Hide Menu Section
  316. *
  317. * @returns {Boolean}
  318. */
  319. _hideMenuSection: function () {
  320. return $('.oe_wait').length === 0;
  321. },
  322. /**
  323. * No menu brand in mobiles
  324. *
  325. * @override
  326. */
  327. _updateMenuBrand: function () {
  328. if (!config.device.isMobile) {
  329. return this._super.apply(this, arguments);
  330. }
  331. },
  332. });
  333. RelationalFields.FieldStatus.include({
  334. /**
  335. * Fold all on mobiles.
  336. *
  337. * @override
  338. */
  339. _setState: function () {
  340. this._super.apply(this, arguments);
  341. if (config.device.isMobile) {
  342. _.map(this.status_information, function (value) {
  343. value.fold = true;
  344. });
  345. }
  346. },
  347. });
  348. // Responsive view "action" buttons
  349. FormRenderer.include({
  350. /**
  351. * In mobiles, put all statusbar buttons in a dropdown.
  352. *
  353. * @override
  354. */
  355. _renderHeaderButtons: function () {
  356. var $buttons = this._super.apply(this, arguments);
  357. if (
  358. !config.device.isMobile ||
  359. !$buttons.is(":has(>:not(.o_invisible_modifier))")
  360. ) {
  361. return $buttons;
  362. }
  363. // $buttons must be appended by JS because all events are bound
  364. $buttons.addClass("dropdown-menu");
  365. var $dropdown = $(core.qweb.render(
  366. 'web_responsive.MenuStatusbarButtons'
  367. ));
  368. $buttons.addClass("dropdown-menu").appendTo($dropdown);
  369. return $dropdown;
  370. },
  371. });
  372. // Chatter Hide Composer
  373. Chatter.include({
  374. _openComposer: function (options) {
  375. if (this._composer &&
  376. options.isLog === this._composer.options.isLog &&
  377. this._composer.$el.is(':visible')) {
  378. this._closeComposer(false);
  379. } else {
  380. this._super.apply(this, arguments);
  381. }
  382. },
  383. });
  384. // Hide AppDrawer or Menu when the action has been completed
  385. ActionManager.include({
  386. /**
  387. * Because the menu aren't closed when click, this method
  388. * searchs for the menu with the action executed to close it.
  389. *
  390. * @param {action} action
  391. * The executed action
  392. */
  393. _hideMenusByAction: function (action) {
  394. var uniq_sel = '[data-action-id='+action.id+']';
  395. // Need close AppDrawer?
  396. $(_.str.sprintf(
  397. '.o_menu_apps .dropdown:has(.dropdown-menu.show:has(%s)) > a',
  398. uniq_sel)).dropdown('toggle');
  399. // Need close Sections Menu?
  400. // TODO: Change to 'hide' in modern Bootstrap >4.1
  401. $(_.str.sprintf(
  402. '.o_menu_sections li.show:has(%s) .dropdown-toggle', uniq_sel))
  403. .dropdown('toggle');
  404. // Need close Mobile?
  405. $(_.str.sprintf('.o_menu_sections.show:has(%s)', uniq_sel))
  406. .collapse('hide');
  407. },
  408. _handleAction: function (action) {
  409. return this._super.apply(this, arguments).always(
  410. $.proxy(this, '_hideMenusByAction', action));
  411. },
  412. });
  413. /**
  414. * Use ALT+SHIFT instead of ALT as hotkey triggerer.
  415. *
  416. * HACK https://github.com/odoo/odoo/issues/30068 - See it to know why.
  417. *
  418. * Cannot patch in `KeyboardNavigationMixin` directly because it's a mixin,
  419. * not a `Class`, and altering a mixin's `prototype` doesn't alter it where
  420. * it has already been used.
  421. *
  422. * Instead, we provide an additional mixin to be used wherever you need to
  423. * enable this behavior.
  424. */
  425. var KeyboardNavigationShiftAltMixin = {
  426. /**
  427. * Alter the key event to require pressing Shift.
  428. *
  429. * This will produce a mocked event object where it will seem that
  430. * `Alt` is not pressed if `Shift` is not pressed.
  431. *
  432. * The reason for this is that original upstream code, found in
  433. * `KeyboardNavigationMixin` is very hardcoded against the `Alt` key,
  434. * so it is more maintainable to mock its input than to rewrite it
  435. * completely.
  436. *
  437. * @param {keyEvent} keyEvent
  438. * Original event object
  439. *
  440. * @returns {keyEvent}
  441. * Altered event object
  442. */
  443. _shiftPressed: function (keyEvent) {
  444. var alt = keyEvent.altKey || keyEvent.key === "Alt",
  445. newEvent = _.extend({}, keyEvent),
  446. shift = keyEvent.shiftKey || keyEvent.key === "Shift";
  447. // Mock event to make it seem like Alt is not pressed
  448. if (alt && !shift) {
  449. newEvent.altKey = false;
  450. if (newEvent.key === "Alt") {
  451. newEvent.key = "Shift";
  452. }
  453. }
  454. return newEvent;
  455. },
  456. _onKeyDown: function (keyDownEvent) {
  457. return this._super(this._shiftPressed(keyDownEvent));
  458. },
  459. _onKeyUp: function (keyUpEvent) {
  460. return this._super(this._shiftPressed(keyUpEvent));
  461. },
  462. };
  463. // Include the SHIFT+ALT mixin wherever
  464. // `KeyboardNavigationMixin` is used upstream
  465. AbstractWebClient.include(KeyboardNavigationShiftAltMixin);
  466. });