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
= 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, '_');
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 ctrl
.savedSearch
.api_params
[name
].splice(idx
, 1);
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
]);
400 this.getFieldLabel
= searchMeta
.getDefaultLabel
;
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
) {
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) {
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;
418 $scope
.fieldsForGroupBy = function() {
419 return {results
: ctrl
.getAllFields('', ['Field', 'Custom'], function(key
) {
420 return _
.contains(ctrl
.savedSearch
.api_params
.groupBy
, key
);
425 function getFieldsForJoin(joinEntity
) {
426 return {results
: ctrl
.getAllFields(':name', ['Field', 'Custom'], null, joinEntity
)};
429 $scope
.fieldsForJoin = function(joinEntity
) {
430 if (!fieldsForJoinGetters
[joinEntity
]) {
431 fieldsForJoinGetters
[joinEntity
] = _
.wrap(joinEntity
, getFieldsForJoin
);
433 return fieldsForJoinGetters
[joinEntity
];
436 $scope
.fieldsForWhere = function() {
437 return {results
: ctrl
.getAllFields(':name')};
440 $scope
.fieldsForHaving = function() {
441 return {results
: ctrl
.getSelectFields()};
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
);
454 this.getAllFields = function(suffix
, allowedTypes
, disabledIf
, topJoin
) {
455 disabledIf
= disabledIf
|| _
.noop
;
457 function formatEntityFields(entityName
, join
) {
458 var prefix
= join
? join
.alias
+ '.' : '',
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
);
468 formatFields(searchMeta
.getEntity(entityName
).fields
, result
, prefix
);
472 function formatFields(fields
, result
, prefix
) {
473 prefix
= typeof prefix
=== 'undefined' ? '' : prefix
;
474 _
.each(fields
, function(field
) {
476 id
: prefix
+ field
.name
+ (field
.options
? suffix
: ''),
478 description
: field
.description
480 if (disabledIf(item
.id
)) {
481 item
.disabled
= true;
483 if (!allowedTypes
|| _
.includes(allowedTypes
, field
.type
)) {
490 var mainEntity
= searchMeta
.getEntity(ctrl
.savedSearch
.api_entity
),
491 joinEntities
= _
.map(ctrl
.savedSearch
.api_params
.join
, 0),
494 function addJoin(join
) {
495 var joinInfo
= searchMeta
.getJoin(join
),
496 joinEntity
= searchMeta
.getEntity(joinInfo
.entity
);
498 text
: joinInfo
.label
,
499 description
: joinInfo
.description
,
500 icon
: joinEntity
.icon
,
501 children
: formatEntityFields(joinEntity
.name
, joinInfo
)
505 // Place specified join at top of list
508 _
.pull(joinEntities
, topJoin
);
512 text
: mainEntity
.title_plural
,
513 icon
: mainEntity
.icon
,
514 children
: formatEntityFields(ctrl
.savedSearch
.api_entity
)
517 // Include SearchKit's pseudo-fields if specifically requested
518 if (allowedTypes
&& _
.includes(allowedTypes
, 'Pseudo')) {
522 children
: formatFields(CRM
.crmSearchAdmin
.pseudoFields
, [])
526 _
.each(joinEntities
, addJoin
);
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
);
536 text
: ctrl
.getFieldLabel(name
),
537 description
: info
.field
&& info
.field
.description
539 if (disabledIf(item
.id
)) {
540 item
.disabled
= true;
546 this.isPseudoField = function(name
) {
547 return _
.findIndex(CRM
.crmSearchAdmin
.pseudoFields
, {name
: name
}) >= 0;
551 * Fetch pseudoconstants for main entity + joined entities
553 * Sets an optionsLoaded property on each entity to avoid duplicate requests
555 * @var string entity - optional additional entity to load
557 function loadFieldOptions(entity
) {
558 var mainEntity
= searchMeta
.getEntity(ctrl
.savedSearch
.api_entity
),
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]],
567 }, {name
: 'options'}];
570 if (typeof mainEntity
.optionsLoaded
=== 'undefined') {
574 // Optional additional entity
575 if (entity
&& typeof searchMeta
.getEntity(entity
).optionsLoaded
=== 'undefined') {
576 enqueue(searchMeta
.getEntity(entity
));
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') {
586 if (bridgeEntity
&& typeof bridgeEntity
.optionsLoaded
=== 'undefined') {
587 enqueue(bridgeEntity
);
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
;
597 entity
.optionsLoaded
= true;
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
) {
608 link
.title
= ts('View %1', {1: entityName
});
609 link
.icon
= 'fa-external-link';
610 link
.style
= 'default';
614 link
.title
= ts('Edit %1', {1: entityName
});
615 link
.icon
= 'fa-pencil';
616 link
.style
= 'default';
620 link
.title
= ts('Delete %1', {1: entityName
});
621 link
.icon
= 'fa-trash';
622 link
.style
= 'danger';
627 // Links to main entity
629 var mainEntity
= searchMeta
.getEntity(ctrl
.savedSearch
.api_entity
),
630 links
= _
.cloneDeep(mainEntity
.paths
|| []);
631 _
.each(links
, function(link
) {
633 addTitle(link
, mainEntity
.title
);
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
);
648 link
.path
= link
.path
.replace(/[.:]/g, '_');
650 link
.join
= join
.alias
;
651 addTitle(link
, join
.label
);
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
: ''));
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
);
683 return _
.uniq(links
, 'path');
689 })(angular
, CRM
.$, CRM
._
);