Merge in 5.33
[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.crmSearchAdmin.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.getJoin = searchMeta.getJoin;
194
195 $scope.getJoinEntities = function() {
196 var joinEntities = _.transform(CRM.crmSearchAdmin.joins[ctrl.savedSearch.api_entity], function(joinEntities, join) {
197 var entity = searchMeta.getEntity(join.entity);
198 if (entity) {
199 joinEntities.push({
200 id: join.entity + ' AS ' + join.alias,
201 description: join.description,
202 text: join.label,
203 icon: entity.icon
204 });
205 }
206 }, []);
207 return {results: joinEntities};
208 };
209
210 $scope.addJoin = function() {
211 // Debounce the onchange event using timeout
212 $timeout(function() {
213 if ($scope.controls.join) {
214 ctrl.savedSearch.api_params.join = ctrl.savedSearch.api_params.join || [];
215 var join = searchMeta.getJoin($scope.controls.join),
216 params = [$scope.controls.join, false];
217 _.each(_.cloneDeep(join.conditions), function(condition) {
218 params.push(condition);
219 });
220 ctrl.savedSearch.api_params.join.push(params);
221 loadFieldOptions();
222 }
223 $scope.controls.join = '';
224 });
225 };
226
227 $scope.changeJoin = function(idx) {
228 if (ctrl.savedSearch.api_params.join[idx][0]) {
229 ctrl.savedSearch.api_params.join[idx].length = 2;
230 loadFieldOptions();
231 } else {
232 ctrl.clearParam('join', idx);
233 }
234 };
235
236 $scope.changeGroupBy = function(idx) {
237 if (!ctrl.savedSearch.api_params.groupBy[idx]) {
238 ctrl.clearParam('groupBy', idx);
239 }
240 // Remove aggregate functions when no grouping
241 if (!ctrl.savedSearch.api_params.groupBy.length) {
242 _.each(ctrl.savedSearch.api_params.select, function(col, pos) {
243 if (_.contains(col, '(')) {
244 var info = searchMeta.parseExpr(col);
245 if (info.fn.category === 'aggregate') {
246 ctrl.savedSearch.api_params.select[pos] = info.path + info.suffix;
247 }
248 }
249 });
250 }
251 };
252
253 function validate() {
254 var errors = [],
255 errorEl,
256 label,
257 tab;
258 if (!ctrl.savedSearch.label) {
259 errorEl = '#crm-saved-search-label';
260 label = ts('Search Label');
261 errors.push(ts('%1 is a required field.', {1: label}));
262 }
263 if (ctrl.groupExists && !ctrl.savedSearch.groups[0].title) {
264 errorEl = '#crm-search-admin-group-title';
265 label = ts('Group Title');
266 errors.push(ts('%1 is a required field.', {1: label}));
267 tab = 'group';
268 }
269 _.each(ctrl.savedSearch.displays, function(display, index) {
270 if (!display.trashed && !display.label) {
271 errorEl = '#crm-search-admin-display-label';
272 label = ts('Display Label');
273 errors.push(ts('%1 is a required field.', {1: label}));
274 tab = 'display_' + index;
275 }
276 });
277 if (errors.length) {
278 if (tab) {
279 $scope.selectTab(tab);
280 }
281 $(errorEl).crmError(errors.join('<br>'), ts('Error Saving'), {expires: 5000});
282 }
283 return !errors.length;
284 }
285
286 /**
287 * Called when clicking on a column header
288 * @param col
289 * @param $event
290 */
291 $scope.setOrderBy = function(col, $event) {
292 var dir = $scope.getOrderBy(col) === 'fa-sort-asc' ? 'DESC' : 'ASC';
293 if (!$event.shiftKey || !ctrl.savedSearch.api_params.orderBy) {
294 ctrl.savedSearch.api_params.orderBy = {};
295 }
296 ctrl.savedSearch.api_params.orderBy[col] = dir;
297 if (ctrl.results) {
298 ctrl.refreshPage();
299 }
300 };
301
302 /**
303 * Returns crm-i icon class for a sortable column
304 * @param col
305 * @returns {string}
306 */
307 $scope.getOrderBy = function(col) {
308 var dir = ctrl.savedSearch.api_params.orderBy && ctrl.savedSearch.api_params.orderBy[col];
309 if (dir) {
310 return 'fa-sort-' + dir.toLowerCase();
311 }
312 return 'fa-sort disabled';
313 };
314
315 $scope.addParam = function(name) {
316 if ($scope.controls[name] && !_.contains(ctrl.savedSearch.api_params[name], $scope.controls[name])) {
317 ctrl.savedSearch.api_params[name].push($scope.controls[name]);
318 if (name === 'groupBy') {
319 // Expand the aggregate block
320 $timeout(function() {
321 $('#crm-search-build-group-aggregate.collapsed .collapsible-title').click();
322 }, 10);
323 }
324 }
325 $scope.controls[name] = '';
326 };
327
328 // Deletes an item from an array param
329 this.clearParam = function(name, idx) {
330 ctrl.savedSearch.api_params[name].splice(idx, 1);
331 };
332
333 // Prevent visual jumps in results table height during loading
334 function lockTableHeight() {
335 var $table = $('.crm-search-results', $element);
336 $table.css('height', $table.height());
337 }
338
339 function unlockTableHeight() {
340 $('.crm-search-results', $element).css('height', '');
341 }
342
343 // Ensure all non-grouped columns are aggregated if using GROUP BY
344 function aggregateGroupByColumns() {
345 if (ctrl.savedSearch.api_params.groupBy.length) {
346 _.each(ctrl.savedSearch.api_params.select, function(col, pos) {
347 if (!_.contains(col, '(') && ctrl.canAggregate(col)) {
348 ctrl.savedSearch.api_params.select[pos] = ctrl.DEFAULT_AGGREGATE_FN + '(' + col + ')';
349 }
350 });
351 }
352 }
353
354 // Debounced callback for loadResults
355 function _loadResultsCallback() {
356 // Multiply limit to read 2 pages at once & save ajax requests
357 var params = _.merge(_.cloneDeep(ctrl.savedSearch.api_params), {debug: true, limit: ctrl.limit * 2});
358 // Select the ids of joined entities (helps with displaying links)
359 _.each(params.join, function(join) {
360 var idField = join[0].split(' AS ')[1] + '.id';
361 if (!_.includes(params.select, idField) && !ctrl.canAggregate(idField)) {
362 params.select.push(idField);
363 }
364 });
365 lockTableHeight();
366 $scope.error = false;
367 if (ctrl.stale) {
368 ctrl.page = 1;
369 ctrl.rowCount = false;
370 }
371 if (ctrl.rowCount === false) {
372 params.select.push('row_count');
373 }
374 params.offset = ctrl.limit * (ctrl.page - 1);
375 crmApi4(ctrl.savedSearch.api_entity, 'get', params).then(function(success) {
376 if (ctrl.stale) {
377 ctrl.results = {};
378 }
379 if (ctrl.rowCount === false) {
380 ctrl.rowCount = success.count;
381 }
382 ctrl.debug = success.debug;
383 // populate this page & the next
384 ctrl.results[ctrl.page] = success.slice(0, ctrl.limit);
385 if (success.length > ctrl.limit) {
386 ctrl.results[ctrl.page + 1] = success.slice(ctrl.limit);
387 }
388 $scope.loading = false;
389 ctrl.stale = false;
390 unlockTableHeight();
391 }, function(error) {
392 $scope.loading = false;
393 ctrl.results = {};
394 ctrl.stale = true;
395 ctrl.debug = error.debug;
396 $scope.error = errorMsg(error);
397 })
398 .finally(function() {
399 if (ctrl.debug) {
400 ctrl.debug.params = JSON.stringify(params, null, 2);
401 if (ctrl.debug.timeIndex) {
402 ctrl.debug.timeIndex = Number.parseFloat(ctrl.debug.timeIndex).toPrecision(2);
403 }
404 }
405 });
406 }
407
408 var _loadResults = _.debounce(_loadResultsCallback, 250);
409
410 function loadResults() {
411 $scope.loading = true;
412 aggregateGroupByColumns();
413 _loadResults();
414 }
415
416 // What to tell the user when search returns an error from the server
417 // Todo: parse error codes and give helpful feedback.
418 function errorMsg(error) {
419 return ts('Ensure all search critera are set correctly and try again.');
420 }
421
422 this.changePage = function() {
423 if (ctrl.stale || !ctrl.results[ctrl.page]) {
424 lockTableHeight();
425 loadResults();
426 }
427 };
428
429 this.refreshAll = function() {
430 ctrl.stale = true;
431 ctrl.selectedRows.length = 0;
432 loadResults();
433 };
434
435 // Refresh results while staying on current page.
436 this.refreshPage = function() {
437 lockTableHeight();
438 ctrl.results = {};
439 loadResults();
440 };
441
442 $scope.onClickSearch = function() {
443 if (ctrl.autoSearch) {
444 ctrl.autoSearch = false;
445 } else {
446 ctrl.refreshAll();
447 }
448 };
449
450 $scope.onClickAuto = function() {
451 ctrl.autoSearch = !ctrl.autoSearch;
452 if (ctrl.autoSearch && ctrl.stale) {
453 ctrl.refreshAll();
454 }
455 $('.crm-search-auto-toggle').blur();
456 };
457
458 $scope.onChangeLimit = function() {
459 // Refresh only if search has already been run
460 if (ctrl.autoSearch || ctrl.results) {
461 // Save page size in localStorage
462 CRM.cache.set('searchPageSize', ctrl.limit);
463 ctrl.refreshAll();
464 }
465 };
466
467 function onChangeSelect(newSelect, oldSelect) {
468 // When removing a column from SELECT, also remove from ORDER BY
469 _.each(_.difference(_.keys(ctrl.savedSearch.api_params.orderBy), newSelect), function(col) {
470 delete ctrl.savedSearch.api_params.orderBy[col];
471 });
472 // Re-arranging or removing columns doesn't merit a refresh, only adding columns does
473 if (!oldSelect || _.difference(newSelect, oldSelect).length) {
474 if (ctrl.autoSearch) {
475 ctrl.refreshPage();
476 } else {
477 ctrl.stale = true;
478 }
479 }
480 if (ctrl.load) {
481 ctrl.saved = false;
482 }
483 }
484
485 function onChangeFilters() {
486 ctrl.stale = true;
487 ctrl.selectedRows.length = 0;
488 if (ctrl.load) {
489 ctrl.saved = false;
490 }
491 if (ctrl.autoSearch) {
492 ctrl.refreshAll();
493 }
494 }
495
496 $scope.selectAllRows = function() {
497 // Deselect all
498 if (ctrl.allRowsSelected) {
499 ctrl.allRowsSelected = false;
500 ctrl.selectedRows.length = 0;
501 return;
502 }
503 // Select all
504 ctrl.allRowsSelected = true;
505 if (ctrl.page === 1 && ctrl.results[1].length < ctrl.limit) {
506 ctrl.selectedRows = _.pluck(ctrl.results[1], 'id');
507 return;
508 }
509 // If more than one page of results, use ajax to fetch all ids
510 $scope.loadingAllRows = true;
511 var params = _.cloneDeep(ctrl.savedSearch.api_params);
512 params.select = ['id'];
513 crmApi4(ctrl.savedSearch.api_entity, 'get', params, ['id']).then(function(ids) {
514 $scope.loadingAllRows = false;
515 ctrl.selectedRows = _.toArray(ids);
516 });
517 };
518
519 $scope.selectRow = function(row) {
520 var index = ctrl.selectedRows.indexOf(row.id);
521 if (index < 0) {
522 ctrl.selectedRows.push(row.id);
523 ctrl.allRowsSelected = (ctrl.rowCount === ctrl.selectedRows.length);
524 } else {
525 ctrl.allRowsSelected = false;
526 ctrl.selectedRows.splice(index, 1);
527 }
528 };
529
530 $scope.isRowSelected = function(row) {
531 return ctrl.allRowsSelected || _.includes(ctrl.selectedRows, row.id);
532 };
533
534 this.getFieldLabel = searchMeta.getDefaultLabel;
535
536 // Is a column eligible to use an aggregate function?
537 this.canAggregate = function(col) {
538 // If the query does not use grouping, never
539 if (!ctrl.savedSearch.api_params.groupBy.length) {
540 return false;
541 }
542 var info = searchMeta.parseExpr(col);
543 // If the column is used for a groupBy, no
544 if (ctrl.savedSearch.api_params.groupBy.indexOf(info.path) > -1) {
545 return false;
546 }
547 // If the entity this column belongs to is being grouped by id, then also no
548 return ctrl.savedSearch.api_params.groupBy.indexOf(info.prefix + 'id') < 0;
549 };
550
551 $scope.formatResult = function(row, col) {
552 var info = searchMeta.parseExpr(col),
553 key = info.fn ? (info.fn.name + ':' + info.path + info.suffix) : col,
554 value = row[key];
555 if (info.fn && info.fn.name === 'COUNT') {
556 return value;
557 }
558 // Output user-facing name/label fields as a link, if possible
559 if (info.field && _.includes(['display_name', 'title', 'label', 'subject'], info.field.name) && !info.fn && typeof value === 'string') {
560 var link = getEntityUrl(row, info);
561 if (link) {
562 return '<a href="' + _.escape(link.url) + '" title="' + _.escape(link.title) + '">' + formatFieldValue(info.field, value) + '</a>';
563 }
564 }
565 return formatFieldValue(info.field, value);
566 };
567
568 // Attempts to construct a view url for a given entity
569 function getEntityUrl(row, info) {
570 var entity = searchMeta.getEntity(info.field.entity),
571 path = _.result(_.findWhere(entity.paths, {action: 'view'}), 'path');
572 // Only proceed if the path metadata exists for this entity
573 if (path) {
574 // Replace tokens in the path (e.g. [id])
575 var tokens = path.match(/\[\w*]/g) || [],
576 replacements = _.transform(tokens, function(replacements, token) {
577 var fieldName = info.prefix + token.slice(1, token.length - 1);
578 if (row[fieldName]) {
579 replacements.push(row[fieldName]);
580 }
581 });
582 // Only proceed if the row contains all the necessary data to resolve tokens
583 if (tokens.length === replacements.length) {
584 _.each(tokens, function(token, index) {
585 path = path.replace(token, replacements[index]);
586 });
587 return {url: CRM.url(path), title: path.title};
588 }
589 }
590 }
591
592 function formatFieldValue(field, value) {
593 var type = field.data_type,
594 result = value;
595 if (_.isArray(value)) {
596 return _.map(value, function(val) {
597 return formatFieldValue(field, val);
598 }).join(', ');
599 }
600 if (value && (type === 'Date' || type === 'Timestamp') && /^\d{4}-\d{2}-\d{2}/.test(value)) {
601 result = CRM.utils.formatDate(value, null, type === 'Timestamp');
602 }
603 else if (type === 'Boolean' && typeof value === 'boolean') {
604 result = value ? ts('Yes') : ts('No');
605 }
606 else if (type === 'Money' && typeof value === 'number') {
607 result = CRM.formatMoney(value);
608 }
609 return _.escape(result);
610 }
611
612 $scope.fieldsForGroupBy = function() {
613 return {results: getAllFields('', function(key) {
614 return _.contains(ctrl.savedSearch.api_params.groupBy, key);
615 })
616 };
617 };
618
619 $scope.fieldsForSelect = function() {
620 return {results: getAllFields(':label', function(key) {
621 return _.contains(ctrl.savedSearch.api_params.select, key);
622 })
623 };
624 };
625
626 $scope.fieldsForWhere = function() {
627 return {results: getAllFields(':name', _.noop)};
628 };
629
630 $scope.fieldsForHaving = function() {
631 return {results: _.transform(ctrl.savedSearch.api_params.select, function(fields, name) {
632 fields.push({id: name, text: ctrl.getFieldLabel(name)});
633 })};
634 };
635
636 $scope.sortableColumnOptions = {
637 axis: 'x',
638 handle: '.crm-draggable',
639 update: function(e, ui) {
640 // Don't allow items to be moved to position 0 if locked
641 if (!ui.item.sortable.dropindex && ctrl.groupExists) {
642 ui.item.sortable.cancel();
643 }
644 }
645 };
646
647 // Sets the default select clause based on commonly-named fields
648 function getDefaultSelect() {
649 var whitelist = ['id', 'name', 'subject', 'display_name', 'label', 'title'];
650 return _.transform(searchMeta.getEntity(ctrl.savedSearch.api_entity).fields, function(select, field) {
651 if (_.includes(whitelist, field.name) || _.includes(field.name, '_type_id')) {
652 select.push(field.name + (field.options ? ':label' : ''));
653 }
654 });
655 }
656
657 function getAllFields(suffix, disabledIf) {
658 function formatFields(entityName, join) {
659 var prefix = join ? join.alias + '.' : '',
660 result = [];
661
662 function addFields(fields) {
663 _.each(fields, function(field) {
664 var item = {
665 id: prefix + field.name + (field.options ? suffix : ''),
666 text: field.label,
667 description: field.description
668 };
669 if (disabledIf(item.id)) {
670 item.disabled = true;
671 }
672 result.push(item);
673 });
674 }
675
676 // Add extra searchable fields from bridge entity
677 if (join && join.bridge) {
678 addFields(_.filter(searchMeta.getEntity(join.bridge).fields, function(field) {
679 return (field.name !== 'id' && field.name !== 'entity_id' && field.name !== 'entity_table' && !field.fk_entity);
680 }));
681 }
682
683 addFields(searchMeta.getEntity(entityName).fields);
684 return result;
685 }
686
687 var mainEntity = searchMeta.getEntity(ctrl.savedSearch.api_entity),
688 result = [{
689 text: mainEntity.title_plural,
690 icon: mainEntity.icon,
691 children: formatFields(ctrl.savedSearch.api_entity)
692 }];
693 _.each(ctrl.savedSearch.api_params.join, function(join) {
694 var joinInfo = searchMeta.getJoin(join[0]),
695 joinEntity = searchMeta.getEntity(joinInfo.entity);
696 result.push({
697 text: joinInfo.label,
698 description: joinInfo.description,
699 icon: joinEntity.icon,
700 children: formatFields(joinEntity.name, joinInfo)
701 });
702 });
703 return result;
704 }
705
706 /**
707 * Fetch pseudoconstants for main entity + joined entities
708 *
709 * Sets an optionsLoaded property on each entity to avoid duplicate requests
710 */
711 function loadFieldOptions() {
712 var mainEntity = searchMeta.getEntity(ctrl.savedSearch.api_entity),
713 entities = {};
714
715 function enqueue(entity) {
716 entity.optionsLoaded = false;
717 entities[entity.name] = [entity.name, 'getFields', {
718 loadOptions: ['id', 'name', 'label', 'description', 'color', 'icon'],
719 where: [['options', '!=', false]],
720 select: ['options']
721 }, {name: 'options'}];
722 }
723
724 if (typeof mainEntity.optionsLoaded === 'undefined') {
725 enqueue(mainEntity);
726 }
727 _.each(ctrl.savedSearch.api_params.join, function(join) {
728 var joinInfo = searchMeta.getJoin(join[0]),
729 joinEntity = searchMeta.getEntity(joinInfo.entity),
730 bridgeEntity = joinInfo.bridge ? searchMeta.getEntity(joinInfo.bridge) : null;
731 if (typeof joinEntity.optionsLoaded === 'undefined') {
732 enqueue(joinEntity);
733 }
734 if (bridgeEntity && typeof bridgeEntity.optionsLoaded === 'undefined') {
735 enqueue(bridgeEntity);
736 }
737 });
738 if (!_.isEmpty(entities)) {
739 crmApi4(entities).then(function(results) {
740 _.each(results, function(fields, entityName) {
741 var entity = searchMeta.getEntity(entityName);
742 _.each(fields, function(options, fieldName) {
743 _.find(entity.fields, {name: fieldName}).options = options;
744 });
745 entity.optionsLoaded = true;
746 });
747 });
748 }
749 }
750
751 }
752 });
753
754 })(angular, CRM.$, CRM._);