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.

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