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