feac4a41e15dd2db18b16c4cc530e1c975e049a1
1 (function(angular
, $, _
) {
4 angular
.module('crmSearchAdmin').component('crmSearchAdmin', {
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'),
13 this.DEFAULT_AGGREGATE_FN
= 'GROUP_CONCAT';
15 this.selectedRows
= [];
16 this.limit
= CRM
.cache
.get('searchPageSize', 30);
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)
22 this.rowCount
= false;
23 this.allRowsSelected
= false;
24 // Have the filters (WHERE, HAVING, GROUP BY, JOIN) changed?
27 $scope
.controls
= {tab
: 'compose', joinType
: 'LEFT'};
29 {k
: 'LEFT', v
: ts('With (optional)')},
30 {k
: 'INNER', v
: ts('With (required)')},
31 {k
: 'EXCLUDE', v
: ts('Without')},
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');
38 $scope
.entities
= formatForSelect2(primaryEntities
, 'name', 'title_plural', ['description', 'icon']);
39 $scope
.getEntity
= searchMeta
.getEntity
;
40 $scope
.getField
= searchMeta
.getField
;
42 editGroups
: CRM
.checkPerm('edit groups')
45 this.$onInit = function() {
46 this.entityTitle
= searchMeta
.getEntity(this.savedSearch
.api_entity
).title_plural
;
48 this.savedSearch
.displays
= this.savedSearch
.displays
|| [];
49 this.savedSearch
.groups
= this.savedSearch
.groups
|| [];
50 this.groupExists
= !!this.savedSearch
.groups
.length
;
52 if (!this.savedSearch
.id
) {
55 expr
: '$ctrl.savedSearch.api_params',
59 select
: getDefaultSelect(),
66 $scope
.$watchCollection('$ctrl.savedSearch.api_params.select', onChangeSelect
);
68 $scope
.$watch('$ctrl.savedSearch.api_params.where', onChangeFilters
, true);
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
);
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);
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);
85 $scope
.$watch('$ctrl.savedSearch', onChangeAnything
, true);
87 // After watcher runs for the first time and messes up the status, set it correctly
89 $scope
.status
= ctrl
.savedSearch
&& ctrl
.savedSearch
.id
? 'saved' : 'unsaved';
95 function onChangeAnything() {
96 $scope
.status
= 'unsaved';
99 this.save = function() {
103 $scope
.status
= 'saving';
104 var params
= _
.cloneDeep(ctrl
.savedSearch
),
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
]]}];
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
]]}];
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
);
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
;
130 ctrl
.savedSearch
.displays
= results
.saved
.displays
|| [];
131 // Wait until after onChangeAnything to update status
132 $timeout(function() {
133 $scope
.status
= newStatus
;
138 this.paramExists = function(param
) {
139 return _
.includes(searchMeta
.getEntity(ctrl
.savedSearch
.api_entity
).params
, param
);
142 this.addDisplay = function(type
) {
143 ctrl
.savedSearch
.displays
.push({
147 $scope
.selectTab('display_' + (ctrl
.savedSearch
.displays
.length
- 1));
150 this.removeDisplay = function(index
) {
151 var display
= ctrl
.savedSearch
.displays
[index
];
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
);
160 $scope
.selectTab('compose');
161 ctrl
.savedSearch
.displays
.splice(index
, 1);
165 this.addGroup = function() {
166 ctrl
.savedSearch
.groups
.push({
169 visibility
: 'User and User Admin Only',
172 ctrl
.groupExists
= true;
173 $scope
.selectTab('group');
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]);
185 ctrl
.savedSearch
.api_params
.select
= _
.uniq(ctrl
.savedSearch
.api_params
.select
);
186 $scope
.controls
.tab
= tab
;
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;
195 if ($scope
.controls
.tab
=== 'group') {
196 $scope
.selectTab('compose');
200 function addNum(name
, num
) {
201 return name
+ (num
< 10 ? '_0' : '_') + num
;
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]);
210 $scope
.getJoin
= searchMeta
.getJoin
;
212 $scope
.getJoinEntities = function() {
213 var existingJoins
= getExistingJoins();
215 function addEntityJoins(entity
, stack
, baseEntity
) {
216 return _
.transform(CRM
.crmSearchAdmin
.joins
[entity
], function(joinEntities
, join
) {
218 // Add all joins that don't just point directly back to the original entity
219 if (!(baseEntity
=== join
.entity
&& !join
.multi
)) {
221 appendJoin(joinEntities
, join
, ++num
, stack
, entity
);
222 } while (addNum((stack
? stack
+ '_' : '') + join
.alias
, num
) in existingJoins
);
227 function appendJoin(collection
, join
, num
, stack
, baseEntity
) {
228 var alias
= addNum((stack
? stack
+ '_' : '') + join
.alias
, num
),
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
236 if (alias
in existingJoins
) {
237 opt
.children
= addEntityJoins(join
.entity
, (stack
? stack
+ '_' : '') + alias
, baseEntity
);
239 collection
.push(opt
);
242 return {results
: addEntityJoins(ctrl
.savedSearch
.api_entity
)};
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
);
256 _
.each(_
.cloneDeep(join
.defaults
), function(condition
) {
257 params
.push(condition
);
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
);
265 $scope
.controls
.join
= '';
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
);
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]);
281 _
.remove(ctrl
.savedSearch
.api_params
.where
, function(clause
) {
282 return clauseUsesJoin(clause
, alias
);
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) {
292 this.changeJoinType = function(join
) {
293 if (join
[1] === 'EXCLUDE') {
294 removeJoinStuff(searchMeta
.getJoin(join
[0]).alias
);
298 $scope
.changeGroupBy = function(idx
) {
299 if (!ctrl
.savedSearch
.api_params
.groupBy
[idx
]) {
300 ctrl
.clearParam('groupBy', idx
);
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
;
315 function clauseUsesJoin(clause
, alias
) {
316 if (clause
[0].indexOf(alias
+ '.') === 0) {
319 if (_
.isArray(clause
[1])) {
320 return clause
[1].some(function(subClause
) {
321 return clauseUsesJoin(subClause
, alias
);
327 // Returns true if a clause contains one of the
328 function clauseUsesFields(clause
, fields
) {
329 if (!fields
|| !fields
.length
) {
332 if (_
.includes(fields
, clause
[0])) {
335 if (_
.isArray(clause
[1])) {
336 return clause
[1].some(function(subClause
) {
337 return clauseUsesField(subClause
, fields
);
343 function validate() {
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
}));
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
}));
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
;
369 $scope
.selectTab(tab
);
371 $(errorEl
).crmError(errors
.join('<br>'), ts('Error Saving'), {expires
: 5000});
373 return !errors
.length
;
377 * Called when clicking on a column header
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
= {};
387 ctrl
.savedSearch
.api_params
.orderBy
[col
] = dir
;
394 * Returns crm-i icon class for a sortable column
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
];
402 return 'fa-sort-' + dir
.toLowerCase();
404 return 'fa-sort disabled';
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();
417 $scope
.controls
[name
] = '';
420 // Deletes an item from an array param
421 this.clearParam = function(name
, idx
) {
422 ctrl
.savedSearch
.api_params
[name
].splice(idx
, 1);
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());
431 function unlockTableHeight() {
432 $('.crm-search-results', $element
).css('height', '');
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, '_');
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
);
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
);
470 $scope
.error
= false;
473 ctrl
.rowCount
= false;
475 params
.offset
= ctrl
.limit
* (ctrl
.page
- 1);
476 crmApi4(ctrl
.savedSearch
.api_entity
, 'get', params
).then(function(success
) {
479 // Get row count for pager
480 if (success
.length
< params
.limit
) {
481 ctrl
.rowCount
= success
.count
;
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
;
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
);
499 $scope
.loading
= false;
503 $scope
.loading
= false;
506 ctrl
.debug
= error
.debug
;
507 $scope
.error
= errorMsg(error
);
509 .finally(function() {
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);
519 var _loadResults
= _
.debounce(_loadResultsCallback
, 250);
521 function loadResults() {
522 $scope
.loading
= true;
523 aggregateGroupByColumns();
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.');
533 this.changePage = function() {
534 if (ctrl
.stale
|| !ctrl
.results
[ctrl
.page
]) {
540 this.refreshAll = function() {
546 // Refresh results while staying on current page.
547 this.refreshPage = function() {
553 $scope
.onClickSearch = function() {
554 if (ctrl
.autoSearch
) {
555 ctrl
.autoSearch
= false;
561 $scope
.onClickAuto = function() {
562 ctrl
.autoSearch
= !ctrl
.autoSearch
;
563 if (ctrl
.autoSearch
&& ctrl
.stale
) {
566 $('.crm-search-auto-toggle').blur();
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
);
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
]);
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
) {
597 function onChangeFilters() {
600 if (ctrl
.autoSearch
) {
605 function clearSelection() {
606 ctrl
.allRowsSelected
= false;
607 ctrl
.selectedRows
.length
= 0;
610 $scope
.selectAllRows = function() {
612 if (ctrl
.allRowsSelected
) {
617 ctrl
.allRowsSelected
= true;
618 if (ctrl
.page
=== 1 && ctrl
.results
[1].length
< ctrl
.limit
) {
619 ctrl
.selectedRows
= _
.pluck(ctrl
.results
[1], 'id');
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
);
634 $scope
.selectRow = function(row
) {
635 var index
= ctrl
.selectedRows
.indexOf(row
.id
);
637 ctrl
.selectedRows
.push(row
.id
);
638 ctrl
.allRowsSelected
= (ctrl
.rowCount
=== ctrl
.selectedRows
.length
);
640 ctrl
.allRowsSelected
= false;
641 ctrl
.selectedRows
.splice(index
, 1);
645 $scope
.isRowSelected = function(row
) {
646 return ctrl
.allRowsSelected
|| _
.includes(ctrl
.selectedRows
, row
.id
);
649 this.getFieldLabel
= searchMeta
.getDefaultLabel
;
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
) {
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) {
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;
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') {
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
);
676 return '<a href="' + _
.escape(link
.url
) + '" title="' + _
.escape(link
.title
) + '">' + formatFieldValue(info
.field
, value
) + '</a>';
679 return formatFieldValue(info
.field
, value
);
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
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] + '_';
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
]);
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
]);
706 return {url
: CRM
.url(path
), title
: path
.title
};
711 function formatFieldValue(field
, value
) {
712 var type
= field
.data_type
,
714 if (_
.isArray(value
)) {
715 return _
.map(value
, function(val
) {
716 return formatFieldValue(field
, val
);
719 if (value
&& (type
=== 'Date' || type
=== 'Timestamp') && /^\d{4}-\d{2}-\d{2}/.test(value
)) {
720 result
= CRM
.utils
.formatDate(value
, null, type
=== 'Timestamp');
722 else if (type
=== 'Boolean' && typeof value
=== 'boolean') {
723 result
= value
? ts('Yes') : ts('No');
725 else if (type
=== 'Money' && typeof value
=== 'number') {
726 result
= CRM
.formatMoney(value
);
728 return _
.escape(result
);
731 $scope
.fieldsForGroupBy = function() {
732 return {results
: ctrl
.getAllFields('', function(key
) {
733 return _
.contains(ctrl
.savedSearch
.api_params
.groupBy
, key
);
738 $scope
.fieldsForSelect = function() {
739 return {results
: ctrl
.getAllFields(':label', function(key
) {
740 return _
.contains(ctrl
.savedSearch
.api_params
.select
, key
);
745 $scope
.fieldsForWhere = function() {
746 return {results
: ctrl
.getAllFields(':name')};
749 $scope
.fieldsForHaving = function() {
750 return {results
: ctrl
.getSelectFields()};
753 $scope
.sortableColumnOptions
= {
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();
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
);
774 this.getAllFields = function(suffix
, disabledIf
) {
775 disabledIf
= disabledIf
|| _
.noop
;
776 function formatFields(entityName
, join
) {
777 var prefix
= join
? join
.alias
+ '.' : '',
780 function addFields(fields
) {
781 _
.each(fields
, function(field
) {
783 id
: prefix
+ field
.name
+ (field
.options
? suffix
: ''),
785 description
: field
.description
787 if (disabledIf(item
.id
)) {
788 item
.disabled
= true;
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
);
801 addFields(searchMeta
.getEntity(entityName
).fields
);
805 var mainEntity
= searchMeta
.getEntity(ctrl
.savedSearch
.api_entity
),
807 text
: mainEntity
.title_plural
,
808 icon
: mainEntity
.icon
,
809 children
: formatFields(ctrl
.savedSearch
.api_entity
)
811 _
.each(ctrl
.savedSearch
.api_params
.join
, function(join
) {
812 var joinInfo
= searchMeta
.getJoin(join
[0]),
813 joinEntity
= searchMeta
.getEntity(joinInfo
.entity
);
815 text
: joinInfo
.label
,
816 description
: joinInfo
.description
,
817 icon
: joinEntity
.icon
,
818 children
: formatFields(joinEntity
.name
, joinInfo
)
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
);
830 text
: ctrl
.getFieldLabel(name
),
831 description
: info
.field
&& info
.field
.description
833 if (disabledIf(item
.id
)) {
834 item
.disabled
= true;
841 * Fetch pseudoconstants for main entity + joined entities
843 * Sets an optionsLoaded property on each entity to avoid duplicate requests
845 * @var string entity - optional additional entity to load
847 function loadFieldOptions(entity
) {
848 var mainEntity
= searchMeta
.getEntity(ctrl
.savedSearch
.api_entity
),
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]],
857 }, {name
: 'options'}];
860 if (typeof mainEntity
.optionsLoaded
=== 'undefined') {
864 // Optional additional entity
865 if (entity
&& typeof searchMeta
.getEntity(entity
).optionsLoaded
=== 'undefined') {
866 enqueue(searchMeta
.getEntity(entity
));
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') {
876 if (bridgeEntity
&& typeof bridgeEntity
.optionsLoaded
=== 'undefined') {
877 enqueue(bridgeEntity
);
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
;
887 entity
.optionsLoaded
= true;
896 })(angular
, CRM
.$, CRM
._
);