Merge pull request #18076 from MegaphoneJon/better-on-behalf-of-2
[civicrm-core.git] / ext / search / ang / search / crmSearch.component.js
CommitLineData
25523059
CW
1(function(angular, $, _) {
2 "use strict";
3
b0422f12 4 angular.module('search').component('crmSearch', {
25523059
CW
5 bindings: {
6 entity: '='
7 },
b0422f12 8 templateUrl: '~/search/crmSearch.html',
25523059
CW
9 controller: function($scope, $element, $timeout, crmApi4, dialogService, searchMeta, formatForSelect2) {
10 var ts = $scope.ts = CRM.ts(),
11 ctrl = this;
12 this.selectedRows = [];
13 this.limit = CRM.cache.get('searchPageSize', 30);
14 this.page = 1;
15 this.params = {};
16 // After a search this.results is an object of result arrays keyed by page,
17 // Prior to searching it's an empty string because 1: falsey and 2: doesn't throw an error if you try to access undefined properties
18 this.results = '';
19 this.rowCount = false;
20 // Have the filters (WHERE, HAVING, GROUP BY, JOIN) changed?
21 this.stale = true;
22 this.allRowsSelected = false;
23
24 $scope.controls = {};
25 $scope.joinTypes = [{k: false, v: ts('Optional')}, {k: true, v: ts('Required')}];
26 $scope.entities = formatForSelect2(CRM.vars.search.schema, 'name', 'title', ['description', 'icon']);
27 this.perm = {
28 editGroups: CRM.checkPerm('edit groups')
29 };
30
31 this.getEntity = searchMeta.getEntity;
32
33 this.paramExists = function(param) {
34 return _.includes(searchMeta.getEntity(ctrl.entity).params, param);
35 };
36
37 $scope.getJoinEntities = function() {
38 var joinEntities = _.transform(CRM.vars.search.links[ctrl.entity], function(joinEntities, link) {
39 var entity = searchMeta.getEntity(link.entity);
40 if (entity) {
41 joinEntities.push({
42 id: link.entity + ' AS ' + link.alias,
43 text: entity.title,
44 description: '(' + link.alias + ')',
45 icon: entity.icon
46 });
47 }
48 }, []);
49 return {results: joinEntities};
50 };
51
52 $scope.addJoin = function() {
53 // Debounce the onchange event using timeout
54 $timeout(function() {
55 if ($scope.controls.join) {
56 ctrl.params.join = ctrl.params.join || [];
57 ctrl.params.join.push([$scope.controls.join, false]);
58 loadFieldOptions();
59 }
60 $scope.controls.join = '';
61 });
62 };
63
64 $scope.changeJoin = function(idx) {
65 if (ctrl.params.join[idx][0]) {
66 ctrl.params.join[idx].length = 2;
67 loadFieldOptions();
68 } else {
69 ctrl.clearParam('join', idx);
70 }
71 };
72
73 $scope.changeGroupBy = function(idx) {
74 if (!ctrl.params.groupBy[idx]) {
75 ctrl.clearParam('groupBy', idx);
76 }
77 };
78
79 /**
80 * Called when clicking on a column header
81 * @param col
82 * @param $event
83 */
84 $scope.setOrderBy = function(col, $event) {
85 var dir = $scope.getOrderBy(col) === 'fa-sort-asc' ? 'DESC' : 'ASC';
86 if (!$event.shiftKey) {
87 ctrl.params.orderBy = {};
88 }
89 ctrl.params.orderBy[col] = dir;
90 };
91
92 /**
93 * Returns crm-i icon class for a sortable column
94 * @param col
95 * @returns {string}
96 */
97 $scope.getOrderBy = function(col) {
98 var dir = ctrl.params.orderBy && ctrl.params.orderBy[col];
99 if (dir) {
100 return 'fa-sort-' + dir.toLowerCase();
101 }
102 return 'fa-sort disabled';
103 };
104
105 $scope.addParam = function(name) {
106 if ($scope.controls[name] && !_.contains(ctrl.params[name], $scope.controls[name])) {
107 ctrl.params[name].push($scope.controls[name]);
108 if (name === 'groupBy') {
109 // Expand the aggregate block
110 $timeout(function() {
111 $('#crm-search-build-group-aggregate.collapsed .collapsible-title').click();
112 }, 10);
113 }
114 }
115 $scope.controls[name] = '';
116 };
117
118 // Deletes an item from an array param
119 this.clearParam = function(name, idx) {
120 ctrl.params[name].splice(idx, 1);
121 };
122
123 // Prevent visual jumps in results table height during loading
124 function lockTableHeight() {
125 var $table = $('.crm-search-results', $element);
126 $table.css('height', $table.height());
127 }
128
129 function unlockTableHeight() {
130 $('.crm-search-results', $element).css('height', '');
131 }
132
133 // Debounced callback for loadResults
134 function _loadResultsCallback() {
135 // Multiply limit to read 2 pages at once & save ajax requests
136 var params = angular.merge({debug: true, limit: ctrl.limit * 2}, ctrl.params);
137 lockTableHeight();
138 $scope.error = false;
139 if (ctrl.stale) {
140 ctrl.page = 1;
141 ctrl.rowCount = false;
142 }
143 if (ctrl.rowCount === false) {
144 params.select.push('row_count');
145 }
146 params.offset = ctrl.limit * (ctrl.page - 1);
147 crmApi4(ctrl.entity, 'get', params).then(function(success) {
148 if (ctrl.stale) {
149 ctrl.results = {};
150 }
151 if (ctrl.rowCount === false) {
152 ctrl.rowCount = success.count;
153 }
154 ctrl.debug = success.debug;
155 // populate this page & the next
156 ctrl.results[ctrl.page] = success.slice(0, ctrl.limit);
157 if (success.length > ctrl.limit) {
158 ctrl.results[ctrl.page + 1] = success.slice(ctrl.limit);
159 }
160 $scope.loading = false;
161 ctrl.stale = false;
162 unlockTableHeight();
163 }, function(error) {
164 $scope.loading = false;
165 ctrl.results = {};
166 ctrl.stale = true;
167 ctrl.debug = error.debug;
168 $scope.error = errorMsg(error);
9c4a1dae
CW
169 })
170 .finally(function() {
171 if (ctrl.debug) {
172 ctrl.debug.params = JSON.stringify(ctrl.params, null, 2);
173 }
174 });
25523059
CW
175 }
176
177 var _loadResults = _.debounce(_loadResultsCallback, 250);
178
179 function loadResults() {
180 $scope.loading = true;
181 _loadResults();
182 }
183
184 // What to tell the user when search returns an error from the server
185 // Todo: parse error codes and give helpful feedback.
186 function errorMsg(error) {
187 return ts('Ensure all search critera are set correctly and try again.');
188 }
189
190 this.changePage = function() {
191 if (ctrl.stale || !ctrl.results[ctrl.page]) {
192 lockTableHeight();
193 loadResults();
194 }
195 };
196
197 this.refreshAll = function() {
198 ctrl.stale = true;
199 ctrl.selectedRows.length = 0;
200 loadResults();
201 };
202
203 // Refresh results while staying on current page.
204 this.refreshPage = function() {
205 lockTableHeight();
206 ctrl.results = {};
207 loadResults();
208 };
209
210 $scope.onClickSearch = function() {
211 if (ctrl.autoSearch) {
212 ctrl.autoSearch = false;
213 } else {
214 ctrl.refreshAll();
215 }
216 };
217
218 $scope.onClickAuto = function() {
219 ctrl.autoSearch = !ctrl.autoSearch;
220 if (ctrl.autoSearch && ctrl.stale) {
221 ctrl.refreshAll();
222 }
0b1769c6 223 $('.crm-search-auto-toggle').blur();
25523059
CW
224 };
225
226 $scope.onChangeLimit = function() {
227 // Refresh only if search has already been run
228 if (ctrl.autoSearch || ctrl.results) {
229 // Save page size in localStorage
230 CRM.cache.set('searchPageSize', ctrl.limit);
231 ctrl.refreshAll();
232 }
233 };
234
235 function onChangeSelect(newSelect, oldSelect) {
236 // Re-arranging or removing columns doesn't merit a refresh, only adding columns does
237 if (!oldSelect || _.difference(newSelect, oldSelect).length) {
238 if (ctrl.autoSearch) {
239 ctrl.refreshPage();
240 } else {
241 ctrl.stale = true;
242 }
243 }
244 }
245
246 function onChangeOrderBy() {
247 if (ctrl.results) {
248 ctrl.refreshPage();
249 }
250 }
251
252 function onChangeFilters() {
253 ctrl.stale = true;
254 ctrl.selectedRows.length = 0;
255 if (ctrl.autoSearch) {
256 ctrl.refreshAll();
257 }
258 }
259
260 $scope.selectAllRows = function() {
261 // Deselect all
262 if (ctrl.allRowsSelected) {
263 ctrl.allRowsSelected = false;
264 ctrl.selectedRows.length = 0;
265 return;
266 }
267 // Select all
268 ctrl.allRowsSelected = true;
269 if (ctrl.page === 1 && ctrl.results[1].length < ctrl.limit) {
270 ctrl.selectedRows = _.pluck(ctrl.results[1], 'id');
271 return;
272 }
273 // If more than one page of results, use ajax to fetch all ids
274 $scope.loadingAllRows = true;
275 var params = _.cloneDeep(ctrl.params);
276 params.select = ['id'];
277 crmApi4(ctrl.entity, 'get', params, ['id']).then(function(ids) {
278 $scope.loadingAllRows = false;
279 ctrl.selectedRows = _.toArray(ids);
280 });
281 };
282
283 $scope.selectRow = function(row) {
284 var index = ctrl.selectedRows.indexOf(row.id);
285 if (index < 0) {
286 ctrl.selectedRows.push(row.id);
287 ctrl.allRowsSelected = (ctrl.rowCount === ctrl.selectedRows.length);
288 } else {
289 ctrl.allRowsSelected = false;
290 ctrl.selectedRows.splice(index, 1);
291 }
292 };
293
294 $scope.isRowSelected = function(row) {
295 return ctrl.allRowsSelected || _.includes(ctrl.selectedRows, row.id);
296 };
297
298 this.getFieldLabel = function(col) {
299 var info = searchMeta.parseExpr(col),
b6b6cb2d 300 label = info.field.label;
25523059
CW
301 if (info.fn) {
302 label = '(' + info.fn.title + ') ' + label;
303 }
304 return label;
305 };
306
307 // Is a column eligible to use an aggregate function?
308 this.canAggregate = function(col) {
309 // If the column is used for a groupBy, no
310 if (ctrl.params.groupBy.indexOf(col) > -1) {
311 return false;
312 }
313 // If the entity this column belongs to is being grouped by id, then also no
314 var info = searchMeta.parseExpr(col);
315 return ctrl.params.groupBy.indexOf(info.prefix + 'id') < 0;
316 };
317
318 $scope.formatResult = function formatResult(row, col) {
319 var info = searchMeta.parseExpr(col),
320 key = info.fn ? (info.fn.name + ':' + info.path + info.suffix) : col,
321 value = row[key];
322 // Handle grouped results
323 if (info.fn && info.fn.name === 'GROUP_CONCAT' && value) {
324 return formatGroupConcatValues(info, value);
325 }
2d198bb4
CW
326 else if (info.fn && info.fn.name === 'COUNT') {
327 return value;
328 }
25523059
CW
329 return formatFieldValue(info.field, value);
330 };
331
332 function formatFieldValue(field, value) {
333 var type = field.data_type;
334 if (value && (type === 'Date' || type === 'Timestamp') && /^\d{4}-\d{2}-\d{2}/.test(value)) {
335 return CRM.utils.formatDate(value, null, type === 'Timestamp');
336 }
337 else if (type === 'Boolean' && typeof value === 'boolean') {
338 return value ? ts('Yes') : ts('No');
339 }
2d198bb4
CW
340 else if (type === 'Money') {
341 return CRM.formatMoney(value);
342 }
39deabd6
CW
343 if (_.isArray(value)) {
344 return value.join(', ');
345 }
25523059
CW
346 return value;
347 }
348
349 function formatGroupConcatValues(info, values) {
350 return _.transform(values.split(','), function(result, val) {
351 if (info.field.options && !info.suffix) {
352 result.push(_.result(getOption(info.field, val), 'label'));
353 } else {
354 result.push(formatFieldValue(info.field, val));
355 }
356 }).join(', ');
357 }
358
359 function getOption(field, value) {
360 return _.find(field.options, function(option) {
361 // Type coersion is intentional
362 return option.id == value;
363 });
364 }
365
366 $scope.fieldsForGroupBy = function() {
367 return {results: getAllFields('', function(key) {
368 return _.contains(ctrl.params.groupBy, key);
369 })
370 };
371 };
372
373 $scope.fieldsForSelect = function() {
374 return {results: getAllFields(':label', function(key) {
375 return _.contains(ctrl.params.select, key);
376 })
377 };
378 };
379
380 $scope.fieldsForWhere = function() {
381 return {results: getAllFields(':name', _.noop)};
382 };
383
384 $scope.fieldsForHaving = function() {
385 return {results: _.transform(ctrl.params.select, function(fields, name) {
386 fields.push({id: name, text: ctrl.getFieldLabel(name)});
387 })};
388 };
389
390 function getDefaultSelect() {
391 return _.filter(['id', 'display_name', 'label', 'title', 'location_type_id:label'], searchMeta.getField);
392 }
393
394 function getAllFields(suffix, disabledIf) {
395 function formatFields(entityName, prefix) {
396 return _.transform(searchMeta.getEntity(entityName).fields, function(result, field) {
397 var item = {
398 id: prefix + field.name + (field.options ? suffix : ''),
b6b6cb2d 399 text: field.label,
25523059
CW
400 description: field.description
401 };
402 if (disabledIf(item.id)) {
403 item.disabled = true;
404 }
405 result.push(item);
406 }, []);
407 }
408
409 var mainEntity = searchMeta.getEntity(ctrl.entity),
410 result = [{
411 text: mainEntity.title,
412 icon: mainEntity.icon,
413 children: formatFields(ctrl.entity, '')
414 }];
415 _.each(ctrl.params.join, function(join) {
416 var joinName = join[0].split(' AS '),
417 joinEntity = searchMeta.getEntity(joinName[0]);
418 result.push({
419 text: joinEntity.title + ' (' + joinName[1] + ')',
420 icon: joinEntity.icon,
421 children: formatFields(joinEntity.name, joinName[1] + '.')
422 });
423 });
424 return result;
425 }
426
427 /**
428 * Fetch pseudoconstants for main entity + joined entities
429 *
430 * Sets an optionsLoaded property on each entity to avoid duplicate requests
431 */
432 function loadFieldOptions() {
433 var mainEntity = searchMeta.getEntity(ctrl.entity),
434 entities = {};
435
436 function enqueue(entity) {
437 entity.optionsLoaded = false;
438 entities[entity.name] = [entity.name, 'getFields', {
439 loadOptions: CRM.vars.search.loadOptions,
440 where: [['options', '!=', false]],
441 select: ['options']
442 }, {name: 'options'}];
443 }
444
445 if (typeof mainEntity.optionsLoaded === 'undefined') {
446 enqueue(mainEntity);
447 }
448 _.each(ctrl.params.join, function(join) {
449 var joinName = join[0].split(' AS '),
450 joinEntity = searchMeta.getEntity(joinName[0]);
451 if (typeof joinEntity.optionsLoaded === 'undefined') {
452 enqueue(joinEntity);
453 }
454 });
455 if (!_.isEmpty(entities)) {
456 crmApi4(entities).then(function(results) {
457 _.each(results, function(fields, entityName) {
458 var entity = searchMeta.getEntity(entityName);
459 _.each(fields, function(options, fieldName) {
460 _.find(entity.fields, {name: fieldName}).options = options;
461 });
462 entity.optionsLoaded = true;
463 });
464 });
465 }
466 }
467
468 this.$onInit = function() {
25523059
CW
469 $scope.$bindToRoute({
470 expr: '$ctrl.params.select',
471 param: 'select',
472 format: 'json',
473 default: getDefaultSelect()
474 });
475 $scope.$watchCollection('$ctrl.params.select', onChangeSelect);
476
477 $scope.$bindToRoute({
478 expr: '$ctrl.params.orderBy',
479 param: 'orderBy',
480 format: 'json',
481 default: {}
482 });
483 $scope.$watchCollection('$ctrl.params.orderBy', onChangeOrderBy);
484
485 $scope.$bindToRoute({
486 expr: '$ctrl.params.where',
487 param: 'where',
488 format: 'json',
489 default: [],
490 deep: true
491 });
492 $scope.$watch('$ctrl.params.where', onChangeFilters, true);
493
494 if (this.paramExists('groupBy')) {
495 $scope.$bindToRoute({
496 expr: '$ctrl.params.groupBy',
497 param: 'groupBy',
498 format: 'json',
499 default: []
500 });
501 }
502 $scope.$watchCollection('$ctrl.params.groupBy', onChangeFilters);
503
504 if (this.paramExists('join')) {
505 $scope.$bindToRoute({
506 expr: '$ctrl.params.join',
507 param: 'join',
508 format: 'json',
509 default: [],
510 deep: true
511 });
512 }
513 $scope.$watch('$ctrl.params.join', onChangeFilters, true);
514
515 if (this.paramExists('having')) {
516 $scope.$bindToRoute({
517 expr: '$ctrl.params.having',
518 param: 'having',
519 format: 'json',
520 default: [],
521 deep: true
522 });
523 }
524 $scope.$watch('$ctrl.params.having', onChangeFilters, true);
27bed3cb
CW
525
526 loadFieldOptions();
25523059
CW
527 };
528
529 $scope.saveGroup = function() {
530 var selectField = ctrl.entity === 'Contact' ? 'id' : 'contact_id';
531 if (ctrl.entity !== 'Contact' && !searchMeta.getField('contact_id')) {
532 CRM.alert(ts('Cannot create smart group from %1.', {1: searchMeta.getEntity(true).title}), ts('Missing contact_id'), 'error', {expires: 5000});
533 return;
534 }
535 var model = {
536 title: '',
537 description: '',
538 visibility: 'User and User Admin Only',
539 group_type: [],
540 id: null,
541 entity: ctrl.entity,
542 params: angular.extend({}, ctrl.params, {version: 4, select: [selectField]})
543 };
544 delete model.params.orderBy;
545 var options = CRM.utils.adjustDialogDefaults({
546 autoOpen: false,
547 title: ts('Save smart group')
548 });
549 dialogService.open('saveSearchDialog', '~/search/saveSmartGroup.html', model, options);
550 };
551 }
552 });
553
554})(angular, CRM.$, CRM._);