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.

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