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.

585 lines
22 KiB

  1. /**********************************************************************************
  2. *
  3. * Copyright (C) 2017 MuK IT GmbH
  4. *
  5. * This program is free software: you can redistribute it and/or modify
  6. * it under the terms of the GNU Affero General Public License as
  7. * published by the Free Software Foundation, either version 3 of the
  8. * License, or (at your option) any later version.
  9. *
  10. * This program is distributed in the hope that it will be useful,
  11. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  12. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  13. * GNU Affero General Public License for more details.
  14. *
  15. * You should have received a copy of the GNU Affero General Public License
  16. * along with this program. If not, see <http://www.gnu.org/licenses/>.
  17. *
  18. **********************************************************************************/
  19. odoo.define('web.SearchPanel', function (require) {
  20. "use strict";
  21. var core = require('web.core');
  22. var Domain = require('web.Domain');
  23. var Widget = require('web.Widget');
  24. var qweb = core.qweb;
  25. var SearchPanel = Widget.extend({
  26. className: 'o_search_panel',
  27. events: {
  28. 'click .o_search_panel_category_value header': '_onCategoryValueClicked',
  29. 'click .o_search_panel_category_value .o_toggle_fold': '_onToggleFoldCategory',
  30. 'click .o_search_panel_filter_group .o_toggle_fold': '_onToggleFoldFilterGroup',
  31. 'change .o_search_panel_filter_value > div > input': '_onFilterValueChanged',
  32. 'change .o_search_panel_filter_group > div > input': '_onFilterGroupChanged',
  33. },
  34. /**
  35. * @override
  36. * @param {Object} params
  37. * @param {Object} [params.defaultCategoryValues={}] the category value to
  38. * activate by default, for each category
  39. * @param {Object} params.fields
  40. * @param {string} params.model
  41. * @param {Object} params.sections
  42. * @param {Array[]} params.searchDomain domain coming from controlPanel
  43. */
  44. init: function (parent, params) {
  45. this._super.apply(this, arguments);
  46. this.categories = _.pick(params.sections, function (section) {
  47. return section.type === 'category';
  48. });
  49. this.filters = _.pick(params.sections, function (section) {
  50. return section.type === 'filter';
  51. });
  52. this.defaultCategoryValues = params.defaultCategoryValues || {};
  53. this.fields = params.fields;
  54. this.model = params.model;
  55. this.searchDomain = params.searchDomain;
  56. this.loadProm = $.Deferred();
  57. this.loadPromLazy = true;
  58. },
  59. willStart: function () {
  60. var self = this;
  61. var loading = $.Deferred();
  62. var loadPromTimer = setTimeout(function () {
  63. if(loading.state() !== 'resolved') {
  64. loading.resolve();
  65. }
  66. }, this.loadPromMaxTime || 250);
  67. this._fetchCategories().then(function () {
  68. self._fetchFilters().then(function () {
  69. if(loading.state() !== 'resolved') {
  70. clearTimeout(loadPromTimer);
  71. self.loadPromLazy = false;
  72. loading.resolve();
  73. }
  74. self.loadProm.resolve();
  75. });
  76. });
  77. return $.when(loading, this._super.apply(this, arguments));
  78. },
  79. start: function () {
  80. var self = this;
  81. if(this.loadProm.state() !== 'resolved') {
  82. this.$el.html($("<div/>", {
  83. 'class': "o_search_panel_loading",
  84. 'html': "<i class='fa fa-spinner fa-pulse' />"
  85. }));
  86. }
  87. this.loadProm.then(function() {
  88. self._render();
  89. });
  90. return this._super.apply(this, arguments);
  91. },
  92. //--------------------------------------------------------------------------
  93. // Public
  94. //--------------------------------------------------------------------------
  95. /**
  96. * @returns {Array[]} the current searchPanel domain based on active
  97. * categories and checked filters
  98. */
  99. getDomain: function () {
  100. return this._getCategoryDomain().concat(this._getFilterDomain());
  101. },
  102. /**
  103. * Reload the filters and re-render. Note that we only reload the filters if
  104. * the controlPanel domain or searchPanel domain has changed.
  105. *
  106. * @param {Object} params
  107. * @param {Array[]} params.searchDomain domain coming from controlPanel
  108. * @returns {$.Promise}
  109. */
  110. update: function (params) {
  111. if(this.loadProm.state() === 'resolved') {
  112. var newSearchDomainStr = JSON.stringify(params.searchDomain);
  113. var currentSearchDomainStr = JSON.stringify(this.searchDomain);
  114. if (this.needReload || (currentSearchDomainStr !== newSearchDomainStr)) {
  115. this.needReload = false;
  116. this.searchDomain = params.searchDomain;
  117. this._fetchFilters().then(this._render.bind(this));
  118. } else {
  119. this._render();
  120. }
  121. }
  122. return $.when();
  123. },
  124. //--------------------------------------------------------------------------
  125. // Private
  126. //--------------------------------------------------------------------------
  127. /**
  128. * @private
  129. * @param {string} categoryId
  130. * @param {Object[]} values
  131. */
  132. _createCategoryTree: function (categoryId, values) {
  133. var category = this.categories[categoryId];
  134. var parentField = category.parentField;
  135. category.values = {};
  136. values.forEach(function (value) {
  137. category.values[value.id] = _.extend({}, value, {
  138. childrenIds: [],
  139. folded: true,
  140. parentId: value[parentField] && value[parentField][0] || false,
  141. });
  142. });
  143. Object.keys(category.values).forEach(function (valueId) {
  144. var value = category.values[valueId];
  145. if (value.parentId && category.values[value.parentId]) {
  146. category.values[value.parentId].childrenIds.push(value.id);
  147. } else {
  148. value.parentId = false;
  149. value[parentField] = false;
  150. }
  151. });
  152. category.rootIds = Object.keys(category.values).filter(function (valueId) {
  153. var value = category.values[valueId];
  154. return value.parentId === false;
  155. });
  156. category.activeValueId = false;
  157. if(!this.loadPromLazy) {
  158. // set active value
  159. var validValues = _.pluck(category.values, 'id').concat([false]);
  160. // set active value from context
  161. var value = this.defaultCategoryValues[category.fieldName];
  162. // if not set in context, or set to an unknown value, set active value
  163. // from localStorage
  164. if (!_.contains(validValues, value)) {
  165. var storageKey = this._getLocalStorageKey(category);
  166. value = this.call('local_storage', 'getItem', storageKey);
  167. }
  168. // if not set in localStorage either, select 'All'
  169. category.activeValueId = _.contains(validValues, value) ? value : false;
  170. // unfold ancestor values of active value to make it is visible
  171. if (category.activeValueId) {
  172. var parentValueIds = this._getAncestorValueIds(category, category.activeValueId);
  173. parentValueIds.forEach(function (parentValue) {
  174. category.values[parentValue].folded = false;
  175. });
  176. }
  177. }
  178. },
  179. /**
  180. * @private
  181. * @param {string} filterId
  182. * @param {Object[]} values
  183. */
  184. _createFilterTree: function (filterId, values) {
  185. var filter = this.filters[filterId];
  186. // restore checked property
  187. values.forEach(function (value) {
  188. var oldValue = filter.values && filter.values[value.id];
  189. value.checked = oldValue && oldValue.checked || false;
  190. });
  191. filter.values = {};
  192. var groupIds = [];
  193. if (filter.groupBy) {
  194. var groups = {};
  195. values.forEach(function (value) {
  196. var groupId = value.group_id;
  197. if (!groups[groupId]) {
  198. if (groupId) {
  199. groupIds.push(groupId);
  200. }
  201. groups[groupId] = {
  202. folded: false,
  203. id: groupId,
  204. name: value.group_name,
  205. values: {},
  206. tooltip: value.group_tooltip,
  207. sequence: value.group_sequence,
  208. sortedValueIds: [],
  209. };
  210. // restore former checked and folded state
  211. var oldGroup = filter.groups && filter.groups[groupId];
  212. groups[groupId].state = oldGroup && oldGroup.state || false;
  213. groups[groupId].folded = oldGroup && oldGroup.folded || false;
  214. }
  215. groups[groupId].values[value.id] = value;
  216. groups[groupId].sortedValueIds.push(value.id);
  217. });
  218. filter.groups = groups;
  219. filter.sortedGroupIds = _.sortBy(groupIds, function (groupId) {
  220. return groups[groupId].sequence || groups[groupId].name;
  221. });
  222. Object.keys(filter.groups).forEach(function (groupId) {
  223. filter.values = _.extend(filter.values, filter.groups[groupId].values);
  224. });
  225. } else {
  226. values.forEach(function (value) {
  227. filter.values[value.id] = value;
  228. });
  229. filter.sortedValueIds = values.map(function (value) {
  230. return value.id;
  231. });
  232. }
  233. },
  234. /**
  235. * Fetch values for each category. This is done only once, at startup.
  236. *
  237. * @private
  238. * @returns {$.Promise} resolved when all categories have been fetched
  239. */
  240. _fetchCategories: function () {
  241. var self = this;
  242. var defs = Object.keys(this.categories).map(function (categoryId) {
  243. var category = self.categories[categoryId];
  244. var field = self.fields[category.fieldName];
  245. var def;
  246. if (field.type === 'selection') {
  247. var values = field.selection.map(function (value) {
  248. return {id: value[0], display_name: value[1]};
  249. });
  250. def = $.when(values);
  251. } else {
  252. var categoryDomain = self._getCategoryDomain();
  253. var filterDomain = self._getFilterDomain();
  254. def = self._rpc({
  255. method: 'search_panel_select_range',
  256. model: self.model,
  257. args: [category.fieldName],
  258. kwargs: {
  259. category_domain: categoryDomain,
  260. filter_domain: filterDomain,
  261. search_domain: self.searchDomain,
  262. },
  263. }, {
  264. shadow: true,
  265. }).then(function (result) {
  266. category.parentField = result.parent_field;
  267. return result.values;
  268. });
  269. }
  270. return def.then(function (values) {
  271. self._createCategoryTree(categoryId, values);
  272. });
  273. });
  274. return $.when.apply($, defs);
  275. },
  276. /**
  277. * Fetch values for each filter. This is done at startup, and at each reload
  278. * (when the controlPanel or searchPanel domain changes).
  279. *
  280. * @private
  281. * @returns {$.Promise} resolved when all filters have been fetched
  282. */
  283. _fetchFilters: function () {
  284. var self = this;
  285. var evalContext = {};
  286. Object.keys(this.categories).forEach(function (categoryId) {
  287. var category = self.categories[categoryId];
  288. evalContext[category.fieldName] = category.activeValueId;
  289. });
  290. var categoryDomain = this._getCategoryDomain();
  291. var filterDomain = this._getFilterDomain();
  292. var defs = Object.keys(this.filters).map(function (filterId) {
  293. var filter = self.filters[filterId];
  294. return self._rpc({
  295. method: 'search_panel_select_multi_range',
  296. model: self.model,
  297. args: [filter.fieldName],
  298. kwargs: {
  299. category_domain: categoryDomain,
  300. comodel_domain: Domain.prototype.stringToArray(filter.domain, evalContext),
  301. disable_counters: filter.disableCounters,
  302. filter_domain: filterDomain,
  303. group_by: filter.groupBy || false,
  304. search_domain: self.searchDomain,
  305. },
  306. }, {
  307. shadow: true,
  308. }).then(function (values) {
  309. self._createFilterTree(filterId, values);
  310. });
  311. });
  312. return $.when.apply($, defs);
  313. },
  314. /**
  315. * Compute and return the domain based on the current active categories.
  316. *
  317. * @private
  318. * @returns {Array[]}
  319. */
  320. _getCategoryDomain: function () {
  321. var self = this;
  322. function categoryToDomain(domain, categoryId) {
  323. var category = self.categories[categoryId];
  324. if (category.activeValueId) {
  325. domain.push([category.fieldName, '=', category.activeValueId]);
  326. } else if(self.loadPromLazy && self.loadProm.state() !== 'resolved') {
  327. var value = self.defaultCategoryValues[category.fieldName];
  328. if (value) {
  329. domain.push([category.fieldName, '=', value]);
  330. }
  331. }
  332. return domain;
  333. }
  334. return Object.keys(this.categories).reduce(categoryToDomain, []);
  335. },
  336. /**
  337. * Compute and return the domain based on the current checked filters.
  338. * The values of a single filter are combined using a simple rule: checked values within
  339. * a same group are combined with an 'OR' (this is expressed as single condition using a list)
  340. * and groups are combined with an 'AND' (expressed by concatenation of conditions).
  341. * If a filter has no groups, its checked values are implicitely considered as forming
  342. * a group (and grouped using an 'OR').
  343. *
  344. * @private
  345. * @returns {Array[]}
  346. */
  347. _getFilterDomain: function () {
  348. var self = this;
  349. function getCheckedValueIds(values) {
  350. return Object.keys(values).reduce(function (checkedValues, valueId) {
  351. if (values[valueId].checked) {
  352. checkedValues.push(values[valueId].id);
  353. }
  354. return checkedValues;
  355. }, []);
  356. }
  357. function filterToDomain(domain, filterId) {
  358. var filter = self.filters[filterId];
  359. if (filter.groups) {
  360. Object.keys(filter.groups).forEach(function (groupId) {
  361. var group = filter.groups[groupId];
  362. var checkedValues = getCheckedValueIds(group.values);
  363. if (checkedValues.length) {
  364. domain.push([filter.fieldName, 'in', checkedValues]);
  365. }
  366. });
  367. } else if (filter.values) {
  368. var checkedValues = getCheckedValueIds(filter.values);
  369. if (checkedValues.length) {
  370. domain.push([filter.fieldName, 'in', checkedValues]);
  371. }
  372. }
  373. return domain;
  374. }
  375. return Object.keys(this.filters).reduce(filterToDomain, []);
  376. },
  377. /**
  378. * The active id of each category is stored in the localStorage, s.t. it
  379. * can be restored afterwards (when the action is reloaded, for instance).
  380. * This function returns the key in the sessionStorage for a given category.
  381. *
  382. * @param {Object} category
  383. * @returns {string}
  384. */
  385. _getLocalStorageKey: function (category) {
  386. return 'searchpanel_' + this.model + '_' + category.fieldName;
  387. },
  388. /**
  389. * @private
  390. * @param {Object} category
  391. * @param {integer} categoryValueId
  392. * @returns {integer[]} list of ids of the ancestors of the given value in
  393. * the given category
  394. */
  395. _getAncestorValueIds: function (category, categoryValueId) {
  396. var categoryValue = category.values[categoryValueId];
  397. var parentId = categoryValue.parentId;
  398. if (parentId) {
  399. return [parentId].concat(this._getAncestorValueIds(category, parentId));
  400. }
  401. return [];
  402. },
  403. /**
  404. * @private
  405. */
  406. _render: function () {
  407. var self = this;
  408. this.$el.empty();
  409. // sort categories and filters according to their index
  410. var categories = Object.keys(this.categories).map(function (categoryId) {
  411. return self.categories[categoryId];
  412. });
  413. var filters = Object.keys(this.filters).map(function (filterId) {
  414. return self.filters[filterId];
  415. });
  416. var sections = categories.concat(filters).sort(function (s1, s2) {
  417. return s1.index - s2.index;
  418. });
  419. sections.forEach(function (section) {
  420. if (Object.keys(section.values).length) {
  421. if (section.type === 'category') {
  422. self.$el.append(self._renderCategory(section));
  423. } else {
  424. self.$el.append(self._renderFilter(section));
  425. }
  426. }
  427. });
  428. },
  429. /**
  430. * @private
  431. * @param {Object} category
  432. * @returns {string}
  433. */
  434. _renderCategory: function (category) {
  435. return qweb.render('SearchPanel.Category', {category: category});
  436. },
  437. /**
  438. * @private
  439. * @param {Object} filter
  440. * @returns {jQuery}
  441. */
  442. _renderFilter: function (filter) {
  443. var $filter = $(qweb.render('SearchPanel.Filter', {filter: filter}));
  444. // set group inputs in indeterminate state when necessary
  445. Object.keys(filter.groups || {}).forEach(function (groupId) {
  446. var state = filter.groups[groupId].state;
  447. // group 'false' is not displayed
  448. if (groupId !== 'false' && state === 'indeterminate') {
  449. $filter
  450. .find('.o_search_panel_filter_group[data-group-id=' + groupId + '] input')
  451. .get(0)
  452. .indeterminate = true;
  453. }
  454. });
  455. return $filter;
  456. },
  457. /**
  458. * Compute the current searchPanel domain based on categories and filters,
  459. * and notify environment of the domain change.
  460. *
  461. * Note that this assumes that the environment will update the searchPanel.
  462. * This is done as such to ensure the coordination between the reloading of
  463. * the searchPanel and the reloading of the data.
  464. *
  465. * @private
  466. */
  467. _notifyDomainUpdated: function () {
  468. this.needReload = true;
  469. this.trigger_up('search_panel_domain_updated', {
  470. domain: this.getDomain(),
  471. });
  472. },
  473. //--------------------------------------------------------------------------
  474. // Handlers
  475. //--------------------------------------------------------------------------
  476. /**
  477. * @private
  478. * @param {MouseEvent} ev
  479. */
  480. _onCategoryValueClicked: function (ev) {
  481. ev.stopPropagation();
  482. var $item = $(ev.currentTarget).closest('.o_search_panel_category_value');
  483. var category = this.categories[$item.data('categoryId')];
  484. var valueId = $item.data('id') || false;
  485. category.activeValueId = valueId;
  486. var storageKey = this._getLocalStorageKey(category);
  487. this.call('local_storage', 'setItem', storageKey, valueId);
  488. this._notifyDomainUpdated();
  489. },
  490. /**
  491. * @private
  492. * @param {MouseEvent} ev
  493. */
  494. _onFilterGroupChanged: function (ev) {
  495. ev.stopPropagation();
  496. var $item = $(ev.target).closest('.o_search_panel_filter_group');
  497. var filter = this.filters[$item.data('filterId')];
  498. var groupId = $item.data('groupId');
  499. var group = filter.groups[groupId];
  500. group.state = group.state === 'checked' ? 'unchecked' : 'checked';
  501. Object.keys(group.values).forEach(function (valueId) {
  502. group.values[valueId].checked = group.state === 'checked';
  503. });
  504. this._notifyDomainUpdated();
  505. },
  506. /**
  507. * @private
  508. * @param {MouseEvent} ev
  509. */
  510. _onFilterValueChanged: function (ev) {
  511. ev.stopPropagation();
  512. var $item = $(ev.target).closest('.o_search_panel_filter_value');
  513. var valueId = $item.data('valueId');
  514. var filter = this.filters[$item.data('filterId')];
  515. var value = filter.values[valueId];
  516. value.checked = !value.checked;
  517. var group = filter.groups && filter.groups[value.group_id];
  518. if (group) {
  519. var valuePartition = _.partition(Object.keys(group.values), function (valueId) {
  520. return group.values[valueId].checked;
  521. });
  522. if (valuePartition[0].length && valuePartition[1].length) {
  523. group.state = 'indeterminate';
  524. } else if (valuePartition[0].length) {
  525. group.state = 'checked';
  526. } else {
  527. group.state = 'unchecked';
  528. }
  529. }
  530. this._notifyDomainUpdated();
  531. },
  532. /**
  533. * @private
  534. * @param {MouseEvent} ev
  535. */
  536. _onToggleFoldCategory: function (ev) {
  537. ev.preventDefault();
  538. ev.stopPropagation();
  539. var $item = $(ev.currentTarget).closest('.o_search_panel_category_value');
  540. var category = this.categories[$item.data('categoryId')];
  541. var valueId = $item.data('id');
  542. category.values[valueId].folded = !category.values[valueId].folded;
  543. this._render();
  544. },
  545. /**
  546. * @private
  547. * @param {MouseEvent} ev
  548. */
  549. _onToggleFoldFilterGroup: function (ev) {
  550. ev.preventDefault();
  551. ev.stopPropagation();
  552. var $item = $(ev.currentTarget).closest('.o_search_panel_filter_group');
  553. var filter = this.filters[$item.data('filterId')];
  554. var groupId = $item.data('groupId');
  555. filter.groups[groupId].folded = !filter.groups[groupId].folded;
  556. this._render();
  557. },
  558. });
  559. return SearchPanel;
  560. });