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