Search kit: Add UI support for multiple, nested joins
[civicrm-core.git] / ext / search / ang / crmSearchAdmin.module.js
1 (function(angular, $, _) {
2 "use strict";
3
4 // Shared between router and searchMeta service
5 var searchEntity,
6 joinIndex,
7 undefined;
8
9 // Declare module and route/controller/services
10 angular.module('crmSearchAdmin', CRM.angRequires('crmSearchAdmin'))
11
12 .config(function($routeProvider) {
13 $routeProvider.when('/list', {
14 controller: 'searchList',
15 templateUrl: '~/crmSearchAdmin/searchList.html',
16 resolve: {
17 // Load data for lists
18 savedSearches: function(crmApi4) {
19 return crmApi4('SavedSearch', 'get', {
20 select: [
21 'id',
22 'name',
23 'label',
24 'api_entity',
25 'api_params',
26 'GROUP_CONCAT(display.name ORDER BY display.id) AS display_name',
27 'GROUP_CONCAT(display.label ORDER BY display.id) AS display_label',
28 'GROUP_CONCAT(display.type:icon ORDER BY display.id) AS display_icon',
29 'GROUP_CONCAT(DISTINCT group.title) AS groups'
30 ],
31 join: [['SearchDisplay AS display'], ['Group AS group']],
32 where: [['api_entity', 'IS NOT NULL']],
33 groupBy: ['id']
34 });
35 }
36 }
37 });
38 $routeProvider.when('/create/:entity', {
39 controller: 'searchCreate',
40 reloadOnSearch: false,
41 template: '<crm-search-admin saved-search="$ctrl.savedSearch"></crm-search-admin>',
42 });
43 $routeProvider.when('/edit/:id', {
44 controller: 'searchEdit',
45 template: '<crm-search-admin saved-search="$ctrl.savedSearch"></crm-search-admin>',
46 resolve: {
47 // Load saved search
48 savedSearch: function($route, crmApi4) {
49 var params = $route.current.params;
50 return crmApi4('SavedSearch', 'get', {
51 where: [['id', '=', params.id]],
52 chain: {
53 groups: ['Group', 'get', {select: ['id', 'title', 'description', 'visibility', 'group_type'], where: [['saved_search_id', '=', '$id']]}],
54 displays: ['SearchDisplay', 'get', {where: [['saved_search_id', '=', '$id']]}]
55 }
56 }, 0);
57 }
58 }
59 });
60 })
61
62 // Controller for creating a new search
63 .controller('searchCreate', function($scope, $routeParams, $location) {
64 searchEntity = $routeParams.entity;
65 $scope.$ctrl = this;
66 this.savedSearch = {
67 api_entity: searchEntity,
68 };
69 // Changing entity will refresh the angular page
70 $scope.$watch('$ctrl.savedSearch.api_entity', function(newEntity, oldEntity) {
71 if (newEntity && oldEntity && newEntity !== oldEntity) {
72 $location.url('/create/' + newEntity);
73 }
74 });
75 })
76
77 // Controller for editing a SavedSearch
78 .controller('searchEdit', function($scope, savedSearch) {
79 searchEntity = savedSearch.api_entity;
80 this.savedSearch = savedSearch;
81 $scope.$ctrl = this;
82 })
83
84 .factory('searchMeta', function() {
85 function getEntity(entityName) {
86 if (entityName) {
87 return _.find(CRM.crmSearchAdmin.schema, {name: entityName});
88 }
89 }
90 // Get join metadata matching a given expression like "Email AS Contact_Email_contact_id_01"
91 function getJoin(fullNameOrAlias) {
92 var alias = _.last(fullNameOrAlias.split(' AS ')),
93 path = alias,
94 baseEntity = searchEntity,
95 label = [],
96 join,
97 result;
98 while (path.length) {
99 /* jshint -W083 */
100 join = _.find(CRM.crmSearchAdmin.joins[baseEntity], function(join) {
101 return new RegExp('^' + join.alias + '_\\d\\d').test(path);
102 });
103 if (!join) {
104 console.warn( 'Join ' + fullNameOrAlias + ' not found.');
105 return;
106 }
107 path = path.replace(join.alias + '_', '');
108 var num = parseInt(path.substr(0, 2), 10);
109 baseEntity = join.entity;
110 label.push(join.label + (num > 1 ? ' ' + num : ''));
111 path = path.replace(/^\d\d_?/, '');
112 }
113 result = _.assign(_.cloneDeep(join), {label: label.join(' - '), alias: alias});
114 // Add the numbered suffix to the join conditions
115 // If this is a deep join, also add the base entity prefix
116 var prefix = alias.replace(new RegExp('_?' + join.alias + '_?\\d?\\d?$'), '');
117 _.each(result.conditions, function(condition) {
118 if (_.isArray(condition)) {
119 _.each(condition, function(ref, side) {
120 if (side !== 1 && _.includes(ref, '.')) {
121 condition[side] = ref.replace(join.alias + '.', alias + '.');
122 } else if (side !== 1 && prefix.length && !_.includes(ref, '"') && !_.includes(ref, "'")) {
123 condition[side] = prefix + '.' + ref;
124 }
125 });
126 }
127 });
128 return result;
129 }
130 function getField(fieldName, entityName) {
131 var dotSplit = fieldName.split('.'),
132 joinEntity = dotSplit.length > 1 ? dotSplit[0] : null,
133 name = _.last(dotSplit).split(':')[0],
134 join,
135 field;
136 // Custom fields contain a dot in their fieldname
137 // If 3 segments, the first is the joinEntity and the last 2 are the custom field
138 if (dotSplit.length === 3) {
139 name = dotSplit[1] + '.' + name;
140 }
141 // If 2 segments, it's ambiguous whether this is a custom field or joined field. Search the main entity first.
142 if (dotSplit.length === 2) {
143 field = _.find(getEntity(entityName).fields, {name: dotSplit[0] + '.' + name});
144 if (field) {
145 field.entity = entityName;
146 return field;
147 }
148 }
149 if (joinEntity) {
150 join = getJoin(joinEntity);
151 entityName = getJoin(joinEntity).entity;
152 }
153 field = _.find(getEntity(entityName).fields, {name: name});
154 if (!field && join && join.bridge) {
155 field = _.find(getEntity(join.bridge).fields, {name: name});
156 }
157 if (field) {
158 field.entity = entityName;
159 return field;
160 }
161 }
162 function parseExpr(expr) {
163 var result = {fn: null, modifier: ''},
164 fieldName = expr,
165 bracketPos = expr.indexOf('(');
166 if (bracketPos >= 0) {
167 var parsed = expr.substr(bracketPos).match(/[ ]?([A-Z]+[ ]+)?([\w.:]+)/);
168 fieldName = parsed[2];
169 result.fn = _.find(CRM.crmSearchAdmin.functions, {name: expr.substring(0, bracketPos)});
170 result.modifier = _.trim(parsed[1]);
171 }
172 result.field = expr ? getField(fieldName, searchEntity) : undefined;
173 if (result.field) {
174 var split = fieldName.split(':'),
175 prefixPos = split[0].lastIndexOf(result.field.name);
176 result.path = split[0];
177 result.prefix = prefixPos > 0 ? result.path.substring(0, prefixPos) : '';
178 result.suffix = !split[1] ? '' : ':' + split[1];
179 }
180 return result;
181 }
182 return {
183 getEntity: getEntity,
184 getField: getField,
185 getJoin: getJoin,
186 parseExpr: parseExpr,
187 getDefaultLabel: function(col) {
188 var info = parseExpr(col),
189 label = info.field.label;
190 if (info.fn) {
191 label = '(' + info.fn.title + ') ' + label;
192 }
193 return label;
194 },
195 // Find all possible search columns that could serve as contact_id for a smart group
196 getSmartGroupColumns: function(api_entity, api_params) {
197 var joins = _.pluck((api_params.join || []), 0),
198 entityCount = {};
199 return _.transform([api_entity].concat(joins), function(columns, joinExpr) {
200 var joinName = joinExpr.split(' AS '),
201 entityName = joinName[0],
202 entity = getEntity(entityName),
203 prefix = joinName[1] ? joinName[1] + '.' : '';
204 _.each(entity.fields, function(field) {
205 if ((entityName === 'Contact' && field.name === 'id') || field.fk_entity === 'Contact') {
206 columns.push({
207 id: prefix + field.name,
208 text: entity.title_plural + (entityCount[entityName] ? ' ' + entityCount[entityName] : '') + ': ' + field.label,
209 icon: entity.icon
210 });
211 }
212 });
213 entityCount[entityName] = 1 + (entityCount[entityName] || 1);
214 });
215 }
216 };
217 });
218
219 })(angular, CRM.$, CRM._);