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