Merge pull request #21435 from colemanw/pseudoFields
[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 = info.path + info.suffix;
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 ctrl.savedSearch.api_params[name].splice(idx, 1);
387 };
388
389 function onChangeSelect(newSelect, oldSelect) {
390 // When removing a column from SELECT, also remove from ORDER BY & HAVING
391 _.each(_.difference(oldSelect, newSelect), function(col) {
392 col = _.last(col.split(' AS '));
393 delete ctrl.savedSearch.api_params.orderBy[col];
394 _.remove(ctrl.savedSearch.api_params.having, function(clause) {
395 return clauseUsesFields(clause, [col]);
396 });
397 });
398 }
399
400 this.getFieldLabel = searchMeta.getDefaultLabel;
401
402 // Is a column eligible to use an aggregate function?
403 this.canAggregate = function(col) {
404 // If the query does not use grouping, never
405 if (!ctrl.savedSearch.api_params.groupBy.length) {
406 return false;
407 }
408 var info = searchMeta.parseExpr(col);
409 // If the column is used for a groupBy, no
410 if (ctrl.savedSearch.api_params.groupBy.indexOf(info.path) > -1) {
411 return false;
412 }
413 // If the entity this column belongs to is being grouped by primary key, then also no
414 var idField = searchMeta.getEntity(info.field.entity).primary_key[0];
415 return ctrl.savedSearch.api_params.groupBy.indexOf(info.prefix + idField) < 0;
416 };
417
418 $scope.fieldsForGroupBy = function() {
419 return {results: ctrl.getAllFields('', ['Field', 'Custom'], function(key) {
420 return _.contains(ctrl.savedSearch.api_params.groupBy, key);
421 })
422 };
423 };
424
425 function getFieldsForJoin(joinEntity) {
426 return {results: ctrl.getAllFields(':name', ['Field', 'Custom'], null, joinEntity)};
427 }
428
429 $scope.fieldsForJoin = function(joinEntity) {
430 if (!fieldsForJoinGetters[joinEntity]) {
431 fieldsForJoinGetters[joinEntity] = _.wrap(joinEntity, getFieldsForJoin);
432 }
433 return fieldsForJoinGetters[joinEntity];
434 };
435
436 $scope.fieldsForWhere = function() {
437 return {results: ctrl.getAllFields(':name')};
438 };
439
440 $scope.fieldsForHaving = function() {
441 return {results: ctrl.getSelectFields()};
442 };
443
444 // Sets the default select clause based on commonly-named fields
445 function getDefaultSelect() {
446 var entity = searchMeta.getEntity(ctrl.savedSearch.api_entity);
447 return _.transform(entity.fields, function(defaultSelect, field) {
448 if (field.name === 'id' || field.name === entity.label_field) {
449 defaultSelect.push(field.name);
450 }
451 });
452 }
453
454 this.getAllFields = function(suffix, allowedTypes, disabledIf, topJoin) {
455 disabledIf = disabledIf || _.noop;
456
457 function formatEntityFields(entityName, join) {
458 var prefix = join ? join.alias + '.' : '',
459 result = [];
460
461 // Add extra searchable fields from bridge entity
462 if (join && join.bridge) {
463 formatFields(_.filter(searchMeta.getEntity(join.bridge).fields, function(field) {
464 return (field.name !== 'id' && field.name !== 'entity_id' && field.name !== 'entity_table' && !field.fk_entity);
465 }), result, prefix);
466 }
467
468 formatFields(searchMeta.getEntity(entityName).fields, result, prefix);
469 return result;
470 }
471
472 function formatFields(fields, result, prefix) {
473 prefix = typeof prefix === 'undefined' ? '' : prefix;
474 _.each(fields, function(field) {
475 var item = {
476 id: prefix + field.name + (field.options ? suffix : ''),
477 text: field.label,
478 description: field.description
479 };
480 if (disabledIf(item.id)) {
481 item.disabled = true;
482 }
483 if (!allowedTypes || _.includes(allowedTypes, field.type)) {
484 result.push(item);
485 }
486 });
487 return result;
488 }
489
490 var mainEntity = searchMeta.getEntity(ctrl.savedSearch.api_entity),
491 joinEntities = _.map(ctrl.savedSearch.api_params.join, 0),
492 result = [];
493
494 function addJoin(join) {
495 var joinInfo = searchMeta.getJoin(join),
496 joinEntity = searchMeta.getEntity(joinInfo.entity);
497 result.push({
498 text: joinInfo.label,
499 description: joinInfo.description,
500 icon: joinEntity.icon,
501 children: formatEntityFields(joinEntity.name, joinInfo)
502 });
503 }
504
505 // Place specified join at top of list
506 if (topJoin) {
507 addJoin(topJoin);
508 _.pull(joinEntities, topJoin);
509 }
510
511 result.push({
512 text: mainEntity.title_plural,
513 icon: mainEntity.icon,
514 children: formatEntityFields(ctrl.savedSearch.api_entity)
515 });
516
517 // Include SearchKit's pseudo-fields if specifically requested
518 if (allowedTypes && _.includes(allowedTypes, 'Pseudo')) {
519 result.push({
520 text: ts('Extra'),
521 icon: 'fa-gear',
522 children: formatFields(CRM.crmSearchAdmin.pseudoFields, [])
523 });
524 }
525
526 _.each(joinEntities, addJoin);
527 return result;
528 };
529
530 this.getSelectFields = function(disabledIf) {
531 disabledIf = disabledIf || _.noop;
532 return _.transform(ctrl.savedSearch.api_params.select, function(fields, name) {
533 var info = searchMeta.parseExpr(name);
534 var item = {
535 id: info.alias,
536 text: ctrl.getFieldLabel(name),
537 description: info.field && info.field.description
538 };
539 if (disabledIf(item.id)) {
540 item.disabled = true;
541 }
542 fields.push(item);
543 });
544 };
545
546 this.isPseudoField = function(name) {
547 return _.findIndex(CRM.crmSearchAdmin.pseudoFields, {name: name}) >= 0;
548 };
549
550 /**
551 * Fetch pseudoconstants for main entity + joined entities
552 *
553 * Sets an optionsLoaded property on each entity to avoid duplicate requests
554 *
555 * @var string entity - optional additional entity to load
556 */
557 function loadFieldOptions(entity) {
558 var mainEntity = searchMeta.getEntity(ctrl.savedSearch.api_entity),
559 entities = {};
560
561 function enqueue(entity) {
562 entity.optionsLoaded = false;
563 entities[entity.name] = [entity.name, 'getFields', {
564 loadOptions: ['id', 'name', 'label', 'description', 'color', 'icon'],
565 where: [['options', '!=', false]],
566 select: ['options']
567 }, {name: 'options'}];
568 }
569
570 if (typeof mainEntity.optionsLoaded === 'undefined') {
571 enqueue(mainEntity);
572 }
573
574 // Optional additional entity
575 if (entity && typeof searchMeta.getEntity(entity).optionsLoaded === 'undefined') {
576 enqueue(searchMeta.getEntity(entity));
577 }
578
579 _.each(ctrl.savedSearch.api_params.join, function(join) {
580 var joinInfo = searchMeta.getJoin(join[0]),
581 joinEntity = searchMeta.getEntity(joinInfo.entity),
582 bridgeEntity = joinInfo.bridge ? searchMeta.getEntity(joinInfo.bridge) : null;
583 if (typeof joinEntity.optionsLoaded === 'undefined') {
584 enqueue(joinEntity);
585 }
586 if (bridgeEntity && typeof bridgeEntity.optionsLoaded === 'undefined') {
587 enqueue(bridgeEntity);
588 }
589 });
590 if (!_.isEmpty(entities)) {
591 crmApi4(entities).then(function(results) {
592 _.each(results, function(fields, entityName) {
593 var entity = searchMeta.getEntity(entityName);
594 _.each(fields, function(options, fieldName) {
595 _.find(entity.fields, {name: fieldName}).options = options;
596 });
597 entity.optionsLoaded = true;
598 });
599 });
600 }
601 }
602
603 // Build a list of all possible links to main entity & join entities
604 this.buildLinks = function() {
605 function addTitle(link, entityName) {
606 switch (link.action) {
607 case 'view':
608 link.title = ts('View %1', {1: entityName});
609 link.icon = 'fa-external-link';
610 link.style = 'default';
611 break;
612
613 case 'update':
614 link.title = ts('Edit %1', {1: entityName});
615 link.icon = 'fa-pencil';
616 link.style = 'default';
617 break;
618
619 case 'delete':
620 link.title = ts('Delete %1', {1: entityName});
621 link.icon = 'fa-trash';
622 link.style = 'danger';
623 break;
624 }
625 }
626
627 // Links to main entity
628 // @return {Array}
629 var mainEntity = searchMeta.getEntity(ctrl.savedSearch.api_entity),
630 links = _.cloneDeep(mainEntity.paths || []);
631 _.each(links, function(link) {
632 link.join = '';
633 addTitle(link, mainEntity.title);
634 });
635 // Links to explicitly joined entities
636 _.each(ctrl.savedSearch.api_params.join, function(joinClause) {
637 var join = searchMeta.getJoin(joinClause[0]),
638 joinEntity = searchMeta.getEntity(join.entity),
639 primaryKey = joinEntity.primary_key[0],
640 // Links for aggregate columns get aggregated using GROUP_CONCAT
641 isAggregate = ctrl.canAggregate(join.alias + '.' + primaryKey),
642 joinPrefix = (isAggregate ? ctrl.DEFAULT_AGGREGATE_FN + '_' : '') + join.alias + '.',
643 bridgeEntity = _.isString(joinClause[2]) ? searchMeta.getEntity(joinClause[2]) : null;
644 _.each(joinEntity.paths, function(path) {
645 var link = _.cloneDeep(path);
646 link.path = link.path.replace(/\[/g, '[' + joinPrefix);
647 if (isAggregate) {
648 link.path = link.path.replace(/[.:]/g, '_');
649 }
650 link.join = join.alias;
651 addTitle(link, join.label);
652 links.push(link);
653 });
654 _.each(bridgeEntity && bridgeEntity.paths, function(path) {
655 var link = _.cloneDeep(path);
656 link.path = link.path.replace(/\[/g, '[' + join.alias + '.');
657 link.join = join.alias;
658 addTitle(link, join.label + (bridgeEntity.bridge_title ? ' ' + bridgeEntity.bridge_title : ''));
659 links.push(link);
660 });
661 });
662 // Links to implicit joins
663 _.each(ctrl.savedSearch.api_params.select, function(fieldName) {
664 if (!_.includes(fieldName, ' AS ')) {
665 var info = searchMeta.parseExpr(fieldName);
666 if (info.field && !info.suffix && !info.fn && info.field.type === 'Field' && (info.field.fk_entity || info.field.name !== info.field.fieldName)) {
667 var idFieldName = info.field.fk_entity ? fieldName : fieldName.substr(0, fieldName.lastIndexOf('.')),
668 idField = searchMeta.parseExpr(idFieldName).field;
669 if (!ctrl.canAggregate(idFieldName)) {
670 var joinEntity = searchMeta.getEntity(idField.fk_entity),
671 label = (idField.join ? idField.join.label + ': ' : '') + (idField.input_attrs && idField.input_attrs.label || idField.label);
672 _.each((joinEntity || {}).paths, function(path) {
673 var link = _.cloneDeep(path);
674 link.path = link.path.replace(/\[id/g, '[' + idFieldName);
675 link.join = idFieldName;
676 addTitle(link, label);
677 links.push(link);
678 });
679 }
680 }
681 }
682 });
683 return _.uniq(links, 'path');
684 };
685
686 }
687 });
688
689 })(angular, CRM.$, CRM._);