1 (function(angular
, $, _
) {
4 angular
.module('searchAdmin').component('crmSearchAdmin', {
8 templateUrl
: '~/searchAdmin/crmSearchAdmin.html',
9 controller: function($scope
, $element
, $timeout
, crmApi4
, dialogService
, searchMeta
, formatForSelect2
) {
10 var ts
= $scope
.ts
= CRM
.ts(),
13 this.DEFAULT_AGGREGATE_FN
= 'GROUP_CONCAT';
15 this.selectedRows
= [];
16 this.limit
= CRM
.cache
.get('searchPageSize', 30);
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)
22 this.rowCount
= false;
23 this.allRowsSelected
= false;
24 // Have the filters (WHERE, HAVING, GROUP BY, JOIN) changed?
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']);
32 editGroups
: CRM
.checkPerm('edit groups')
35 this.$onInit = function() {
36 this.entityTitle
= searchMeta
.getEntity(this.savedSearch
.api_entity
).title_plural
;
38 this.savedSearch
.displays
= this.savedSearch
.displays
|| [];
39 this.savedSearch
.groups
= this.savedSearch
.groups
|| [];
40 this.groupExists
= !!this.savedSearch
.groups
.length
;
42 if (!this.savedSearch
.api_params
) {
43 this.savedSearch
.api_params
= {
45 select
: getDefaultSelect(),
51 $scope
.$watchCollection('$ctrl.savedSearch.api_params.select', onChangeSelect
);
53 $scope
.$watch('$ctrl.savedSearch.api_params.where', onChangeFilters
, true);
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
);
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);
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);
70 $scope
.$watch('$ctrl.savedSearch', onChangeAnything
, true);
72 // Is this savedSearch record saved, unsaved or saving
73 $scope
.status
= this.savedSearch
&& this.savedSearch
.id
? 'saved' : 'unsaved';
78 function onChangeAnything() {
79 $scope
.status
= 'unsaved';
82 this.save = function() {
83 $scope
.status
= 'saving';
84 var params
= _
.cloneDeep(ctrl
.savedSearch
),
87 if (ctrl
.groupExists
) {
88 chain
.groups
= ['Group', 'save', {defaults
: {saved_search_id
: '$id'}, records
: params
.groups
}];
90 } else if (params
.id
) {
91 apiCalls
.deleteGroup
= ['Group', 'delete', {where
: [['saved_search_id', '=', params
.id
]]}];
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
]]}];
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';
110 this.paramExists = function(param
) {
111 return _
.includes(searchMeta
.getEntity(ctrl
.savedSearch
.api_entity
).params
, param
);
114 this.addDisplay = function(type
) {
115 ctrl
.savedSearch
.displays
.push({
120 $scope
.selectTab('display_' + (ctrl
.savedSearch
.displays
.length
- 1));
123 this.removeDisplay = function(index
) {
124 var display
= ctrl
.savedSearch
.displays
[index
];
126 display
.trashed
= !display
.trashed
;
128 $scope
.selectTab('compose');
129 ctrl
.savedSearch
.displays
.splice(index
, 1);
133 this.addGroup = function() {
134 ctrl
.savedSearch
.groups
.push({
137 visibility
: 'User and User Admin Only',
140 ctrl
.groupExists
= true;
141 $scope
.selectTab('group');
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]);
152 ctrl
.savedSearch
.api_params
.select
= _
.uniq(ctrl
.savedSearch
.api_params
.select
);
153 $scope
.controls
.tab
= tab
;
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;
161 $scope
.selectTab('compose');
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
);
169 id
: link
.entity
+ ' AS ' + link
.alias
,
170 text
: entity
.title_plural
,
171 description
: '(' + link
.alias
+ ')',
176 return {results
: joinEntities
};
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]);
187 $scope
.controls
.join
= '';
191 $scope
.changeJoin = function(idx
) {
192 if (ctrl
.savedSearch
.api_params
.join
[idx
][0]) {
193 ctrl
.savedSearch
.api_params
.join
[idx
].length
= 2;
196 ctrl
.clearParam('join', idx
);
200 $scope
.changeGroupBy = function(idx
) {
201 if (!ctrl
.savedSearch
.api_params
.groupBy
[idx
]) {
202 ctrl
.clearParam('groupBy', idx
);
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
;
218 * Called when clicking on a column header
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
= {};
227 ctrl
.savedSearch
.api_params
.orderBy
[col
] = dir
;
234 * Returns crm-i icon class for a sortable column
238 $scope
.getOrderBy = function(col
) {
239 var dir
= ctrl
.savedSearch
.api_params
.orderBy
&& ctrl
.savedSearch
.api_params
.orderBy
[col
];
241 return 'fa-sort-' + dir
.toLowerCase();
243 return 'fa-sort disabled';
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();
256 $scope
.controls
[name
] = '';
259 // Deletes an item from an array param
260 this.clearParam = function(name
, idx
) {
261 ctrl
.savedSearch
.api_params
[name
].splice(idx
, 1);
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());
270 function unlockTableHeight() {
271 $('.crm-search-results', $element
).css('height', '');
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
+ ')';
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
);
290 $scope
.error
= false;
293 ctrl
.rowCount
= false;
295 if (ctrl
.rowCount
=== false) {
296 params
.select
.push('row_count');
298 params
.offset
= ctrl
.limit
* (ctrl
.page
- 1);
299 crmApi4(ctrl
.savedSearch
.api_entity
, 'get', params
).then(function(success
) {
303 if (ctrl
.rowCount
=== false) {
304 ctrl
.rowCount
= success
.count
;
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
);
312 $scope
.loading
= false;
316 $scope
.loading
= false;
319 ctrl
.debug
= error
.debug
;
320 $scope
.error
= errorMsg(error
);
322 .finally(function() {
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);
332 var _loadResults
= _
.debounce(_loadResultsCallback
, 250);
334 function loadResults() {
335 $scope
.loading
= true;
336 aggregateGroupByColumns();
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.');
346 this.changePage = function() {
347 if (ctrl
.stale
|| !ctrl
.results
[ctrl
.page
]) {
353 this.refreshAll = function() {
355 ctrl
.selectedRows
.length
= 0;
359 // Refresh results while staying on current page.
360 this.refreshPage = function() {
366 $scope
.onClickSearch = function() {
367 if (ctrl
.autoSearch
) {
368 ctrl
.autoSearch
= false;
374 $scope
.onClickAuto = function() {
375 ctrl
.autoSearch
= !ctrl
.autoSearch
;
376 if (ctrl
.autoSearch
&& ctrl
.stale
) {
379 $('.crm-search-auto-toggle').blur();
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
);
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
];
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
) {
409 function onChangeFilters() {
411 ctrl
.selectedRows
.length
= 0;
415 if (ctrl
.autoSearch
) {
420 $scope
.selectAllRows = function() {
422 if (ctrl
.allRowsSelected
) {
423 ctrl
.allRowsSelected
= false;
424 ctrl
.selectedRows
.length
= 0;
428 ctrl
.allRowsSelected
= true;
429 if (ctrl
.page
=== 1 && ctrl
.results
[1].length
< ctrl
.limit
) {
430 ctrl
.selectedRows
= _
.pluck(ctrl
.results
[1], 'id');
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
);
443 $scope
.selectRow = function(row
) {
444 var index
= ctrl
.selectedRows
.indexOf(row
.id
);
446 ctrl
.selectedRows
.push(row
.id
);
447 ctrl
.allRowsSelected
= (ctrl
.rowCount
=== ctrl
.selectedRows
.length
);
449 ctrl
.allRowsSelected
= false;
450 ctrl
.selectedRows
.splice(index
, 1);
454 $scope
.isRowSelected = function(row
) {
455 return ctrl
.allRowsSelected
|| _
.includes(ctrl
.selectedRows
, row
.id
);
458 this.getFieldLabel = function(col
) {
459 var info
= searchMeta
.parseExpr(col
),
460 label
= info
.field
.label
;
462 label
= '(' + info
.fn
.title
+ ') ' + label
;
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) {
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;
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
,
482 if (info
.fn
&& info
.fn
.name
=== 'COUNT') {
485 return formatFieldValue(info
.field
, value
);
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
);
495 if (value
&& (type
=== 'Date' || type
=== 'Timestamp') && /^\d{4}-\d{2}-\d{2}/.test(value
)) {
496 return CRM
.utils
.formatDate(value
, null, type
=== 'Timestamp');
498 else if (type
=== 'Boolean' && typeof value
=== 'boolean') {
499 return value
? ts('Yes') : ts('No');
501 else if (type
=== 'Money' && typeof value
=== 'number') {
502 return CRM
.formatMoney(value
);
507 $scope
.fieldsForGroupBy = function() {
508 return {results
: getAllFields('', function(key
) {
509 return _
.contains(ctrl
.savedSearch
.api_params
.groupBy
, key
);
514 $scope
.fieldsForSelect = function() {
515 return {results
: getAllFields(':label', function(key
) {
516 return _
.contains(ctrl
.savedSearch
.api_params
.select
, key
);
521 $scope
.fieldsForWhere = function() {
522 return {results
: getAllFields(':name', _
.noop
)};
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
)});
531 $scope
.sortableColumnOptions
= {
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();
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' : ''));
552 function getAllFields(suffix
, disabledIf
) {
553 function formatFields(entityName
, prefix
) {
554 return _
.transform(searchMeta
.getEntity(entityName
).fields
, function(result
, field
) {
556 id
: prefix
+ field
.name
+ (field
.options
? suffix
: ''),
558 description
: field
.description
560 if (disabledIf(item
.id
)) {
561 item
.disabled
= true;
567 var mainEntity
= searchMeta
.getEntity(ctrl
.savedSearch
.api_entity
),
569 text
: mainEntity
.title_plural
,
570 icon
: mainEntity
.icon
,
571 children
: formatFields(ctrl
.savedSearch
.api_entity
, '')
573 _
.each(ctrl
.savedSearch
.api_params
.join
, function(join
) {
574 var joinName
= join
[0].split(' AS '),
575 joinEntity
= searchMeta
.getEntity(joinName
[0]);
577 text
: joinEntity
.title_plural
+ ' (' + joinName
[1] + ')',
578 icon
: joinEntity
.icon
,
579 children
: formatFields(joinEntity
.name
, joinName
[1] + '.')
586 * Fetch pseudoconstants for main entity + joined entities
588 * Sets an optionsLoaded property on each entity to avoid duplicate requests
590 function loadFieldOptions() {
591 var mainEntity
= searchMeta
.getEntity(ctrl
.savedSearch
.api_entity
),
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]],
600 }, {name
: 'options'}];
603 if (typeof mainEntity
.optionsLoaded
=== 'undefined') {
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') {
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
;
620 entity
.optionsLoaded
= true;
629 })(angular
, CRM
.$, CRM
._
);