1 (function(angular
, $, _
) {
4 angular
.module('search').component('crmSearch', {
9 templateUrl
: '~/search/crmSearch.html',
10 controller: function($scope
, $element
, $timeout
, crmApi4
, dialogService
, searchMeta
, formatForSelect2
) {
11 var ts
= $scope
.ts
= CRM
.ts(),
14 this.DEFAULT_AGGREGATE_FN
= 'GROUP_CONCAT';
16 this.selectedRows
= [];
17 this.limit
= CRM
.cache
.get('searchPageSize', 30);
20 // After a search this.results is an object of result arrays keyed by page,
21 // 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)
23 this.rowCount
= false;
24 // Have the filters (WHERE, HAVING, GROUP BY, JOIN) changed?
26 this.allRowsSelected
= false;
29 $scope
.joinTypes
= [{k
: false, v
: ts('Optional')}, {k
: true, v
: ts('Required')}];
30 $scope
.entities
= formatForSelect2(CRM
.vars
.search
.schema
, 'name', 'titlePlural', ['description', 'icon']);
32 editGroups
: CRM
.checkPerm('edit groups')
35 this.getEntity
= searchMeta
.getEntity
;
37 this.paramExists = function(param
) {
38 return _
.includes(searchMeta
.getEntity(ctrl
.entity
).params
, param
);
41 $scope
.getJoinEntities = function() {
42 var joinEntities
= _
.transform(CRM
.vars
.search
.links
[ctrl
.entity
], function(joinEntities
, link
) {
43 var entity
= searchMeta
.getEntity(link
.entity
);
46 id
: link
.entity
+ ' AS ' + link
.alias
,
47 text
: entity
.titlePlural
,
48 description
: '(' + link
.alias
+ ')',
53 return {results
: joinEntities
};
56 $scope
.addJoin = function() {
57 // Debounce the onchange event using timeout
59 if ($scope
.controls
.join
) {
60 ctrl
.params
.join
= ctrl
.params
.join
|| [];
61 ctrl
.params
.join
.push([$scope
.controls
.join
, false]);
64 $scope
.controls
.join
= '';
68 $scope
.changeJoin = function(idx
) {
69 if (ctrl
.params
.join
[idx
][0]) {
70 ctrl
.params
.join
[idx
].length
= 2;
73 ctrl
.clearParam('join', idx
);
77 $scope
.changeGroupBy = function(idx
) {
78 if (!ctrl
.params
.groupBy
[idx
]) {
79 ctrl
.clearParam('groupBy', idx
);
81 // Remove aggregate functions when no grouping
82 if (!ctrl
.params
.groupBy
.length
) {
83 _
.each(ctrl
.params
.select
, function(col
, pos
) {
84 if (_
.contains(col
, '(')) {
85 var info
= searchMeta
.parseExpr(col
);
86 if (info
.fn
.category
=== 'aggregate') {
87 ctrl
.params
.select
[pos
] = info
.path
+ info
.suffix
;
95 * Called when clicking on a column header
99 $scope
.setOrderBy = function(col
, $event
) {
100 var dir
= $scope
.getOrderBy(col
) === 'fa-sort-asc' ? 'DESC' : 'ASC';
101 if (!$event
.shiftKey
) {
102 ctrl
.params
.orderBy
= {};
104 ctrl
.params
.orderBy
[col
] = dir
;
111 * Returns crm-i icon class for a sortable column
115 $scope
.getOrderBy = function(col
) {
116 var dir
= ctrl
.params
.orderBy
&& ctrl
.params
.orderBy
[col
];
118 return 'fa-sort-' + dir
.toLowerCase();
120 return 'fa-sort disabled';
123 $scope
.addParam = function(name
) {
124 if ($scope
.controls
[name
] && !_
.contains(ctrl
.params
[name
], $scope
.controls
[name
])) {
125 ctrl
.params
[name
].push($scope
.controls
[name
]);
126 if (name
=== 'groupBy') {
127 // Expand the aggregate block
128 $timeout(function() {
129 $('#crm-search-build-group-aggregate.collapsed .collapsible-title').click();
133 $scope
.controls
[name
] = '';
136 // Deletes an item from an array param
137 this.clearParam = function(name
, idx
) {
138 ctrl
.params
[name
].splice(idx
, 1);
141 // Prevent visual jumps in results table height during loading
142 function lockTableHeight() {
143 var $table
= $('.crm-search-results', $element
);
144 $table
.css('height', $table
.height());
147 function unlockTableHeight() {
148 $('.crm-search-results', $element
).css('height', '');
151 // Ensure all non-grouped columns are aggregated if using GROUP BY
152 function aggregateGroupByColumns() {
153 if (ctrl
.params
.groupBy
.length
) {
154 _
.each(ctrl
.params
.select
, function(col
, pos
) {
155 if (!_
.contains(col
, '(') && ctrl
.canAggregate(col
)) {
156 ctrl
.params
.select
[pos
] = ctrl
.DEFAULT_AGGREGATE_FN
+ '(' + col
+ ')';
162 // Debounced callback for loadResults
163 function _loadResultsCallback() {
164 // Multiply limit to read 2 pages at once & save ajax requests
165 var params
= angular
.merge({debug
: true, limit
: ctrl
.limit
* 2}, ctrl
.params
);
167 $scope
.error
= false;
170 ctrl
.rowCount
= false;
172 if (ctrl
.rowCount
=== false) {
173 params
.select
.push('row_count');
175 params
.offset
= ctrl
.limit
* (ctrl
.page
- 1);
176 crmApi4(ctrl
.entity
, 'get', params
).then(function(success
) {
180 if (ctrl
.rowCount
=== false) {
181 ctrl
.rowCount
= success
.count
;
183 ctrl
.debug
= success
.debug
;
184 // populate this page & the next
185 ctrl
.results
[ctrl
.page
] = success
.slice(0, ctrl
.limit
);
186 if (success
.length
> ctrl
.limit
) {
187 ctrl
.results
[ctrl
.page
+ 1] = success
.slice(ctrl
.limit
);
189 $scope
.loading
= false;
193 $scope
.loading
= false;
196 ctrl
.debug
= error
.debug
;
197 $scope
.error
= errorMsg(error
);
199 .finally(function() {
201 ctrl
.debug
.params
= JSON
.stringify(_
.extend({version
: 4}, ctrl
.params
), null, 2);
202 if (ctrl
.debug
.timeIndex
) {
203 ctrl
.debug
.timeIndex
= Number
.parseFloat(ctrl
.debug
.timeIndex
).toPrecision(2);
209 var _loadResults
= _
.debounce(_loadResultsCallback
, 250);
211 function loadResults() {
212 $scope
.loading
= true;
213 aggregateGroupByColumns();
217 // What to tell the user when search returns an error from the server
218 // Todo: parse error codes and give helpful feedback.
219 function errorMsg(error
) {
220 return ts('Ensure all search critera are set correctly and try again.');
223 this.changePage = function() {
224 if (ctrl
.stale
|| !ctrl
.results
[ctrl
.page
]) {
230 this.refreshAll = function() {
232 ctrl
.selectedRows
.length
= 0;
236 // Refresh results while staying on current page.
237 this.refreshPage = function() {
243 $scope
.onClickSearch = function() {
244 if (ctrl
.autoSearch
) {
245 ctrl
.autoSearch
= false;
251 $scope
.onClickAuto = function() {
252 ctrl
.autoSearch
= !ctrl
.autoSearch
;
253 if (ctrl
.autoSearch
&& ctrl
.stale
) {
256 $('.crm-search-auto-toggle').blur();
259 $scope
.onChangeLimit = function() {
260 // Refresh only if search has already been run
261 if (ctrl
.autoSearch
|| ctrl
.results
) {
262 // Save page size in localStorage
263 CRM
.cache
.set('searchPageSize', ctrl
.limit
);
268 function onChangeSelect(newSelect
, oldSelect
) {
269 // When removing a column from SELECT, also remove from ORDER BY
270 _
.each(_
.difference(_
.keys(ctrl
.params
.orderBy
), newSelect
), function(col
) {
271 delete ctrl
.params
.orderBy
[col
];
273 // Re-arranging or removing columns doesn't merit a refresh, only adding columns does
274 if (!oldSelect
|| _
.difference(newSelect
, oldSelect
).length
) {
275 if (ctrl
.autoSearch
) {
282 ctrl
.load
.saved
= false;
286 function onChangeFilters() {
288 ctrl
.selectedRows
.length
= 0;
290 ctrl
.load
.saved
= false;
292 if (ctrl
.autoSearch
) {
297 $scope
.selectAllRows = function() {
299 if (ctrl
.allRowsSelected
) {
300 ctrl
.allRowsSelected
= false;
301 ctrl
.selectedRows
.length
= 0;
305 ctrl
.allRowsSelected
= true;
306 if (ctrl
.page
=== 1 && ctrl
.results
[1].length
< ctrl
.limit
) {
307 ctrl
.selectedRows
= _
.pluck(ctrl
.results
[1], 'id');
310 // If more than one page of results, use ajax to fetch all ids
311 $scope
.loadingAllRows
= true;
312 var params
= _
.cloneDeep(ctrl
.params
);
313 params
.select
= ['id'];
314 crmApi4(ctrl
.entity
, 'get', params
, ['id']).then(function(ids
) {
315 $scope
.loadingAllRows
= false;
316 ctrl
.selectedRows
= _
.toArray(ids
);
320 $scope
.selectRow = function(row
) {
321 var index
= ctrl
.selectedRows
.indexOf(row
.id
);
323 ctrl
.selectedRows
.push(row
.id
);
324 ctrl
.allRowsSelected
= (ctrl
.rowCount
=== ctrl
.selectedRows
.length
);
326 ctrl
.allRowsSelected
= false;
327 ctrl
.selectedRows
.splice(index
, 1);
331 $scope
.isRowSelected = function(row
) {
332 return ctrl
.allRowsSelected
|| _
.includes(ctrl
.selectedRows
, row
.id
);
335 this.getFieldLabel = function(col
) {
336 var info
= searchMeta
.parseExpr(col
),
337 label
= info
.field
.label
;
339 label
= '(' + info
.fn
.title
+ ') ' + label
;
344 // Is a column eligible to use an aggregate function?
345 this.canAggregate = function(col
) {
346 var info
= searchMeta
.parseExpr(col
);
347 // If the column is used for a groupBy, no
348 if (ctrl
.params
.groupBy
.indexOf(info
.path
) > -1) {
351 // If the entity this column belongs to is being grouped by id, then also no
352 return ctrl
.params
.groupBy
.indexOf(info
.prefix
+ 'id') < 0;
355 $scope
.formatResult
= function formatResult(row
, col
) {
356 var info
= searchMeta
.parseExpr(col
),
357 key
= info
.fn
? (info
.fn
.name
+ ':' + info
.path
+ info
.suffix
) : col
,
359 if (info
.fn
&& info
.fn
.name
=== 'COUNT') {
362 return formatFieldValue(info
.field
, value
);
365 function formatFieldValue(field
, value
) {
366 var type
= field
.data_type
;
367 if (_
.isArray(value
)) {
368 return _
.map(value
, function(val
) {
369 return formatFieldValue(field
, val
);
372 if (value
&& (type
=== 'Date' || type
=== 'Timestamp') && /^\d{4}-\d{2}-\d{2}/.test(value
)) {
373 return CRM
.utils
.formatDate(value
, null, type
=== 'Timestamp');
375 else if (type
=== 'Boolean' && typeof value
=== 'boolean') {
376 return value
? ts('Yes') : ts('No');
378 else if (type
=== 'Money' && typeof value
=== 'number') {
379 return CRM
.formatMoney(value
);
384 $scope
.fieldsForGroupBy = function() {
385 return {results
: getAllFields('', function(key
) {
386 return _
.contains(ctrl
.params
.groupBy
, key
);
391 $scope
.fieldsForSelect = function() {
392 return {results
: getAllFields(':label', function(key
) {
393 return _
.contains(ctrl
.params
.select
, key
);
398 $scope
.fieldsForWhere = function() {
399 return {results
: getAllFields(':name', _
.noop
)};
402 $scope
.fieldsForHaving = function() {
403 return {results
: _
.transform(ctrl
.params
.select
, function(fields
, name
) {
404 fields
.push({id
: name
, text
: ctrl
.getFieldLabel(name
)});
408 function getDefaultSelect() {
409 return _
.filter(['id', 'display_name', 'label', 'title', 'location_type_id:label'], function(field
) {
410 return !!searchMeta
.getField(field
, ctrl
.entity
);
414 function getAllFields(suffix
, disabledIf
) {
415 function formatFields(entityName
, prefix
) {
416 return _
.transform(searchMeta
.getEntity(entityName
).fields
, function(result
, field
) {
418 id
: prefix
+ field
.name
+ (field
.options
? suffix
: ''),
420 description
: field
.description
422 if (disabledIf(item
.id
)) {
423 item
.disabled
= true;
429 var mainEntity
= searchMeta
.getEntity(ctrl
.entity
),
431 text
: mainEntity
.titlePlural
,
432 icon
: mainEntity
.icon
,
433 children
: formatFields(ctrl
.entity
, '')
435 _
.each(ctrl
.params
.join
, function(join
) {
436 var joinName
= join
[0].split(' AS '),
437 joinEntity
= searchMeta
.getEntity(joinName
[0]);
439 text
: joinEntity
.titlePlural
+ ' (' + joinName
[1] + ')',
440 icon
: joinEntity
.icon
,
441 children
: formatFields(joinEntity
.name
, joinName
[1] + '.')
448 * Fetch pseudoconstants for main entity + joined entities
450 * Sets an optionsLoaded property on each entity to avoid duplicate requests
452 function loadFieldOptions() {
453 var mainEntity
= searchMeta
.getEntity(ctrl
.entity
),
456 function enqueue(entity
) {
457 entity
.optionsLoaded
= false;
458 entities
[entity
.name
] = [entity
.name
, 'getFields', {
459 loadOptions
: CRM
.vars
.search
.loadOptions
,
460 where
: [['options', '!=', false]],
462 }, {name
: 'options'}];
465 if (typeof mainEntity
.optionsLoaded
=== 'undefined') {
468 _
.each(ctrl
.params
.join
, function(join
) {
469 var joinName
= join
[0].split(' AS '),
470 joinEntity
= searchMeta
.getEntity(joinName
[0]);
471 if (typeof joinEntity
.optionsLoaded
=== 'undefined') {
475 if (!_
.isEmpty(entities
)) {
476 crmApi4(entities
).then(function(results
) {
477 _
.each(results
, function(fields
, entityName
) {
478 var entity
= searchMeta
.getEntity(entityName
);
479 _
.each(fields
, function(options
, fieldName
) {
480 _
.find(entity
.fields
, {name
: fieldName
}).options
= options
;
482 entity
.optionsLoaded
= true;
488 this.$onInit = function() {
489 $scope
.$bindToRoute({
490 expr
: '$ctrl.params.select',
493 default: getDefaultSelect()
495 $scope
.$watchCollection('$ctrl.params.select', onChangeSelect
);
497 $scope
.$bindToRoute({
498 expr
: '$ctrl.params.orderBy',
504 $scope
.$bindToRoute({
505 expr
: '$ctrl.params.where',
511 $scope
.$watch('$ctrl.params.where', onChangeFilters
, true);
513 if (this.paramExists('groupBy')) {
514 $scope
.$bindToRoute({
515 expr
: '$ctrl.params.groupBy',
521 $scope
.$watchCollection('$ctrl.params.groupBy', onChangeFilters
);
523 if (this.paramExists('join')) {
524 $scope
.$bindToRoute({
525 expr
: '$ctrl.params.join',
532 $scope
.$watch('$ctrl.params.join', onChangeFilters
, true);
534 if (this.paramExists('having')) {
535 $scope
.$bindToRoute({
536 expr
: '$ctrl.params.having',
543 $scope
.$watch('$ctrl.params.having', onChangeFilters
, true);
546 this.params
= this.load
.api_params
;
547 $timeout(function() {
548 ctrl
.load
.saved
= true;
555 $scope
.saveGroup = function() {
559 visibility
: 'User and User Admin Only',
561 id
: ctrl
.load
? ctrl
.load
.id
: null,
562 api_entity
: ctrl
.entity
,
563 api_params
: _
.cloneDeep(angular
.extend({}, ctrl
.params
, {version
: 4}))
565 delete model
.api_params
.orderBy
;
566 if (ctrl
.load
&& ctrl
.load
.api_params
&& ctrl
.load
.api_params
.select
&& ctrl
.load
.api_params
.select
[0]) {
567 model
.api_params
.select
.unshift(ctrl
.load
.api_params
.select
[0]);
569 var options
= CRM
.utils
.adjustDialogDefaults({
571 title
: ts('Save smart group')
573 dialogService
.open('saveSearchDialog', '~/search/saveSmartGroup.html', model
, options
)
576 ctrl
.load
.saved
= true;
583 })(angular
, CRM
.$, CRM
._
);