PageRenderTime 46ms CodeModel.GetById 11ms RepoModel.GetById 0ms app.codeStats 1ms

/addons/web/static/src/js/views/search/search_view.js

https://gitlab.com/padjis/mapan
JavaScript | 1283 lines | 993 code | 58 blank | 232 comment | 136 complexity | 2aac592512c28b28e2f971eeae3457c9 MD5 | raw file
  1. odoo.define('web.SearchView', function (require) {
  2. "use strict";
  3. var AutoComplete = require('web.AutoComplete');
  4. var config = require('web.config');
  5. var core = require('web.core');
  6. var Domain = require('web.Domain');
  7. var FavoriteMenu = require('web.FavoriteMenu');
  8. var FiltersMenu = require('web.FiltersMenu');
  9. var GroupByMenu = require('web.GroupByMenu');
  10. var pyUtils = require('web.py_utils');
  11. var search_inputs = require('web.search_inputs');
  12. var TimeRangeMenu = require('web.TimeRangeMenu');
  13. var TimeRangeMenuOptions = require('web.TimeRangeMenuOptions');
  14. var utils = require('web.utils');
  15. var Widget = require('web.Widget');
  16. var _t = core._t;
  17. var ComparisonOptions = TimeRangeMenuOptions.ComparisonOptions;
  18. var PeriodOptions = TimeRangeMenuOptions.PeriodOptions;
  19. var Backbone = window.Backbone;
  20. var FacetValue = Backbone.Model.extend({});
  21. var FacetValues = Backbone.Collection.extend({
  22. model: FacetValue
  23. });
  24. var DEFAULT_INTERVAL = 'month';
  25. var DEFAULT_PERIOD = 'this_month';
  26. var Facet = Backbone.Model.extend({
  27. initialize: function (attrs) {
  28. var values = attrs.values;
  29. delete attrs.values;
  30. Backbone.Model.prototype.initialize.apply(this, arguments);
  31. this.values = new FacetValues(values || []);
  32. this.values.on('add remove change reset', function (_, options) {
  33. this.trigger('change', this, options);
  34. }, this);
  35. },
  36. get: function (key) {
  37. if (key !== 'values') {
  38. return Backbone.Model.prototype.get.call(this, key);
  39. }
  40. return this.values.toJSON();
  41. },
  42. set: function (key, value) {
  43. if (key !== 'values') {
  44. return Backbone.Model.prototype.set.call(this, key, value);
  45. }
  46. this.values.reset(value);
  47. },
  48. toJSON: function () {
  49. var out = {};
  50. var attrs = this.attributes;
  51. for(var att in attrs) {
  52. if (!attrs.hasOwnProperty(att) || att === 'field') {
  53. continue;
  54. }
  55. out[att] = attrs[att];
  56. }
  57. out.values = this.values.toJSON();
  58. return out;
  59. }
  60. });
  61. var SearchQuery = Backbone.Collection.extend({
  62. model: Facet,
  63. initialize: function () {
  64. Backbone.Collection.prototype.initialize.apply(
  65. this, arguments);
  66. this.on('change', function (facet) {
  67. if(!facet.values.isEmpty()) { return; }
  68. this.remove(facet, {silent: true});
  69. }, this);
  70. },
  71. add: function (values, options) {
  72. options = options || {};
  73. if (!values) {
  74. values = [];
  75. } else if (!(values instanceof Array)) {
  76. values = [values];
  77. }
  78. _(values).each(function (value) {
  79. var model = this._prepareModel(value, options);
  80. var previous = this.detect(function (facet) {
  81. return facet.get('category') === model.get('category') &&
  82. facet.get('field') === model.get('field');
  83. });
  84. if (previous) {
  85. previous.values.add(model.get('values'), _.omit(options, 'at', 'merge'));
  86. return;
  87. }
  88. Backbone.Collection.prototype.add.call(this, model, options);
  89. }, this);
  90. // warning: in backbone 1.0+ add is supposed to return the added models,
  91. // but here toggle may delegate to add and return its value directly.
  92. // return value of neither seems actually used but should be tested
  93. // before change, probably
  94. return this;
  95. },
  96. toggle: function (value, options) {
  97. options = options || {};
  98. var facet = this.detect(function (facet) {
  99. return facet.get('category') === value.category
  100. && facet.get('field') === value.field;
  101. });
  102. if (!facet) {
  103. return this.add(value, options);
  104. }
  105. var changed = false;
  106. _(value.values).each(function (val) {
  107. var already_value = facet.values.detect(function (v) {
  108. return v.get('value') === val.value
  109. && v.get('label') === val.label;
  110. });
  111. // toggle value
  112. if (already_value) {
  113. facet.values.remove(already_value, {silent: true});
  114. } else {
  115. facet.values.add(val, {silent: true});
  116. }
  117. changed = true;
  118. });
  119. // "Commit" changes to values array as a single call, so observers of
  120. // change event don't get misled by intermediate incomplete toggling
  121. // states
  122. facet.trigger('change', facet);
  123. return this;
  124. }
  125. });
  126. var InputView = Widget.extend({
  127. template: 'SearchView.InputView',
  128. events: {
  129. focus: function () { this.trigger('focused', this); },
  130. blur: function () { this.$el.val(''); this.trigger('blurred', this); },
  131. keydown: 'onKeydown',
  132. 'compositionend': '_onCompositionend',
  133. 'compositionstart': '_onCompositionstart',
  134. },
  135. onKeydown: function (e) {
  136. if (this._isComposing) {
  137. return;
  138. }
  139. switch (e.which) {
  140. case $.ui.keyCode.BACKSPACE:
  141. if(this.$el.val() === '') {
  142. var preceding = this.getParent().siblingSubview(this, -1);
  143. if (preceding && (preceding instanceof FacetView)) {
  144. preceding.model.destroy();
  145. }
  146. }
  147. break;
  148. case $.ui.keyCode.LEFT: // Stop propagation to parent if not at beginning of input value
  149. if(this.el.selectionStart > 0) {
  150. e.stopPropagation();
  151. }
  152. break;
  153. case $.ui.keyCode.RIGHT: // Stop propagation to parent if not at end of input value
  154. if(this.el.selectionStart < this.$el.val().length) {
  155. e.stopPropagation();
  156. }
  157. break;
  158. }
  159. },
  160. /**
  161. * @private
  162. * @param {CompositionEvent} ev
  163. */
  164. _onCompositionend: function (ev) {
  165. this._isComposing = false;
  166. },
  167. /**
  168. * @private
  169. * @param {CompositionEvent} ev
  170. */
  171. _onCompositionstart: function (ev) {
  172. this._isComposing = true;
  173. },
  174. });
  175. var FacetView = Widget.extend({
  176. template: 'SearchView.FacetView',
  177. events: {
  178. 'focus': function () { this.trigger('focused', this); },
  179. 'blur': function () {
  180. this.trigger('blurred', this); },
  181. 'click': function (e) {
  182. if ($(e.target).hasClass('o_facet_remove')) {
  183. this.model.destroy();
  184. return false;
  185. }
  186. this.$el.focus();
  187. e.stopPropagation();
  188. },
  189. 'keydown': function (e) {
  190. var keys = $.ui.keyCode;
  191. switch (e.which) {
  192. case keys.BACKSPACE:
  193. case keys.DELETE:
  194. this.model.destroy();
  195. return false;
  196. }
  197. }
  198. },
  199. /*
  200. * @param {Widget} parent
  201. * @param {Object} model
  202. * @param {Object} intervalMapping, a key is a field name and the corresponding value
  203. * is the current interval used
  204. * (necessarily the field is of type 'date' or 'datetime')
  205. * @param {Object} periodMapping, a key is a field name and the corresponding value
  206. * is the current period used
  207. * (necessarily the field is of type 'date' or 'datetime')
  208. */
  209. init: function (parent, model, intervalMapping, periodMapping) {
  210. this._super(parent);
  211. this.model = model;
  212. this.intervalMapping = intervalMapping;
  213. this.periodMapping = periodMapping;
  214. this.model.on('change', this.model_changed, this);
  215. },
  216. destroy: function () {
  217. this.model.off('change', this.model_changed, this);
  218. this._super();
  219. },
  220. start: function () {
  221. var self = this;
  222. var $e = this.$('.o_facet_values').last();
  223. return $.when(this._super()).then(function () {
  224. return $.when.apply(null, self.model.values.map(function (value, index) {
  225. if (index > 0) {
  226. $('<span/>', {html: self.model.get('separator') || _t(" or ")}).addClass('o_facet_values_sep').appendTo($e);
  227. }
  228. var couple;
  229. var option;
  230. if (value.attributes.value && value.attributes.value.attrs) {
  231. if (value.attributes.value.attrs.isPeriod) {
  232. couple = _.findWhere(self.periodMapping, {filter: value.attributes.value});
  233. option = couple ? couple.period : value.attributes.value.attrs.default_period;
  234. }
  235. if (value.attributes.value.attrs.isDate) {
  236. couple = _.findWhere(self.intervalMapping, {groupby: value.attributes.value});
  237. option = couple ?
  238. couple.interval :
  239. (value.attributes.value.attrs.defaultInterval || DEFAULT_INTERVAL);
  240. }
  241. }
  242. return new FacetValueView(self, value, option).appendTo($e);
  243. }));
  244. });
  245. },
  246. model_changed: function () {
  247. this.$el.text(this.$el.text() + '*');
  248. }
  249. });
  250. var FacetValueView = Widget.extend({
  251. template: 'SearchView.FacetView.Value',
  252. /*
  253. * @param {Widget} parent
  254. * @param {Object} model
  255. * @param {Object} option (optional) is used in case the facet value
  256. * corresponds to a groupby with an associated 'date'
  257. * 'datetime' field.
  258. * @param {Object} comparison (optional) is used in case the facet value
  259. * comes from the time range menu and comparison is active
  260. */
  261. init: function (parent, model, option) {
  262. this._super(parent);
  263. this.model = model;
  264. var optionDescription = _.extend({}, {
  265. day: 'Day',
  266. week: 'Week',
  267. month: 'Month',
  268. quarter: 'Quarter',
  269. year: 'Year',
  270. }, {
  271. today: 'Today',
  272. this_week: 'This Week',
  273. this_month: 'This Month',
  274. this_quarter: 'This Quarter',
  275. this_year: 'This Year',
  276. yesterday: 'Yesterday',
  277. last_week: 'Last Week',
  278. last_month: 'Last Month',
  279. last_quarter: 'Last Quarter',
  280. last_year: 'Last Year',
  281. last_7_days: 'Last 7 Days',
  282. last_30_days: 'Last 30 Days',
  283. last_365_days: 'Last 365 Days',
  284. });
  285. if (option) {
  286. var optionLabel = optionDescription[option];
  287. this.optionLabel = _t(optionLabel);
  288. }
  289. this.model.on('change', this.model_changed, this);
  290. },
  291. destroy: function () {
  292. this.model.off('change', this.model_changed, this);
  293. this._super();
  294. },
  295. model_changed: function () {
  296. this.$el.text(this.$el.text() + '*');
  297. }
  298. });
  299. var SearchView = Widget.extend({
  300. events: {
  301. 'click .o_searchview_more': function (e) {
  302. $(e.target).toggleClass('fa-search-plus fa-search-minus');
  303. var visibleSearchMenu = this.call('local_storage', 'getItem', 'visible_search_menu');
  304. this.call('local_storage', 'setItem', 'visible_search_menu', visibleSearchMenu === false);
  305. this.toggle_buttons();
  306. },
  307. 'keydown .o_searchview_input, .o_searchview_facet': function (e) {
  308. if (this._isInputComposing) {
  309. return;
  310. }
  311. switch(e.which) {
  312. case $.ui.keyCode.LEFT:
  313. this.focusPreceding(e.target);
  314. e.preventDefault();
  315. break;
  316. case $.ui.keyCode.RIGHT:
  317. if(!this.autocomplete.is_expandable()) {
  318. this.focusFollowing(e.target);
  319. }
  320. e.preventDefault();
  321. break;
  322. case $.ui.keyCode.DOWN:
  323. if (!this.autocomplete.is_expanded()) {
  324. e.preventDefault();
  325. this.trigger_up('navigation_move', {direction: 'down'});
  326. break;
  327. }
  328. }
  329. },
  330. 'compositionend .o_searchview_input': '_onCompositionendInput',
  331. 'compositionstart .o_searchview_input': '_onCompositionstartInput',
  332. },
  333. custom_events: {
  334. menu_item_toggled: '_onItemToggled',
  335. item_option_changed: '_onItemOptionChanged',
  336. new_groupby: '_onNewGroupby',
  337. new_filters: '_onNewFilters',
  338. time_range_modified: '_onTimeRangeModified',
  339. time_range_removed: '_onTimeRangeRemoved',
  340. },
  341. defaults: _.extend({}, Widget.prototype.defaults, {
  342. hidden: false,
  343. disable_custom_filters: false,
  344. disable_groupby: false,
  345. disable_favorites: false,
  346. disable_filters: false,
  347. disableTimeRangeMenu: true,
  348. }),
  349. template: "SearchView",
  350. /**
  351. * @constructs SearchView
  352. * @extends View
  353. *
  354. * @param parent
  355. * @param dataset
  356. * @param fvg
  357. * @param {Object} [options]
  358. * @param {Boolean} [options.hidden=false] hide the search view
  359. * @param {Boolean} [options.disable_custom_filters=false] do not load custom filters from ir.filters
  360. */
  361. init: function (parent, dataset, fvg, options) {
  362. this._super.apply(this, arguments);
  363. this.options = options;
  364. this.dataset = dataset;
  365. this.fields_view = this._processFieldsView(_.clone(fvg));
  366. this.fields = this.fields_view.fields;
  367. this.query = undefined;
  368. this.title = this.options.action && this.options.action.name;
  369. this.action = this.options.action || {};
  370. this.search_fields = [];
  371. this.hasFavorites = false;
  372. this.noDateFields = true;
  373. var field;
  374. for (var key in this.fields) {
  375. field = this.fields[key];
  376. if (_.contains(['date', 'datetime'], field.type) && field.sortable) {
  377. this.noDateFields = false;
  378. break;
  379. }
  380. }
  381. this.activeItemIds = {
  382. groupByCategory: [],
  383. filterCategory: []
  384. };
  385. this.groupsMapping = [];
  386. this.groupbysMapping = [];
  387. this.filtersMapping = [];
  388. this.intervalMapping = [];
  389. this.periodMapping = [];
  390. this.filters = [];
  391. this.groupbys = [];
  392. this.timeRanges = options.action && options.action.context ?
  393. options.action.context.time_ranges : undefined;
  394. var visibleSearchMenu = this.call('local_storage', 'getItem', 'visible_search_menu');
  395. this.visible_filters = (visibleSearchMenu !== false);
  396. this.input_subviews = []; // for user input in searchbar
  397. this.search_defaults = this.options.search_defaults || {};
  398. this.headless = this.options.hidden && _.isEmpty(this.search_defaults);
  399. this.$buttons = this.options.$buttons;
  400. this.filters_menu = undefined;
  401. this.groupby_menu = undefined;
  402. this.favorite_menu = undefined;
  403. },
  404. willStart: function () {
  405. var self = this;
  406. var def;
  407. if (!this.options.disable_favorites) {
  408. def = this.loadFilters(this.dataset, this.action.id).then(function (filters) {
  409. self.favorite_filters = filters;
  410. });
  411. }
  412. return $.when(this._super(), def);
  413. },
  414. start: function () {
  415. var self= this;
  416. if (this.headless) {
  417. this.do_hide();
  418. }
  419. this.toggle_visibility(false);
  420. this.setup_global_completion();
  421. this.query = new SearchQuery()
  422. .on('add change reset remove', this.proxy('do_search'))
  423. .on('change', this.proxy('renderChangedFacets'))
  424. .on('add reset remove', this.proxy('renderFacets'));
  425. this.$('.o_searchview_more')
  426. .toggleClass('fa-search-minus', this.visible_filters)
  427. .toggleClass('fa-search-plus', !this.visible_filters);
  428. var def;
  429. this.prepare_search_inputs();
  430. var $buttons = this._getButtonsElement();
  431. if ($buttons) {
  432. if (!this.options.disable_favorites) {
  433. this.favorite_menu = new FavoriteMenu(this, this.query, this.dataset.model, this.action, this.favorite_filters);
  434. def = this.favorite_menu.appendTo($buttons);
  435. }
  436. }
  437. return $.when(def)
  438. .then(this.set_default_filters.bind(this))
  439. .then(function () {
  440. var menu_defs = [];
  441. self.timeRangeMenu = self._createTimeRangeMenu();
  442. menu_defs.push(self.timeRangeMenu.prependTo($buttons));
  443. self.timeRangeMenu.do_hide();
  444. self.displayedTimeRangeMenu = self.options.disableTimeRangeMenu !== undefined &&
  445. !self.options.disableTimeRangeMenu;
  446. self.displayTimeRangeMenu(self.displayedTimeRangeMenu);
  447. if (!self.options.disable_groupby) {
  448. self.groupby_menu = self._createGroupByMenu();
  449. menu_defs.push(self.groupby_menu.prependTo($buttons));
  450. }
  451. if (!self.options.disable_filters) {
  452. self.filters_menu = self._createFiltersMenu();
  453. menu_defs.push(self.filters_menu.prependTo($buttons));
  454. }
  455. return $.when.apply($, menu_defs);
  456. });
  457. },
  458. /*
  459. *
  460. * @param {boolean}
  461. */
  462. displayTimeRangeMenu: function (b) {
  463. if (!b || this.noDateFields) {
  464. this.timeRangeMenu.do_hide();
  465. } else {
  466. this.timeRangeMenu.do_show();
  467. }
  468. },
  469. on_attach_callback: function () {
  470. this._focusInput();
  471. },
  472. get_title: function () {
  473. return this.title;
  474. },
  475. set_default_filters: function () {
  476. var self = this,
  477. default_custom_filter = this.$buttons && this.favorite_menu && this.favorite_menu.get_default_filter();
  478. if (!self.options.disable_custom_filters && default_custom_filter) {
  479. this.hasFavorites = true;
  480. return this.favorite_menu.toggle_filter(default_custom_filter, true);
  481. }
  482. if (!_.isEmpty(this.search_defaults) || this.timeRanges) {
  483. var inputs = this.search_fields.concat(this.filters, this.groupbys);
  484. var search_defaults = _.invoke(inputs, 'facet_for_defaults', this.search_defaults);
  485. var defaultTimeRange = this._searchDefaultTimeRange();
  486. search_defaults.push(defaultTimeRange);
  487. return $.when.apply(null, search_defaults).then(function () {
  488. var facets = _.compact(arguments);
  489. self.query.reset(facets, {preventSearch: true});
  490. });
  491. }
  492. this.query.reset([], {preventSearch: true});
  493. return $.when();
  494. },
  495. /**
  496. * Performs the search view collection of widget data.
  497. *
  498. * If the collection went well (all fields are valid), then triggers
  499. * :js:func:`instance.web.SearchView.on_search`.
  500. *
  501. * If at least one field failed its validation, triggers
  502. * :js:func:`instance.web.SearchView.on_invalid` instead.
  503. *
  504. * @param [_query]
  505. * @param {Object} [options]
  506. */
  507. do_search: function (_query, options) {
  508. if (options && options.preventSearch) {
  509. return;
  510. }
  511. var search = this.build_search_data();
  512. this.trigger_up('search', search);
  513. },
  514. /**
  515. * @param {boolean} noDomainEvaluation determines if domain are evaluated or not.
  516. * By default, domains are evaluated.
  517. *
  518. * Extract search data from the view's facets.
  519. *
  520. * Result is an object with 3 (own) properties:
  521. *
  522. * domains
  523. * Array of domains
  524. * contexts
  525. * Array of contexts
  526. * groupbys
  527. * Array of domains, in groupby order rather than view order
  528. *
  529. * @return {Object}
  530. */
  531. build_search_data: function (noDomainEvaluation) {
  532. var domains = [], contexts = [], groupbys = [];
  533. noDomainEvaluation = noDomainEvaluation || false;
  534. this.query.each(function (facet) {
  535. // field is actually a FilterGroup!
  536. var field = facet.get('field');
  537. var domain = field.get_domain(facet, noDomainEvaluation);
  538. if (domain) {
  539. domains.push(domain);
  540. }
  541. var context = field.get_context(facet, noDomainEvaluation);
  542. if (context) {
  543. contexts.push(context);
  544. }
  545. var group_by = field.get_groupby(facet);
  546. if (group_by) {
  547. groupbys.push.apply(groupbys, group_by);
  548. }
  549. });
  550. var intervalMappingNormalized = {};
  551. _.each(this.intervalMapping, function (couple) {
  552. var fieldName = couple.groupby.fieldName;
  553. var interval = couple.interval;
  554. intervalMappingNormalized[fieldName] = interval;
  555. });
  556. return {
  557. domains: domains,
  558. contexts: contexts,
  559. groupbys: groupbys,
  560. intervalMapping: intervalMappingNormalized,
  561. };
  562. },
  563. toggle_visibility: function (is_visible) {
  564. this.do_toggle(!this.headless && is_visible);
  565. if (this.$buttons) {
  566. this.$buttons.toggle(!this.headless && is_visible && this.visible_filters);
  567. }
  568. this._focusInput();
  569. },
  570. /**
  571. * puts the focus on the search input
  572. */
  573. _focusInput: function () {
  574. if (!config.device.touch && config.device.size_class >= config.device.SIZES.MD) {
  575. this.$('input').focus();
  576. }
  577. },
  578. toggle_buttons: function (is_visible) {
  579. this.visible_filters = is_visible || !this.visible_filters;
  580. if (this.$buttons) {
  581. this.$buttons.toggle(this.visible_filters);
  582. }
  583. },
  584. /**
  585. * Sets up search view's view-wide auto-completion widget
  586. */
  587. setup_global_completion: function () {
  588. var self = this;
  589. this.autocomplete = new AutoComplete(this, {
  590. source: this.proxy('complete_global_search'),
  591. select: this.proxy('select_completion'),
  592. get_search_string: function () {
  593. return self.$('.o_searchview_input').val().trim();
  594. },
  595. });
  596. this.autocomplete.appendTo(this.$('.o_searchview_input_container'));
  597. },
  598. /**
  599. * Provide auto-completion result for req.term (an array to `resp`)
  600. *
  601. * @param {Object} req request to complete
  602. * @param {String} req.term searched term to complete
  603. * @param {Function} resp response callback
  604. */
  605. complete_global_search: function (req, resp) {
  606. var inputs = this.search_fields.concat(this.filters, this.groupbys);
  607. $.when.apply(null, _(inputs).chain()
  608. .filter(function (input) { return input.visible(); })
  609. .invoke('complete', req.term)
  610. .value()).then(function () {
  611. resp(_(arguments).chain()
  612. .compact()
  613. .flatten(true)
  614. .value());
  615. });
  616. },
  617. /**
  618. * Action to perform in case of selection: create a facet (model)
  619. * and add it to the search collection
  620. *
  621. * @param {Object} e selection event, preventDefault to avoid setting value on object
  622. * @param {Object} ui selection information
  623. * @param {Object} ui.item selected completion item
  624. */
  625. select_completion: function (e, ui) {
  626. var facet = ui.item.facet;
  627. e.preventDefault();
  628. if(facet && facet.values && facet.values.length && String(facet.values[0].value).trim() !== "") {
  629. this.query.add(facet);
  630. } else {
  631. this.query.trigger('add');
  632. }
  633. },
  634. subviewForRoot: function (subview_root) {
  635. return _(this.input_subviews).detect(function (subview) {
  636. return subview.$el[0] === subview_root;
  637. });
  638. },
  639. siblingSubview: function (subview, direction, wrap_around) {
  640. var index = _(this.input_subviews).indexOf(subview) + direction;
  641. if (wrap_around && index < 0) {
  642. index = this.input_subviews.length - 1;
  643. } else if (wrap_around && index >= this.input_subviews.length) {
  644. index = 0;
  645. }
  646. return this.input_subviews[index];
  647. },
  648. focusPreceding: function (subview_root) {
  649. return this.siblingSubview(
  650. this.subviewForRoot(subview_root), -1, true)
  651. .$el.focus();
  652. },
  653. focusFollowing: function (subview_root) {
  654. return this.siblingSubview(
  655. this.subviewForRoot(subview_root), +1, true)
  656. .$el.focus();
  657. },
  658. /**
  659. */
  660. renderFacets: function () {
  661. var self = this;
  662. var started = [];
  663. _.invoke(this.input_subviews, 'destroy');
  664. this.input_subviews = [];
  665. var activeItemIds = {
  666. groupByCategory: [],
  667. filterCategory: [],
  668. };
  669. var timeRangeMenuIsActive;
  670. this.query.each(function (facet) {
  671. var values = facet.get('values');
  672. if (facet.attributes.cat === "groupByCategory") {
  673. activeItemIds.groupByCategory = activeItemIds.groupByCategory.concat(
  674. _.uniq(
  675. values.reduce(
  676. function (acc, value) {
  677. var groupby = value.value;
  678. var description = _.findWhere(self.groupbysMapping, {groupby: groupby});
  679. if (description) {
  680. acc.push(description.groupbyId);
  681. }
  682. return acc;
  683. },
  684. []
  685. )
  686. )
  687. );
  688. }
  689. if (facet.attributes.cat === "filterCategory") {
  690. activeItemIds.filterCategory = activeItemIds.filterCategory.concat(
  691. _.uniq(
  692. values.reduce(
  693. function (acc, value) {
  694. var filter = value.value;
  695. var description = _.findWhere(self.filtersMapping, {filter: filter});
  696. if (description) {
  697. acc.push(description.filterId);
  698. }
  699. return acc;
  700. },
  701. []
  702. )
  703. )
  704. );
  705. }
  706. if (facet.attributes.cat === "timeRangeCategory") {
  707. timeRangeMenuIsActive = true;
  708. }
  709. var f = new FacetView(this, facet, this.intervalMapping, this.periodMapping);
  710. started.push(f.appendTo(self.$('.o_searchview_input_container')));
  711. self.input_subviews.push(f);
  712. }, this);
  713. var i = new InputView(this);
  714. started.push(i.appendTo(self.$('.o_searchview_input_container')));
  715. self.input_subviews.push(i);
  716. _.each(this.input_subviews, function (childView) {
  717. childView.on('focused', self, self.proxy('childFocused'));
  718. childView.on('blurred', self, self.proxy('childBlurred'));
  719. });
  720. $.when.apply(null, started).then(function () {
  721. if (!config.device.isMobile) {
  722. // in mobile mode, we would rathor not focusing manually the
  723. // input, because it opens up the integrated keyboard, which is
  724. // not what you expect when you just selected a filter.
  725. _.last(self.input_subviews).$el.focus();
  726. }
  727. if (self.groupby_menu) {
  728. self.groupby_menu.updateItemsStatus(activeItemIds.groupByCategory);
  729. }
  730. if (self.filters_menu) {
  731. self.filters_menu.updateItemsStatus(activeItemIds.filterCategory); }
  732. if (self.displayedTimeRangeMenu && !timeRangeMenuIsActive) {
  733. self.timeRangeMenu.deactivate();
  734. }
  735. });
  736. },
  737. childFocused: function () {
  738. this.$el.addClass('active');
  739. },
  740. childBlurred: function () {
  741. this.$el.val('').removeClass('active').trigger('blur');
  742. this.autocomplete.close();
  743. },
  744. /**
  745. * Call the renderFacets method with the correct arguments.
  746. * This is due to the fact that change events are called with two arguments
  747. * (model, options) while add, reset and remove events are called with
  748. * (collection, model, options) as arguments
  749. */
  750. renderChangedFacets: function (model, options) {
  751. this.renderFacets(undefined, model, options);
  752. },
  753. // it should parse the arch field of the view, instantiate the corresponding
  754. // filters/fields, and put them in the correct variables:
  755. // * this.search_fields is a list of all the fields,
  756. // * this.filters: groups of filters
  757. // * this.group_by: group_bys
  758. prepare_search_inputs: function () {
  759. var self = this,
  760. arch = this.fields_view.arch;
  761. var filters = [].concat.apply([], _.map(arch.children, function (item) {
  762. return item.tag !== 'group' ? eval_item(item) : item.children.map(eval_item);
  763. }));
  764. function eval_item (item) {
  765. var category = 'filters';
  766. if (item.attrs.context) {
  767. try {
  768. var context = pyUtils.eval('context', item.attrs.context);
  769. if (context.group_by) {
  770. category = 'group_by';
  771. item.attrs.fieldName = context.group_by.split(':')[0];
  772. item.attrs.isDate = _.contains(['date', 'datetime'], self.fields[item.attrs.fieldName].type);
  773. item.attrs.defaultInterval = context.group_by.split(':')[1];
  774. }
  775. } catch (e) {}
  776. }
  777. if (item.attrs.date) {
  778. item.attrs.default_period = item.attrs.default_period || DEFAULT_PERIOD;
  779. item.attrs.type = self.fields[item.attrs.date].type;
  780. }
  781. item.attrs.isPeriod = !!item.attrs.date;
  782. return {
  783. item: item,
  784. category: category,
  785. };
  786. }
  787. var current_group = [],
  788. current_category = 'filters',
  789. categories = {filters: this.filters, group_by: this.groupbys, timeRanges: this.timeRanges};
  790. _.each(filters.concat({category:'filters', item: 'separator'}), function (filter) {
  791. if (filter.item.tag === 'filter' && filter.category === current_category) {
  792. return current_group.push(new search_inputs.Filter(filter.item, self));
  793. }
  794. if (current_group.length) {
  795. var group = new search_inputs.FilterGroup(current_group, self, self.intervalMapping, self.periodMapping);
  796. categories[current_category].push(group);
  797. current_group = [];
  798. }
  799. if (filter.item.tag === 'field') {
  800. var attrs = filter.item.attrs;
  801. var field = self.fields_view.fields[attrs.name];
  802. // M2O combined with selection widget is pointless and broken in search views,
  803. // but has been used in the past for unsupported hacks -> ignore it
  804. if (field.type === "many2one" && attrs.widget === "selection") {
  805. attrs.widget = undefined;
  806. }
  807. var Obj = core.search_widgets_registry.getAny([attrs.widget, field.type]);
  808. if (Obj) {
  809. self.search_fields.push(new (Obj) (filter.item, field, self));
  810. }
  811. }
  812. if (filter.item.tag === 'filter') {
  813. current_group.push(new search_inputs.Filter(filter.item, self));
  814. }
  815. current_category = filter.category;
  816. });
  817. },
  818. //--------------------------------------------------------------------------
  819. // Public
  820. //--------------------------------------------------------------------------
  821. /**
  822. * Updates the domain of the search view by adding and/or removing filters.
  823. *
  824. * @todo: the way it is done could be improved, but the actual state of the
  825. * searchview doesn't allow to do much better.
  826. * @param {Array<Object>} newFilters list of filters to add, described by
  827. * objects with keys domain (the domain as an Array), and help (the text
  828. * to display in the facet)
  829. * @param {Array<Object>} filtersToRemove list of filters to remove
  830. * (previously added ones)
  831. * @returns {Array<Object>} list of added filters (to pass as filtersToRemove
  832. * for a further call to this function)
  833. */
  834. updateFilters: function (newFilters, filtersToRemove) {
  835. var self = this;
  836. var addedFilters = _.map(newFilters, function (filter) {
  837. var domain = filter.domain;
  838. if (domain instanceof Array) {
  839. domain = Domain.prototype.arrayToString(domain);
  840. }
  841. filter = {
  842. attrs: {domain: domain, help: filter.help},
  843. };
  844. var filterWidget = new search_inputs.Filter(filter);
  845. var filterGroup = new search_inputs.FilterGroup([filterWidget], self, self.intervalMapping, self.periodMapping);
  846. var facet = filterGroup.make_facet([filterGroup.make_value(filter)]);
  847. self.query.add([facet], {silent: true});
  848. return _.last(self.query.models);
  849. });
  850. _.each(filtersToRemove, function (filter) {
  851. self.query.remove(filter, {silent: true});
  852. });
  853. this.query.trigger('reset');
  854. return addedFilters;
  855. },
  856. //--------------------------------------------------------------------------
  857. // Private
  858. //--------------------------------------------------------------------------
  859. /**
  860. * Will return $element where Filters, Group By and Favorite buttons are
  861. * going to be pushed. This method is overriden by the mobile search view
  862. * to add these buttons somewhere else in the dom.
  863. *
  864. * @private
  865. * @returns {jQueryElement}
  866. */
  867. _getButtonsElement: function () {
  868. return this.$buttons;
  869. },
  870. /**
  871. * Create a groupby menu. Note that this method has a side effect: it
  872. * builds a mapping from a filter name to a 'search filter'.
  873. *
  874. * @private
  875. * @returns {Widget} the processed fieldsView
  876. */
  877. _createFiltersMenu: function () {
  878. var self = this;
  879. var filters = [];
  880. this.filters.forEach(function (group) {
  881. var groupId = _.uniqueId('__group__');
  882. group.filters.forEach(function (filter) {
  883. if (!filter.attrs.modifiers.invisible) {
  884. var filterId = _.uniqueId('__filter__');
  885. var isPeriod = filter.attrs.isPeriod;
  886. var defaultPeriod = filter.attrs.default_period;
  887. var isActive = !self.hasFavorites && !!self.search_defaults[filter.attrs.name];
  888. filters.push({
  889. itemId: filterId,
  890. description: filter.attrs.string || filter.attrs.help ||
  891. filter.attrs.name || filter.attrs.domain || 'Ω',
  892. domain: filter.attrs.domain,
  893. fieldName: filter.attrs.date,
  894. isPeriod: isPeriod,
  895. defaultOptionId: defaultPeriod,
  896. isActive: isActive,
  897. groupId: groupId,
  898. });
  899. self.filtersMapping.push({filterId: filterId, filter: filter, groupId: groupId});
  900. if (isPeriod) {
  901. self.periodMapping.push({filter: filter, period: defaultPeriod, type: filter.attrs.type});
  902. }
  903. }
  904. });
  905. self.groupsMapping.push({groupId: groupId, group: group, category: 'Filters'});
  906. });
  907. return new FiltersMenu(self, filters, self.fields);
  908. },
  909. /**
  910. * Create a groupby menu. Note that this method has a side effect: it
  911. * builds a mapping from a filter name to a 'search filter'.
  912. *
  913. * @private
  914. * @returns {Widget} the processed fieldsView
  915. */
  916. _createGroupByMenu: function () {
  917. var self = this;
  918. var groupbys = [];
  919. this.groupbys.forEach(function (group) {
  920. var groupId = _.uniqueId('__group__');
  921. group.filters.forEach(function (groupby) {
  922. if (!groupby.attrs.modifiers.invisible) {
  923. var groupbyId = _.uniqueId('__groupby__');
  924. var fieldName = groupby.attrs.fieldName;
  925. var isDate = groupby.attrs.isDate;
  926. var defaultInterval = groupby.attrs.defaultInterval || DEFAULT_INTERVAL;
  927. var isActive = !self.hasFavorites && !!self.search_defaults[groupby.attrs.name];
  928. groupbys.push({
  929. itemId: groupbyId,
  930. description: groupby.attrs.string || groupby.attrs.help || groupby.attrs.name
  931. || groupby.attrs.fieldName || 'Ω',
  932. isDate: isDate,
  933. fieldName: fieldName,
  934. defaultOptionId: defaultInterval,
  935. isActive: isActive,
  936. groupId: groupId,
  937. });
  938. if (isDate) {
  939. self.intervalMapping.push({groupby: groupby, interval: defaultInterval});
  940. }
  941. self.groupbysMapping.push({groupbyId: groupbyId, groupby: groupby, groupId: groupId});
  942. }
  943. });
  944. self.groupsMapping.push({groupId: groupId, group: group, category: 'Group By'});
  945. group.updateIntervalMapping(self.intervalMapping);
  946. });
  947. return new GroupByMenu(this, groupbys, this.fields);
  948. },
  949. /**
  950. * Create a time range menu.
  951. *
  952. * @private
  953. * @returns {Widget} the range menu
  954. */
  955. _createTimeRangeMenu: function () {
  956. return new TimeRangeMenu(this, this.fields, this.timeRanges);
  957. },
  958. /**
  959. * Processes a fieldsView in place. In particular, parses its arch.
  960. *
  961. * @todo: this function is also defined in AbstractView ; this code
  962. * duplication could be removed once the SearchView will be rewritten.
  963. * @private
  964. * @param {Object} fv
  965. * @param {string} fv.arch
  966. * @returns {Object} the processed fieldsView
  967. */
  968. _processFieldsView: function (fv) {
  969. var doc = $.parseXML(fv.arch).documentElement;
  970. fv.arch = utils.xml_to_json(doc, true);
  971. return fv;
  972. },
  973. /**
  974. * @returns {Deferred}
  975. */
  976. _searchDefaultTimeRange: function () {
  977. if (this.timeRanges) {
  978. var timeRange = "[]";
  979. var timeRangeDescription;
  980. var comparisonTimeRange = "[]";
  981. var comparisonTimeRangeDescription;
  982. var dateField = {
  983. name: this.timeRanges.field,
  984. type: this.fields[this.timeRanges.field].type,
  985. description: this.fields[this.timeRanges.field].string,
  986. };
  987. timeRange = Domain.prototype.constructDomain(
  988. dateField.name,
  989. this.timeRanges.range,
  990. dateField.type
  991. );
  992. timeRangeDescription = _.findWhere(
  993. PeriodOptions,
  994. {optionId: this.timeRanges.range}
  995. ).description;
  996. if (this.timeRanges.comparison_range) {
  997. comparisonTimeRange = Domain.prototype.constructDomain(
  998. dateField.name,
  999. this.timeRanges.range,
  1000. dateField.type,
  1001. null,
  1002. this.timeRanges.comparison_range
  1003. );
  1004. comparisonTimeRangeDescription = _.findWhere(
  1005. ComparisonOptions,
  1006. {optionId: this.timeRanges.comparison_range}
  1007. ).description;
  1008. }
  1009. return $.when({
  1010. cat: 'timeRangeCategory',
  1011. category: _t("Time Range"),
  1012. icon: 'fa fa-calendar',
  1013. field: {
  1014. get_context: function (facet, noDomainEvaluation) {
  1015. if (!noDomainEvaluation) {
  1016. timeRange = Domain.prototype.stringToArray(timeRange);
  1017. comparisonTimeRange = Domain.prototype.stringToArray(comparisonTimeRange);
  1018. }
  1019. return {
  1020. timeRangeMenuData: {
  1021. timeRange: timeRange,
  1022. timeRangeDescription: timeRangeDescription,
  1023. comparisonTimeRange: comparisonTimeRange,
  1024. comparisonTimeRangeDescription: comparisonTimeRangeDescription,
  1025. }
  1026. };
  1027. },
  1028. get_groupby: function () {},
  1029. get_domain: function () {}
  1030. },
  1031. isRange: true,
  1032. values: [{
  1033. label: dateField.description + ': ' + timeRangeDescription +
  1034. (
  1035. comparisonTimeRangeDescription ?
  1036. (' / ' + comparisonTimeRangeDescription) :
  1037. ''
  1038. ),
  1039. value: null,
  1040. }],
  1041. });
  1042. } else {
  1043. return $.when();
  1044. }
  1045. },
  1046. //--------------------------------------------------------------------------
  1047. // Handlers
  1048. //--------------------------------------------------------------------------
  1049. /**
  1050. * @rivate
  1051. * @param {CompositionEvent} ev
  1052. */
  1053. _onCompositionendInput: function () {
  1054. this._isInputComposing = false;
  1055. },
  1056. /**
  1057. * @rivate
  1058. * @param {CompositionEvent} ev
  1059. */
  1060. _onCompositionstartInput: function () {
  1061. this._isInputComposing = true;
  1062. },
  1063. /**
  1064. *
  1065. * this function is called in response to an event 'on_item_toggled'.
  1066. * this kind of event is triggered by the filters menu or the groupby menu
  1067. * when a user has clicked on a item (a filter or a groupby).
  1068. * The query is modified accordingly to the new state (active or not) of that item
  1069. *
  1070. * @private
  1071. * @param {OdooEvent} event
  1072. */
  1073. _onItemToggled: function (event) {
  1074. var group;
  1075. if (event.data.category === 'groupByCategory') {
  1076. var groupby = _.findWhere(this.groupbysMapping, {groupbyId: event.data.itemId}).groupby;
  1077. group = _.findWhere(this.groupsMapping, {groupId: event.data.groupId}).group;
  1078. if (event.data.optionId) {
  1079. var interval = event.data.optionId;
  1080. _.findWhere(this.intervalMapping, {groupby: groupby}).interval = interval;
  1081. group.updateIntervalMapping(this.intervalMapping);
  1082. }
  1083. group.toggle(groupby);
  1084. }
  1085. if (event.data.category === 'filterCategory') {
  1086. var filter = _.findWhere(this.filtersMapping, {filterId: event.data.itemId}).filter;
  1087. group = _.findWhere(this.groupsMapping, {groupId: event.data.groupId}).group;
  1088. if (event.data.optionId) {
  1089. var period = event.data.optionId;
  1090. _.findWhere(this.periodMapping, {filter: filter}).period = period;
  1091. group.updatePeriodMapping(this.periodMapping);
  1092. }
  1093. group.toggle(filter);
  1094. }
  1095. },
  1096. /**
  1097. * this function is called when a new groupby has been added to the groupby menu
  1098. * via the 'Add Custom Groupby' submenu. The query is modified with the new groupby
  1099. * added to it as active. The communication betwenn the groupby menu and the search view
  1100. * is maintained by properly updating the mappings.
  1101. * @private
  1102. * @param {OdooEvent} event
  1103. */
  1104. _onNewGroupby: function (event) {
  1105. var isDate = event.data.isDate;
  1106. var attrs = {
  1107. context:"{'group_by':'" + event.data.fieldName + "''}",
  1108. name: event.data.description,
  1109. fieldName: event.data.fieldName,
  1110. isDate: isDate,
  1111. };
  1112. var groupby = new search_inputs.Filter({attrs: attrs}, this);
  1113. if (event.data.optionId) {
  1114. var interval = event.data.optionId;
  1115. this.intervalMapping.push({groupby: groupby, interval: interval});
  1116. }
  1117. var group = new search_inputs.FilterGroup([groupby], this, this.intervalMapping, this.periodMapping);
  1118. this.groupbysMapping.push({
  1119. groupbyId: event.data.itemId,
  1120. groupby: groupby,
  1121. groupId: event.data.groupId,
  1122. });
  1123. this.groupsMapping.push({
  1124. groupId: event.data.groupId,
  1125. group: group,
  1126. category: 'Group By',
  1127. });
  1128. group.toggle(groupby);
  1129. },
  1130. /**
  1131. * this function is called when a new filter has been added to the filters menu
  1132. * via the 'Add Custom Filter' submenu. The query is modified with the new filter
  1133. * added to it as active. The communication betwenn the filters menu and the search view
  1134. * is maintained by properly updating the mappings.
  1135. * @private
  1136. * @param {OdooEvent} event
  1137. */
  1138. _onNewFilters: function (event) {
  1139. var self= this;
  1140. var filter;
  1141. var filters = [];
  1142. var groupId;
  1143. _.each(event.data, function (filterDescription) {
  1144. filter = new search_inputs.Filter(filterDescription.filter, this);
  1145. filters.push(filter);
  1146. self.filtersMapping.push({
  1147. filterId: filterDescription.itemId,
  1148. filter: filter,
  1149. groupId: filterDescription.groupId,
  1150. });
  1151. // filters belong to the same group
  1152. if (!groupId) {
  1153. groupId = filterDescription.groupId;
  1154. }
  1155. });
  1156. var group = new search_inputs.FilterGroup(filters, this, this.intervalMapping, this.periodMapping);
  1157. filters.forEach(function (filter) {
  1158. group.toggle(filter, {silent: true});
  1159. });
  1160. this.groupsMapping.push({
  1161. groupId: groupId,
  1162. group: group,
  1163. category: 'Filters',
  1164. });
  1165. this.query.trigger('reset');
  1166. },
  1167. /**
  1168. * this function is called when the option related to an item (filter or groupby) has been
  1169. * changed by the user. The query is modified appropriately.
  1170. *
  1171. * @private
  1172. * @param {OdooEvent} event
  1173. */
  1174. _onItemOptionChanged: function (event) {
  1175. var group;
  1176. if (event.data.category === 'groupByCategory') {
  1177. var groupby = _.findWhere(this.groupbysMapping, {groupbyId: event.data.itemId}).groupby;
  1178. var interval = event.data.optionId;
  1179. _.findWhere(this.intervalMapping, {groupby: groupby}).interval = interval;
  1180. group = _.findWhere(this.groupsMapping, {groupId: event.data.groupId}).group;
  1181. group.updateIntervalMapping(this.intervalMapping);
  1182. this.query.trigger('reset');
  1183. }
  1184. if (event.data.category === 'filterCategory') {
  1185. var filter = _.findWhere(this.filtersMapping, {filterId: event.data.itemId}).filter;
  1186. var period = event.data.optionId;
  1187. _.findWhere(this.periodMapping, {filter: filter}).period = period;
  1188. group = _.findWhere(this.groupsMapping, {groupId: event.data.groupId}).group;
  1189. group.updatePeriodMapping(this.periodMapping);
  1190. this.query.trigger('reset');
  1191. }
  1192. },
  1193. /*
  1194. * @private
  1195. * @param {JQueryEvent} event
  1196. */
  1197. _onTimeRangeModified: function () {
  1198. var facet = this.timeRangeMenu.facetFor();
  1199. var current = this.query.find(function (facet) {
  1200. return facet.get('cat') === 'timeRangeCategory';
  1201. });
  1202. if (current) {
  1203. this.query.remove(current, {silent: true});
  1204. }
  1205. this.query.add(facet);
  1206. },
  1207. /*
  1208. * @private
  1209. */
  1210. _onTimeRangeRemoved: function () {
  1211. var current = this.query.find(function (facet) {
  1212. return facet.get('cat') === 'timeRangeCategory';
  1213. });
  1214. if (current) {
  1215. this.query.remove(current);
  1216. }
  1217. },
  1218. });
  1219. _.extend(SearchView, {
  1220. SearchQuery: SearchQuery,
  1221. Facet: Facet,
  1222. });
  1223. return SearchView;
  1224. });