From 5b46e216584380acc94f8aab828c3f40addb2c07 Mon Sep 17 00:00:00 2001 From: Coleman Watts Date: Sun, 10 Jan 2016 00:31:27 -0500 Subject: [PATCH] CRM-17795 - Support joins in api explorer --- CRM/Admin/Page/APIExplorer.php | 3 +- Civi/API/SelectQuery.php | 4 +- templates/CRM/Admin/Page/APIExplorer.hlp | 19 +++ templates/CRM/Admin/Page/APIExplorer.js | 188 +++++++++++++++++++---- templates/CRM/Admin/Page/APIExplorer.tpl | 43 +++++- 5 files changed, 220 insertions(+), 37 deletions(-) diff --git a/CRM/Admin/Page/APIExplorer.php b/CRM/Admin/Page/APIExplorer.php index 0df6d0c9e2..de63dbc997 100644 --- a/CRM/Admin/Page/APIExplorer.php +++ b/CRM/Admin/Page/APIExplorer.php @@ -45,7 +45,8 @@ class CRM_Admin_Page_APIExplorer extends CRM_Core_Page { CRM_Core_Resources::singleton() ->addScriptFile('civicrm', 'templates/CRM/Admin/Page/APIExplorer.js') ->addScriptFile('civicrm', 'bower_components/google-code-prettify/bin/prettify.min.js', 99) - ->addStyleFile('civicrm', 'bower_components/google-code-prettify/bin/prettify.min.css', 99); + ->addStyleFile('civicrm', 'bower_components/google-code-prettify/bin/prettify.min.css', 99) + ->addVars('explorer', array('max_joins' => \Civi\API\SelectQuery::MAX_JOINS)); $this->assign('operators', CRM_Core_DAO::acceptedSQLOperators()); diff --git a/Civi/API/SelectQuery.php b/Civi/API/SelectQuery.php index 3652c93865..3c0a7228f5 100644 --- a/Civi/API/SelectQuery.php +++ b/Civi/API/SelectQuery.php @@ -41,6 +41,8 @@ namespace Civi\API; */ class SelectQuery { + const MAX_JOINS = 4; + /** * @var \CRM_Core_DAO */ @@ -361,7 +363,7 @@ class SelectQuery { continue; } // More than 4 joins deep seems excessive - DOS attack? - if ($depth > 4) { + if ($depth > self::MAX_JOINS) { throw new \API_Exception("Maximum number of joins exceeded in api.{$this->entity}.get"); } if (!isset($fkField['FKApiName']) && !isset($fkField['FKClassName'])) { diff --git a/templates/CRM/Admin/Page/APIExplorer.hlp b/templates/CRM/Admin/Page/APIExplorer.hlp index c351e8dafe..cb9b555fe8 100644 --- a/templates/CRM/Admin/Page/APIExplorer.hlp +++ b/templates/CRM/Admin/Page/APIExplorer.hlp @@ -59,3 +59,22 @@

{/htxt} + +{htxt id="api-chain-title"} + {ts}Api Chaining{/ts} +{/htxt} +{htxt id="api-chain"} +

{ts}Chains allow you to execute api calls on the results of this one.{/ts}

+

{ts}Example: When creating a contact, you can chain the Email.create api to add email addresses for them.{/ts}

+

{ts}Disambiguation: api chains are different from joins in that chaining executes a separate api call for every result returned from the main call.{/ts}

+{/htxt} + +{htxt id="api-join-title"} + {ts}Api Joins{/ts} +{/htxt} +{htxt id="api-join"} +

{ts}Join this api call to return values from or filter on a related entity.{/ts}

+

{ts}The new fields will be selectable from the "Params" and "Fields to return" lists.{/ts}

+

{ts}Example: If the api entity is Email, you can also fetch the display name of the contact this email belongs to.{/ts}

+

{ts}Disambiguation: Joins are different from api chaining in that joins execute as a single SELECT query, and only work for api "get" operations.{/ts}

+{/htxt} diff --git a/templates/CRM/Admin/Page/APIExplorer.js b/templates/CRM/Admin/Page/APIExplorer.js index d1d81e7327..953bd66fce 100644 --- a/templates/CRM/Admin/Page/APIExplorer.js +++ b/templates/CRM/Admin/Page/APIExplorer.js @@ -4,6 +4,7 @@ var entity, action, + joins = [], actions = {values: ['get']}, fields = [], getFieldData = {}, @@ -50,15 +51,74 @@ } } + /** + * Data provider for select2 "field" selectors + * @returns {{results: Array.}} + */ + function returnFields() { + return {results: fields.concat({id: '-', text: ts('Other') + '...', description: ts('Choose a field not in this list')})}; + } + + /** + * Recursively populates data for select2 "field" selectors + * @param fields + * @param entity + * @param action + * @param prefix + */ + function populateFields(fields, entity, action, prefix) { + _.each(getFieldsCache[entity+action].values, function(field) { + var name = prefix + field.name, + pos = fields.length; + fields.push({ + id: name, + text: field.title || field.name, + multi: !!field['api.multiple'], + description: field.description || '', + required: !(!field['api.required'] || field['api.required'] === '0') + }); + if (joins[name]) { + fields[pos].children = []; + populateFields(fields[pos].children, joins[name], 'get', name + '.'); + } + if (!prefix && field['api.required'] && field['api.required'] !== '0') { + required.push(field.name); + } + }); + } + + /** + * Fetch metadata for a field by name - searches across joins + * @param name string + * @returns {*} + */ + function getField(name) { + if (getFieldData[name]) { + return getFieldData[name]; + } + var ent = entity, + act = action, + prefix = ''; + _.each(name.split('.'), function(piece) { + if (joins[prefix]) { + ent = joins[prefix]; + act = 'get'; + } + name = piece; + prefix += (prefix.length ? '.' : '') + piece; + }); + return getFieldsCache[ent+act].values[name] || {}; + } + /** * Add a "fields" row - * @param name + * @param name string */ function addField(name) { $('#api-params').append($(fieldTpl({name: name || '', noOps: _.includes(NO_OPERATORS, action)}))); var $row = $('tr:last-child', '#api-params'); $('input.api-param-name', $row).crmSelect2({ - data: fields.concat({id: '-', text: ts('Other') + '...', description: ts('Choose a field not in this list')}), + data: returnFields, formatSelection: function(field) { return field.text + (field.required ? ' *' : ''); @@ -116,7 +176,7 @@ $row = $selector.closest('tr'); if (entity) { $selector.prop('disabled', true); - CRM.api3(entity, 'getactions') + getActions(entity) .done(function(actions) { $selector.prop('disabled', false); CRM.utils.setOptions($('.api-chain-action', $row), _.transform(actions.values, function(ret, item) {ret.push({value: item, key: item});})); @@ -144,14 +204,23 @@ } CRM.api3(apiCalls) .done(function(data) { + data.getfields.values = _.indexBy(data.getfields.values, 'name'); getFieldsCache[entity+action] = data.getfields; getActionsCache[entity] = getActionsCache[entity] || data.getactions; - response.resolve(data.getfields); + response.resolve(getFieldsCache[entity+action]); }); } return response; } + function getActions(entity) { + if (getActionsCache[entity]) { + return $.Deferred().resolve(getActionsCache[entity]); + } else { + return CRM.api3(entity, 'getactions'); + } + } + /** * Respond to changing the main entity+action */ @@ -166,6 +235,7 @@ text: ts('Action') }); getFieldData.api_action = { + name: 'api_action', options: _.reduce(actions.values, function(ret, item) { ret[item] = item; return ret; @@ -181,23 +251,11 @@ if (data.deprecated) CRM.alert(data.deprecated, entity + ' Deprecated'); } onChangeAction(action); - _.each(data.values, function(field) { - if (field.name) { - getFieldData[field.name] = field; - fields.push({ - id: field.name, - text: field.title || field.name, - multi: !!field['api.multiple'], - description: field.description || '', - required: !(!field['api.required'] || field['api.required'] === '0') - }); - if (field['api.required'] && field['api.required'] !== '0') { - required.push(field.name); - } - } - }); + getFieldData = data.values; + populateFields(fields, entity, action, ''); showFields(required); - if (action === 'get' || action === 'getsingle' || action == 'getvalue' || action === 'getstat') { + renderJoinSelector(); + if (_.includes(['get', 'getsingle', 'getvalue', 'getstat'], action)) { showReturn(); } }); @@ -211,9 +269,12 @@ function showReturn() { var title = ts('Fields to return'), params = { - data: fields, + data: returnFields, multiple: true, - placeholder: ts('Leave blank for default') + placeholder: ts('Leave blank for default'), + formatResult: function(field) { + return field.text + '
' + field.description + '
'; + } }; if (action == 'getstat') { title = ts('Group by'); @@ -286,7 +347,7 @@ } function isYesNo(fieldName) { - return getFieldData[fieldName] && getFieldData[fieldName].type === 16; + return getField(fieldName).type === 16; } /** @@ -297,7 +358,7 @@ * @returns boolean */ function isSelect(fieldName, operator) { - var fieldSpec = getFieldData[fieldName] || {}; + var fieldSpec = getField(fieldName); return (isYesNo(fieldName) || fieldSpec.options || fieldSpec.FKApiName) && !_.includes(TEXT, operator); } @@ -340,6 +401,7 @@ $valField = $('input.api-param-value', $row), multiSelect = isMultiSelect(name, operator), currentVal = $valField.val(), + fieldSpec = getField(name), wasSelect = $valField.data('select2'); if (wasSelect) { $valField.crmEntityRef('destroy'); @@ -369,17 +431,17 @@ }); } // Select options - else if (getFieldData[name].options) { + else if (fieldSpec.options) { $valField.select2({ multiple: multiSelect, - data: _.map(getFieldData[name].options, function (value, key) { + data: _.map(fieldSpec.options, function (value, key) { return {id: key, text: value}; }) }); } // EntityRef else { - var entity = getFieldData[name].FKApiName; + var entity = fieldSpec.FKApiName; $valField.attr('placeholder', entity == 'Contact' ? '[' + ts('Auto-Select Current User') + ']' : ts('- select -')); $valField.crmEntityRef({ entity: entity, @@ -746,6 +808,61 @@ } } + /** + * Renders nested checkboxes for adding joins to an api.get call + */ + function renderJoinSelector() { + $('#api-join').hide(); + if (!_.includes(['Contact', 'Contribution', 'Pledge'], entity) && _.includes(['get', 'getsingle'], action)) { + var joinable = {}; + (function recurse(fields, joinable, prefix, depth) { + _.each(fields, function(field) { + if (field.FKApiName && field.FKClassName) { + var name = prefix + field.name; + joinable[name] = { + title: field.title, + entity: field.FKApiName, + checked: !!joins[name] + }; + if (joins[name] && depth < CRM.vars.explorer.max_joins) { + joinable[name].children = {}; + recurse(getFieldsCache[field.FKApiName+'get'].values, joinable[name].children, name + '.', depth+1); + } + } + }); + })(getFieldData, joinable, '', 1); + if (!_.isEmpty(joinable)) { + // Send joinTpl as a param so it can recursively call itself to render children + $('#api-join').show().children('div').html(joinTpl({joins: joinable, tpl: joinTpl})); + } + } + } + + /** + * When adding or removing a join from an api.get call + */ + function onSelectJoin() { + var name = $(this).val(), + ent = $(this).data('entity'); + fields = []; + $('input', '#api-join').prop('disabled', true); + if ($(this).is(':checked')) { + joins[name] = ent; + $('input.api-param-name, #api-return-value').addClass('loading'); + getMetadata(ent, 'get').done(function() { + renderJoinSelector(); + populateFields(fields, entity, action, ''); + $('input.api-param-name, #api-return-value').removeClass('loading'); + }); + } else { + joins = _.omit(joins, function(entity, n) { + return n.indexOf(name) === 0; + }); + renderJoinSelector(); + populateFields(fields, entity, action, ''); + } + } + $(document).ready(function() { // Set up tabs - bind active tab to document hash because... it's cool? document.location.hash = document.location.hash || 'explorer'; @@ -773,6 +890,7 @@ .on('change', '#api-entity, #api-action', function() { entity = $('#api-entity').val(); action = $('#api-action').val(); + joins = {}; if ($(this).is('#api-entity')) { $('#api-action').addClass('loading'); } @@ -784,14 +902,17 @@ }) .on('change keyup', 'input.api-input, #api-params select', buildParams) .on('submit', submit); + $('#api-params') .on('change', 'input.api-param-name, select.api-param-op', renderValueField) - .on('change', 'input.api-param-name, .api-option-name', function() { - if ($(this).val() === '-' && $(this).data('select2')) { - $(this) - .crmSelect2('destroy') - .val('') - .focus(); + .on('select2-selecting', 'input.api-param-name, .api-option-name, #api-return-value', function(e) { + if (e.val === '-') { + $(this).one('change', function() { + $(this) + .crmSelect2('destroy') + .val('') + .focus(); + }); } }) .on('click', '.api-param-remove', function(e) { @@ -800,6 +921,7 @@ buildParams(); }) .on('change', 'select.api-chain-entity', getChainedAction); + $('#api-join').on('change', 'input', onSelectJoin); $('#example-entity').on('change', getExamples); $('#example-action').on('change', getExample); $('#doc-entity').on('change', getDocEntity); diff --git a/templates/CRM/Admin/Page/APIExplorer.tpl b/templates/CRM/Admin/Page/APIExplorer.tpl index 399fb586b9..7ab8599f49 100644 --- a/templates/CRM/Admin/Page/APIExplorer.tpl +++ b/templates/CRM/Admin/Page/APIExplorer.tpl @@ -85,6 +85,22 @@ display: inline; font-weight: bold; } + #mainTabContainer label.api-checkbox-label { + font-weight: normal; + } + #mainTabContainer h4 { + font-weight: bold; + font-size: 1.2em; + margin: .2em .2em 0.5em; + } + #api-join { + margin-top: 1em; + font-size: .8em; + } + #api-join ul { + margin: 0; + padding: 0 0 0.25em 2.5em; + } #api-generated-wraper, #api-result { overflow: auto; @@ -174,15 +190,20 @@    -