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