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