Merge pull request #21577 from jmcclelland/display-backend-recur-options
[civicrm-core.git] / ext / search_kit / 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) {
10 var ts = $scope.ts = CRM.ts('org.civicrm.search_kit'),
11 ctrl = this,
12 fieldsForJoinGetters = {};
13
14 this.DEFAULT_AGGREGATE_FN = 'GROUP_CONCAT';
15
16 this.displayTypes = _.indexBy(CRM.crmSearchAdmin.displayTypes, 'id');
17
18 $scope.controls = {tab: 'compose', joinType: 'LEFT'};
19 $scope.joinTypes = [
20 {k: 'LEFT', v: ts('With (optional)')},
21 {k: 'INNER', v: ts('With (required)')},
22 {k: 'EXCLUDE', v: ts('Without')},
23 ];
24 $scope.getEntity = searchMeta.getEntity;
25 $scope.getField = searchMeta.getField;
26 this.perm = {
27 editGroups: CRM.checkPerm('edit groups')
28 };
29
30 this.$onInit = function() {
31 this.entityTitle = searchMeta.getEntity(this.savedSearch.api_entity).title_plural;
32
33 this.savedSearch.displays = this.savedSearch.displays || [];
34 this.savedSearch.groups = this.savedSearch.groups || [];
35 this.savedSearch.tag_id = this.savedSearch.tag_id || [];
36 this.groupExists = !!this.savedSearch.groups.length;
37
38 if (!this.savedSearch.id) {
39 $scope.$bindToRoute({
40 param: 'params',
41 expr: '$ctrl.savedSearch.api_params',
42 deep: true,
43 default: {
44 version: 4,
45 select: getDefaultSelect(),
46 orderBy: {},
47 where: [],
48 }
49 });
50 }
51
52 $scope.mainEntitySelect = searchMeta.getPrimaryAndSecondaryEntitySelect();
53
54 $scope.$watchCollection('$ctrl.savedSearch.api_params.select', onChangeSelect);
55
56 if (this.paramExists('groupBy')) {
57 this.savedSearch.api_params.groupBy = this.savedSearch.api_params.groupBy || [];
58 }
59
60 if (this.paramExists('join')) {
61 this.savedSearch.api_params.join = this.savedSearch.api_params.join || [];
62 }
63
64 if (this.paramExists('having')) {
65 this.savedSearch.api_params.having = this.savedSearch.api_params.having || [];
66 }
67
68 $scope.$watch('$ctrl.savedSearch', onChangeAnything, true);
69
70 // After watcher runs for the first time and messes up the status, set it correctly
71 $timeout(function() {
72 $scope.status = ctrl.savedSearch && ctrl.savedSearch.id ? 'saved' : 'unsaved';
73 });
74
75 loadFieldOptions();
76 };
77
78 function onChangeAnything() {
79 $scope.status = 'unsaved';
80 }
81
82 this.save = function() {
83 if (!validate()) {
84 return;
85 }
86 $scope.status = 'saving';
87 var params = _.cloneDeep(ctrl.savedSearch),
88 apiCalls = {},
89 chain = {};
90 if (ctrl.groupExists) {
91 chain.groups = ['Group', 'save', {defaults: {saved_search_id: '$id'}, records: params.groups}];
92 delete params.groups;
93 } else if (params.id) {
94 apiCalls.deleteGroup = ['Group', 'delete', {where: [['saved_search_id', '=', params.id]]}];
95 }
96 _.remove(params.displays, {trashed: true});
97 if (params.displays && params.displays.length) {
98 chain.displays = ['SearchDisplay', 'replace', {where: [['saved_search_id', '=', '$id']], records: params.displays}];
99 } else if (params.id) {
100 apiCalls.deleteDisplays = ['SearchDisplay', 'delete', {where: [['saved_search_id', '=', params.id]]}];
101 }
102 delete params.displays;
103 if (params.tag_id && params.tag_id.length) {
104 chain.tag_id = ['EntityTag', 'replace', {
105 where: [['entity_id', '=', '$id'], ['entity_table', '=', 'civicrm_saved_search']],
106 records: _.transform(params.tag_id, function(records, id) {records.push({tag_id: id});})
107 }];
108 } else if (params.id) {
109 chain.tag_id = ['EntityTag', 'delete', {
110 where: [['entity_id', '=', '$id'], ['entity_table', '=', 'civicrm_saved_search']]
111 }];
112 }
113 delete params.tag_id;
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 loadFieldOptions('Group');
174 $scope.smartGroupColumns = searchMeta.getSmartGroupColumns(ctrl.savedSearch.api_entity, ctrl.savedSearch.api_params);
175 var smartGroupColumns = _.map($scope.smartGroupColumns, 'id');
176 if (smartGroupColumns.length && !_.includes(smartGroupColumns, ctrl.savedSearch.api_params.select[0])) {
177 ctrl.savedSearch.api_params.select.unshift(smartGroupColumns[0]);
178 }
179 }
180 ctrl.savedSearch.api_params.select = _.uniq(ctrl.savedSearch.api_params.select);
181 $scope.controls.tab = tab;
182 };
183
184 this.removeGroup = function() {
185 ctrl.groupExists = !ctrl.groupExists;
186 $scope.status = 'unsaved';
187 if (!ctrl.groupExists && (!ctrl.savedSearch.groups.length || !ctrl.savedSearch.groups[0].id)) {
188 ctrl.savedSearch.groups.length = 0;
189 }
190 if ($scope.controls.tab === 'group') {
191 $scope.selectTab('compose');
192 }
193 };
194
195 function addNum(name, num) {
196 return name + (num < 10 ? '_0' : '_') + num;
197 }
198
199 function getExistingJoins() {
200 return _.transform(ctrl.savedSearch.api_params.join || [], function(joins, join) {
201 joins[join[0].split(' AS ')[1]] = searchMeta.getJoin(join[0]);
202 }, {});
203 }
204
205 $scope.getJoin = searchMeta.getJoin;
206
207 $scope.getJoinEntities = function() {
208 var existingJoins = getExistingJoins();
209
210 function addEntityJoins(entity, stack, baseEntity) {
211 return _.transform(CRM.crmSearchAdmin.joins[entity], function(joinEntities, join) {
212 var num = 0;
213 // Add all joins that don't just point directly back to the original entity
214 if (!(baseEntity === join.entity && !join.multi)) {
215 do {
216 appendJoin(joinEntities, join, ++num, stack, entity);
217 } while (addNum((stack ? stack + '_' : '') + join.alias, num) in existingJoins);
218 }
219 }, []);
220 }
221
222 function appendJoin(collection, join, num, stack, baseEntity) {
223 var alias = addNum((stack ? stack + '_' : '') + join.alias, num),
224 opt = {
225 id: join.entity + ' AS ' + alias,
226 description: join.description,
227 text: join.label + (num > 1 ? ' ' + num : ''),
228 icon: searchMeta.getEntity(join.entity).icon,
229 disabled: alias in existingJoins
230 };
231 if (alias in existingJoins) {
232 opt.children = addEntityJoins(join.entity, (stack ? stack + '_' : '') + alias, baseEntity);
233 }
234 collection.push(opt);
235 }
236
237 return {results: addEntityJoins(ctrl.savedSearch.api_entity)};
238 };
239
240 this.addJoin = function(value) {
241 if (value) {
242 ctrl.savedSearch.api_params.join = ctrl.savedSearch.api_params.join || [];
243 var join = searchMeta.getJoin(value),
244 entity = searchMeta.getEntity(join.entity),
245 params = [value, $scope.controls.joinType || 'LEFT'];
246 _.each(_.cloneDeep(join.conditions), function(condition) {
247 params.push(condition);
248 });
249 _.each(_.cloneDeep(join.defaults), function(condition) {
250 params.push(condition);
251 });
252 ctrl.savedSearch.api_params.join.push(params);
253 if (entity.label_field && $scope.controls.joinType !== 'EXCLUDE') {
254 ctrl.savedSearch.api_params.select.push(join.alias + '.' + entity.label_field);
255 }
256 loadFieldOptions();
257 }
258 };
259
260 // Remove an explicit join + all SELECT, WHERE & other JOINs that use it
261 this.removeJoin = function(index) {
262 var alias = searchMeta.getJoin(ctrl.savedSearch.api_params.join[index][0]).alias;
263 ctrl.clearParam('join', index);
264 removeJoinStuff(alias);
265 };
266
267 function removeJoinStuff(alias) {
268 _.remove(ctrl.savedSearch.api_params.select, function(item) {
269 var pattern = new RegExp('\\b' + alias + '\\.');
270 return pattern.test(item.split(' AS ')[0]);
271 });
272 _.remove(ctrl.savedSearch.api_params.where, function(clause) {
273 return clauseUsesJoin(clause, alias);
274 });
275 _.eachRight(ctrl.savedSearch.api_params.join, function(item, i) {
276 var joinAlias = searchMeta.getJoin(item[0]).alias;
277 if (joinAlias !== alias && joinAlias.indexOf(alias) === 0) {
278 ctrl.removeJoin(i);
279 }
280 });
281 }
282
283 this.changeJoinType = function(join) {
284 if (join[1] === 'EXCLUDE') {
285 removeJoinStuff(searchMeta.getJoin(join[0]).alias);
286 }
287 };
288
289 $scope.changeGroupBy = function(idx) {
290 // When clearing a selection
291 if (!ctrl.savedSearch.api_params.groupBy[idx]) {
292 ctrl.clearParam('groupBy', idx);
293 }
294 reconcileAggregateColumns();
295 };
296
297 function reconcileAggregateColumns() {
298 _.each(ctrl.savedSearch.api_params.select, function(col, pos) {
299 var info = searchMeta.parseExpr(col),
300 fieldExpr = (_.findWhere(info.args, {type: 'field'}) || {}).value;
301 if (ctrl.canAggregate(col)) {
302 // Ensure all non-grouped columns are aggregated if using GROUP BY
303 if (!info.fn || info.fn.category !== 'aggregate') {
304 ctrl.savedSearch.api_params.select[pos] = ctrl.DEFAULT_AGGREGATE_FN + '(DISTINCT ' + fieldExpr + ') AS ' + ctrl.DEFAULT_AGGREGATE_FN + '_' + fieldExpr.replace(/[.:]/g, '_');
305 }
306 } else {
307 // Remove aggregate functions when no grouping
308 if (info.fn && info.fn.category === 'aggregate') {
309 ctrl.savedSearch.api_params.select[pos] = fieldExpr;
310 }
311 }
312 });
313 }
314
315 function clauseUsesJoin(clause, alias) {
316 if (clause[0].indexOf(alias + '.') === 0) {
317 return true;
318 }
319 if (_.isArray(clause[1])) {
320 return clause[1].some(function(subClause) {
321 return clauseUsesJoin(subClause, alias);
322 });
323 }
324 return false;
325 }
326
327 // Returns true if a clause contains one of the
328 function clauseUsesFields(clause, fields) {
329 if (!fields || !fields.length) {
330 return false;
331 }
332 if (_.includes(fields, clause[0])) {
333 return true;
334 }
335 if (_.isArray(clause[1])) {
336 return clause[1].some(function(subClause) {
337 return clauseUsesField(subClause, fields);
338 });
339 }
340 return false;
341 }
342
343 function validate() {
344 var errors = [],
345 errorEl,
346 label,
347 tab;
348 if (!ctrl.savedSearch.label) {
349 errorEl = '#crm-saved-search-label';
350 label = ts('Search Label');
351 errors.push(ts('%1 is a required field.', {1: label}));
352 }
353 if (ctrl.groupExists && !ctrl.savedSearch.groups[0].title) {
354 errorEl = '#crm-search-admin-group-title';
355 label = ts('Group Title');
356 errors.push(ts('%1 is a required field.', {1: label}));
357 tab = 'group';
358 }
359 _.each(ctrl.savedSearch.displays, function(display, index) {
360 if (!display.trashed && !display.label) {
361 errorEl = '#crm-search-admin-display-label';
362 label = ts('Display Label');
363 errors.push(ts('%1 is a required field.', {1: label}));
364 tab = 'display_' + index;
365 }
366 });
367 if (errors.length) {
368 if (tab) {
369 $scope.selectTab(tab);
370 }
371 $(errorEl).crmError(errors.join('<br>'), ts('Error Saving'), {expires: 5000});
372 }
373 return !errors.length;
374 }
375
376 this.addParam = function(name, value) {
377 if (value && !_.contains(ctrl.savedSearch.api_params[name], value)) {
378 ctrl.savedSearch.api_params[name].push(value);
379 // This needs to be called when adding a field as well as changing groupBy
380 reconcileAggregateColumns();
381 }
382 };
383
384 // Deletes an item from an array param
385 this.clearParam = function(name, idx) {
386 if (name === 'select') {
387 // Function selectors use `ng-repeat` with `track by $index` so must be refreshed when splicing the array
388 ctrl.hideFuncitons();
389 }
390 ctrl.savedSearch.api_params[name].splice(idx, 1);
391 };
392
393 this.hideFuncitons = function() {
394 $scope.controls.showFunctions = false;
395 };
396
397 function onChangeSelect(newSelect, oldSelect) {
398 // When removing a column from SELECT, also remove from ORDER BY & HAVING
399 _.each(_.difference(oldSelect, newSelect), function(col) {
400 col = _.last(col.split(' AS '));
401 delete ctrl.savedSearch.api_params.orderBy[col];
402 _.remove(ctrl.savedSearch.api_params.having, function(clause) {
403 return clauseUsesFields(clause, [col]);
404 });
405 });
406 }
407
408 this.getFieldLabel = searchMeta.getDefaultLabel;
409
410 // Is a column eligible to use an aggregate function?
411 this.canAggregate = function(col) {
412 // If the query does not use grouping, never
413 if (!ctrl.savedSearch.api_params.groupBy.length) {
414 return false;
415 }
416 var arg = _.findWhere(searchMeta.parseExpr(col).args, {type: 'field'}) || {};
417 // If the column is not a database field, no
418 if (!arg.field || !arg.field.entity || arg.field.type !== 'Field') {
419 return false;
420 }
421 // If the column is used for a groupBy, no
422 if (ctrl.savedSearch.api_params.groupBy.indexOf(arg.path) > -1) {
423 return false;
424 }
425 // If the entity this column belongs to is being grouped by primary key, then also no
426 var idField = searchMeta.getEntity(arg.field.entity).primary_key[0];
427 return ctrl.savedSearch.api_params.groupBy.indexOf(arg.prefix + idField) < 0;
428 };
429
430 $scope.fieldsForGroupBy = function() {
431 return {results: ctrl.getAllFields('', ['Field', 'Custom'], function(key) {
432 return _.contains(ctrl.savedSearch.api_params.groupBy, key);
433 })
434 };
435 };
436
437 function getFieldsForJoin(joinEntity) {
438 return {results: ctrl.getAllFields(':name', ['Field'], null, joinEntity)};
439 }
440
441 // @return {function}
442 $scope.fieldsForJoin = function(joinEntity) {
443 if (!fieldsForJoinGetters[joinEntity]) {
444 fieldsForJoinGetters[joinEntity] = _.wrap(joinEntity, getFieldsForJoin);
445 }
446 return fieldsForJoinGetters[joinEntity];
447 };
448
449 $scope.fieldsForWhere = function() {
450 return {results: ctrl.getAllFields(':name')};
451 };
452
453 $scope.fieldsForHaving = function() {
454 return {results: ctrl.getSelectFields()};
455 };
456
457 // Sets the default select clause based on commonly-named fields
458 function getDefaultSelect() {
459 var entity = searchMeta.getEntity(ctrl.savedSearch.api_entity);
460 return _.transform(entity.fields, function(defaultSelect, field) {
461 if (field.name === 'id' || field.name === entity.label_field) {
462 defaultSelect.push(field.name);
463 }
464 });
465 }
466
467 this.getAllFields = function(suffix, allowedTypes, disabledIf, topJoin) {
468 disabledIf = disabledIf || _.noop;
469
470 function formatEntityFields(entityName, join) {
471 var prefix = join ? join.alias + '.' : '',
472 result = [];
473
474 // Add extra searchable fields from bridge entity
475 if (join && join.bridge) {
476 formatFields(_.filter(searchMeta.getEntity(join.bridge).fields, function(field) {
477 return (field.name !== 'id' && field.name !== 'entity_id' && field.name !== 'entity_table' && !field.fk_entity);
478 }), result, prefix);
479 }
480
481 formatFields(searchMeta.getEntity(entityName).fields, result, prefix);
482 return result;
483 }
484
485 function formatFields(fields, result, prefix) {
486 prefix = typeof prefix === 'undefined' ? '' : prefix;
487 _.each(fields, function(field) {
488 var item = {
489 id: prefix + field.name + (field.options ? suffix : ''),
490 text: field.label,
491 description: field.description
492 };
493 if (disabledIf(item.id)) {
494 item.disabled = true;
495 }
496 if (!allowedTypes || _.includes(allowedTypes, field.type)) {
497 result.push(item);
498 }
499 });
500 return result;
501 }
502
503 var mainEntity = searchMeta.getEntity(ctrl.savedSearch.api_entity),
504 joinEntities = _.map(ctrl.savedSearch.api_params.join, 0),
505 result = [];
506
507 function addJoin(join) {
508 var joinInfo = searchMeta.getJoin(join),
509 joinEntity = searchMeta.getEntity(joinInfo.entity);
510 result.push({
511 text: joinInfo.label,
512 description: joinInfo.description,
513 icon: joinEntity.icon,
514 children: formatEntityFields(joinEntity.name, joinInfo)
515 });
516 }
517
518 // Place specified join at top of list
519 if (topJoin) {
520 addJoin(topJoin);
521 _.pull(joinEntities, topJoin);
522 }
523
524 result.push({
525 text: mainEntity.title_plural,
526 icon: mainEntity.icon,
527 children: formatEntityFields(ctrl.savedSearch.api_entity)
528 });
529
530 // Include SearchKit's pseudo-fields if specifically requested
531 if (allowedTypes && _.includes(allowedTypes, 'Pseudo')) {
532 result.push({
533 text: ts('Extra'),
534 icon: 'fa-gear',
535 children: formatFields(CRM.crmSearchAdmin.pseudoFields, [])
536 });
537 }
538
539 _.each(joinEntities, addJoin);
540 return result;
541 };
542
543 this.getSelectFields = function(disabledIf) {
544 disabledIf = disabledIf || _.noop;
545 return _.transform(ctrl.savedSearch.api_params.select, function(fields, name) {
546 var info = searchMeta.parseExpr(name);
547 var item = {
548 id: info.alias,
549 text: ctrl.getFieldLabel(name),
550 description: info.fn ? info.fn.description : info.args[0].field && info.args[0].field.description
551 };
552 if (disabledIf(item.id)) {
553 item.disabled = true;
554 }
555 fields.push(item);
556 });
557 };
558
559 this.isPseudoField = function(name) {
560 return _.findIndex(CRM.crmSearchAdmin.pseudoFields, {name: name}) >= 0;
561 };
562
563 /**
564 * Fetch pseudoconstants for main entity + joined entities
565 *
566 * Sets an optionsLoaded property on each entity to avoid duplicate requests
567 *
568 * @var string entity - optional additional entity to load
569 */
570 function loadFieldOptions(entity) {
571 var mainEntity = searchMeta.getEntity(ctrl.savedSearch.api_entity),
572 entities = {};
573
574 function enqueue(entity) {
575 entity.optionsLoaded = false;
576 entities[entity.name] = [entity.name, 'getFields', {
577 loadOptions: ['id', 'name', 'label', 'description', 'color', 'icon'],
578 where: [['options', '!=', false]],
579 select: ['options']
580 }, {name: 'options'}];
581 }
582
583 if (typeof mainEntity.optionsLoaded === 'undefined') {
584 enqueue(mainEntity);
585 }
586
587 // Optional additional entity
588 if (entity && typeof searchMeta.getEntity(entity).optionsLoaded === 'undefined') {
589 enqueue(searchMeta.getEntity(entity));
590 }
591
592 _.each(ctrl.savedSearch.api_params.join, function(join) {
593 var joinInfo = searchMeta.getJoin(join[0]),
594 joinEntity = searchMeta.getEntity(joinInfo.entity),
595 bridgeEntity = joinInfo.bridge ? searchMeta.getEntity(joinInfo.bridge) : null;
596 if (typeof joinEntity.optionsLoaded === 'undefined') {
597 enqueue(joinEntity);
598 }
599 if (bridgeEntity && typeof bridgeEntity.optionsLoaded === 'undefined') {
600 enqueue(bridgeEntity);
601 }
602 });
603 if (!_.isEmpty(entities)) {
604 crmApi4(entities).then(function(results) {
605 _.each(results, function(fields, entityName) {
606 var entity = searchMeta.getEntity(entityName);
607 _.each(fields, function(options, fieldName) {
608 _.find(entity.fields, {name: fieldName}).options = options;
609 });
610 entity.optionsLoaded = true;
611 });
612 });
613 }
614 }
615
616 // Build a list of all possible links to main entity & join entities
617 this.buildLinks = function() {
618 function addTitle(link, entityName) {
619 switch (link.action) {
620 case 'view':
621 link.title = ts('View %1', {1: entityName});
622 link.icon = 'fa-external-link';
623 link.style = 'default';
624 break;
625
626 case 'update':
627 link.title = ts('Edit %1', {1: entityName});
628 link.icon = 'fa-pencil';
629 link.style = 'default';
630 break;
631
632 case 'delete':
633 link.title = ts('Delete %1', {1: entityName});
634 link.icon = 'fa-trash';
635 link.style = 'danger';
636 break;
637 }
638 }
639
640 // Links to main entity
641 // @return {Array}
642 var mainEntity = searchMeta.getEntity(ctrl.savedSearch.api_entity),
643 links = _.cloneDeep(mainEntity.paths || []);
644 _.each(links, function(link) {
645 link.join = '';
646 addTitle(link, mainEntity.title);
647 });
648 // Links to explicitly joined entities
649 _.each(ctrl.savedSearch.api_params.join, function(joinClause) {
650 var join = searchMeta.getJoin(joinClause[0]),
651 joinEntity = searchMeta.getEntity(join.entity),
652 primaryKey = joinEntity.primary_key[0],
653 // Links for aggregate columns get aggregated using GROUP_CONCAT
654 isAggregate = ctrl.canAggregate(join.alias + '.' + primaryKey),
655 joinPrefix = (isAggregate ? ctrl.DEFAULT_AGGREGATE_FN + '_' : '') + join.alias + '.',
656 bridgeEntity = _.isString(joinClause[2]) ? searchMeta.getEntity(joinClause[2]) : null;
657 _.each(joinEntity.paths, function(path) {
658 var link = _.cloneDeep(path);
659 link.path = link.path.replace(/\[/g, '[' + joinPrefix);
660 if (isAggregate) {
661 link.path = link.path.replace(/[.:]/g, '_');
662 }
663 link.join = join.alias;
664 addTitle(link, join.label);
665 links.push(link);
666 });
667 _.each(bridgeEntity && bridgeEntity.paths, function(path) {
668 var link = _.cloneDeep(path);
669 link.path = link.path.replace(/\[/g, '[' + join.alias + '.');
670 link.join = join.alias;
671 addTitle(link, join.label + (bridgeEntity.bridge_title ? ' ' + bridgeEntity.bridge_title : ''));
672 links.push(link);
673 });
674 });
675 // Links to implicit joins
676 _.each(ctrl.savedSearch.api_params.select, function(fieldName) {
677 if (!_.includes(fieldName, ' AS ')) {
678 var info = searchMeta.parseExpr(fieldName).args[0];
679 if (info.field && !info.suffix && !info.fn && info.field.type === 'Field' && (info.field.fk_entity || info.field.name !== info.field.fieldName)) {
680 var idFieldName = info.field.fk_entity ? fieldName : fieldName.substr(0, fieldName.lastIndexOf('.')),
681 idField = searchMeta.parseExpr(idFieldName).args[0].field;
682 if (!ctrl.canAggregate(idFieldName)) {
683 var joinEntity = searchMeta.getEntity(idField.fk_entity),
684 label = (idField.join ? idField.join.label + ': ' : '') + (idField.input_attrs && idField.input_attrs.label || idField.label);
685 _.each((joinEntity || {}).paths, function(path) {
686 var link = _.cloneDeep(path);
687 link.path = link.path.replace(/\[id/g, '[' + idFieldName);
688 link.join = idFieldName;
689 addTitle(link, label);
690 links.push(link);
691 });
692 }
693 }
694 }
695 });
696 return _.uniq(links, 'path');
697 };
698
699 }
700 });
701
702 })(angular, CRM.$, CRM._);