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