1 (function(angular
, $, _
) {
4 angular
.module('crmSearchAdmin').component('crmSearchAdmin', {
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'),
12 fieldsForJoinGetters
= {};
14 this.DEFAULT_AGGREGATE_FN
= 'GROUP_CONCAT';
16 this.displayTypes
= _
.indexBy(CRM
.crmSearchAdmin
.displayTypes
, 'id');
18 $scope
.controls
= {tab
: 'compose', joinType
: 'LEFT'};
20 {k
: 'LEFT', v
: ts('With (optional)')},
21 {k
: 'INNER', v
: ts('With (required)')},
22 {k
: 'EXCLUDE', v
: ts('Without')},
24 $scope
.getEntity
= searchMeta
.getEntity
;
25 $scope
.getField
= searchMeta
.getField
;
27 editGroups
: CRM
.checkPerm('edit groups')
30 this.$onInit = function() {
31 this.entityTitle
= searchMeta
.getEntity(this.savedSearch
.api_entity
).title_plural
;
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
;
38 if (!this.savedSearch
.id
) {
41 expr
: '$ctrl.savedSearch.api_params',
45 select
: getDefaultSelect(),
52 $scope
.mainEntitySelect
= searchMeta
.getPrimaryAndSecondaryEntitySelect();
54 $scope
.$watchCollection('$ctrl.savedSearch.api_params.select', onChangeSelect
);
56 if (this.paramExists('groupBy')) {
57 this.savedSearch
.api_params
.groupBy
= this.savedSearch
.api_params
.groupBy
|| [];
60 if (this.paramExists('join')) {
61 this.savedSearch
.api_params
.join
= this.savedSearch
.api_params
.join
|| [];
64 if (this.paramExists('having')) {
65 this.savedSearch
.api_params
.having
= this.savedSearch
.api_params
.having
|| [];
68 $scope
.$watch('$ctrl.savedSearch', onChangeAnything
, true);
70 // After watcher runs for the first time and messes up the status, set it correctly
72 $scope
.status
= ctrl
.savedSearch
&& ctrl
.savedSearch
.id
? 'saved' : 'unsaved';
78 function onChangeAnything() {
79 $scope
.status
= 'unsaved';
82 this.save = function() {
86 $scope
.status
= 'saving';
87 var params
= _
.cloneDeep(ctrl
.savedSearch
),
90 if (ctrl
.groupExists
) {
91 chain
.groups
= ['Group', 'save', {defaults
: {saved_search_id
: '$id'}, records
: params
.groups
}];
93 } else if (params
.id
) {
94 apiCalls
.deleteGroup
= ['Group', 'delete', {where
: [['saved_search_id', '=', params
.id
]]}];
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
]]}];
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
});})
108 } else if (params
.id
) {
109 chain
.tag_id
= ['EntityTag', 'delete', {
110 where
: [['entity_id', '=', '$id'], ['entity_table', '=', 'civicrm_saved_search']]
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
);
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
;
125 ctrl
.savedSearch
.displays
= results
.saved
.displays
|| [];
126 // Wait until after onChangeAnything to update status
127 $timeout(function() {
128 $scope
.status
= newStatus
;
133 this.paramExists = function(param
) {
134 return _
.includes(searchMeta
.getEntity(ctrl
.savedSearch
.api_entity
).params
, param
);
137 this.addDisplay = function(type
) {
138 ctrl
.savedSearch
.displays
.push({
142 $scope
.selectTab('display_' + (ctrl
.savedSearch
.displays
.length
- 1));
145 this.removeDisplay = function(index
) {
146 var display
= ctrl
.savedSearch
.displays
[index
];
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
);
155 $scope
.selectTab('compose');
156 ctrl
.savedSearch
.displays
.splice(index
, 1);
160 this.addGroup = function() {
161 ctrl
.savedSearch
.groups
.push({
164 visibility
: 'User and User Admin Only',
167 ctrl
.groupExists
= true;
168 $scope
.selectTab('group');
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]);
180 ctrl
.savedSearch
.api_params
.select
= _
.uniq(ctrl
.savedSearch
.api_params
.select
);
181 $scope
.controls
.tab
= tab
;
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;
190 if ($scope
.controls
.tab
=== 'group') {
191 $scope
.selectTab('compose');
195 function addNum(name
, num
) {
196 return name
+ (num
< 10 ? '_0' : '_') + num
;
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]);
205 $scope
.getJoin
= searchMeta
.getJoin
;
207 $scope
.getJoinEntities = function() {
208 var existingJoins
= getExistingJoins();
210 function addEntityJoins(entity
, stack
, baseEntity
) {
211 return _
.transform(CRM
.crmSearchAdmin
.joins
[entity
], function(joinEntities
, join
) {
213 // Add all joins that don't just point directly back to the original entity
214 if (!(baseEntity
=== join
.entity
&& !join
.multi
)) {
216 appendJoin(joinEntities
, join
, ++num
, stack
, entity
);
217 } while (addNum((stack
? stack
+ '_' : '') + join
.alias
, num
) in existingJoins
);
222 function appendJoin(collection
, join
, num
, stack
, baseEntity
) {
223 var alias
= addNum((stack
? stack
+ '_' : '') + join
.alias
, num
),
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
231 if (alias
in existingJoins
) {
232 opt
.children
= addEntityJoins(join
.entity
, (stack
? stack
+ '_' : '') + alias
, baseEntity
);
234 collection
.push(opt
);
237 return {results
: addEntityJoins(ctrl
.savedSearch
.api_entity
)};
240 this.addJoin = function(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
);
249 _
.each(_
.cloneDeep(join
.defaults
), function(condition
) {
250 params
.push(condition
);
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
);
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
);
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]);
272 _
.remove(ctrl
.savedSearch
.api_params
.where
, function(clause
) {
273 return clauseUsesJoin(clause
, alias
);
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) {
283 this.changeJoinType = function(join
) {
284 if (join
[1] === 'EXCLUDE') {
285 removeJoinStuff(searchMeta
.getJoin(join
[0]).alias
);
289 $scope
.changeGroupBy = function(idx
) {
290 // When clearing a selection
291 if (!ctrl
.savedSearch
.api_params
.groupBy
[idx
]) {
292 ctrl
.clearParam('groupBy', idx
);
294 reconcileAggregateColumns();
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, '_');
307 // Remove aggregate functions when no grouping
308 if (info
.fn
&& info
.fn
.category
=== 'aggregate') {
309 ctrl
.savedSearch
.api_params
.select
[pos
] = fieldExpr
;
315 function clauseUsesJoin(clause
, alias
) {
316 if (clause
[0].indexOf(alias
+ '.') === 0) {
319 if (_
.isArray(clause
[1])) {
320 return clause
[1].some(function(subClause
) {
321 return clauseUsesJoin(subClause
, alias
);
327 // Returns true if a clause contains one of the
328 function clauseUsesFields(clause
, fields
) {
329 if (!fields
|| !fields
.length
) {
332 if (_
.includes(fields
, clause
[0])) {
335 if (_
.isArray(clause
[1])) {
336 return clause
[1].some(function(subClause
) {
337 return clauseUsesField(subClause
, fields
);
343 function validate() {
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
}));
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
}));
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
;
369 $scope
.selectTab(tab
);
371 $(errorEl
).crmError(errors
.join('<br>'), ts('Error Saving'), {expires
: 5000});
373 return !errors
.length
;
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();
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();
390 ctrl
.savedSearch
.api_params
[name
].splice(idx
, 1);
393 this.hideFuncitons = function() {
394 $scope
.controls
.showFunctions
= false;
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
]);
408 this.getFieldLabel
= searchMeta
.getDefaultLabel
;
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
) {
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') {
421 // If the column is used for a groupBy, no
422 if (ctrl
.savedSearch
.api_params
.groupBy
.indexOf(arg
.path
) > -1) {
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;
430 $scope
.fieldsForGroupBy = function() {
431 return {results
: ctrl
.getAllFields('', ['Field', 'Custom'], function(key
) {
432 return _
.contains(ctrl
.savedSearch
.api_params
.groupBy
, key
);
437 function getFieldsForJoin(joinEntity
) {
438 return {results
: ctrl
.getAllFields(':name', ['Field'], null, joinEntity
)};
441 // @return {function}
442 $scope
.fieldsForJoin = function(joinEntity
) {
443 if (!fieldsForJoinGetters
[joinEntity
]) {
444 fieldsForJoinGetters
[joinEntity
] = _
.wrap(joinEntity
, getFieldsForJoin
);
446 return fieldsForJoinGetters
[joinEntity
];
449 $scope
.fieldsForWhere = function() {
450 return {results
: ctrl
.getAllFields(':name')};
453 $scope
.fieldsForHaving = function() {
454 return {results
: ctrl
.getSelectFields()};
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
);
467 this.getAllFields = function(suffix
, allowedTypes
, disabledIf
, topJoin
) {
468 disabledIf
= disabledIf
|| _
.noop
;
470 function formatEntityFields(entityName
, join
) {
471 var prefix
= join
? join
.alias
+ '.' : '',
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
);
481 formatFields(searchMeta
.getEntity(entityName
).fields
, result
, prefix
);
485 function formatFields(fields
, result
, prefix
) {
486 prefix
= typeof prefix
=== 'undefined' ? '' : prefix
;
487 _
.each(fields
, function(field
) {
489 id
: prefix
+ field
.name
+ (field
.options
? suffix
: ''),
491 description
: field
.description
493 if (disabledIf(item
.id
)) {
494 item
.disabled
= true;
496 if (!allowedTypes
|| _
.includes(allowedTypes
, field
.type
)) {
503 var mainEntity
= searchMeta
.getEntity(ctrl
.savedSearch
.api_entity
),
504 joinEntities
= _
.map(ctrl
.savedSearch
.api_params
.join
, 0),
507 function addJoin(join
) {
508 var joinInfo
= searchMeta
.getJoin(join
),
509 joinEntity
= searchMeta
.getEntity(joinInfo
.entity
);
511 text
: joinInfo
.label
,
512 description
: joinInfo
.description
,
513 icon
: joinEntity
.icon
,
514 children
: formatEntityFields(joinEntity
.name
, joinInfo
)
518 // Place specified join at top of list
521 _
.pull(joinEntities
, topJoin
);
525 text
: mainEntity
.title_plural
,
526 icon
: mainEntity
.icon
,
527 children
: formatEntityFields(ctrl
.savedSearch
.api_entity
)
530 // Include SearchKit's pseudo-fields if specifically requested
531 if (allowedTypes
&& _
.includes(allowedTypes
, 'Pseudo')) {
535 children
: formatFields(CRM
.crmSearchAdmin
.pseudoFields
, [])
539 _
.each(joinEntities
, addJoin
);
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
);
549 text
: ctrl
.getFieldLabel(name
),
550 description
: info
.fn
? info
.fn
.description
: info
.args
[0].field
&& info
.args
[0].field
.description
552 if (disabledIf(item
.id
)) {
553 item
.disabled
= true;
559 this.isPseudoField = function(name
) {
560 return _
.findIndex(CRM
.crmSearchAdmin
.pseudoFields
, {name
: name
}) >= 0;
564 * Fetch pseudoconstants for main entity + joined entities
566 * Sets an optionsLoaded property on each entity to avoid duplicate requests
568 * @var string entity - optional additional entity to load
570 function loadFieldOptions(entity
) {
571 var mainEntity
= searchMeta
.getEntity(ctrl
.savedSearch
.api_entity
),
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]],
580 }, {name
: 'options'}];
583 if (typeof mainEntity
.optionsLoaded
=== 'undefined') {
587 // Optional additional entity
588 if (entity
&& typeof searchMeta
.getEntity(entity
).optionsLoaded
=== 'undefined') {
589 enqueue(searchMeta
.getEntity(entity
));
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') {
599 if (bridgeEntity
&& typeof bridgeEntity
.optionsLoaded
=== 'undefined') {
600 enqueue(bridgeEntity
);
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
;
610 entity
.optionsLoaded
= true;
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
) {
621 link
.title
= ts('View %1', {1: entityName
});
622 link
.icon
= 'fa-external-link';
623 link
.style
= 'default';
627 link
.title
= ts('Edit %1', {1: entityName
});
628 link
.icon
= 'fa-pencil';
629 link
.style
= 'default';
633 link
.title
= ts('Delete %1', {1: entityName
});
634 link
.icon
= 'fa-trash';
635 link
.style
= 'danger';
640 // Links to main entity
642 var mainEntity
= searchMeta
.getEntity(ctrl
.savedSearch
.api_entity
),
643 links
= _
.cloneDeep(mainEntity
.paths
|| []);
644 _
.each(links
, function(link
) {
646 addTitle(link
, mainEntity
.title
);
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
);
661 link
.path
= link
.path
.replace(/[.:]/g, '_');
663 link
.join
= join
.alias
;
664 addTitle(link
, join
.label
);
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
: ''));
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
);
696 return _
.uniq(links
, 'path');
702 })(angular
, CRM
.$, CRM
._
);