feac4a41e15dd2db18b16c4cc530e1c975e049a1
[civicrm-core.git] / ext / search / ang / crmSearchAdmin / crmSearchAdmin.component.js
1 (function(angular, $, _) {
2 "use strict";
3
4 angular.module('crmSearchAdmin').component('crmSearchAdmin', {
5 bindings: {
6 savedSearch: '<'
7 },
8 templateUrl: '~/crmSearchAdmin/crmSearchAdmin.html',
9 controller: function($scope, $element, $location, $timeout, crmApi4, dialogService, searchMeta, formatForSelect2) {
10 var ts = $scope.ts = CRM.ts('org.civicrm.search'),
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.crmSearchAdmin.displayTypes, 'id');
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', joinType: 'LEFT'};
28 $scope.joinTypes = [
29 {k: 'LEFT', v: ts('With (optional)')},
30 {k: 'INNER', v: ts('With (required)')},
31 {k: 'EXCLUDE', v: ts('Without')},
32 ];
33 // Try to create a sensible list of entities one might want to search for,
34 // excluding those whos primary purpose is to provide joins or option lists to other entities
35 var primaryEntities = _.filter(CRM.crmSearchAdmin.schema, function(entity) {
36 return !_.includes(entity.type, 'EntityBridge') && !_.includes(entity.type, 'OptionList');
37 });
38 $scope.entities = formatForSelect2(primaryEntities, 'name', 'title_plural', ['description', 'icon']);
39 $scope.getEntity = searchMeta.getEntity;
40 $scope.getField = searchMeta.getField;
41 this.perm = {
42 editGroups: CRM.checkPerm('edit groups')
43 };
44
45 this.$onInit = function() {
46 this.entityTitle = searchMeta.getEntity(this.savedSearch.api_entity).title_plural;
47
48 this.savedSearch.displays = this.savedSearch.displays || [];
49 this.savedSearch.groups = this.savedSearch.groups || [];
50 this.groupExists = !!this.savedSearch.groups.length;
51
52 if (!this.savedSearch.id) {
53 $scope.$bindToRoute({
54 param: 'params',
55 expr: '$ctrl.savedSearch.api_params',
56 deep: true,
57 default: {
58 version: 4,
59 select: getDefaultSelect(),
60 orderBy: {},
61 where: [],
62 }
63 });
64 }
65
66 $scope.$watchCollection('$ctrl.savedSearch.api_params.select', onChangeSelect);
67
68 $scope.$watch('$ctrl.savedSearch.api_params.where', onChangeFilters, true);
69
70 if (this.paramExists('groupBy')) {
71 this.savedSearch.api_params.groupBy = this.savedSearch.api_params.groupBy || [];
72 $scope.$watchCollection('$ctrl.savedSearch.api_params.groupBy', onChangeFilters);
73 }
74
75 if (this.paramExists('join')) {
76 this.savedSearch.api_params.join = this.savedSearch.api_params.join || [];
77 $scope.$watch('$ctrl.savedSearch.api_params.join', onChangeFilters, true);
78 }
79
80 if (this.paramExists('having')) {
81 this.savedSearch.api_params.having = this.savedSearch.api_params.having || [];
82 $scope.$watch('$ctrl.savedSearch.api_params.having', onChangeFilters, true);
83 }
84
85 $scope.$watch('$ctrl.savedSearch', onChangeAnything, true);
86
87 // After watcher runs for the first time and messes up the status, set it correctly
88 $timeout(function() {
89 $scope.status = ctrl.savedSearch && ctrl.savedSearch.id ? 'saved' : 'unsaved';
90 });
91
92 loadFieldOptions();
93 };
94
95 function onChangeAnything() {
96 $scope.status = 'unsaved';
97 }
98
99 this.save = function() {
100 if (!validate()) {
101 return;
102 }
103 $scope.status = 'saving';
104 var params = _.cloneDeep(ctrl.savedSearch),
105 apiCalls = {},
106 chain = {};
107 if (ctrl.groupExists) {
108 chain.groups = ['Group', 'save', {defaults: {saved_search_id: '$id'}, records: params.groups}];
109 delete params.groups;
110 } else if (params.id) {
111 apiCalls.deleteGroup = ['Group', 'delete', {where: [['saved_search_id', '=', params.id]]}];
112 }
113 if (params.displays && params.displays.length) {
114 chain.displays = ['SearchDisplay', 'replace', {where: [['saved_search_id', '=', '$id']], records: params.displays}];
115 } else if (params.id) {
116 apiCalls.deleteDisplays = ['SearchDisplay', 'delete', {where: [['saved_search_id', '=', params.id]]}];
117 }
118 delete params.displays;
119 apiCalls.saved = ['SavedSearch', 'save', {records: [params], chain: chain}, 0];
120 crmApi4(apiCalls).then(function(results) {
121 // After saving a new search, redirect to the edit url
122 if (!ctrl.savedSearch.id) {
123 $location.url('edit/' + results.saved.id);
124 }
125 // Set new status to saved unless the user changed something in the interim
126 var newStatus = $scope.status === 'unsaved' ? 'unsaved' : 'saved';
127 if (results.saved.groups && results.saved.groups.length) {
128 ctrl.savedSearch.groups[0].id = results.saved.groups[0].id;
129 }
130 ctrl.savedSearch.displays = results.saved.displays || [];
131 // Wait until after onChangeAnything to update status
132 $timeout(function() {
133 $scope.status = newStatus;
134 });
135 });
136 };
137
138 this.paramExists = function(param) {
139 return _.includes(searchMeta.getEntity(ctrl.savedSearch.api_entity).params, param);
140 };
141
142 this.addDisplay = function(type) {
143 ctrl.savedSearch.displays.push({
144 type: type,
145 label: ''
146 });
147 $scope.selectTab('display_' + (ctrl.savedSearch.displays.length - 1));
148 };
149
150 this.removeDisplay = function(index) {
151 var display = ctrl.savedSearch.displays[index];
152 if (display.id) {
153 display.trashed = !display.trashed;
154 if ($scope.controls.tab === ('display_' + index) && display.trashed) {
155 $scope.selectTab('compose');
156 } else if (!display.trashed) {
157 $scope.selectTab('display_' + index);
158 }
159 } else {
160 $scope.selectTab('compose');
161 ctrl.savedSearch.displays.splice(index, 1);
162 }
163 };
164
165 this.addGroup = function() {
166 ctrl.savedSearch.groups.push({
167 title: '',
168 description: '',
169 visibility: 'User and User Admin Only',
170 group_type: []
171 });
172 ctrl.groupExists = true;
173 $scope.selectTab('group');
174 };
175
176 $scope.selectTab = function(tab) {
177 if (tab === 'group') {
178 loadFieldOptions('Group');
179 $scope.smartGroupColumns = searchMeta.getSmartGroupColumns(ctrl.savedSearch.api_entity, ctrl.savedSearch.api_params);
180 var smartGroupColumns = _.map($scope.smartGroupColumns, 'id');
181 if (smartGroupColumns.length && !_.includes(smartGroupColumns, ctrl.savedSearch.api_params.select[0])) {
182 ctrl.savedSearch.api_params.select.unshift(smartGroupColumns[0]);
183 }
184 }
185 ctrl.savedSearch.api_params.select = _.uniq(ctrl.savedSearch.api_params.select);
186 $scope.controls.tab = tab;
187 };
188
189 this.removeGroup = function() {
190 ctrl.groupExists = !ctrl.groupExists;
191 $scope.status = 'unsaved';
192 if (!ctrl.groupExists && (!ctrl.savedSearch.groups.length || !ctrl.savedSearch.groups[0].id)) {
193 ctrl.savedSearch.groups.length = 0;
194 }
195 if ($scope.controls.tab === 'group') {
196 $scope.selectTab('compose');
197 }
198 };
199
200 function addNum(name, num) {
201 return name + (num < 10 ? '_0' : '_') + num;
202 }
203
204 function getExistingJoins() {
205 return _.transform(ctrl.savedSearch.api_params.join || [], function(joins, join) {
206 joins[join[0].split(' AS ')[1]] = searchMeta.getJoin(join[0]);
207 }, {});
208 }
209
210 $scope.getJoin = searchMeta.getJoin;
211
212 $scope.getJoinEntities = function() {
213 var existingJoins = getExistingJoins();
214
215 function addEntityJoins(entity, stack, baseEntity) {
216 return _.transform(CRM.crmSearchAdmin.joins[entity], function(joinEntities, join) {
217 var num = 0;
218 // Add all joins that don't just point directly back to the original entity
219 if (!(baseEntity === join.entity && !join.multi)) {
220 do {
221 appendJoin(joinEntities, join, ++num, stack, entity);
222 } while (addNum((stack ? stack + '_' : '') + join.alias, num) in existingJoins);
223 }
224 }, []);
225 }
226
227 function appendJoin(collection, join, num, stack, baseEntity) {
228 var alias = addNum((stack ? stack + '_' : '') + join.alias, num),
229 opt = {
230 id: join.entity + ' AS ' + alias,
231 description: join.description,
232 text: join.label + (num > 1 ? ' ' + num : ''),
233 icon: searchMeta.getEntity(join.entity).icon,
234 disabled: alias in existingJoins
235 };
236 if (alias in existingJoins) {
237 opt.children = addEntityJoins(join.entity, (stack ? stack + '_' : '') + alias, baseEntity);
238 }
239 collection.push(opt);
240 }
241
242 return {results: addEntityJoins(ctrl.savedSearch.api_entity)};
243 };
244
245 $scope.addJoin = function() {
246 // Debounce the onchange event using timeout
247 $timeout(function() {
248 if ($scope.controls.join) {
249 ctrl.savedSearch.api_params.join = ctrl.savedSearch.api_params.join || [];
250 var join = searchMeta.getJoin($scope.controls.join),
251 entity = searchMeta.getEntity(join.entity),
252 params = [$scope.controls.join, $scope.controls.joinType || 'LEFT'];
253 _.each(_.cloneDeep(join.conditions), function(condition) {
254 params.push(condition);
255 });
256 _.each(_.cloneDeep(join.defaults), function(condition) {
257 params.push(condition);
258 });
259 ctrl.savedSearch.api_params.join.push(params);
260 if (entity.label_field && $scope.controls.joinType !== 'EXCLUDE') {
261 ctrl.savedSearch.api_params.select.push(join.alias + '.' + entity.label_field);
262 }
263 loadFieldOptions();
264 }
265 $scope.controls.join = '';
266 });
267 };
268
269 // Remove an explicit join + all SELECT, WHERE & other JOINs that use it
270 this.removeJoin = function(index) {
271 var alias = searchMeta.getJoin(ctrl.savedSearch.api_params.join[index][0]).alias;
272 ctrl.clearParam('join', index);
273 removeJoinStuff(alias);
274 };
275
276 function removeJoinStuff(alias) {
277 _.remove(ctrl.savedSearch.api_params.select, function(item) {
278 var pattern = new RegExp('\\b' + alias + '\\.');
279 return pattern.test(item.split(' AS ')[0]);
280 });
281 _.remove(ctrl.savedSearch.api_params.where, function(clause) {
282 return clauseUsesJoin(clause, alias);
283 });
284 _.eachRight(ctrl.savedSearch.api_params.join, function(item, i) {
285 var joinAlias = searchMeta.getJoin(item[0]).alias;
286 if (joinAlias !== alias && joinAlias.indexOf(alias) === 0) {
287 ctrl.removeJoin(i);
288 }
289 });
290 }
291
292 this.changeJoinType = function(join) {
293 if (join[1] === 'EXCLUDE') {
294 removeJoinStuff(searchMeta.getJoin(join[0]).alias);
295 }
296 };
297
298 $scope.changeGroupBy = function(idx) {
299 if (!ctrl.savedSearch.api_params.groupBy[idx]) {
300 ctrl.clearParam('groupBy', idx);
301 }
302 // Remove aggregate functions when no grouping
303 if (!ctrl.savedSearch.api_params.groupBy.length) {
304 _.each(ctrl.savedSearch.api_params.select, function(col, pos) {
305 if (_.contains(col, '(')) {
306 var info = searchMeta.parseExpr(col);
307 if (info.fn.category === 'aggregate') {
308 ctrl.savedSearch.api_params.select[pos] = info.path + info.suffix;
309 }
310 }
311 });
312 }
313 };
314
315 function clauseUsesJoin(clause, alias) {
316 if (clause[0].indexOf(alias + '.') === 0) {
317 return true;
318 }
319 if (_.isArray(clause[1])) {
320 return clause[1].some(function(subClause) {
321 return clauseUsesJoin(subClause, alias);
322 });
323 }
324 return false;
325 }
326
327 // Returns true if a clause contains one of the
328 function clauseUsesFields(clause, fields) {
329 if (!fields || !fields.length) {
330 return false;
331 }
332 if (_.includes(fields, clause[0])) {
333 return true;
334 }
335 if (_.isArray(clause[1])) {
336 return clause[1].some(function(subClause) {
337 return clauseUsesField(subClause, fields);
338 });
339 }
340 return false;
341 }
342
343 function validate() {
344 var errors = [],
345 errorEl,
346 label,
347 tab;
348 if (!ctrl.savedSearch.label) {
349 errorEl = '#crm-saved-search-label';
350 label = ts('Search Label');
351 errors.push(ts('%1 is a required field.', {1: label}));
352 }
353 if (ctrl.groupExists && !ctrl.savedSearch.groups[0].title) {
354 errorEl = '#crm-search-admin-group-title';
355 label = ts('Group Title');
356 errors.push(ts('%1 is a required field.', {1: label}));
357 tab = 'group';
358 }
359 _.each(ctrl.savedSearch.displays, function(display, index) {
360 if (!display.trashed && !display.label) {
361 errorEl = '#crm-search-admin-display-label';
362 label = ts('Display Label');
363 errors.push(ts('%1 is a required field.', {1: label}));
364 tab = 'display_' + index;
365 }
366 });
367 if (errors.length) {
368 if (tab) {
369 $scope.selectTab(tab);
370 }
371 $(errorEl).crmError(errors.join('<br>'), ts('Error Saving'), {expires: 5000});
372 }
373 return !errors.length;
374 }
375
376 /**
377 * Called when clicking on a column header
378 * @param col
379 * @param $event
380 */
381 $scope.setOrderBy = function(col, $event) {
382 col = _.last(col.split(' AS '));
383 var dir = $scope.getOrderBy(col) === 'fa-sort-asc' ? 'DESC' : 'ASC';
384 if (!$event.shiftKey || !ctrl.savedSearch.api_params.orderBy) {
385 ctrl.savedSearch.api_params.orderBy = {};
386 }
387 ctrl.savedSearch.api_params.orderBy[col] = dir;
388 if (ctrl.results) {
389 ctrl.refreshPage();
390 }
391 };
392
393 /**
394 * Returns crm-i icon class for a sortable column
395 * @param col
396 * @returns {string}
397 */
398 $scope.getOrderBy = function(col) {
399 col = _.last(col.split(' AS '));
400 var dir = ctrl.savedSearch.api_params.orderBy && ctrl.savedSearch.api_params.orderBy[col];
401 if (dir) {
402 return 'fa-sort-' + dir.toLowerCase();
403 }
404 return 'fa-sort disabled';
405 };
406
407 $scope.addParam = function(name) {
408 if ($scope.controls[name] && !_.contains(ctrl.savedSearch.api_params[name], $scope.controls[name])) {
409 ctrl.savedSearch.api_params[name].push($scope.controls[name]);
410 if (name === 'groupBy') {
411 // Expand the aggregate block
412 $timeout(function() {
413 $('#crm-search-build-group-aggregate.collapsed .collapsible-title').click();
414 }, 10);
415 }
416 }
417 $scope.controls[name] = '';
418 };
419
420 // Deletes an item from an array param
421 this.clearParam = function(name, idx) {
422 ctrl.savedSearch.api_params[name].splice(idx, 1);
423 };
424
425 // Prevent visual jumps in results table height during loading
426 function lockTableHeight() {
427 var $table = $('.crm-search-results', $element);
428 $table.css('height', $table.height());
429 }
430
431 function unlockTableHeight() {
432 $('.crm-search-results', $element).css('height', '');
433 }
434
435 // Ensure all non-grouped columns are aggregated if using GROUP BY
436 function aggregateGroupByColumns() {
437 if (ctrl.savedSearch.api_params.groupBy.length) {
438 _.each(ctrl.savedSearch.api_params.select, function(col, pos) {
439 if (!_.contains(col, '(') && ctrl.canAggregate(col)) {
440 ctrl.savedSearch.api_params.select[pos] = ctrl.DEFAULT_AGGREGATE_FN + '(DISTINCT ' + col + ') AS ' + ctrl.DEFAULT_AGGREGATE_FN + '_DISTINCT_' + col.replace(/[.:]/g, '_');
441 }
442 });
443 }
444 }
445
446 // Debounced callback for loadResults
447 function _loadResultsCallback() {
448 // Multiply limit to read 2 pages at once & save ajax requests
449 var params = _.merge(_.cloneDeep(ctrl.savedSearch.api_params), {debug: true, limit: ctrl.limit * 2});
450 // Select the ids of implicitly joined entities (helps with displaying links)
451 _.each(params.select, function(fieldName) {
452 if (_.includes(fieldName, '.') && !_.includes(fieldName, ' AS ')) {
453 var info = searchMeta.parseExpr(fieldName);
454 if (info.field && !info.suffix && !info.fn && (info.field.entity !== info.field.baseEntity)) {
455 var idField = fieldName.substr(0, fieldName.lastIndexOf('.')) + '_id';
456 if (!_.includes(params.select, idField) && !ctrl.canAggregate(idField)) {
457 params.select.push(idField);
458 }
459 }
460 }
461 });
462 // Select the ids of explicitly joined entities (helps with displaying links)
463 _.each(params.join, function(join) {
464 var idField = join[0].split(' AS ')[1] + '.id';
465 if (!_.includes(params.select, idField) && !ctrl.canAggregate(idField)) {
466 params.select.push(idField);
467 }
468 });
469 lockTableHeight();
470 $scope.error = false;
471 if (ctrl.stale) {
472 ctrl.page = 1;
473 ctrl.rowCount = false;
474 }
475 params.offset = ctrl.limit * (ctrl.page - 1);
476 crmApi4(ctrl.savedSearch.api_entity, 'get', params).then(function(success) {
477 if (ctrl.stale) {
478 ctrl.results = {};
479 // Get row count for pager
480 if (success.length < params.limit) {
481 ctrl.rowCount = success.count;
482 } else {
483 var countParams = _.cloneDeep(params);
484 // Select is only needed needed by HAVING
485 countParams.select = countParams.having && countParams.having.length ? countParams.select : [];
486 countParams.select.push('row_count');
487 delete countParams.debug;
488 crmApi4(ctrl.savedSearch.api_entity, 'get', countParams).then(function(result) {
489 ctrl.rowCount = result.count;
490 });
491 }
492 }
493 ctrl.debug = success.debug;
494 // populate this page & the next
495 ctrl.results[ctrl.page] = success.slice(0, ctrl.limit);
496 if (success.length > ctrl.limit) {
497 ctrl.results[ctrl.page + 1] = success.slice(ctrl.limit);
498 }
499 $scope.loading = false;
500 ctrl.stale = false;
501 unlockTableHeight();
502 }, function(error) {
503 $scope.loading = false;
504 ctrl.results = {};
505 ctrl.stale = true;
506 ctrl.debug = error.debug;
507 $scope.error = errorMsg(error);
508 })
509 .finally(function() {
510 if (ctrl.debug) {
511 ctrl.debug.params = JSON.stringify(params, null, 2);
512 if (ctrl.debug.timeIndex) {
513 ctrl.debug.timeIndex = Number.parseFloat(ctrl.debug.timeIndex).toPrecision(2);
514 }
515 }
516 });
517 }
518
519 var _loadResults = _.debounce(_loadResultsCallback, 250);
520
521 function loadResults() {
522 $scope.loading = true;
523 aggregateGroupByColumns();
524 _loadResults();
525 }
526
527 // What to tell the user when search returns an error from the server
528 // Todo: parse error codes and give helpful feedback.
529 function errorMsg(error) {
530 return ts('Ensure all search critera are set correctly and try again.');
531 }
532
533 this.changePage = function() {
534 if (ctrl.stale || !ctrl.results[ctrl.page]) {
535 lockTableHeight();
536 loadResults();
537 }
538 };
539
540 this.refreshAll = function() {
541 ctrl.stale = true;
542 clearSelection();
543 loadResults();
544 };
545
546 // Refresh results while staying on current page.
547 this.refreshPage = function() {
548 lockTableHeight();
549 ctrl.results = {};
550 loadResults();
551 };
552
553 $scope.onClickSearch = function() {
554 if (ctrl.autoSearch) {
555 ctrl.autoSearch = false;
556 } else {
557 ctrl.refreshAll();
558 }
559 };
560
561 $scope.onClickAuto = function() {
562 ctrl.autoSearch = !ctrl.autoSearch;
563 if (ctrl.autoSearch && ctrl.stale) {
564 ctrl.refreshAll();
565 }
566 $('.crm-search-auto-toggle').blur();
567 };
568
569 $scope.onChangeLimit = function() {
570 // Refresh only if search has already been run
571 if (ctrl.autoSearch || ctrl.results) {
572 // Save page size in localStorage
573 CRM.cache.set('searchPageSize', ctrl.limit);
574 ctrl.refreshAll();
575 }
576 };
577
578 function onChangeSelect(newSelect, oldSelect) {
579 // When removing a column from SELECT, also remove from ORDER BY & HAVING
580 _.each(_.difference(oldSelect, newSelect), function(col) {
581 col = _.last(col.split(' AS '));
582 delete ctrl.savedSearch.api_params.orderBy[col];
583 _.remove(ctrl.savedSearch.api_params.having, function(clause) {
584 return clauseUsesFields(clause, [col]);
585 });
586 });
587 // Re-arranging or removing columns doesn't merit a refresh, only adding columns does
588 if (!oldSelect || _.difference(newSelect, oldSelect).length) {
589 if (ctrl.autoSearch) {
590 ctrl.refreshPage();
591 } else {
592 ctrl.stale = true;
593 }
594 }
595 }
596
597 function onChangeFilters() {
598 ctrl.stale = true;
599 clearSelection();
600 if (ctrl.autoSearch) {
601 ctrl.refreshAll();
602 }
603 }
604
605 function clearSelection() {
606 ctrl.allRowsSelected = false;
607 ctrl.selectedRows.length = 0;
608 }
609
610 $scope.selectAllRows = function() {
611 // Deselect all
612 if (ctrl.allRowsSelected) {
613 clearSelection();
614 return;
615 }
616 // Select all
617 ctrl.allRowsSelected = true;
618 if (ctrl.page === 1 && ctrl.results[1].length < ctrl.limit) {
619 ctrl.selectedRows = _.pluck(ctrl.results[1], 'id');
620 return;
621 }
622 // If more than one page of results, use ajax to fetch all ids
623 $scope.loadingAllRows = true;
624 var params = _.cloneDeep(ctrl.savedSearch.api_params);
625 // Select is only needed needed by HAVING
626 params.select = params.having && params.having.length ? params.select : [];
627 params.select.push('id');
628 crmApi4(ctrl.savedSearch.api_entity, 'get', params, ['id']).then(function(ids) {
629 $scope.loadingAllRows = false;
630 ctrl.selectedRows = _.toArray(ids);
631 });
632 };
633
634 $scope.selectRow = function(row) {
635 var index = ctrl.selectedRows.indexOf(row.id);
636 if (index < 0) {
637 ctrl.selectedRows.push(row.id);
638 ctrl.allRowsSelected = (ctrl.rowCount === ctrl.selectedRows.length);
639 } else {
640 ctrl.allRowsSelected = false;
641 ctrl.selectedRows.splice(index, 1);
642 }
643 };
644
645 $scope.isRowSelected = function(row) {
646 return ctrl.allRowsSelected || _.includes(ctrl.selectedRows, row.id);
647 };
648
649 this.getFieldLabel = searchMeta.getDefaultLabel;
650
651 // Is a column eligible to use an aggregate function?
652 this.canAggregate = function(col) {
653 // If the query does not use grouping, never
654 if (!ctrl.savedSearch.api_params.groupBy.length) {
655 return false;
656 }
657 var info = searchMeta.parseExpr(col);
658 // If the column is used for a groupBy, no
659 if (ctrl.savedSearch.api_params.groupBy.indexOf(info.path) > -1) {
660 return false;
661 }
662 // If the entity this column belongs to is being grouped by id, then also no
663 return ctrl.savedSearch.api_params.groupBy.indexOf(info.prefix + 'id') < 0;
664 };
665
666 $scope.formatResult = function(row, col) {
667 var info = searchMeta.parseExpr(col),
668 value = row[info.alias];
669 if (info.fn && info.fn.name === 'COUNT') {
670 return value;
671 }
672 // Output user-facing name/label fields as a link, if possible
673 if (info.field && _.last(info.field.name.split('.')) === searchMeta.getEntity(info.field.entity).label_field && !info.fn && typeof value === 'string') {
674 var link = getEntityUrl(row, info);
675 if (link) {
676 return '<a href="' + _.escape(link.url) + '" title="' + _.escape(link.title) + '">' + formatFieldValue(info.field, value) + '</a>';
677 }
678 }
679 return formatFieldValue(info.field, value);
680 };
681
682 // Attempts to construct a view url for a given entity
683 function getEntityUrl(row, info) {
684 var entity = searchMeta.getEntity(info.field.entity),
685 path = _.result(_.findWhere(entity.paths, {action: 'view'}), 'path');
686 // Only proceed if the path metadata exists for this entity
687 if (path) {
688 // Replace tokens in the path (e.g. [id])
689 var tokens = path.match(/\[\w*]/g) || [],
690 prefix = info.prefix;
691 // For implicit join fields
692 if (info.field.name.split('.').length > 1) {
693 prefix += info.field.name.split('.')[0] + '_';
694 }
695 var replacements = _.transform(tokens, function(replacements, token) {
696 var fieldName = prefix + token.slice(1, token.length - 1);
697 if (row[fieldName]) {
698 replacements.push(row[fieldName]);
699 }
700 });
701 // Only proceed if the row contains all the necessary data to resolve tokens
702 if (tokens.length === replacements.length) {
703 _.each(tokens, function(token, index) {
704 path = path.replace(token, replacements[index]);
705 });
706 return {url: CRM.url(path), title: path.title};
707 }
708 }
709 }
710
711 function formatFieldValue(field, value) {
712 var type = field.data_type,
713 result = value;
714 if (_.isArray(value)) {
715 return _.map(value, function(val) {
716 return formatFieldValue(field, val);
717 }).join(', ');
718 }
719 if (value && (type === 'Date' || type === 'Timestamp') && /^\d{4}-\d{2}-\d{2}/.test(value)) {
720 result = CRM.utils.formatDate(value, null, type === 'Timestamp');
721 }
722 else if (type === 'Boolean' && typeof value === 'boolean') {
723 result = value ? ts('Yes') : ts('No');
724 }
725 else if (type === 'Money' && typeof value === 'number') {
726 result = CRM.formatMoney(value);
727 }
728 return _.escape(result);
729 }
730
731 $scope.fieldsForGroupBy = function() {
732 return {results: ctrl.getAllFields('', function(key) {
733 return _.contains(ctrl.savedSearch.api_params.groupBy, key);
734 })
735 };
736 };
737
738 $scope.fieldsForSelect = function() {
739 return {results: ctrl.getAllFields(':label', function(key) {
740 return _.contains(ctrl.savedSearch.api_params.select, key);
741 })
742 };
743 };
744
745 $scope.fieldsForWhere = function() {
746 return {results: ctrl.getAllFields(':name')};
747 };
748
749 $scope.fieldsForHaving = function() {
750 return {results: ctrl.getSelectFields()};
751 };
752
753 $scope.sortableColumnOptions = {
754 axis: 'x',
755 handle: '.crm-draggable',
756 update: function(e, ui) {
757 // Don't allow items to be moved to position 0 if locked
758 if (!ui.item.sortable.dropindex && ctrl.groupExists) {
759 ui.item.sortable.cancel();
760 }
761 }
762 };
763
764 // Sets the default select clause based on commonly-named fields
765 function getDefaultSelect() {
766 var entity = searchMeta.getEntity(ctrl.savedSearch.api_entity);
767 return _.transform(entity.fields, function(defaultSelect, field) {
768 if (field.name === 'id' || field.name === entity.label_field) {
769 defaultSelect.push(field.name);
770 }
771 });
772 }
773
774 this.getAllFields = function(suffix, disabledIf) {
775 disabledIf = disabledIf || _.noop;
776 function formatFields(entityName, join) {
777 var prefix = join ? join.alias + '.' : '',
778 result = [];
779
780 function addFields(fields) {
781 _.each(fields, function(field) {
782 var item = {
783 id: prefix + field.name + (field.options ? suffix : ''),
784 text: field.label,
785 description: field.description
786 };
787 if (disabledIf(item.id)) {
788 item.disabled = true;
789 }
790 result.push(item);
791 });
792 }
793
794 // Add extra searchable fields from bridge entity
795 if (join && join.bridge) {
796 addFields(_.filter(searchMeta.getEntity(join.bridge).fields, function(field) {
797 return (field.name !== 'id' && field.name !== 'entity_id' && field.name !== 'entity_table' && !field.fk_entity);
798 }));
799 }
800
801 addFields(searchMeta.getEntity(entityName).fields);
802 return result;
803 }
804
805 var mainEntity = searchMeta.getEntity(ctrl.savedSearch.api_entity),
806 result = [{
807 text: mainEntity.title_plural,
808 icon: mainEntity.icon,
809 children: formatFields(ctrl.savedSearch.api_entity)
810 }];
811 _.each(ctrl.savedSearch.api_params.join, function(join) {
812 var joinInfo = searchMeta.getJoin(join[0]),
813 joinEntity = searchMeta.getEntity(joinInfo.entity);
814 result.push({
815 text: joinInfo.label,
816 description: joinInfo.description,
817 icon: joinEntity.icon,
818 children: formatFields(joinEntity.name, joinInfo)
819 });
820 });
821 return result;
822 };
823
824 this.getSelectFields = function(disabledIf) {
825 disabledIf = disabledIf || _.noop;
826 return _.transform(ctrl.savedSearch.api_params.select, function(fields, name) {
827 var info = searchMeta.parseExpr(name);
828 var item = {
829 id: info.alias,
830 text: ctrl.getFieldLabel(name),
831 description: info.field && info.field.description
832 };
833 if (disabledIf(item.id)) {
834 item.disabled = true;
835 }
836 fields.push(item);
837 });
838 };
839
840 /**
841 * Fetch pseudoconstants for main entity + joined entities
842 *
843 * Sets an optionsLoaded property on each entity to avoid duplicate requests
844 *
845 * @var string entity - optional additional entity to load
846 */
847 function loadFieldOptions(entity) {
848 var mainEntity = searchMeta.getEntity(ctrl.savedSearch.api_entity),
849 entities = {};
850
851 function enqueue(entity) {
852 entity.optionsLoaded = false;
853 entities[entity.name] = [entity.name, 'getFields', {
854 loadOptions: ['id', 'name', 'label', 'description', 'color', 'icon'],
855 where: [['options', '!=', false]],
856 select: ['options']
857 }, {name: 'options'}];
858 }
859
860 if (typeof mainEntity.optionsLoaded === 'undefined') {
861 enqueue(mainEntity);
862 }
863
864 // Optional additional entity
865 if (entity && typeof searchMeta.getEntity(entity).optionsLoaded === 'undefined') {
866 enqueue(searchMeta.getEntity(entity));
867 }
868
869 _.each(ctrl.savedSearch.api_params.join, function(join) {
870 var joinInfo = searchMeta.getJoin(join[0]),
871 joinEntity = searchMeta.getEntity(joinInfo.entity),
872 bridgeEntity = joinInfo.bridge ? searchMeta.getEntity(joinInfo.bridge) : null;
873 if (typeof joinEntity.optionsLoaded === 'undefined') {
874 enqueue(joinEntity);
875 }
876 if (bridgeEntity && typeof bridgeEntity.optionsLoaded === 'undefined') {
877 enqueue(bridgeEntity);
878 }
879 });
880 if (!_.isEmpty(entities)) {
881 crmApi4(entities).then(function(results) {
882 _.each(results, function(fields, entityName) {
883 var entity = searchMeta.getEntity(entityName);
884 _.each(fields, function(options, fieldName) {
885 _.find(entity.fields, {name: fieldName}).options = options;
886 });
887 entity.optionsLoaded = true;
888 });
889 });
890 }
891 }
892
893 }
894 });
895
896 })(angular, CRM.$, CRM._);