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