public static function getSchema() {
$schema = [];
$entities = \Civi\Api4\Entity::get()
- ->addSelect('name', 'title', 'title_plural', 'description', 'icon')
+ ->addSelect('name', 'title', 'title_plural', 'description', 'icon', 'paths')
->addWhere('name', '!=', 'Entity')
->addOrderBy('title_plural')
->setChain([
foreach ($entities as $entity) {
// Skip if entity doesn't have a 'get' action or the user doesn't have permission to use get
if ($entity['get']) {
+ // Add paths (but only RUD actions) with translated titles
+ foreach ($entity['paths'] as $action => $path) {
+ unset($entity['paths'][$action]);
+ switch ($action) {
+ case 'view':
+ $title = ts('View %1', [1 => $entity['title']]);
+ break;
+
+ case 'edit':
+ $title = ts('Edit %1', [1 => $entity['title']]);
+ break;
+
+ case 'delete':
+ $title = ts('Delete %1', [1 => $entity['title']]);
+ break;
+
+ default:
+ continue 2;
+ }
+ $entity['paths'][] = [
+ 'path' => $path,
+ 'title' => $title,
+ 'action' => $action,
+ ];
+ }
$entity['fields'] = civicrm_api4($entity['name'], 'getFields', [
'select' => $getFields,
'where' => [['name', 'NOT IN', ['api_key', 'hash']]],
function getField(fieldName, entityName) {
var dotSplit = fieldName.split('.'),
joinEntity = dotSplit.length > 1 ? dotSplit[0] : null,
- name = _.last(dotSplit).split(':')[0];
+ name = _.last(dotSplit).split(':')[0],
+ field;
// Custom fields contain a dot in their fieldname
// If 3 segments, the first is the joinEntity and the last 2 are the custom field
if (dotSplit.length === 3) {
}
// If 2 segments, it's ambiguous whether this is a custom field or joined field. Search the main entity first.
if (dotSplit.length === 2) {
- var field = _.find(getEntity(entityName).fields, {name: dotSplit[0] + '.' + name});
+ field = _.find(getEntity(entityName).fields, {name: dotSplit[0] + '.' + name});
if (field) {
+ field.entity = entityName;
return field;
}
}
if (joinEntity) {
entityName = _.find(CRM.vars.search.links[entityName], {alias: joinEntity}).entity;
}
- return _.find(getEntity(entityName).fields, {name: name});
+ field = _.find(getEntity(entityName).fields, {name: name});
+ if (field) {
+ field.entity = entityName;
+ return field;
+ }
}
return {
getEntity: getEntity,
<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">
- {{ formatResult(row, col) }}
- </td>
+ <td ng-repeat="col in $ctrl.savedSearch.api_params.select" ng-bind-html="formatResult(row, col)"></td>
<td></td>
</tr>
</tbody>
this.addDisplay = function(type) {
ctrl.savedSearch.displays.push({
type: type,
- label: '',
- settings: {}
+ label: ''
});
$scope.selectTab('display_' + (ctrl.savedSearch.displays.length - 1));
};
// Debounced callback for loadResults
function _loadResultsCallback() {
// Multiply limit to read 2 pages at once & save ajax requests
- var params = angular.merge({debug: true, limit: ctrl.limit * 2}, ctrl.savedSearch.api_params);
+ var params = _.merge(_.cloneDeep(ctrl.savedSearch.api_params), {debug: true, limit: ctrl.limit * 2});
+ // Select the ids of joined entities (helps with displaying links)
+ _.each(params.join, function(join) {
+ var idField = join[0].split(' AS ')[1] + '.id';
+ if (!_.includes(params.select, idField) && !ctrl.canAggregate(idField)) {
+ params.select.push(idField);
+ }
+ });
lockTableHeight();
$scope.error = false;
if (ctrl.stale) {
})
.finally(function() {
if (ctrl.debug) {
- ctrl.debug.params = JSON.stringify(_.extend({version: 4}, ctrl.savedSearch.api_params), null, 2);
+ ctrl.debug.params = JSON.stringify(params, null, 2);
if (ctrl.debug.timeIndex) {
ctrl.debug.timeIndex = Number.parseFloat(ctrl.debug.timeIndex).toPrecision(2);
}
// Is a column eligible to use an aggregate function?
this.canAggregate = function(col) {
+ // If the query does not use grouping, never
+ if (!ctrl.savedSearch.api_params.groupBy.length) {
+ return false;
+ }
var info = searchMeta.parseExpr(col);
// If the column is used for a groupBy, no
if (ctrl.savedSearch.api_params.groupBy.indexOf(info.path) > -1) {
return ctrl.savedSearch.api_params.groupBy.indexOf(info.prefix + 'id') < 0;
};
- $scope.formatResult = function formatResult(row, col) {
+ $scope.formatResult = function(row, col) {
var info = searchMeta.parseExpr(col),
key = info.fn ? (info.fn.name + ':' + info.path + info.suffix) : col,
value = row[key];
if (info.fn && info.fn.name === 'COUNT') {
return value;
}
+ // Output user-facing name/label fields as a link, if possible
+ if (info.field && _.includes(['display_name', 'title', 'label', 'subject'], info.field.name) && !info.fn && typeof value === 'string') {
+ var link = getEntityUrl(row, info);
+ if (link) {
+ return '<a href="' + _.escape(link.url) + '" title="' + _.escape(link.title) + '">' + formatFieldValue(info.field, value) + '</a>';
+ }
+ }
return formatFieldValue(info.field, value);
};
+ // Attempts to construct a view url for a given entity
+ function getEntityUrl(row, info) {
+ 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) || [],
+ replacements = _.transform(tokens, function(replacements, token) {
+ var fieldName = info.prefix + token.slice(1, token.length - 1);
+ if (row[fieldName]) {
+ replacements.push(row[fieldName]);
+ }
+ });
+ // Only proceed if the row contains all the necessary data to resolve tokens
+ if (tokens.length === replacements.length) {
+ _.each(tokens, function(token, index) {
+ path = path.replace(token, replacements[index]);
+ });
+ return {url: CRM.url(path), title: path.title};
+ }
+ }
+ }
+
function formatFieldValue(field, value) {
- var type = field.data_type;
+ var type = field.data_type,
+ result = value;
if (_.isArray(value)) {
return _.map(value, function(val) {
return formatFieldValue(field, val);
}).join(', ');
}
if (value && (type === 'Date' || type === 'Timestamp') && /^\d{4}-\d{2}-\d{2}/.test(value)) {
- return CRM.utils.formatDate(value, null, type === 'Timestamp');
+ result = CRM.utils.formatDate(value, null, type === 'Timestamp');
}
else if (type === 'Boolean' && typeof value === 'boolean') {
- return value ? ts('Yes') : ts('No');
+ result = value ? ts('Yes') : ts('No');
}
else if (type === 'Money' && typeof value === 'number') {
- return CRM.formatMoney(value);
+ result = CRM.formatMoney(value);
}
- return value;
+ return _.escape(result);
}
$scope.fieldsForGroupBy = function() {
--- /dev/null
+(function(angular, $, _) {
+ "use strict";
+
+ angular.module('crmSearchAdmin').component('crmSearchAdminLinkSelect', {
+ bindings: {
+ column: '<',
+ links: '<'
+ },
+ templateUrl: '~/crmSearchAdmin/crmSearchAdminLinkSelect.html',
+ controller: function ($scope, $element, $timeout) {
+ var ts = $scope.ts = CRM.ts(),
+ ctrl = this;
+
+ function onChange() {
+ var val = $('select', $element).val();
+ if (val !== ctrl.column.link) {
+ var link = ctrl.getLink(val);
+ if (link) {
+ ctrl.column.link = link.path;
+ ctrl.column.title = link.title;
+ } else if (val === 'civicrm/') {
+ ctrl.column.link = val;
+ $timeout(function() {
+ $('input', $element).focus();
+ });
+ } else {
+ ctrl.column.link = '';
+ ctrl.column.title = '';
+ }
+ }
+ }
+
+ this.$onInit = function() {
+ $('select', $element).on('change', function() {
+ $scope.$apply(onChange);
+ });
+ };
+
+ this.getLink = function(path) {
+ return _.findWhere(ctrl.links, {path: path});
+ };
+
+ }
+ });
+
+})(angular, CRM.$, CRM._);
--- /dev/null
+<select class="form-control">
+ <option value="" ng-selected="!$ctrl.column.link" >{{ ts('None') }}</option>
+ <option ng-repeat="link in $ctrl.links" value="{{ link.path }}" ng-selected="$ctrl.column.link === link.path">
+ {{ link.title }}
+ </option>
+ <option value="civicrm/" ng-selected="$ctrl.column.link && !$ctrl.getLink($ctrl.column.link)">
+ {{ ts('Other...') }}
+ </option>
+</select>
+<input class="form-control" type="text" ng-model="$ctrl.column.link" ng-model-options="{updateOn: 'blur'}" ng-show="$ctrl.column.link && !$ctrl.getLink($ctrl.column.link)" />
ctrl.hiddenColumns.splice(index, 1);
};
- this.toggleLink = function(col) {
- col.link = col.link ? '' : (window.location.pathname + window.location.search).replace('civicrm/admin/search', 'civicrm/');
- };
-
this.$onInit = function () {
ctrl.getFieldLabel = ctrl.crmSearchAdmin.getFieldLabel;
+ if (!ctrl.display.settings) {
+ ctrl.display.settings = {
+ limit: 20,
+ pager: true
+ };
+ }
if (!ctrl.display.settings.columns) {
ctrl.display.settings.columns = _.transform(ctrl.apiParams.select, function(columns, fieldExpr) {
columns.push(fieldToColumn(fieldExpr));
}
});
}
+ ctrl.links = _.cloneDeep(searchMeta.getEntity(ctrl.apiEntity).paths || []);
+ _.each(ctrl.apiParams.join, function(join) {
+ var joinName = join[0].split(' AS '),
+ joinEntity = searchMeta.getEntity(joinName[0]);
+ _.each(joinEntity.paths, function(path) {
+ var link = _.cloneDeep(path);
+ link.path = link.path.replace(/\[/g, '[' + joinName[1] + '.');
+ ctrl.links.push(link);
+ });
+ });
};
}
</button>
</div>
<div class="form-inline">
- <label>{{ ts('Link:') }} <input type="checkbox" ng-checked="!!col.link" ng-click="$ctrl.toggleLink(col)" /></label>
- <input class="form-control" type="text" ng-model="col.link" ng-model-options="{updateOn: 'blur'}" ng-show="!!col.link" />
+ <label>{{ ts('Link:') }}</label>
+ <crm-search-admin-link-select column="col" links="$ctrl.links"></crm-search-admin-link-select>
+ </div>
+ <div class="form-inline">
+ <label>{{ ts('Tooltip:') }}</label>
+ <input class="form-control" type="text" ng-model="col.title" />
</div>
</fieldset>
</fieldset>
if (_.isEmpty(params.where)) {
params.where = [];
}
+ // Select the ids of joined entities (helps with displaying links)
+ _.each(params.join, function(join) {
+ var joinEntity = join[0].split(' AS ')[1],
+ idField = joinEntity + '.id';
+ if (!_.includes(params.select, idField) && !canAggregate('id', joinEntity + '.')) {
+ params.select.push(idField);
+ }
+ });
_.each(ctrl.filters, function(value, key) {
if (value) {
params.where.push([key, 'CONTAINS', value]);
}
result = _.escape(result);
if (col.link) {
- result = '<a href="' + replaceTokens(col.link, row) + '">' + result + '</a>';
+ result = '<a href="' + getUrl(col.link, row) + '">' + result + '</a>';
}
return result;
}
+ function getUrl(link, row) {
+ var url = replaceTokens(link, row);
+ if (url.slice(0, 1) !== '/' && url.slice(0, 4) !== 'http') {
+ url = CRM.url(url);
+ }
+ return _.escape(url);
+ }
+
function replaceTokens(str, data) {
_.each(data, function(value, key) {
str = str.replace('[' + key + ']', value);
return str;
}
+ function canAggregate(fieldName, prefix) {
+ // If the query does not use grouping, never
+ if (!ctrl.apiParams.groupBy.length) {
+ return false;
+ }
+ // If the column is used for a groupBy, no
+ if (ctrl.apiParams.groupBy.indexOf(prefix + fieldName) > -1) {
+ return false;
+ }
+ // If the entity this column belongs to is being grouped by id, then also no
+ return ctrl.apiParams.groupBy.indexOf(prefix + 'id') < 0;
+ }
+
$scope.selectAllRows = function() {
// Deselect all
if (ctrl.allRowsSelected) {
<td ng-if="$ctrl.settings.actions">
<input type="checkbox" ng-checked="isRowSelected(row)" ng-click="selectRow(row)" ng-disabled="!(!loadingAllRows && row.id)">
</td>
- <td ng-repeat="col in $ctrl.columns" ng-bind-html="formatResult(row, col)">
+ <td ng-repeat="col in $ctrl.columns" ng-bind-html="formatResult(row, col)" title="{{:: col.title }}">
</td>
<td></td>
</tr>
</tbody>
</table>
-<div class="text-center" ng-if="$ctrl.rowCount">
+<div class="text-center" ng-if="$ctrl.rowCount && $ctrl.settings.pager">
<ul uib-pagination
class="pagination"
boundary-links="true"