Merge pull request #19087 from civicrm/5.32
[civicrm-core.git] / ext / search / ang / crmSearchAdmin / crmSearchAdmin.component.js
1 (function(angular, $, _) {
2 "use strict";
3
4 angular.module('crmSearchAdmin').component('crmSearchAdmin', {
5 bindings: {
6 savedSearch: '<'
7 },
8 templateUrl: '~/crmSearchAdmin/crmSearchAdmin.html',
9 controller: function($scope, $element, $location, $timeout, crmApi4, dialogService, searchMeta, formatForSelect2) {
10 var ts = $scope.ts = CRM.ts(),
11 ctrl = this;
12
13 this.DEFAULT_AGGREGATE_FN = 'GROUP_CONCAT';
14
15 this.selectedRows = [];
16 this.limit = CRM.cache.get('searchPageSize', 30);
17 this.page = 1;
18 this.displayTypes = _.indexBy(CRM.crmSearchAdmin.displayTypes, 'name');
19 // After a search this.results is an object of result arrays keyed by page,
20 // Initially this.results is an empty string because 1: it's falsey (unlike an empty object) and 2: it doesn't throw an error if you try to access undefined properties (unlike null)
21 this.results = '';
22 this.rowCount = false;
23 this.allRowsSelected = false;
24 // Have the filters (WHERE, HAVING, GROUP BY, JOIN) changed?
25 this.stale = true;
26
27 $scope.controls = {tab: 'compose'};
28 $scope.joinTypes = [{k: false, v: ts('Optional')}, {k: true, v: ts('Required')}];
29 $scope.groupOptions = CRM.crmSearchActions.groupOptions;
30 // Try to create a sensible list of entities one might want to search for,
31 // excluding those whos primary purpose is to provide joins or option lists to other entities
32 var primaryEntities = _.filter(CRM.vars.search.schema, function(entity) {
33 return !_.includes(entity.type, 'EntityBridge') && !_.includes(entity.type, 'OptionList');
34 });
35 $scope.entities = formatForSelect2(primaryEntities, 'name', 'title_plural', ['description', 'icon']);
36 this.perm = {
37 editGroups: CRM.checkPerm('edit groups')
38 };
39
40 this.$onInit = function() {
41 this.entityTitle = searchMeta.getEntity(this.savedSearch.api_entity).title_plural;
42
43 this.savedSearch.displays = this.savedSearch.displays || [];
44 this.savedSearch.groups = this.savedSearch.groups || [];
45 this.groupExists = !!this.savedSearch.groups.length;
46
47 if (!this.savedSearch.id) {
48 $scope.$bindToRoute({
49 param: 'params',
50 expr: '$ctrl.savedSearch.api_params',
51 deep: true,
52 default: {
53 version: 4,
54 select: getDefaultSelect(),
55 orderBy: {},
56 where: [],
57 }
58 });
59 }
60
61 $scope.$watchCollection('$ctrl.savedSearch.api_params.select', onChangeSelect);
62
63 $scope.$watch('$ctrl.savedSearch.api_params.where', onChangeFilters, true);
64
65 if (this.paramExists('groupBy')) {
66 this.savedSearch.api_params.groupBy = this.savedSearch.api_params.groupBy || [];
67 $scope.$watchCollection('$ctrl.savedSearch.api_params.groupBy', onChangeFilters);
68 }
69
70 if (this.paramExists('join')) {
71 this.savedSearch.api_params.join = this.savedSearch.api_params.join || [];
72 $scope.$watch('$ctrl.savedSearch.api_params.join', onChangeFilters, true);
73 }
74
75 if (this.paramExists('having')) {
76 this.savedSearch.api_params.having = this.savedSearch.api_params.having || [];
77 $scope.$watch('$ctrl.savedSearch.api_params.having', onChangeFilters, true);
78 }
79
80 $scope.$watch('$ctrl.savedSearch', onChangeAnything, true);
81
82 // After watcher runs for the first time and messes up the status, set it correctly
83 $timeout(function() {
84 $scope.status = ctrl.savedSearch && ctrl.savedSearch.id ? 'saved' : 'unsaved';
85 });
86
87 loadFieldOptions();
88 };
89
90 function onChangeAnything() {
91 $scope.status = 'unsaved';
92 }
93
94 this.save = function() {
95 if (!validate()) {
96 return;
97 }
98 $scope.status = 'saving';
99 var params = _.cloneDeep(ctrl.savedSearch),
100 apiCalls = {},
101 chain = {};
102 if (ctrl.groupExists) {
103 chain.groups = ['Group', 'save', {defaults: {saved_search_id: '$id'}, records: params.groups}];
104 delete params.groups;
105 } else if (params.id) {
106 apiCalls.deleteGroup = ['Group', 'delete', {where: [['saved_search_id', '=', params.id]]}];
107 }
108 if (params.displays && params.displays.length) {
109 chain.displays = ['SearchDisplay', 'replace', {where: [['saved_search_id', '=', '$id']], records: params.displays}];
110 } else if (params.id) {
111 apiCalls.deleteDisplays = ['SearchDisplay', 'delete', {where: [['saved_search_id', '=', params.id]]}];
112 }
113 delete params.displays;
114 apiCalls.saved = ['SavedSearch', 'save', {records: [params], chain: chain}, 0];
115 crmApi4(apiCalls).then(function(results) {
116 // After saving a new search, redirect to the edit url
117 if (!ctrl.savedSearch.id) {
118 $location.url('edit/' + results.saved.id);
119 }
120 // Set new status to saved unless the user changed something in the interim
121 var newStatus = $scope.status === 'unsaved' ? 'unsaved' : 'saved';
122 if (results.saved.groups && results.saved.groups.length) {
123 ctrl.savedSearch.groups[0].id = results.saved.groups[0].id;
124 }
125 ctrl.savedSearch.displays = results.saved.displays || [];
126 // Wait until after onChangeAnything to update status
127 $timeout(function() {
128 $scope.status = newStatus;
129 });
130 });
131 };
132
133 this.paramExists = function(param) {
134 return _.includes(searchMeta.getEntity(ctrl.savedSearch.api_entity).params, param);
135 };
136
137 this.addDisplay = function(type) {
138 ctrl.savedSearch.displays.push({
139 type: type,
140 label: ''
141 });
142 $scope.selectTab('display_' + (ctrl.savedSearch.displays.length - 1));
143 };
144
145 this.removeDisplay = function(index) {
146 var display = ctrl.savedSearch.displays[index];
147 if (display.id) {
148 display.trashed = !display.trashed;
149 if ($scope.controls.tab === ('display_' + index) && display.trashed) {
150 $scope.selectTab('compose');
151 } else if (!display.trashed) {
152 $scope.selectTab('display_' + index);
153 }
154 } else {
155 $scope.selectTab('compose');
156 ctrl.savedSearch.displays.splice(index, 1);
157 }
158 };
159
160 this.addGroup = function() {
161 ctrl.savedSearch.groups.push({
162 title: '',
163 description: '',
164 visibility: 'User and User Admin Only',
165 group_type: []
166 });
167 ctrl.groupExists = true;
168 $scope.selectTab('group');
169 };
170
171 $scope.selectTab = function(tab) {
172 if (tab === 'group') {
173 $scope.smartGroupColumns = searchMeta.getSmartGroupColumns(ctrl.savedSearch.api_entity, ctrl.savedSearch.api_params);
174 var smartGroupColumns = _.map($scope.smartGroupColumns, 'id');
175 if (smartGroupColumns.length && !_.includes(smartGroupColumns, ctrl.savedSearch.api_params.select[0])) {
176 ctrl.savedSearch.api_params.select.unshift(smartGroupColumns[0]);
177 }
178 }
179 ctrl.savedSearch.api_params.select = _.uniq(ctrl.savedSearch.api_params.select);
180 $scope.controls.tab = tab;
181 };
182
183 this.removeGroup = function() {
184 ctrl.groupExists = !ctrl.groupExists;
185 if (!ctrl.groupExists && (!ctrl.savedSearch.groups.length || !ctrl.savedSearch.groups[0].id)) {
186 ctrl.savedSearch.groups.length = 0;
187 }
188 if ($scope.controls.tab === 'group') {
189 $scope.selectTab('compose');
190 }
191 };
192
193 $scope.getJoinEntities = function() {
194 var joinEntities = _.transform(CRM.vars.search.links[ctrl.savedSearch.api_entity], function(joinEntities, link) {
195 var entity = searchMeta.getEntity(link.entity);
196 if (entity) {
197 joinEntities.push({
198 id: link.entity + ' AS ' + link.alias,
199 text: entity.title_plural,
200 description: '(' + link.alias + ')',
201 icon: entity.icon
202 });
203 }
204 }, []);
205 return {results: joinEntities};
206 };
207
208 $scope.addJoin = function() {
209 // Debounce the onchange event using timeout
210 $timeout(function() {
211 if ($scope.controls.join) {
212 ctrl.savedSearch.api_params.join = ctrl.savedSearch.api_params.join || [];
213 ctrl.savedSearch.api_params.join.push([$scope.controls.join, false]);
214 loadFieldOptions();
215 }
216 $scope.controls.join = '';
217 });
218 };
219
220 $scope.changeJoin = function(idx) {
221 if (ctrl.savedSearch.api_params.join[idx][0]) {
222 ctrl.savedSearch.api_params.join[idx].length = 2;
223 loadFieldOptions();
224 } else {
225 ctrl.clearParam('join', idx);
226 }
227 };
228
229 $scope.changeGroupBy = function(idx) {
230 if (!ctrl.savedSearch.api_params.groupBy[idx]) {
231 ctrl.clearParam('groupBy', idx);
232 }
233 // Remove aggregate functions when no grouping
234 if (!ctrl.savedSearch.api_params.groupBy.length) {
235 _.each(ctrl.savedSearch.api_params.select, function(col, pos) {
236 if (_.contains(col, '(')) {
237 var info = searchMeta.parseExpr(col);
238 if (info.fn.category === 'aggregate') {
239 ctrl.savedSearch.api_params.select[pos] = info.path + info.suffix;
240 }
241 }
242 });
243 }
244 };
245
246 function validate() {
247 var errors = [],
248 errorEl,
249 label,
250 tab;
251 if (!ctrl.savedSearch.label) {
252 errorEl = '#crm-saved-search-label';
253 label = ts('Search Label');
254 errors.push(ts('%1 is a required field.', {1: label}));
255 }
256 if (ctrl.groupExists && !ctrl.savedSearch.groups[0].title) {
257 errorEl = '#crm-search-admin-group-title';
258 label = ts('Group Title');
259 errors.push(ts('%1 is a required field.', {1: label}));
260 tab = 'group';
261 }
262 _.each(ctrl.savedSearch.displays, function(display, index) {
263 if (!display.trashed && !display.label) {
264 errorEl = '#crm-search-admin-display-label';
265 label = ts('Display Label');
266 errors.push(ts('%1 is a required field.', {1: label}));
267 tab = 'display_' + index;
268 }
269 });
270 if (errors.length) {
271 if (tab) {
272 $scope.selectTab(tab);
273 }
274 $(errorEl).crmError(errors.join('<br>'), ts('Error Saving'), {expires: 5000});
275 }
276 return !errors.length;
277 }
278
279 /**
280 * Called when clicking on a column header
281 * @param col
282 * @param $event
283 */
284 $scope.setOrderBy = function(col, $event) {
285 var dir = $scope.getOrderBy(col) === 'fa-sort-asc' ? 'DESC' : 'ASC';
286 if (!$event.shiftKey || !ctrl.savedSearch.api_params.orderBy) {
287 ctrl.savedSearch.api_params.orderBy = {};
288 }
289 ctrl.savedSearch.api_params.orderBy[col] = dir;
290 if (ctrl.results) {
291 ctrl.refreshPage();
292 }
293 };
294
295 /**
296 * Returns crm-i icon class for a sortable column
297 * @param col
298 * @returns {string}
299 */
300 $scope.getOrderBy = function(col) {
301 var dir = ctrl.savedSearch.api_params.orderBy && ctrl.savedSearch.api_params.orderBy[col];
302 if (dir) {
303 return 'fa-sort-' + dir.toLowerCase();
304 }
305 return 'fa-sort disabled';
306 };
307
308 $scope.addParam = function(name) {
309 if ($scope.controls[name] && !_.contains(ctrl.savedSearch.api_params[name], $scope.controls[name])) {
310 ctrl.savedSearch.api_params[name].push($scope.controls[name]);
311 if (name === 'groupBy') {
312 // Expand the aggregate block
313 $timeout(function() {
314 $('#crm-search-build-group-aggregate.collapsed .collapsible-title').click();
315 }, 10);
316 }
317 }
318 $scope.controls[name] = '';
319 };
320
321 // Deletes an item from an array param
322 this.clearParam = function(name, idx) {
323 ctrl.savedSearch.api_params[name].splice(idx, 1);
324 };
325
326 // Prevent visual jumps in results table height during loading
327 function lockTableHeight() {
328 var $table = $('.crm-search-results', $element);
329 $table.css('height', $table.height());
330 }
331
332 function unlockTableHeight() {
333 $('.crm-search-results', $element).css('height', '');
334 }
335
336 // Ensure all non-grouped columns are aggregated if using GROUP BY
337 function aggregateGroupByColumns() {
338 if (ctrl.savedSearch.api_params.groupBy.length) {
339 _.each(ctrl.savedSearch.api_params.select, function(col, pos) {
340 if (!_.contains(col, '(') && ctrl.canAggregate(col)) {
341 ctrl.savedSearch.api_params.select[pos] = ctrl.DEFAULT_AGGREGATE_FN + '(' + col + ')';
342 }
343 });
344 }
345 }
346
347 // Debounced callback for loadResults
348 function _loadResultsCallback() {
349 // Multiply limit to read 2 pages at once & save ajax requests
350 var params = _.merge(_.cloneDeep(ctrl.savedSearch.api_params), {debug: true, limit: ctrl.limit * 2});
351 // Select the ids of joined entities (helps with displaying links)
352 _.each(params.join, function(join) {
353 var idField = join[0].split(' AS ')[1] + '.id';
354 if (!_.includes(params.select, idField) && !ctrl.canAggregate(idField)) {
355 params.select.push(idField);
356 }
357 });
358 lockTableHeight();
359 $scope.error = false;
360 if (ctrl.stale) {
361 ctrl.page = 1;
362 ctrl.rowCount = false;
363 }
364 if (ctrl.rowCount === false) {
365 params.select.push('row_count');
366 }
367 params.offset = ctrl.limit * (ctrl.page - 1);
368 crmApi4(ctrl.savedSearch.api_entity, 'get', params).then(function(success) {
369 if (ctrl.stale) {
370 ctrl.results = {};
371 }
372 if (ctrl.rowCount === false) {
373 ctrl.rowCount = success.count;
374 }
375 ctrl.debug = success.debug;
376 // populate this page & the next
377 ctrl.results[ctrl.page] = success.slice(0, ctrl.limit);
378 if (success.length > ctrl.limit) {
379 ctrl.results[ctrl.page + 1] = success.slice(ctrl.limit);
380 }
381 $scope.loading = false;
382 ctrl.stale = false;
383 unlockTableHeight();
384 }, function(error) {
385 $scope.loading = false;
386 ctrl.results = {};
387 ctrl.stale = true;
388 ctrl.debug = error.debug;
389 $scope.error = errorMsg(error);
390 })
391 .finally(function() {
392 if (ctrl.debug) {
393 ctrl.debug.params = JSON.stringify(params, null, 2);
394 if (ctrl.debug.timeIndex) {
395 ctrl.debug.timeIndex = Number.parseFloat(ctrl.debug.timeIndex).toPrecision(2);
396 }
397 }
398 });
399 }
400
401 var _loadResults = _.debounce(_loadResultsCallback, 250);
402
403 function loadResults() {
404 $scope.loading = true;
405 aggregateGroupByColumns();
406 _loadResults();
407 }
408
409 // What to tell the user when search returns an error from the server
410 // Todo: parse error codes and give helpful feedback.
411 function errorMsg(error) {
412 return ts('Ensure all search critera are set correctly and try again.');
413 }
414
415 this.changePage = function() {
416 if (ctrl.stale || !ctrl.results[ctrl.page]) {
417 lockTableHeight();
418 loadResults();
419 }
420 };
421
422 this.refreshAll = function() {
423 ctrl.stale = true;
424 ctrl.selectedRows.length = 0;
425 loadResults();
426 };
427
428 // Refresh results while staying on current page.
429 this.refreshPage = function() {
430 lockTableHeight();
431 ctrl.results = {};
432 loadResults();
433 };
434
435 $scope.onClickSearch = function() {
436 if (ctrl.autoSearch) {
437 ctrl.autoSearch = false;
438 } else {
439 ctrl.refreshAll();
440 }
441 };
442
443 $scope.onClickAuto = function() {
444 ctrl.autoSearch = !ctrl.autoSearch;
445 if (ctrl.autoSearch && ctrl.stale) {
446 ctrl.refreshAll();
447 }
448 $('.crm-search-auto-toggle').blur();
449 };
450
451 $scope.onChangeLimit = function() {
452 // Refresh only if search has already been run
453 if (ctrl.autoSearch || ctrl.results) {
454 // Save page size in localStorage
455 CRM.cache.set('searchPageSize', ctrl.limit);
456 ctrl.refreshAll();
457 }
458 };
459
460 function onChangeSelect(newSelect, oldSelect) {
461 // When removing a column from SELECT, also remove from ORDER BY
462 _.each(_.difference(_.keys(ctrl.savedSearch.api_params.orderBy), newSelect), function(col) {
463 delete ctrl.savedSearch.api_params.orderBy[col];
464 });
465 // Re-arranging or removing columns doesn't merit a refresh, only adding columns does
466 if (!oldSelect || _.difference(newSelect, oldSelect).length) {
467 if (ctrl.autoSearch) {
468 ctrl.refreshPage();
469 } else {
470 ctrl.stale = true;
471 }
472 }
473 if (ctrl.load) {
474 ctrl.saved = false;
475 }
476 }
477
478 function onChangeFilters() {
479 ctrl.stale = true;
480 ctrl.selectedRows.length = 0;
481 if (ctrl.load) {
482 ctrl.saved = false;
483 }
484 if (ctrl.autoSearch) {
485 ctrl.refreshAll();
486 }
487 }
488
489 $scope.selectAllRows = function() {
490 // Deselect all
491 if (ctrl.allRowsSelected) {
492 ctrl.allRowsSelected = false;
493 ctrl.selectedRows.length = 0;
494 return;
495 }
496 // Select all
497 ctrl.allRowsSelected = true;
498 if (ctrl.page === 1 && ctrl.results[1].length < ctrl.limit) {
499 ctrl.selectedRows = _.pluck(ctrl.results[1], 'id');
500 return;
501 }
502 // If more than one page of results, use ajax to fetch all ids
503 $scope.loadingAllRows = true;
504 var params = _.cloneDeep(ctrl.savedSearch.api_params);
505 params.select = ['id'];
506 crmApi4(ctrl.savedSearch.api_entity, 'get', params, ['id']).then(function(ids) {
507 $scope.loadingAllRows = false;
508 ctrl.selectedRows = _.toArray(ids);
509 });
510 };
511
512 $scope.selectRow = function(row) {
513 var index = ctrl.selectedRows.indexOf(row.id);
514 if (index < 0) {
515 ctrl.selectedRows.push(row.id);
516 ctrl.allRowsSelected = (ctrl.rowCount === ctrl.selectedRows.length);
517 } else {
518 ctrl.allRowsSelected = false;
519 ctrl.selectedRows.splice(index, 1);
520 }
521 };
522
523 $scope.isRowSelected = function(row) {
524 return ctrl.allRowsSelected || _.includes(ctrl.selectedRows, row.id);
525 };
526
527 this.getFieldLabel = searchMeta.getDefaultLabel;
528
529 // Is a column eligible to use an aggregate function?
530 this.canAggregate = function(col) {
531 // If the query does not use grouping, never
532 if (!ctrl.savedSearch.api_params.groupBy.length) {
533 return false;
534 }
535 var info = searchMeta.parseExpr(col);
536 // If the column is used for a groupBy, no
537 if (ctrl.savedSearch.api_params.groupBy.indexOf(info.path) > -1) {
538 return false;
539 }
540 // If the entity this column belongs to is being grouped by id, then also no
541 return ctrl.savedSearch.api_params.groupBy.indexOf(info.prefix + 'id') < 0;
542 };
543
544 $scope.formatResult = function(row, col) {
545 var info = searchMeta.parseExpr(col),
546 key = info.fn ? (info.fn.name + ':' + info.path + info.suffix) : col,
547 value = row[key];
548 if (info.fn && info.fn.name === 'COUNT') {
549 return value;
550 }
551 // Output user-facing name/label fields as a link, if possible
552 if (info.field && _.includes(['display_name', 'title', 'label', 'subject'], info.field.name) && !info.fn && typeof value === 'string') {
553 var link = getEntityUrl(row, info);
554 if (link) {
555 return '<a href="' + _.escape(link.url) + '" title="' + _.escape(link.title) + '">' + formatFieldValue(info.field, value) + '</a>';
556 }
557 }
558 return formatFieldValue(info.field, value);
559 };
560
561 // Attempts to construct a view url for a given entity
562 function getEntityUrl(row, info) {
563 var entity = searchMeta.getEntity(info.field.entity),
564 path = _.result(_.findWhere(entity.paths, {action: 'view'}), 'path');
565 // Only proceed if the path metadata exists for this entity
566 if (path) {
567 // Replace tokens in the path (e.g. [id])
568 var tokens = path.match(/\[\w*]/g) || [],
569 replacements = _.transform(tokens, function(replacements, token) {
570 var fieldName = info.prefix + token.slice(1, token.length - 1);
571 if (row[fieldName]) {
572 replacements.push(row[fieldName]);
573 }
574 });
575 // Only proceed if the row contains all the necessary data to resolve tokens
576 if (tokens.length === replacements.length) {
577 _.each(tokens, function(token, index) {
578 path = path.replace(token, replacements[index]);
579 });
580 return {url: CRM.url(path), title: path.title};
581 }
582 }
583 }
584
585 function formatFieldValue(field, value) {
586 var type = field.data_type,
587 result = value;
588 if (_.isArray(value)) {
589 return _.map(value, function(val) {
590 return formatFieldValue(field, val);
591 }).join(', ');
592 }
593 if (value && (type === 'Date' || type === 'Timestamp') && /^\d{4}-\d{2}-\d{2}/.test(value)) {
594 result = CRM.utils.formatDate(value, null, type === 'Timestamp');
595 }
596 else if (type === 'Boolean' && typeof value === 'boolean') {
597 result = value ? ts('Yes') : ts('No');
598 }
599 else if (type === 'Money' && typeof value === 'number') {
600 result = CRM.formatMoney(value);
601 }
602 return _.escape(result);
603 }
604
605 $scope.fieldsForGroupBy = function() {
606 return {results: getAllFields('', function(key) {
607 return _.contains(ctrl.savedSearch.api_params.groupBy, key);
608 })
609 };
610 };
611
612 $scope.fieldsForSelect = function() {
613 return {results: getAllFields(':label', function(key) {
614 return _.contains(ctrl.savedSearch.api_params.select, key);
615 })
616 };
617 };
618
619 $scope.fieldsForWhere = function() {
620 return {results: getAllFields(':name', _.noop)};
621 };
622
623 $scope.fieldsForHaving = function() {
624 return {results: _.transform(ctrl.savedSearch.api_params.select, function(fields, name) {
625 fields.push({id: name, text: ctrl.getFieldLabel(name)});
626 })};
627 };
628
629 $scope.sortableColumnOptions = {
630 axis: 'x',
631 handle: '.crm-draggable',
632 update: function(e, ui) {
633 // Don't allow items to be moved to position 0 if locked
634 if (!ui.item.sortable.dropindex && ctrl.groupExists) {
635 ui.item.sortable.cancel();
636 }
637 }
638 };
639
640 // Sets the default select clause based on commonly-named fields
641 function getDefaultSelect() {
642 var whitelist = ['id', 'name', 'subject', 'display_name', 'label', 'title'];
643 return _.transform(searchMeta.getEntity(ctrl.savedSearch.api_entity).fields, function(select, field) {
644 if (_.includes(whitelist, field.name) || _.includes(field.name, '_type_id')) {
645 select.push(field.name + (field.options ? ':label' : ''));
646 }
647 });
648 }
649
650 function getAllFields(suffix, disabledIf) {
651 function formatFields(entityName, prefix) {
652 return _.transform(searchMeta.getEntity(entityName).fields, function(result, field) {
653 var item = {
654 id: prefix + field.name + (field.options ? suffix : ''),
655 text: field.label,
656 description: field.description
657 };
658 if (disabledIf(item.id)) {
659 item.disabled = true;
660 }
661 result.push(item);
662 }, []);
663 }
664
665 var mainEntity = searchMeta.getEntity(ctrl.savedSearch.api_entity),
666 result = [{
667 text: mainEntity.title_plural,
668 icon: mainEntity.icon,
669 children: formatFields(ctrl.savedSearch.api_entity, '')
670 }];
671 _.each(ctrl.savedSearch.api_params.join, function(join) {
672 var joinName = join[0].split(' AS '),
673 joinEntity = searchMeta.getEntity(joinName[0]);
674 result.push({
675 text: joinEntity.title_plural + ' (' + joinName[1] + ')',
676 icon: joinEntity.icon,
677 children: formatFields(joinEntity.name, joinName[1] + '.')
678 });
679 });
680 return result;
681 }
682
683 /**
684 * Fetch pseudoconstants for main entity + joined entities
685 *
686 * Sets an optionsLoaded property on each entity to avoid duplicate requests
687 */
688 function loadFieldOptions() {
689 var mainEntity = searchMeta.getEntity(ctrl.savedSearch.api_entity),
690 entities = {};
691
692 function enqueue(entity) {
693 entity.optionsLoaded = false;
694 entities[entity.name] = [entity.name, 'getFields', {
695 loadOptions: ['id', 'name', 'label', 'description', 'color', 'icon'],
696 where: [['options', '!=', false]],
697 select: ['options']
698 }, {name: 'options'}];
699 }
700
701 if (typeof mainEntity.optionsLoaded === 'undefined') {
702 enqueue(mainEntity);
703 }
704 _.each(ctrl.savedSearch.api_params.join, function(join) {
705 var joinName = join[0].split(' AS '),
706 joinEntity = searchMeta.getEntity(joinName[0]);
707 if (typeof joinEntity.optionsLoaded === 'undefined') {
708 enqueue(joinEntity);
709 }
710 });
711 if (!_.isEmpty(entities)) {
712 crmApi4(entities).then(function(results) {
713 _.each(results, function(fields, entityName) {
714 var entity = searchMeta.getEntity(entityName);
715 _.each(fields, function(options, fieldName) {
716 _.find(entity.fields, {name: fieldName}).options = options;
717 });
718 entity.optionsLoaded = true;
719 });
720 });
721 }
722 }
723
724 }
725 });
726
727 })(angular, CRM.$, CRM._);