$page = explode(':', $this->return)[1];
}
$limit = !empty($settings['pager']['expose_limit']) && $this->limit ? $this->limit : NULL;
+ $apiParams['debug'] = $this->debug;
$apiParams['limit'] = $limit ?? $settings['limit'] ?? NULL;
$apiParams['offset'] = $page ? $apiParams['limit'] * ($page - 1) : 0;
$apiParams['orderBy'] = $this->getOrderByFromSort();
$result->rowCount = $apiResult->rowCount;
$result->exchangeArray($apiResult->getArrayCopy());
+ $result->debug = $apiResult->debug;
}
/**
deferred.resolve($(this).val());
});
return deferred.promise;
+ },
+ // Returns name of explicit or implicit join, for links
+ getJoinEntity: function(info) {
+ if (info.field.fk_entity || info.field.name !== info.field.fieldName) {
+ return info.prefix + (info.field.fk_entity ? info.field.name : info.field.name.substr(0, info.field.name.lastIndexOf('.')));
+ } else if (info.prefix) {
+ return info.prefix.replace('.', '');
+ }
+ return '';
}
};
})
+++ /dev/null
-<hr>
-<div class="form-inline">
- <div class="btn-group" role="group">
- <button type="button" class="btn btn-primary{{ $ctrl.autoSearch ? '-outline' : '' }}" ng-click="onClickSearch()" ng-disabled="loading || (!$ctrl.autoSearch && !$ctrl.stale)">
- <i class="crm-i {{ loading ? 'fa-spin fa-spinner' : 'fa-search' }}"></i>
- {{:: ts('Search') }}
- </button>
- <button type="button" class="btn crm-search-auto-toggle btn-primary{{ $ctrl.autoSearch ? '' : '-outline' }}" ng-click="onClickAuto()">
- <i class="crm-i fa-toggle-{{ $ctrl.autoSearch ? 'on' : 'off' }}"></i>
- {{:: ts('Auto') }}
- </button>
- </div>
- <crm-search-tasks entity="$ctrl.savedSearch.api_entity" ids="$ctrl.selectedRows" refresh="$ctrl.refreshPage()"></crm-search-tasks>
-</div>
+++ /dev/null
-<fieldset class="crm-collapsible collapsed">
- <legend class="collapsible-title">{{:: ts('Query Info') }}</legend>
- <div>
- <pre ng-if="$ctrl.debug.timeIndex">{{ ts('Request took %1 seconds.', {1: $ctrl.debug.timeIndex}) }}</pre>
- <div><strong>API:</strong></div>
- <pre>{{ $ctrl.debug.params }}</pre>
- <div><strong>SQL:</strong></div>
- <pre ng-repeat="query in $ctrl.debug.sql">{{ query }}</pre>
- </div>
-</fieldset>
+++ /dev/null
-<div class="crm-flex-box">
- <div>
- <div class="form-inline">
- <label ng-if="$ctrl.rowCount === false"><i class="crm-i fa-spin fa-spinner"></i></label>
- <label ng-if="$ctrl.rowCount === 1">
- {{ $ctrl.selectedRows.length ? ts('%1 selected of 1 result', {1: $ctrl.selectedRows.length}) : ts('1 result') }}
- </label>
- <label ng-if="$ctrl.rowCount === 0 || $ctrl.rowCount > 1">
- {{ $ctrl.selectedRows.length ? ts('%1 selected of %2 results', {1: $ctrl.selectedRows.length, 2: $ctrl.rowCount}) : ts('%1 results', {1: $ctrl.rowCount}) }}
- </label>
- </div>
- </div>
- <div class="text-center crm-flex-2">
- <ul uib-pagination ng-if="$ctrl.rowCount && !$ctrl.stale"
- class="pagination"
- boundary-links="true"
- total-items="$ctrl.rowCount"
- ng-model="$ctrl.page"
- ng-change="$ctrl.changePage()"
- items-per-page="$ctrl.limit"
- max-size="6"
- force-ellipses="true"
- previous-text="‹"
- next-text="›"
- first-text="«"
- last-text="»"
- ></ul>
- </div>
- <div class="form-inline text-right">
- <label for="crm-search-results-page-size" >
- {{:: ts('Page Size') }}
- </label>
- <input class="form-control" id="crm-search-results-page-size" type="number" ng-model="$ctrl.limit" min="10" step="10" ng-change="onChangeLimit()">
- </div>
-</div>
+++ /dev/null
-<table>
- <thead>
- <tr ng-model="$ctrl.savedSearch.api_params.select" ui-sortable="sortableColumnOptions">
- <th class="crm-search-result-select">
- <input type="checkbox" ng-checked="$ctrl.allRowsSelected" ng-click="selectAllRows()" ng-disabled="!($ctrl.rowCount && loading === false && !loadingAllRows && $ctrl.results[$ctrl.page] && $ctrl.results[$ctrl.page][0].id)">
- </th>
- <th ng-repeat="col in $ctrl.savedSearch.api_params.select" ng-click="setOrderBy(col, $event)" title="{{$index || !$ctrl.groupExists ? ts('Drag to reorder columns, click to sort results (shift-click to sort by multiple).') : ts('Column reserved for smart group.')}}">
- <i class="crm-i {{ getOrderBy(col) }}"></i>
- <span ng-class="{'crm-draggable': $index || !$ctrl.groupExists}">{{ $ctrl.getFieldLabel(col) }}</span>
- <span ng-switch="$index || !$ctrl.groupExists ? 'sortable' : 'locked'">
- <i ng-switch-when="locked" class="crm-i fa-lock" aria-hidden="true"></i>
- <a href ng-switch-default class="crm-hover-button" title="{{:: ts('Clear') }}" ng-click="$ctrl.clearParam('select', $index)"><i class="crm-i fa-times" aria-hidden="true"></i></a>
- </span>
- </th>
- <th class="form-inline">
- <input class="form-control crm-action-menu fa-plus"
- crm-ui-select="::{data: fieldsForSelect, placeholder: ts('Add'), width: '80px', containerCss: {minWidth: '80px'}, dropdownCss: {width: '300px'}}"
- on-crm-ui-select="$ctrl.addParam('select', selection)" >
- </th>
- </tr>
- </thead>
- <tbody>
- <tr ng-repeat="row in $ctrl.results[$ctrl.page]">
- <td>
- <input type="checkbox" ng-checked="isRowSelected(row)" ng-click="selectRow(row)" ng-disabled="!(loading === false && !loadingAllRows && row.id)">
- </td>
- <td ng-repeat="col in $ctrl.savedSearch.api_params.select" ng-bind-html="formatResult(row, col)"></td>
- <td></td>
- </tr>
- </tbody>
-</table>
-<div class="messages warning no-popup" ng-if="error">
- <h4>{{:: ts('An error occurred') }}</h4>
- <p>{{ error }}</p>
-</div>
this.DEFAULT_AGGREGATE_FN = 'GROUP_CONCAT';
- this.selectedRows = [];
- this.limit = CRM.crmSearchAdmin.defaultPagerSize;
- this.page = 1;
this.displayTypes = _.indexBy(CRM.crmSearchAdmin.displayTypes, 'id');
- // After a search this.results is an object of result arrays keyed by page,
- // 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)
- this.results = '';
- this.rowCount = false;
- this.allRowsSelected = false;
- // Have the filters (WHERE, HAVING, GROUP BY, JOIN) changed?
- this.stale = true;
$scope.controls = {tab: 'compose', joinType: 'LEFT'};
$scope.joinTypes = [
$scope.$watchCollection('$ctrl.savedSearch.api_params.select', onChangeSelect);
- $scope.$watch('$ctrl.savedSearch.api_params.where', onChangeFilters, true);
-
if (this.paramExists('groupBy')) {
this.savedSearch.api_params.groupBy = this.savedSearch.api_params.groupBy || [];
- $scope.$watchCollection('$ctrl.savedSearch.api_params.groupBy', onChangeFilters);
}
if (this.paramExists('join')) {
this.savedSearch.api_params.join = this.savedSearch.api_params.join || [];
- $scope.$watch('$ctrl.savedSearch.api_params.join', onChangeFilters, true);
}
if (this.paramExists('having')) {
this.savedSearch.api_params.having = this.savedSearch.api_params.having || [];
- $scope.$watch('$ctrl.savedSearch.api_params.having', onChangeFilters, true);
}
$scope.$watch('$ctrl.savedSearch', onChangeAnything, true);
return !errors.length;
}
- /**
- * Called when clicking on a column header
- * @param col
- * @param $event
- */
- $scope.setOrderBy = function(col, $event) {
- col = _.last(col.split(' AS '));
- var dir = $scope.getOrderBy(col) === 'fa-sort-asc' ? 'DESC' : 'ASC';
- if (!$event.shiftKey || !ctrl.savedSearch.api_params.orderBy) {
- ctrl.savedSearch.api_params.orderBy = {};
- }
- ctrl.savedSearch.api_params.orderBy[col] = dir;
- if (ctrl.results) {
- ctrl.refreshPage();
- }
- };
-
- /**
- * Returns crm-i icon class for a sortable column
- * @param col
- * @returns {string}
- */
- $scope.getOrderBy = function(col) {
- col = _.last(col.split(' AS '));
- var dir = ctrl.savedSearch.api_params.orderBy && ctrl.savedSearch.api_params.orderBy[col];
- if (dir) {
- return 'fa-sort-' + dir.toLowerCase();
- }
- return 'fa-sort disabled';
- };
-
this.addParam = function(name, value) {
if (value && !_.contains(ctrl.savedSearch.api_params[name], value)) {
ctrl.savedSearch.api_params[name].push(value);
ctrl.savedSearch.api_params[name].splice(idx, 1);
};
- // Prevent visual jumps in results table height during loading
- function lockTableHeight() {
- var $table = $('.crm-search-results', $element);
- $table.css('height', $table.height());
- }
-
- function unlockTableHeight() {
- $('.crm-search-results', $element).css('height', '');
- }
-
- // Debounced callback for loadResults
- function _loadResultsCallback() {
- // Multiply limit to read 2 pages at once & save ajax requests
- var params = _.merge(_.cloneDeep(ctrl.savedSearch.api_params), {debug: true, limit: ctrl.limit * 2});
- // Select the join field of implicitly joined entities (helps with displaying links)
- _.each(params.select, function(fieldName) {
- if (_.includes(fieldName, '.') && !_.includes(fieldName, ' AS ')) {
- var info = searchMeta.parseExpr(fieldName);
- if (info.field && !info.suffix && !info.fn && (info.field.name !== info.field.fieldName)) {
- var idField = fieldName.substr(0, fieldName.lastIndexOf('.'));
- if (!_.includes(params.select, idField) && !ctrl.canAggregate(idField)) {
- params.select.push(idField);
- }
- }
- }
- });
- // Select primary key of explicitly joined entities (helps with displaying links)
- _.each(params.join, function(join) {
- var entity = join[0].split(' AS ')[0],
- alias = join[0].split(' AS ')[1],
- primaryKeys = searchMeta.getEntity(entity).primary_key,
- idField = alias + '.' + primaryKeys[0];
- if (primaryKeys.length && !_.includes(params.select, idField) && !ctrl.canAggregate(idField)) {
- params.select.push(idField);
- }
- });
- lockTableHeight();
- $scope.error = false;
- if (ctrl.stale) {
- ctrl.page = 1;
- ctrl.rowCount = false;
- }
- params.offset = ctrl.limit * (ctrl.page - 1);
- crmApi4(ctrl.savedSearch.api_entity, 'get', params).then(function(success) {
- if (ctrl.stale) {
- ctrl.results = {};
- // Get row count for pager
- if (success.length < params.limit) {
- ctrl.rowCount = success.count;
- } else {
- var countParams = _.cloneDeep(params);
- // Select is only needed needed by HAVING
- countParams.select = countParams.having && countParams.having.length ? countParams.select : [];
- countParams.select.push('row_count');
- delete countParams.debug;
- crmApi4(ctrl.savedSearch.api_entity, 'get', countParams).then(function(result) {
- ctrl.rowCount = result.count;
- });
- }
- }
- ctrl.debug = success.debug;
- // populate this page & the next
- ctrl.results[ctrl.page] = success.slice(0, ctrl.limit);
- if (success.length > ctrl.limit) {
- ctrl.results[ctrl.page + 1] = success.slice(ctrl.limit);
- }
- $scope.loading = false;
- ctrl.stale = false;
- unlockTableHeight();
- }, function(error) {
- $scope.loading = false;
- ctrl.results = {};
- ctrl.stale = true;
- ctrl.debug = error.debug;
- $scope.error = errorMsg(error);
- })
- .finally(function() {
- if (ctrl.debug) {
- ctrl.debug.params = JSON.stringify(params, null, 2);
- if (ctrl.debug.timeIndex) {
- ctrl.debug.timeIndex = Number.parseFloat(ctrl.debug.timeIndex).toPrecision(2);
- }
- }
- });
- }
-
- var _loadResults = _.debounce(_loadResultsCallback, 250);
-
- function loadResults() {
- $scope.loading = true;
- _loadResults();
- }
-
- // What to tell the user when search returns an error from the server
- // Todo: parse error codes and give helpful feedback.
- function errorMsg(error) {
- return ts('Ensure all search critera are set correctly and try again.');
- }
-
- this.changePage = function() {
- if (ctrl.stale || !ctrl.results[ctrl.page]) {
- lockTableHeight();
- loadResults();
- }
- };
-
- this.refreshAll = function() {
- ctrl.stale = true;
- clearSelection();
- loadResults();
- };
-
- // Refresh results while staying on current page.
- this.refreshPage = function() {
- lockTableHeight();
- ctrl.results = {};
- loadResults();
- };
-
- $scope.onClickSearch = function() {
- if (ctrl.autoSearch) {
- ctrl.autoSearch = false;
- } else {
- ctrl.refreshAll();
- }
- };
-
- $scope.onClickAuto = function() {
- ctrl.autoSearch = !ctrl.autoSearch;
- if (ctrl.autoSearch && ctrl.stale) {
- ctrl.refreshAll();
- }
- $('.crm-search-auto-toggle').blur();
- };
-
- $scope.onChangeLimit = function() {
- // Refresh only if search has already been run
- if (ctrl.autoSearch || ctrl.results) {
- ctrl.refreshAll();
- }
- };
-
function onChangeSelect(newSelect, oldSelect) {
// When removing a column from SELECT, also remove from ORDER BY & HAVING
_.each(_.difference(oldSelect, newSelect), function(col) {
return clauseUsesFields(clause, [col]);
});
});
- // Re-arranging or removing columns doesn't merit a refresh, only adding columns does
- if (!oldSelect || _.difference(newSelect, oldSelect).length) {
- if (ctrl.autoSearch) {
- ctrl.refreshPage();
- } else {
- ctrl.stale = true;
- }
- }
- }
-
- function onChangeFilters() {
- ctrl.stale = true;
- clearSelection();
- if (ctrl.autoSearch) {
- ctrl.refreshAll();
- }
- }
-
- function clearSelection() {
- ctrl.allRowsSelected = false;
- ctrl.selectedRows.length = 0;
}
- $scope.selectAllRows = function() {
- // Deselect all
- if (ctrl.allRowsSelected) {
- clearSelection();
- return;
- }
- // Select all
- ctrl.allRowsSelected = true;
- if (ctrl.page === 1 && ctrl.results[1].length < ctrl.limit) {
- ctrl.selectedRows = _.pluck(ctrl.results[1], 'id');
- return;
- }
- // If more than one page of results, use ajax to fetch all ids
- $scope.loadingAllRows = true;
- var params = _.cloneDeep(ctrl.savedSearch.api_params);
- // Select is only needed needed by HAVING
- params.select = params.having && params.having.length ? params.select : [];
- params.select.push('id');
- crmApi4(ctrl.savedSearch.api_entity, 'get', params, ['id']).then(function(ids) {
- $scope.loadingAllRows = false;
- ctrl.selectedRows = _.toArray(ids);
- });
- };
-
- $scope.selectRow = function(row) {
- var index = ctrl.selectedRows.indexOf(row.id);
- if (index < 0) {
- ctrl.selectedRows.push(row.id);
- ctrl.allRowsSelected = (ctrl.rowCount === ctrl.selectedRows.length);
- } else {
- ctrl.allRowsSelected = false;
- ctrl.selectedRows.splice(index, 1);
- }
- };
-
- $scope.isRowSelected = function(row) {
- return ctrl.allRowsSelected || _.includes(ctrl.selectedRows, row.id);
- };
-
this.getFieldLabel = searchMeta.getDefaultLabel;
// Is a column eligible to use an aggregate function?
return ctrl.savedSearch.api_params.groupBy.indexOf(info.prefix + idField) < 0;
};
- $scope.formatResult = function(row, col) {
- var info = searchMeta.parseExpr(col),
- value = row[info.alias];
- return formatFieldValue(row, info, value);
- };
-
- // Attempts to construct a view url for a given entity
- function getEntityUrl(row, info, index) {
- var entity = searchMeta.getEntity(info.field.entity),
- path = _.result(_.findWhere(entity.paths, {action: 'view'}), 'path');
- // Only proceed if the path metadata exists for this entity
- if (path) {
- // Replace tokens in the path (e.g. [id])
- var tokens = path.match(/\[\w*]/g) || [],
- prefix = info.prefix;
- var replacements = _.transform(tokens, function(replacements, token) {
- var fieldName = token.slice(1, token.length - 1);
- // For implicit join fields
- if (fieldName === 'id' && info.field.name !== info.field.fieldName) {
- fieldName = info.field.name.substr(0, info.field.name.lastIndexOf('.'));
- }
- var replacement = row[prefix + fieldName];
- if (replacement) {
- replacements.push(_.isArray(replacement) ? replacement[index] : replacement);
- }
- });
- // Only proceed if the row contains all the necessary data to resolve tokens
- if (tokens.length === replacements.length) {
- _.each(tokens, function(token, key) {
- path = path.replace(token, replacements[key]);
- });
- return {url: CRM.url(path), title: path.title};
- }
- }
- }
-
- function formatFieldValue(row, info, value, index) {
- var type = (info.fn && info.fn.dataType) || info.field.data_type,
- result = value,
- link;
- if (_.isArray(value)) {
- return _.map(value, function(val, idx) {
- return formatFieldValue(row, info, val, idx);
- }).join(', ');
- }
- if (value && (type === 'Date' || type === 'Timestamp') && /^\d{4}-\d{2}-\d{2}/.test(value)) {
- result = CRM.utils.formatDate(value, null, type === 'Timestamp');
- }
- else if (type === 'Boolean' && typeof value === 'boolean') {
- result = value ? ts('Yes') : ts('No');
- }
- else if (type === 'Money' && typeof value === 'number') {
- result = CRM.formatMoney(value);
- }
- // Output user-facing name/label fields as a link, if possible
- if (info.field.fieldName === searchMeta.getEntity(info.field.entity).label_field && !info.fn) {
- link = getEntityUrl(row, info, index || 0);
- }
- if (link) {
- return '<a href="' + _.escape(link.url) + '" title="' + _.escape(link.title) + '">' + _.escape(result) + '</a>';
- }
- return _.escape(result);
- }
-
$scope.fieldsForGroupBy = function() {
return {results: ctrl.getAllFields('', ['Field', 'Custom'], function(key) {
return _.contains(ctrl.savedSearch.api_params.groupBy, key);
};
};
- $scope.fieldsForSelect = function() {
- return {results: ctrl.getAllFields(':label', ['Field', 'Custom', 'Extra'], function(key) {
- return _.contains(ctrl.savedSearch.api_params.select, key);
- })
- };
- };
-
function getFieldsForJoin(joinEntity) {
return {results: ctrl.getAllFields(':name', ['Field', 'Custom'], null, joinEntity)};
}
return {results: ctrl.getSelectFields()};
};
- $scope.sortableColumnOptions = {
- axis: 'x',
- handle: '.crm-draggable',
- update: function(e, ui) {
- // Don't allow items to be moved to position 0 if locked
- if (!ui.item.sortable.dropindex && ctrl.groupExists) {
- ui.item.sortable.cancel();
- }
- }
- };
-
// Sets the default select clause based on commonly-named fields
function getDefaultSelect() {
var entity = searchMeta.getEntity(ctrl.savedSearch.api_entity);
}
}
+ // Build a list of all possible links to main entity & join entities
+ this.buildLinks = function() {
+ function addTitle(link, entityName) {
+ switch (link.action) {
+ case 'view':
+ link.title = ts('View %1', {1: entityName});
+ break;
+
+ case 'update':
+ link.title = ts('Edit %1', {1: entityName});
+ break;
+
+ case 'delete':
+ link.title = ts('Delete %1', {1: entityName});
+ break;
+ }
+ }
+
+ // Links to main entity
+ var mainEntity = searchMeta.getEntity(ctrl.savedSearch.api_entity),
+ links = _.cloneDeep(mainEntity.paths || []);
+ _.each(links, function(link) {
+ link.join = '';
+ addTitle(link, mainEntity.title);
+ });
+ // Links to explicitly joined entities
+ _.each(ctrl.savedSearch.api_params.join, function(joinClause) {
+ var join = searchMeta.getJoin(joinClause[0]),
+ joinEntity = searchMeta.getEntity(join.entity),
+ bridgeEntity = _.isString(joinClause[2]) ? searchMeta.getEntity(joinClause[2]) : null;
+ _.each(joinEntity.paths, function(path) {
+ var link = _.cloneDeep(path);
+ link.path = link.path.replace(/\[/g, '[' + join.alias + '.');
+ link.join = join.alias;
+ addTitle(link, join.label);
+ links.push(link);
+ });
+ _.each(bridgeEntity && bridgeEntity.paths, function(path) {
+ var link = _.cloneDeep(path);
+ link.path = link.path.replace(/\[/g, '[' + join.alias + '.');
+ link.join = join.alias;
+ addTitle(link, join.label + (bridgeEntity.bridge_title ? ' ' + bridgeEntity.bridge_title : ''));
+ links.push(link);
+ });
+ });
+ // Links to implicit joins
+ _.each(ctrl.savedSearch.api_params.select, function(fieldName) {
+ if (!_.includes(fieldName, ' AS ')) {
+ var info = searchMeta.parseExpr(fieldName);
+ if (info.field && !info.suffix && !info.fn && (info.field.fk_entity || info.field.name !== info.field.fieldName)) {
+ var idFieldName = info.field.fk_entity ? fieldName : fieldName.substr(0, fieldName.lastIndexOf('.')),
+ idField = searchMeta.parseExpr(idFieldName).field;
+ if (!ctrl.canAggregate(idFieldName)) {
+ var joinEntity = searchMeta.getEntity(idField.fk_entity),
+ label = (idField.join ? idField.join.label + ': ' : '') + (idField.input_attrs && idField.input_attrs.label || idField.label);
+ _.each((joinEntity || {}).paths, function(path) {
+ var link = _.cloneDeep(path);
+ link.path = link.path.replace(/\[id/g, '[' + idFieldName);
+ link.join = idFieldName;
+ addTitle(link, label);
+ links.push(link);
+ });
+ }
+ }
+ }
+ });
+ return _.uniq(links, 'path');
+ };
+
}
});
<ul class="nav nav-pills nav-stacked" ng-include="'~/crmSearchAdmin/tabs.html'"></ul>
<div class="crm-flex-4" ng-switch="controls.tab">
<div ng-switch-when="compose">
- <div ng-include="'~/crmSearchAdmin/compose/criteria.html'"></div>
- <div ng-include="'~/crmSearchAdmin/compose/controls.html'"></div>
- <div ng-include="'~/crmSearchAdmin/compose/debug.html'" ng-if="$ctrl.debug"></div>
- <div ng-include="'~/crmSearchAdmin/compose/results.html'" class="crm-search-results"></div>
- <div ng-include="'~/crmSearchAdmin/compose/pager.html'" ng-if="$ctrl.results"></div>
+ <div ng-include="'~/crmSearchAdmin/compose.html'"></div>
+ <crm-search-admin-results-table search="$ctrl.savedSearch"></crm-search-admin-results-table>
</div>
<div ng-switch-when="group">
<fieldset ng-include="'~/crmSearchAdmin/group.html'"></fieldset>
this.getLinks = function(columnKey) {
if (!ctrl.links) {
- ctrl.links = {'*': buildLinks()};
+ ctrl.links = {'*': ctrl.crmSearchAdmin.buildLinks()};
}
if (!columnKey) {
return ctrl.links['*'];
}
var expr = ctrl.getExprFromSelect(columnKey),
info = searchMeta.parseExpr(expr),
- joinEntity = '';
- if (info.field.fk_entity || info.field.name !== info.field.fieldName) {
- joinEntity = info.prefix + (info.field.fk_entity ? info.field.name : info.field.name.substr(0, info.field.name.lastIndexOf('.')));
- } else if (info.prefix) {
- joinEntity = info.prefix.replace('.', '');
- }
+ joinEntity = searchMeta.getJoinEntity(info);
if (!ctrl.links[joinEntity]) {
- ctrl.links[joinEntity] = _.filter(ctrl.links['*'], function(link) {
- return joinEntity === (link.join || '');
- });
+ ctrl.links[joinEntity] = _.filter(ctrl.links['*'], {join: joinEntity});
}
return ctrl.links[joinEntity];
};
- // Build a list of all possible links to main entity or join entities
- function buildLinks() {
- function addTitle(link, entityName) {
- switch (link.action) {
- case 'view':
- link.title = ts('View %1', {1: entityName});
- break;
-
- case 'update':
- link.title = ts('Edit %1', {1: entityName});
- break;
-
- case 'delete':
- link.title = ts('Delete %1', {1: entityName});
- break;
- }
- }
-
- // Links to main entity
- var mainEntity = searchMeta.getEntity(ctrl.savedSearch.api_entity),
- links = _.cloneDeep(mainEntity.paths || []);
- _.each(links, function(link) {
- addTitle(link, mainEntity.title);
- });
- // Links to explicitly joined entities
- _.each(ctrl.savedSearch.api_params.join, function(joinClause) {
- var join = searchMeta.getJoin(joinClause[0]),
- joinEntity = searchMeta.getEntity(join.entity),
- bridgeEntity = _.isString(joinClause[2]) ? searchMeta.getEntity(joinClause[2]) : null;
- _.each(joinEntity.paths, function(path) {
- var link = _.cloneDeep(path);
- link.path = link.path.replace(/\[/g, '[' + join.alias + '.');
- link.join = join.alias;
- addTitle(link, join.label);
- links.push(link);
- });
- _.each(bridgeEntity && bridgeEntity.paths, function(path) {
- var link = _.cloneDeep(path);
- link.path = link.path.replace(/\[/g, '[' + join.alias + '.');
- link.join = join.alias;
- addTitle(link, join.label + (bridgeEntity.bridge_title ? ' ' + bridgeEntity.bridge_title : ''));
- links.push(link);
- });
- });
- // Links to implicit joins
- _.each(ctrl.savedSearch.api_params.select, function(fieldName) {
- if (!_.includes(fieldName, ' AS ')) {
- var info = searchMeta.parseExpr(fieldName);
- if (info.field && !info.suffix && !info.fn && (info.field.fk_entity || info.field.name !== info.field.fieldName)) {
- var idFieldName = info.field.fk_entity ? fieldName : fieldName.substr(0, fieldName.lastIndexOf('.')),
- idField = searchMeta.parseExpr(idFieldName).field;
- if (!ctrl.crmSearchAdmin.canAggregate(idFieldName)) {
- var joinEntity = searchMeta.getEntity(idField.fk_entity),
- label = (idField.join ? idField.join.label + ': ' : '') + (idField.input_attrs && idField.input_attrs.label || idField.label);
- _.each((joinEntity || {}).paths, function(path) {
- var link = _.cloneDeep(path);
- link.path = link.path.replace(/\[id/g, '[' + idFieldName);
- link.join = idFieldName;
- addTitle(link, label);
- links.push(link);
- });
- }
- }
- }
- });
- return _.uniq(links, 'path');
- }
-
this.pickIcon = function(model, key) {
searchMeta.pickIcon().then(function(icon) {
model[key] = icon;
--- /dev/null
+<div class="input-group">
+ <div class="input-group-btn" title="{{:: ts('Should the search be run immediately, or wait for the user to click a button?') }}">
+ <button type="button" class="btn btn-outline-default" ng-click="$ctrl.display.settings.button = null" ng-class="{active: !$ctrl.display.settings.button}">
+ <i class="crm-i fa-{{ $ctrl.display.settings.button ? '' : 'check-' }}circle-o"></i>
+ {{:: ts('Auto-Run') }}
+ </button>
+ <button type="button" class="btn btn-outline-default" ng-click="$ctrl.display.settings.button = ts('Search')" ng-class="{active: $ctrl.display.settings.button}">
+ <i class="crm-i fa-{{ !$ctrl.display.settings.button ? '' : 'check-' }}circle-o"></i>
+ {{:: ts('Search Button') }}
+ </button>
+ </div>
+ <input type="text" ng-show="$ctrl.display.settings.button" ng-model="$ctrl.display.settings.button" ng-model-options="{updateOn: 'blur'}" class="form-control" title="{{:: ts('Search button text') }}" placeholder="{{:: ts('Search button text') }}">
+</div>
{{ symbol.label }}
</option>
</select>
+ <div class="form-group" ng-include="'~/crmSearchAdmin/displays/common/searchButtonConfig.html'"></div>
</div>
<search-admin-pager-config display="$ctrl.display"></search-admin-pager-config>
</fieldset>
<span>{{:: ts('Enable Actions') }}</span>
</label>
</div>
+ <div class="form-group" ng-include="'~/crmSearchAdmin/displays/common/searchButtonConfig.html'"></div>
</div>
<search-admin-pager-config display="$ctrl.display"></search-admin-pager-config>
</fieldset>
--- /dev/null
+(function(angular, $, _) {
+ "use strict";
+
+ // Specialized searchDisplay, only used by Admins
+ angular.module('crmSearchAdmin').component('crmSearchAdminResultsTable', {
+ bindings: {
+ search: '<'
+ },
+ require: {
+ crmSearchAdmin: '^crmSearchAdmin'
+ },
+ templateUrl: '~/crmSearchAdmin/resultsTable/crmSearchAdminResultsTable.html',
+ controller: function($scope, searchMeta, searchDisplayBaseTrait, searchDisplayTasksTrait, searchDisplaySortableTrait) {
+ var ts = $scope.ts = CRM.ts('org.civicrm.search_kit'),
+ // Mix in traits to this controller
+ ctrl = angular.extend(this, searchDisplayBaseTrait, searchDisplayTasksTrait, searchDisplaySortableTrait);
+
+ // Output user-facing name/label fields as a link, if possible
+ function getViewLink(fieldExpr, links) {
+ var info = searchMeta.parseExpr(fieldExpr),
+ entity = searchMeta.getEntity(info.field.entity);
+ if (!info.fn && entity && info.field.fieldName === entity.label_field) {
+ var joinEntity = searchMeta.getJoinEntity(info);
+ return _.find(links, {join: joinEntity, action: 'view'});
+ }
+ }
+
+ function buildSettings() {
+ var links = ctrl.crmSearchAdmin.buildLinks();
+ ctrl.apiEntity = ctrl.search.api_entity;
+ ctrl.display = {
+ type: 'table',
+ settings: {
+ limit: CRM.crmSearchAdmin.defaultPagerSize,
+ pager: {show_count: true, expose_limit: true},
+ actions: true,
+ button: ts('Search'),
+ columns: _.transform(ctrl.search.api_params.select, function(columns, fieldExpr) {
+ var column = {label: true},
+ link = getViewLink(fieldExpr, links);
+ if (link) {
+ column.title = link.title;
+ column.link = {
+ path: link.path,
+ target: '_blank'
+ };
+ }
+ columns.push(searchMeta.fieldToColumn(fieldExpr, column));
+ })
+ }
+ };
+ ctrl.debug = {
+ apiParams: JSON.stringify(ctrl.search.api_params, null, 2)
+ };
+ ctrl.settings = ctrl.display.settings;
+ }
+
+ this.$onInit = function() {
+ buildSettings();
+ this.initializeDisplay($scope, $());
+ $scope.$watch('$ctrl.search.api_entity', buildSettings);
+ $scope.$watch('$ctrl.search.api_params', buildSettings, true);
+ };
+
+ // Refresh current page
+ this.refresh = function(row) {
+ ctrl.runSearch();
+ };
+
+ // Add callbacks for pre & post run
+ this.onPreRun.push(function(apiParams) {
+ apiParams.debug = true;
+ });
+
+ this.onPostRun.push(function(result) {
+ ctrl.debug = _.extend(_.pick(ctrl.debug, 'apiParams'), result.debug);
+ });
+
+ $scope.sortableColumnOptions = {
+ axis: 'x',
+ handle: '.crm-draggable',
+ update: function(e, ui) {
+ // Don't allow items to be moved to position 0 if locked
+ if (!ui.item.sortable.dropindex && ctrl.crmSearchAdmin.groupExists) {
+ ui.item.sortable.cancel();
+ }
+ }
+ };
+
+ $scope.fieldsForSelect = function() {
+ return {results: ctrl.crmSearchAdmin.getAllFields(':label', ['Field', 'Custom', 'Extra'], function(key) {
+ return _.contains(ctrl.search.api_params.select, key);
+ })
+ };
+ };
+
+ $scope.addColumn = function(col) {
+ ctrl.crmSearchAdmin.addParam('select', col);
+ };
+
+ $scope.removeColumn = function(index) {
+ ctrl.crmSearchAdmin.clearParam('select', index);
+ };
+
+ }
+ });
+
+})(angular, CRM.$, CRM._);
--- /dev/null
+<div class="crm-search-display crm-search-display-table">
+ <div ng-include="'~/crmSearchAdmin/resultsTable/debug.html'"></div>
+ <div class="form-inline">
+ <div class="btn-group" ng-include="'~/crmSearchDisplay/SearchButton.html'"></div>
+ <crm-search-tasks entity="$ctrl.apiEntity" ids="$ctrl.selectedRows" refresh="$ctrl.getResults()"></crm-search-tasks>
+ </div>
+ <table>
+ <thead>
+ <tr ng-model="$ctrl.search.api_params.select" ui-sortable="sortableColumnOptions">
+ <th class="crm-search-result-select" ng-if=":: $ctrl.settings.actions">
+ <input type="checkbox" ng-disabled="$ctrl.loading || !$ctrl.results.length" ng-checked="$ctrl.allRowsSelected" ng-click="$ctrl.selectAllRows()" >
+ </th>
+ <th ng-repeat="item in $ctrl.search.api_params.select" ng-click="$ctrl.setSort($ctrl.settings.columns[$index], $event)" title="{{$index || !$ctrl.crmSearchAdmin.groupExists ? ts('Drag to reorder columns, click to sort results (shift-click to sort by multiple).') : ts('Column reserved for smart group.')}}">
+ <i class="crm-i {{ $ctrl.getSort($ctrl.settings.columns[$index]) }}"></i>
+ <span ng-class="{'crm-draggable': $index || !$ctrl.crmSearchAdmin.groupExists}">{{ $ctrl.settings.columns[$index].label }}</span>
+ <span ng-switch="$index || !$ctrl.crmSearchAdmin.groupExists ? 'sortable' : 'locked'">
+ <i ng-switch-when="locked" class="crm-i fa-lock" aria-hidden="true"></i>
+ <a href ng-switch-default class="crm-hover-button" title="{{:: ts('Clear') }}" ng-click="removeColumn($index); $event.stopPropagation();"><i class="crm-i fa-times" aria-hidden="true"></i></a>
+ </span>
+ </th>
+ <th class="form-inline">
+ <input class="form-control crm-action-menu fa-plus"
+ crm-ui-select="::{data: fieldsForSelect, placeholder: ts('Add'), width: '80px', containerCss: {minWidth: '80px'}, dropdownCss: {width: '300px'}}"
+ on-crm-ui-select="addColumn(selection)" >
+ </th>
+ </tr>
+ </thead>
+ <tbody ng-include="'~/crmSearchDisplayTable/crmSearchDisplayTableBody.html'"></tbody>
+ </table>
+ <div ng-include="'~/crmSearchDisplay/Pager.html'"></div>
+</div>
--- /dev/null
+<fieldset id="crm-search-admin-debug">
+ <legend ng-click="$ctrl.showDebug = !$ctrl.showDebug">
+ <i class="crm-i fa-caret-{{ !$ctrl.showDebug ? 'right' : 'down' }}"></i>
+ {{:: ts('Query Info') }}
+ </legend>
+ <div ng-if="$ctrl.showDebug">
+ <pre ng-if="$ctrl.debug.timeIndex">{{ ts('Request took %1 seconds.', {1: $ctrl.debug.timeIndex}) }}</pre>
+ <div>
+ <strong>API:</strong>
+ </div>
+ <pre>{{ $ctrl.debug.apiParams }}</pre>
+ <div ng-if="$ctrl.debug.sql">
+ <strong>SQL:</strong>
+ </div>
+ <pre ng-repeat="query in $ctrl.debug.sql">{{ query }}</pre>
+ </div>
+</fieldset>
--- /dev/null
+<button type="button" class="btn btn-primary" ng-click="$ctrl.getResults()" ng-disabled="$ctrl.loading">
+ <i ng-if="$ctrl.loading" class="crm-i fa-spin fa-spinner"></i>
+ <i ng-if="!$ctrl.loading" class="crm-i fa-search"></i>
+ {{:: $ctrl.settings.button }}
+</button>
page: 1,
rowCount: null,
getUrl: getUrl,
+ // Arrays may contain callback functions for various events
+ onChangeFilters: [],
+ onPreRun: [],
+ onPostRun: [],
// Called by the controller's $onInit function
initializeDisplay: function($scope, $element) {
this.sort = this.settings.sort ? _.cloneDeep(this.settings.sort) : [];
this.getResults = _.debounce(function() {
- ctrl.runSearch();
+ $scope.$apply(function() {
+ ctrl.runSearch();
+ });
}, 100);
// If search is embedded in contact summary tab, display count in tab-header
function onChangeFilters() {
ctrl.page = 1;
ctrl.rowCount = null;
- if (ctrl.onChangeFilters) {
- ctrl.onChangeFilters();
+ _.each(ctrl.onChangeFilters, function(callback) {
+ callback.call(ctrl);
+ });
+ if (!ctrl.settings.button) {
+ ctrl.getResults();
}
- ctrl.getResults();
}
function onChangePageSize() {
ctrl.page = 1;
- ctrl.getResults();
+ // Only refresh if search has already been run
+ if (ctrl.results) {
+ ctrl.getResults();
+ }
}
if (this.afFieldset) {
// Call SearchDisplay.run and update ctrl.results and ctrl.rowCount
runSearch: function() {
- var ctrl = this;
- return crmApi4('SearchDisplay', 'run', ctrl.getApiParams()).then(function(results) {
+ var ctrl = this,
+ apiParams = this.getApiParams();
+ this.loading = true;
+ _.each(ctrl.onPreRun, function(callback) {
+ callback.call(ctrl, apiParams);
+ });
+ return crmApi4('SearchDisplay', 'run', apiParams).then(function(results) {
ctrl.results = results;
- ctrl.editing = false;
+ ctrl.editing = ctrl.loading = false;
if (!ctrl.rowCount) {
if (!ctrl.limit || results.length < ctrl.limit) {
ctrl.rowCount = results.length;
});
}
}
+ _.each(ctrl.onPostRun, function(callback) {
+ callback.call(ctrl, results, 'success');
+ });
+ }, function(error) {
+ ctrl.results = [];
+ ctrl.editing = ctrl.loading = false;
+ _.each(ctrl.onPostRun, function(callback) {
+ callback.call(ctrl, error, 'error');
+ });
});
},
replaceTokens: function(value, row) {
} else {
this.sort.push([col.key, dir]);
}
- this.getResults();
+ if (this.results || !this.settings.button) {
+ this.getResults();
+ }
}
};
<div class="crm-search-display crm-search-display-list">
+ <div ng-include="'~/crmSearchDisplay/SearchButton.html'" ng-if="$ctrl.settings.button"></div>
<ol ng-if=":: $ctrl.settings.style === 'ol'" ng-include="'~/crmSearchDisplayList/crmSearchDisplayListItems.html'" ng-style="{'list-style': $ctrl.settings.symbol}"></ol>
<ul ng-if=":: $ctrl.settings.style !== 'ol'" ng-include="'~/crmSearchDisplayList/crmSearchDisplayListItems.html'" ng-style="{'list-style': $ctrl.settings.symbol}"></ul>
<div ng-include="'~/crmSearchDisplay/Pager.html'"></div>
<div class="crm-search-display crm-search-display-table">
- <div class="form-inline" ng-if="$ctrl.settings.actions">
- <crm-search-tasks entity="$ctrl.apiEntity" ids="$ctrl.selectedRows" refresh="$ctrl.getResults()"></crm-search-tasks>
+ <div class="form-inline">
+ <div class="btn-group" ng-include="'~/crmSearchDisplay/SearchButton.html'" ng-if="$ctrl.settings.button"></div>
+ <crm-search-tasks ng-if="$ctrl.settings.actions" entity="$ctrl.apiEntity" ids="$ctrl.selectedRows" refresh="$ctrl.getResults()"></crm-search-tasks>
</div>
<table>
<thead>
<tr>
<th class="crm-search-result-select" ng-if=":: $ctrl.settings.actions">
- <input type="checkbox" ng-checked="$ctrl.allRowsSelected" ng-click="$ctrl.selectAllRows()" >
+ <input type="checkbox" ng-disabled="$ctrl.loading || !$ctrl.results.length" ng-checked="$ctrl.allRowsSelected" ng-click="$ctrl.selectAllRows()" >
</th>
<th ng-repeat="col in $ctrl.settings.columns" ng-click="$ctrl.setSort(col, $event)" title="{{:: ts('Click to sort results (shift-click to sort by multiple).') }}">
<i ng-if="col.type === 'field'" class="crm-i {{ $ctrl.getSort(col) }}"></i>
return this.allRowsSelected || _.includes(this.selectedRows, row.id);
},
- // Reset selection when filters are changed
- onChangeFilters: function() {
+ // Overwrite empty onChangeFilters array from searchDisplayBaseTrait
+ onChangeFilters: [function() {
+ // Reset selection when filters are changed
this.selectedRows.length = 0;
this.allRowsSelected = false;
- }
+ }]
};
});
min-width: 500px;
}
-#bootstrap-theme #crm-search-results-page-size {
- width: 5em;
-}
-#bootstrap-theme .crm-search-results {
- min-height: 200px;
-}
-
#bootstrap-theme.crm-search .nav-stacked {
margin-left: 0;
margin-right: 20px;