1 (function(angular
, $, _
) {
4 angular
.module('search').component('crmSearch', {
8 templateUrl
: '~/search/crmSearch.html',
9 controller: function($scope
, $element
, $timeout
, crmApi4
, dialogService
, searchMeta
, formatForSelect2
) {
10 var ts
= $scope
.ts
= CRM
.ts(),
12 this.selectedRows
= [];
13 this.limit
= CRM
.cache
.get('searchPageSize', 30);
16 // After a search this.results is an object of result arrays keyed by page,
17 // 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.rowCount
= false;
20 // Have the filters (WHERE, HAVING, GROUP BY, JOIN) changed?
22 this.allRowsSelected
= false;
25 $scope
.joinTypes
= [{k
: false, v
: ts('Optional')}, {k
: true, v
: ts('Required')}];
26 $scope
.entities
= formatForSelect2(CRM
.vars
.search
.schema
, 'name', 'title', ['description', 'icon']);
28 editGroups
: CRM
.checkPerm('edit groups')
31 this.getEntity
= searchMeta
.getEntity
;
33 this.paramExists = function(param
) {
34 return _
.includes(searchMeta
.getEntity(ctrl
.entity
).params
, param
);
37 $scope
.getJoinEntities = function() {
38 var joinEntities
= _
.transform(CRM
.vars
.search
.links
[ctrl
.entity
], function(joinEntities
, link
) {
39 var entity
= searchMeta
.getEntity(link
.entity
);
42 id
: link
.entity
+ ' AS ' + link
.alias
,
44 description
: '(' + link
.alias
+ ')',
49 return {results
: joinEntities
};
52 $scope
.addJoin = function() {
53 // Debounce the onchange event using timeout
55 if ($scope
.controls
.join
) {
56 ctrl
.params
.join
= ctrl
.params
.join
|| [];
57 ctrl
.params
.join
.push([$scope
.controls
.join
, false]);
60 $scope
.controls
.join
= '';
64 $scope
.changeJoin = function(idx
) {
65 if (ctrl
.params
.join
[idx
][0]) {
66 ctrl
.params
.join
[idx
].length
= 2;
69 ctrl
.clearParam('join', idx
);
73 $scope
.changeGroupBy = function(idx
) {
74 if (!ctrl
.params
.groupBy
[idx
]) {
75 ctrl
.clearParam('groupBy', idx
);
80 * Called when clicking on a column header
84 $scope
.setOrderBy = function(col
, $event
) {
85 var dir
= $scope
.getOrderBy(col
) === 'fa-sort-asc' ? 'DESC' : 'ASC';
86 if (!$event
.shiftKey
) {
87 ctrl
.params
.orderBy
= {};
89 ctrl
.params
.orderBy
[col
] = dir
;
93 * Returns crm-i icon class for a sortable column
97 $scope
.getOrderBy = function(col
) {
98 var dir
= ctrl
.params
.orderBy
&& ctrl
.params
.orderBy
[col
];
100 return 'fa-sort-' + dir
.toLowerCase();
102 return 'fa-sort disabled';
105 $scope
.addParam = function(name
) {
106 if ($scope
.controls
[name
] && !_
.contains(ctrl
.params
[name
], $scope
.controls
[name
])) {
107 ctrl
.params
[name
].push($scope
.controls
[name
]);
108 if (name
=== 'groupBy') {
109 // Expand the aggregate block
110 $timeout(function() {
111 $('#crm-search-build-group-aggregate.collapsed .collapsible-title').click();
115 $scope
.controls
[name
] = '';
118 // Deletes an item from an array param
119 this.clearParam = function(name
, idx
) {
120 ctrl
.params
[name
].splice(idx
, 1);
123 // Prevent visual jumps in results table height during loading
124 function lockTableHeight() {
125 var $table
= $('.crm-search-results', $element
);
126 $table
.css('height', $table
.height());
129 function unlockTableHeight() {
130 $('.crm-search-results', $element
).css('height', '');
133 // Debounced callback for loadResults
134 function _loadResultsCallback() {
135 // Multiply limit to read 2 pages at once & save ajax requests
136 var params
= angular
.merge({debug
: true, limit
: ctrl
.limit
* 2}, ctrl
.params
);
138 $scope
.error
= false;
141 ctrl
.rowCount
= false;
143 if (ctrl
.rowCount
=== false) {
144 params
.select
.push('row_count');
146 params
.offset
= ctrl
.limit
* (ctrl
.page
- 1);
147 crmApi4(ctrl
.entity
, 'get', params
).then(function(success
) {
151 if (ctrl
.rowCount
=== false) {
152 ctrl
.rowCount
= success
.count
;
154 ctrl
.debug
= success
.debug
;
155 // populate this page & the next
156 ctrl
.results
[ctrl
.page
] = success
.slice(0, ctrl
.limit
);
157 if (success
.length
> ctrl
.limit
) {
158 ctrl
.results
[ctrl
.page
+ 1] = success
.slice(ctrl
.limit
);
160 $scope
.loading
= false;
164 $scope
.loading
= false;
167 ctrl
.debug
= error
.debug
;
168 $scope
.error
= errorMsg(error
);
170 .finally(function() {
172 ctrl
.debug
.params
= JSON
.stringify(ctrl
.params
, null, 2);
177 var _loadResults
= _
.debounce(_loadResultsCallback
, 250);
179 function loadResults() {
180 $scope
.loading
= true;
184 // What to tell the user when search returns an error from the server
185 // Todo: parse error codes and give helpful feedback.
186 function errorMsg(error
) {
187 return ts('Ensure all search critera are set correctly and try again.');
190 this.changePage = function() {
191 if (ctrl
.stale
|| !ctrl
.results
[ctrl
.page
]) {
197 this.refreshAll = function() {
199 ctrl
.selectedRows
.length
= 0;
203 // Refresh results while staying on current page.
204 this.refreshPage = function() {
210 $scope
.onClickSearch = function() {
211 if (ctrl
.autoSearch
) {
212 ctrl
.autoSearch
= false;
218 $scope
.onClickAuto = function() {
219 ctrl
.autoSearch
= !ctrl
.autoSearch
;
220 if (ctrl
.autoSearch
&& ctrl
.stale
) {
223 $('.crm-search-auto-toggle').blur();
226 $scope
.onChangeLimit = function() {
227 // Refresh only if search has already been run
228 if (ctrl
.autoSearch
|| ctrl
.results
) {
229 // Save page size in localStorage
230 CRM
.cache
.set('searchPageSize', ctrl
.limit
);
235 function onChangeSelect(newSelect
, oldSelect
) {
236 // Re-arranging or removing columns doesn't merit a refresh, only adding columns does
237 if (!oldSelect
|| _
.difference(newSelect
, oldSelect
).length
) {
238 if (ctrl
.autoSearch
) {
246 function onChangeOrderBy() {
252 function onChangeFilters() {
254 ctrl
.selectedRows
.length
= 0;
255 if (ctrl
.autoSearch
) {
260 $scope
.selectAllRows = function() {
262 if (ctrl
.allRowsSelected
) {
263 ctrl
.allRowsSelected
= false;
264 ctrl
.selectedRows
.length
= 0;
268 ctrl
.allRowsSelected
= true;
269 if (ctrl
.page
=== 1 && ctrl
.results
[1].length
< ctrl
.limit
) {
270 ctrl
.selectedRows
= _
.pluck(ctrl
.results
[1], 'id');
273 // If more than one page of results, use ajax to fetch all ids
274 $scope
.loadingAllRows
= true;
275 var params
= _
.cloneDeep(ctrl
.params
);
276 params
.select
= ['id'];
277 crmApi4(ctrl
.entity
, 'get', params
, ['id']).then(function(ids
) {
278 $scope
.loadingAllRows
= false;
279 ctrl
.selectedRows
= _
.toArray(ids
);
283 $scope
.selectRow = function(row
) {
284 var index
= ctrl
.selectedRows
.indexOf(row
.id
);
286 ctrl
.selectedRows
.push(row
.id
);
287 ctrl
.allRowsSelected
= (ctrl
.rowCount
=== ctrl
.selectedRows
.length
);
289 ctrl
.allRowsSelected
= false;
290 ctrl
.selectedRows
.splice(index
, 1);
294 $scope
.isRowSelected = function(row
) {
295 return ctrl
.allRowsSelected
|| _
.includes(ctrl
.selectedRows
, row
.id
);
298 this.getFieldLabel = function(col
) {
299 var info
= searchMeta
.parseExpr(col
),
300 label
= info
.field
.label
;
302 label
= '(' + info
.fn
.title
+ ') ' + label
;
307 // Is a column eligible to use an aggregate function?
308 this.canAggregate = function(col
) {
309 // If the column is used for a groupBy, no
310 if (ctrl
.params
.groupBy
.indexOf(col
) > -1) {
313 // If the entity this column belongs to is being grouped by id, then also no
314 var info
= searchMeta
.parseExpr(col
);
315 return ctrl
.params
.groupBy
.indexOf(info
.prefix
+ 'id') < 0;
318 $scope
.formatResult
= function formatResult(row
, col
) {
319 var info
= searchMeta
.parseExpr(col
),
320 key
= info
.fn
? (info
.fn
.name
+ ':' + info
.path
+ info
.suffix
) : col
,
322 // Handle grouped results
323 if (info
.fn
&& info
.fn
.name
=== 'GROUP_CONCAT' && value
) {
324 return formatGroupConcatValues(info
, value
);
326 else if (info
.fn
&& info
.fn
.name
=== 'COUNT') {
329 return formatFieldValue(info
.field
, value
);
332 function formatFieldValue(field
, value
) {
333 var type
= field
.data_type
;
334 if (value
&& (type
=== 'Date' || type
=== 'Timestamp') && /^\d{4}-\d{2}-\d{2}/.test(value
)) {
335 return CRM
.utils
.formatDate(value
, null, type
=== 'Timestamp');
337 else if (type
=== 'Boolean' && typeof value
=== 'boolean') {
338 return value
? ts('Yes') : ts('No');
340 else if (type
=== 'Money') {
341 return CRM
.formatMoney(value
);
346 function formatGroupConcatValues(info
, values
) {
347 return _
.transform(values
.split(','), function(result
, val
) {
348 if (info
.field
.options
&& !info
.suffix
) {
349 result
.push(_
.result(getOption(info
.field
, val
), 'label'));
351 result
.push(formatFieldValue(info
.field
, val
));
356 function getOption(field
, value
) {
357 return _
.find(field
.options
, function(option
) {
358 // Type coersion is intentional
359 return option
.id
== value
;
363 $scope
.fieldsForGroupBy = function() {
364 return {results
: getAllFields('', function(key
) {
365 return _
.contains(ctrl
.params
.groupBy
, key
);
370 $scope
.fieldsForSelect = function() {
371 return {results
: getAllFields(':label', function(key
) {
372 return _
.contains(ctrl
.params
.select
, key
);
377 $scope
.fieldsForWhere = function() {
378 return {results
: getAllFields(':name', _
.noop
)};
381 $scope
.fieldsForHaving = function() {
382 return {results
: _
.transform(ctrl
.params
.select
, function(fields
, name
) {
383 fields
.push({id
: name
, text
: ctrl
.getFieldLabel(name
)});
387 function getDefaultSelect() {
388 return _
.filter(['id', 'display_name', 'label', 'title', 'location_type_id:label'], searchMeta
.getField
);
391 function getAllFields(suffix
, disabledIf
) {
392 function formatFields(entityName
, prefix
) {
393 return _
.transform(searchMeta
.getEntity(entityName
).fields
, function(result
, field
) {
395 id
: prefix
+ field
.name
+ (field
.options
? suffix
: ''),
397 description
: field
.description
399 if (disabledIf(item
.id
)) {
400 item
.disabled
= true;
406 var mainEntity
= searchMeta
.getEntity(ctrl
.entity
),
408 text
: mainEntity
.title
,
409 icon
: mainEntity
.icon
,
410 children
: formatFields(ctrl
.entity
, '')
412 _
.each(ctrl
.params
.join
, function(join
) {
413 var joinName
= join
[0].split(' AS '),
414 joinEntity
= searchMeta
.getEntity(joinName
[0]);
416 text
: joinEntity
.title
+ ' (' + joinName
[1] + ')',
417 icon
: joinEntity
.icon
,
418 children
: formatFields(joinEntity
.name
, joinName
[1] + '.')
425 * Fetch pseudoconstants for main entity + joined entities
427 * Sets an optionsLoaded property on each entity to avoid duplicate requests
429 function loadFieldOptions() {
430 var mainEntity
= searchMeta
.getEntity(ctrl
.entity
),
433 function enqueue(entity
) {
434 entity
.optionsLoaded
= false;
435 entities
[entity
.name
] = [entity
.name
, 'getFields', {
436 loadOptions
: CRM
.vars
.search
.loadOptions
,
437 where
: [['options', '!=', false]],
439 }, {name
: 'options'}];
442 if (typeof mainEntity
.optionsLoaded
=== 'undefined') {
445 _
.each(ctrl
.params
.join
, function(join
) {
446 var joinName
= join
[0].split(' AS '),
447 joinEntity
= searchMeta
.getEntity(joinName
[0]);
448 if (typeof joinEntity
.optionsLoaded
=== 'undefined') {
452 if (!_
.isEmpty(entities
)) {
453 crmApi4(entities
).then(function(results
) {
454 _
.each(results
, function(fields
, entityName
) {
455 var entity
= searchMeta
.getEntity(entityName
);
456 _
.each(fields
, function(options
, fieldName
) {
457 _
.find(entity
.fields
, {name
: fieldName
}).options
= options
;
459 entity
.optionsLoaded
= true;
465 this.$onInit = function() {
466 $scope
.$bindToRoute({
467 expr
: '$ctrl.params.select',
470 default: getDefaultSelect()
472 $scope
.$watchCollection('$ctrl.params.select', onChangeSelect
);
474 $scope
.$bindToRoute({
475 expr
: '$ctrl.params.orderBy',
480 $scope
.$watchCollection('$ctrl.params.orderBy', onChangeOrderBy
);
482 $scope
.$bindToRoute({
483 expr
: '$ctrl.params.where',
489 $scope
.$watch('$ctrl.params.where', onChangeFilters
, true);
491 if (this.paramExists('groupBy')) {
492 $scope
.$bindToRoute({
493 expr
: '$ctrl.params.groupBy',
499 $scope
.$watchCollection('$ctrl.params.groupBy', onChangeFilters
);
501 if (this.paramExists('join')) {
502 $scope
.$bindToRoute({
503 expr
: '$ctrl.params.join',
510 $scope
.$watch('$ctrl.params.join', onChangeFilters
, true);
512 if (this.paramExists('having')) {
513 $scope
.$bindToRoute({
514 expr
: '$ctrl.params.having',
521 $scope
.$watch('$ctrl.params.having', onChangeFilters
, true);
526 $scope
.saveGroup = function() {
527 var selectField
= ctrl
.entity
=== 'Contact' ? 'id' : 'contact_id';
528 if (ctrl
.entity
!== 'Contact' && !searchMeta
.getField('contact_id')) {
529 CRM
.alert(ts('Cannot create smart group from %1.', {1: searchMeta
.getEntity(true).title
}), ts('Missing contact_id'), 'error', {expires
: 5000});
535 visibility
: 'User and User Admin Only',
539 params
: angular
.extend({}, ctrl
.params
, {version
: 4, select
: [selectField
]})
541 delete model
.params
.orderBy
;
542 var options
= CRM
.utils
.adjustDialogDefaults({
544 title
: ts('Save smart group')
546 dialogService
.open('saveSearchDialog', '~/search/saveSmartGroup.html', model
, options
);
551 })(angular
, CRM
.$, CRM
._
);