CRM-17857 - Update api explorer to exclude participant joins
[civicrm-core.git] / templates / CRM / Admin / Page / APIExplorer.js
1 (function($, _, undefined) {
2 "use strict";
3 /* jshint validthis: true */
4 var
5 entity,
6 action,
7 joins = [],
8 actions = {values: ['get']},
9 fields = [],
10 getFieldData = {},
11 getFieldsCache = {},
12 getActionsCache = {},
13 params = {},
14 smartyPhp,
15 entityDoc,
16 fieldTpl = _.template($('#api-param-tpl').html()),
17 optionsTpl = _.template($('#api-options-tpl').html()),
18 returnTpl = _.template($('#api-return-tpl').html()),
19 chainTpl = _.template($('#api-chain-tpl').html()),
20 docCodeTpl = _.template($('#doc-code-tpl').html()),
21 joinTpl = _.template($('#join-tpl').html()),
22
23 // The following apis do not support the syntax for joins
24 // FIXME: the solution is to convert these apis to use _civicrm_api3_basic_get
25 NO_JOINS = ['Contact', 'Contribution', 'Pledge', 'Participant'],
26
27 // These types of entityRef don't require any input to open
28 // FIXME: ought to be in getfields metadata
29 OPEN_IMMEDIATELY = ['RelationshipType', 'Event', 'Group', 'Tag'],
30
31 // Actions that don't support fancy operators
32 NO_OPERATORS = ['create', 'update', 'delete', 'setvalue', 'getoptions', 'getactions', 'getfields'],
33
34 // Actions that don't support multiple values
35 NO_MULTI = ['delete', 'getoptions', 'getactions', 'getfields', 'getfield', 'setvalue'],
36
37 // Operators with special properties
38 BOOL = ['IS NULL', 'IS NOT NULL'],
39 TEXT = ['LIKE', 'NOT LIKE'],
40 MULTI = ['IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN'];
41
42 /**
43 * Call prettyPrint function and perform additional formatting
44 * @param ele
45 */
46 function prettyPrint(ele) {
47 if (typeof window.prettyPrint === 'function') {
48 $(ele).removeClass('prettyprinted').addClass('prettyprint');
49
50 window.prettyPrint();
51
52 // Highlight errors in api result
53 $('span:contains("error_code"),span:contains("error_message")', '#api-result')
54 .siblings('span.str').css('color', '#B00');
55 }
56 }
57
58 /**
59 * Data provider for select2 "field" selectors
60 * @returns {{results: Array.<T>}}
61 */
62 function returnFields() {
63 return {results: fields.concat({id: '-', text: ts('Other') + '...', description: ts('Choose a field not in this list')})};
64 }
65
66 /**
67 * Recursively populates data for select2 "field" selectors
68 * @param fields
69 * @param entity
70 * @param action
71 * @param prefix
72 * @param required
73 */
74 function populateFields(fields, entity, action, prefix, required) {
75 _.each(getFieldsCache[entity+action].values, function(field) {
76 var name = prefix + field.name,
77 pos = fields.length;
78 fields.push({
79 id: name,
80 text: field.title || field.name,
81 multi: !!field['api.multiple'],
82 description: field.description || '',
83 required: !(!field['api.required'] || field['api.required'] === '0')
84 });
85 if (joins[name]) {
86 fields[pos].children = [];
87 populateFields(fields[pos].children, joins[name], 'get', name + '.');
88 }
89 if (!prefix && required && field['api.required'] && field['api.required'] !== '0') {
90 required.push(field.name);
91 }
92 });
93 }
94
95 /**
96 * Fetch metadata for a field by name - searches across joins
97 * @param name string
98 * @returns {*}
99 */
100 function getField(name) {
101 if (!name) {
102 return {};
103 }
104 if (getFieldData[name]) {
105 return getFieldData[name];
106 }
107 var ent = entity,
108 act = action,
109 prefix = '';
110 _.each(name.split('.'), function(piece) {
111 if (joins[prefix]) {
112 ent = joins[prefix];
113 act = 'get';
114 }
115 name = piece;
116 prefix += (prefix.length ? '.' : '') + piece;
117 });
118 return getFieldsCache[ent+act].values[name] || {};
119 }
120
121 /**
122 * Add a "fields" row
123 * @param name string
124 */
125 function addField(name) {
126 $('#api-params').append($(fieldTpl({name: name || '', noOps: _.includes(NO_OPERATORS, action)})));
127 var $row = $('tr:last-child', '#api-params');
128 $('input.api-param-name', $row).crmSelect2({
129 data: returnFields,
130 formatSelection: function(field) {
131 return field.text +
132 (field.required ? ' <span class="crm-marker">*</span>' : '');
133 },
134 formatResult: function(field) {
135 return field.text +
136 (field.required ? ' <span class="crm-marker">*</span>' : '') +
137 '<div class="api-field-desc">' + field.description + '</div>';
138 }
139 }).change();
140 }
141
142 /**
143 * Add a new "options" row
144 */
145 function addOptionField() {
146 if ($('.api-options-row', '#api-params').length) {
147 $('.api-options-row:last', '#api-params').after($(optionsTpl({})));
148 } else {
149 $('#api-params').append($(optionsTpl({})));
150 }
151 var $row = $('.api-options-row:last', '#api-params');
152 $('.api-option-name', $row).crmSelect2({data: [
153 {id: 'limit', text: 'limit'},
154 {id: 'offset', text: 'offset'},
155 {id: 'sort', text: 'sort'},
156 {id: 'metadata', text: 'metadata'},
157 {id: '-', text: ts('Other') + '...'}
158 ]});
159 }
160
161 /**
162 * Add an "api chain" row
163 */
164 function addChainField() {
165 $('#api-params').append($(chainTpl({})));
166 var $row = $('tr:last-child', '#api-params');
167 $('.api-chain-entity', $row).crmSelect2({
168 formatSelection: function(item) {
169 return '<i class="crm-i fa-link"></i> API ' +
170 ($(item.element).hasClass('strikethrough') ? '<span class="strikethrough">' + item.text + '</span>' : item.text);
171 },
172 placeholder: '<i class="crm-i fa-link"></i> ' + ts('Entity'),
173 escapeMarkup: function(m) {return m;}
174 });
175 }
176
177 /**
178 * Fetch available actions for selected chained entity
179 */
180 function getChainedAction() {
181 var
182 $selector = $(this),
183 entity = $selector.val(),
184 $row = $selector.closest('tr');
185 if (entity) {
186 $selector.prop('disabled', true);
187 getActions(entity)
188 .done(function(actions) {
189 $selector.prop('disabled', false);
190 CRM.utils.setOptions($('.api-chain-action', $row), _.transform(actions.values, function(ret, item) {ret.push({value: item, key: item});}));
191 });
192 }
193 }
194
195 /**
196 * Fetch metadata from the api and cache locally for performance
197 * Returns a deferred object which resolves to entity.getfields
198 */
199 function getMetadata(entity, action) {
200 var response = $.Deferred();
201 if (getFieldsCache[entity+action]) {
202 response.resolve(getFieldsCache[entity+action]);
203 } else {
204 var apiCalls = {
205 getfields: [entity, 'getfields', {
206 api_action: action,
207 options: {get_options: 'all', get_options_context: 'match'}
208 }]
209 };
210 if (!getActionsCache[entity]) {
211 apiCalls.getactions = [entity, 'getactions'];
212 }
213 CRM.api3(apiCalls)
214 .done(function(data) {
215 data.getfields.values = _.indexBy(data.getfields.values, 'name');
216 getFieldsCache[entity+action] = data.getfields;
217 getActionsCache[entity] = getActionsCache[entity] || data.getactions;
218 response.resolve(getFieldsCache[entity+action]);
219 });
220 }
221 return response;
222 }
223
224 /**
225 * TODO: This works given the current code structure but would cause race conditions if called many times per second
226 * @param entity string
227 * @returns $.Deferred
228 */
229 function getActions(entity) {
230 if (getActionsCache[entity]) {
231 return $.Deferred().resolve(getActionsCache[entity]);
232 } else {
233 return CRM.api3(entity, 'getactions');
234 }
235 }
236
237 /**
238 * Respond to changing the main entity+action
239 */
240 function onChangeEntityOrAction(changedElement) {
241 var required = [];
242 fields = [];
243 joins = [];
244 getFieldData = {};
245 // Special case for getfields
246 if (action === 'getfields') {
247 fields.push({
248 id: 'api_action',
249 text: ts('Action')
250 });
251 getFieldData.api_action = {
252 name: 'api_action',
253 options: _.reduce(actions.values, function(ret, item) {
254 ret[item] = item;
255 return ret;
256 }, {})
257 };
258 showFields(['api_action']);
259 renderJoinSelector();
260 return;
261 }
262 getMetadata(entity, action).done(function(data) {
263 if ($(changedElement).is('#api-entity')) {
264 actions = getActionsCache[entity];
265 populateActions();
266 if (data.deprecated) CRM.alert(data.deprecated, entity + ' Deprecated');
267 }
268 onChangeAction(action);
269 getFieldData = data.values;
270 populateFields(fields, entity, action, '', required);
271 showFields(required);
272 renderJoinSelector();
273 if (_.includes(['get', 'getsingle', 'getvalue', 'getstat'], action)) {
274 showReturn();
275 }
276 });
277 }
278
279 /**
280 * For "get" actions show the "return" options
281 *
282 * TODO: Too many hard-coded actions here. Need a way to fetch this from metadata
283 */
284 function showReturn() {
285 var title = ts('Fields to return'),
286 params = {
287 data: returnFields,
288 multiple: true,
289 placeholder: ts('Leave blank for default'),
290 formatResult: function(field) {
291 return field.text + '<div class="api-field-desc">' + field.description + '</div>';
292 }
293 };
294 if (action == 'getstat') {
295 title = ts('Group by');
296 }
297 if (action == 'getvalue') {
298 title = ts('Return Value');
299 params.placeholder = ts('Select field');
300 params.multiple = false;
301 }
302 $('#api-params').prepend($(returnTpl({title: title, required: action == 'getvalue'})));
303 $('#api-return-value').crmSelect2(params);
304 }
305
306 /**
307 * Test whether an action is deprecated
308 * @param action
309 * @returns {boolean}
310 */
311 function isActionDeprecated(action) {
312 return !!(typeof actions.deprecated === 'object' && actions.deprecated[action]);
313 }
314
315 /**
316 * Render action text depending on deprecation status
317 * @param option
318 * @returns {string}
319 */
320 function renderAction(option) {
321 return isActionDeprecated(option.id) ? '<span class="strikethrough">' + option.text + '</span>' : option.text;
322 }
323
324 /**
325 * Called after getActions to populate action list
326 */
327 function populateActions() {
328 var val = $('#api-action').val();
329 $('#api-action').removeClass('loading').select2({
330 data: _.transform(actions.values, function(ret, item) {ret.push({text: item, id: item});}),
331 formatSelection: renderAction,
332 formatResult: renderAction
333 });
334 // If previously selected action is not available, set it to 'get' if possible
335 if (!_.includes(actions.values, val)) {
336 $('#api-action').select2('val', !_.includes(actions.values, 'get') ? actions.values[0] : 'get', true);
337 }
338 }
339
340 /**
341 * Check for and display action-specific deprecation notices
342 * @param action
343 */
344 function onChangeAction(action) {
345 if (isActionDeprecated(action)) {
346 CRM.alert(actions.deprecated[action], action + ' deprecated');
347 }
348 }
349
350 /**
351 * Called after getfields to show buttons and required fields
352 * @param required
353 */
354 function showFields(required) {
355 $('#api-params').empty();
356 $('#api-param-buttons').show();
357 if (required.length) {
358 _.each(required, addField);
359 } else {
360 addField();
361 }
362 }
363
364 function isYesNo(fieldName) {
365 return getField(fieldName).type === 16;
366 }
367
368 /**
369 * Should we render a select or textfield?
370 *
371 * @param fieldName
372 * @param operator
373 * @returns boolean
374 */
375 function isSelect(fieldName, operator) {
376 var fieldSpec = getField(fieldName);
377 return (isYesNo(fieldName) || fieldSpec.options || fieldSpec.FKApiName) && !_.includes(TEXT, operator);
378 }
379
380 /**
381 * Should we render a select as single or multi?
382 *
383 * @param fieldName
384 * @param operator
385 * @returns boolean
386 */
387 function isMultiSelect(fieldName, operator) {
388 if (isYesNo(fieldName) || _.includes(NO_MULTI, action)) {
389 return false;
390 }
391 if (_.includes(MULTI, operator)) {
392 return true;
393 }
394 // The = operator is ambiguous but all others can be safely assumed to be single
395 if (operator !== '=') {
396 return false;
397 }
398 return true;
399 /*
400 * Attempt to resolve the ambiguity of the = operator using metadata
401 * commented out because there is not enough metadata in the api at this time
402 * to accurately figure it out.
403 */
404 // var field = fieldName && _.find(fields, 'id', fieldName);
405 // return field && field.multi;
406 }
407
408 /**
409 * Render value input as a textfield, option list, entityRef, or hidden,
410 * Depending on selected param name and operator
411 */
412 function renderValueField() {
413 var $row = $(this).closest('tr'),
414 name = $('input.api-param-name', $row).val(),
415 operator = $('.api-param-op', $row).val(),
416 $valField = $('input.api-param-value', $row),
417 multiSelect = isMultiSelect(name, operator),
418 currentVal = $valField.val(),
419 fieldSpec = getField(name),
420 wasSelect = $valField.data('select2');
421 if (wasSelect) {
422 $valField.crmEntityRef('destroy');
423 }
424 $valField.attr('placeholder', ts('Value'));
425 // Boolean fields only have 1 possible value
426 if (_.includes(BOOL, operator)) {
427 $valField.css('visibility', 'hidden').val('1');
428 return;
429 }
430 $valField.css('visibility', '');
431 // Option list or entityRef input
432 if (isSelect(name, operator)) {
433 $valField.attr('placeholder', ts('- select -'));
434 // Reset value before switching to a select from something else
435 if ($(this).is('.api-param-name') || !wasSelect) {
436 $valField.val('');
437 }
438 // When switching from multi-select to single select
439 else if (!multiSelect && _.includes(currentVal, ',')) {
440 $valField.val(currentVal.split(',')[0]);
441 }
442 // Yes-No options
443 if (isYesNo(name)) {
444 $valField.select2({
445 data: [{id: 1, text: ts('Yes')}, {id: 0, text: ts('No')}]
446 });
447 }
448 // Select options
449 else if (fieldSpec.options) {
450 $valField.select2({
451 multiple: multiSelect,
452 data: _.map(fieldSpec.options, function (value, key) {
453 return {id: key, text: value};
454 })
455 });
456 }
457 // EntityRef
458 else {
459 var entity = fieldSpec.FKApiName;
460 $valField.attr('placeholder', entity == 'Contact' ? '[' + ts('Auto-Select Current User') + ']' : ts('- select -'));
461 $valField.crmEntityRef({
462 entity: entity,
463 select: {
464 multiple: multiSelect,
465 minimumInputLength: _.includes(OPEN_IMMEDIATELY, entity) ? 0 : 1,
466 // If user types a numeric id, allow it as a choice
467 createSearchChoice: function(input) {
468 var match = /[1-9][0-9]*/.exec(input);
469 if (match && match[0] === input) {
470 return {id: input, label: input};
471 }
472 }
473 }
474 });
475 }
476 }
477 }
478
479 /**
480 * Attempt to parse a string into a value of the intended type
481 * @param val string
482 * @param makeArray bool
483 */
484 function evaluate(val, makeArray) {
485 try {
486 if (!val.length) {
487 return makeArray ? [] : '';
488 }
489 var first = val.charAt(0),
490 last = val.slice(-1);
491 // Simple types
492 if (val === 'true' || val === 'false' || val === 'null') {
493 /* jshint evil: true */
494 return eval(val);
495 }
496 // Quoted strings
497 if ((first === '"' || first === "'") && last === first) {
498 return val.slice(1, -1);
499 }
500 // Parse json - use eval rather than $.parseJSON because it's less strict about formatting
501 if ((first === '[' && last === ']') || (first === '{' && last === '}')) {
502 return eval('(' + val + ')');
503 }
504 // Transform csv to array
505 if (makeArray) {
506 var result = [];
507 $.each(val.split(','), function(k, v) {
508 result.push(evaluate($.trim(v)) || v);
509 });
510 return result;
511 }
512 // Integers - skip any multidigit number that starts with 0 to avoid oddities (it will be treated as a string below)
513 if (!isNaN(val) && val.search(/[^\d]/) < 0 && (val.length === 1 || first !== '0')) {
514 return parseInt(val, 10);
515 }
516 // Ok ok it's really a string
517 return val;
518 } catch(e) {
519 // If eval crashed return undefined
520 return undefined;
521 }
522 }
523
524 /**
525 * Format value to look like php code
526 * TODO: Use short array syntax when we drop support for php 5.3
527 * @param val
528 */
529 function phpFormat(val) {
530 var ret = '';
531 if ($.isPlainObject(val)) {
532 $.each(val, function(k, v) {
533 ret += (ret ? ', ' : '') + "'" + k + "' => " + phpFormat(v);
534 });
535 return 'array(' + ret + ')';
536 }
537 if ($.isArray(val)) {
538 $.each(val, function(k, v) {
539 ret += (ret ? ', ' : '') + phpFormat(v);
540 });
541 return 'array(' + ret + ')';
542 }
543 return JSON.stringify(val).replace(/\$/g, '\\$');
544 }
545
546 /**
547 * @param value string
548 * @param js string
549 * @param key string
550 */
551 function smartyFormat(value, js, key) {
552 var varName = 'param_' + key.replace(/[. -]/g, '_').toLowerCase();
553 // Can't pass array literals directly into smarty so we add a php snippet
554 if (_.includes(js, '[') || _.includes(js, '{')) {
555 smartyPhp.push('$this->assign("'+ varName + '", '+ phpFormat(value) +');');
556 return '$' + varName;
557 }
558 return js;
559 }
560
561 /**
562 * Create the params array from user input
563 * @param e
564 */
565 function buildParams(e) {
566 params = {};
567 $('.api-param-checkbox:checked').each(function() {
568 params[this.name] = 1;
569 });
570 $('input.api-param-value, input.api-option-value').each(function() {
571 var $row = $(this).closest('tr'),
572 input = $(this).val(),
573 op = $('select.api-param-op', $row).val() || '=',
574 name = $('input.api-param-name', $row).val(),
575 // Workaround for ambiguity of the = operator
576 makeArray = (op === '=' && isSelect(name, op)) ? _.includes(input, ',') : op !== '=' && isMultiSelect(name, op),
577 val = evaluate(input, makeArray);
578
579 // Ignore blank values for the return field
580 if ($(this).is('#api-return-value') && !val) {
581 return;
582 }
583 // Special syntax for api chaining
584 if (!name && $('select.api-chain-entity', $row).val()) {
585 name = 'api.' + $('select.api-chain-entity', $row).val() + '.' + $('select.api-chain-action', $row).val();
586 }
587 // Special handling for options
588 if ($(this).is('.api-option-value')) {
589 op = $('input.api-option-name', $row).val();
590 if (op) {
591 name = 'options';
592 }
593 }
594 // Default for contact ref fields
595 if ($(this).is('.crm-contact-ref') && input === '') {
596 val = evaluate('user_contact_id', makeArray);
597 }
598 if (name && val !== undefined) {
599 params[name] = op === '=' ? val : (params[name] || {});
600 if (op !== '=') {
601 params[name][op] = val;
602 }
603 if ($(this).hasClass('crm-error')) {
604 clearError(this);
605 }
606 }
607 else if (name && (!e || e.type !== 'keyup')) {
608 setError(this);
609 }
610 });
611 if (entity && action) {
612 formatQuery();
613 }
614 }
615
616 /**
617 * Display error message on incorrectly-formatted params
618 * @param el
619 */
620 function setError(el) {
621 if (!$(el).hasClass('crm-error')) {
622 var msg = ts('Syntax error: input should be valid JSON or a quoted string.');
623 $(el)
624 .addClass('crm-error')
625 .css('width', '82%')
626 .attr('title', msg)
627 .before('<i class="crm-i fa-exclamation-triangle crm-i-red" title="'+msg+'"></i> ')
628 .tooltip();
629 }
630 }
631
632 /**
633 * Remove error message
634 * @param el
635 */
636 function clearError(el) {
637 $(el)
638 .removeClass('crm-error')
639 .attr('title', '')
640 .css('width', '85%')
641 .tooltip('destroy')
642 .siblings('.fa-exclamation-triangle').remove();
643 }
644
645 /**
646 * Render the api request in various formats
647 */
648 function formatQuery() {
649 var i = 0, q = {
650 smarty: "{crmAPI var='result' entity='" + entity + "' action='" + action + "'" + (params.sequential ? '' : ' sequential=0'),
651 php: "$result = civicrm_api3('" + entity + "', '" + action + "'",
652 json: "CRM.api3('" + entity + "', '" + action + "'",
653 drush: "drush cvapi " + entity + '.' + action + ' ',
654 wpcli: "wp cv api " + entity + '.' + action + ' ',
655 rest: CRM.config.resourceBase + "extern/rest.php?entity=" + entity + "&action=" + action + "&api_key=userkey&key=sitekey&json=" + JSON.stringify(params)
656 };
657 smartyPhp = [];
658 $.each(params, function(key, value) {
659 var json = JSON.stringify(value),
660 // Encourage 'return' to be an array - at least in php & js
661 js = key === 'return' ? JSON.stringify(evaluate(value, true)) : json,
662 php = key === 'return' ? phpFormat(evaluate(value, true)) : phpFormat(value);
663 if (!(i++)) {
664 q.php += ", array(\n";
665 q.json += ", {\n";
666 } else {
667 q.json += ",\n";
668 }
669 q.php += " '" + key + "' => " + php + ",\n";
670 q.json += " \"" + key + '": ' + js;
671 // smarty already defaults to sequential
672 if (key !== 'sequential') {
673 q.smarty += ' ' + key + '=' + smartyFormat(value, json, key);
674 }
675 // FIXME: This is not totally correct cli syntax
676 q.drush += key + '=' + json + ' ';
677 q.wpcli += key + '=' + json + ' ';
678 });
679 if (i) {
680 q.php += ")";
681 q.json += "\n}";
682 }
683 q.php += ");";
684 q.json += ").done(function(result) {\n // do something\n});";
685 q.smarty += "}\n{foreach from=$result.values item=" + entity.toLowerCase() + "}\n {$" + entity.toLowerCase() + ".some_field}\n{/foreach}";
686 if (!_.includes(action, 'get')) {
687 q.smarty = '{* Smarty API only works with get actions *}';
688 } else if (smartyPhp.length) {
689 q.smarty = "{php}\n " + smartyPhp.join("\n ") + "\n{/php}\n" + q.smarty;
690 }
691 $.each(q, function(type, val) {
692 $('#api-' + type).text(val);
693 });
694 prettyPrint('#api-generated pre');
695 }
696
697 /**
698 * Submit button handler
699 * @param e
700 */
701 function submit(e) {
702 e.preventDefault();
703 if (!entity || !action) {
704 alert(ts('Select an entity.'));
705 return;
706 }
707 if (!_.includes(action, 'get') && action != 'check') {
708 var msg = action === 'delete' ? ts('This will delete data from CiviCRM. Are you sure?') : ts('This will write to the database. Continue?');
709 CRM.confirm({title: ts('Confirm %1', {1: action}), message: msg}).on('crmConfirm:yes', execute);
710 } else {
711 execute();
712 }
713 }
714
715 /**
716 * Execute api call and display the results
717 * Note: We have to manually execute the ajax in order to add the secret extra "prettyprint" param
718 */
719 function execute() {
720 var footer;
721 $('#api-result').html('<div class="crm-loading-element"></div>');
722 $.ajax({
723 url: CRM.url('civicrm/ajax/rest'),
724 data: {
725 entity: entity,
726 action: action,
727 prettyprint: 1,
728 json: JSON.stringify(params)
729 },
730 type: _.includes(action, 'get') ? 'GET' : 'POST',
731 dataType: 'text'
732 }).done(function(text) {
733 // There may be debug information appended to the end of the json string
734 var footerPos = text.indexOf("\n}<");
735 if (footerPos) {
736 footer = text.substr(footerPos + 2);
737 text = text.substr(0, footerPos + 2);
738 }
739 $('#api-result').text(text);
740 prettyPrint('#api-result');
741 if (footer) {
742 $('#api-result').append(footer);
743 }
744 });
745 }
746
747 /**
748 * Fetch list of example files for a given entity
749 */
750 function getExamples() {
751 CRM.utils.setOptions($('#example-action').prop('disabled', true).addClass('loading'), []);
752 $.getJSON(CRM.url('civicrm/ajax/apiexample', {entity: $(this).val()}))
753 .done(function(result) {
754 CRM.utils.setOptions($('#example-action').prop('disabled', false).removeClass('loading'), result);
755 });
756 }
757
758 /**
759 * Fetch and display an example file
760 */
761 function getExample() {
762 var
763 entity = $('#example-entity').val(),
764 action = $('#example-action').val();
765 if (entity && action) {
766 $('#example-result').block();
767 $.get(CRM.url('civicrm/ajax/apiexample', {file: entity + '/' + action}))
768 .done(function(result) {
769 $('#example-result').unblock().text(result);
770 prettyPrint('#example-result');
771 });
772 } else {
773 $('#example-result').text($('#example-result').attr('placeholder'));
774 }
775 }
776
777 /**
778 * Fetch entity docs & actions
779 */
780 function getDocEntity() {
781 CRM.utils.setOptions($('#doc-action').prop('disabled', true).addClass('loading'), []);
782 $.getJSON(CRM.url('civicrm/ajax/apidoc', {entity: $(this).val()}))
783 .done(function(result) {
784 entityDoc = result.doc;
785 CRM.utils.setOptions($('#doc-action').prop('disabled', false).removeClass('loading'), result.actions);
786 $('#doc-result').html(result.doc);
787 prettyPrint('#doc-result pre');
788 });
789 }
790
791 /**
792 * Fetch entity+action docs & code
793 */
794 function getDocAction() {
795 var
796 entity = $('#doc-entity').val(),
797 action = $('#doc-action').val();
798 if (entity && action) {
799 $('#doc-result').block();
800 $.get(CRM.url('civicrm/ajax/apidoc', {entity: entity, action: action}))
801 .done(function(result) {
802 $('#doc-result').unblock().html(result.doc);
803 if (result.code) {
804 $('#doc-result').append(docCodeTpl(result));
805 }
806 prettyPrint('#doc-result pre');
807 });
808 } else {
809 $('#doc-result').html(entityDoc);
810 prettyPrint('#doc-result pre');
811 }
812 checkBookKeepingEntity(entity, action);
813 }
814
815 /**
816 * Check if entity is Financial Trxn and Entity Financial Trxn
817 * and Action is Create, delete, update etc then display warning
818 */
819 function checkBookKeepingEntity(entity, action) {
820 if ($.inArray(entity, ['EntityFinancialTrxn', 'FinancialTrxn']) > -1 && $.inArray(action, ['delete', 'setvalue', 'replace', 'create']) > -1) {
821 var msg = ts('Given the importance of auditability, extension developers are strongly discouraged from writing code to add, update or delete entries in the civicrm_financial_item, civicrm_entity_financial_trxn, and civicrm_financial_trxn tables. Before publishing an extension on civicrm.org that does any of this, please ask for a special bookkeeping code review for the extension.');
822 CRM.alert(msg, 'warning');
823 }
824 }
825
826 /**
827 * Renders nested checkboxes for adding joins to an api.get call
828 */
829 function renderJoinSelector() {
830 $('#api-join').hide();
831 if (!_.includes(NO_JOINS, entity) && _.includes(['get', 'getsingle'], action)) {
832 var joinable = {};
833 (function recurse(fields, joinable, prefix, depth, entities) {
834 _.each(fields, function(field) {
835 var entity = field.FKApiName;
836 if (entity && field.FKClassName) {
837 var name = prefix + field.name;
838 joinable[name] = {
839 title: field.title,
840 entity: entity,
841 checked: !!joins[name]
842 };
843 // Expose further joins if we are not over the limit or recursing onto the same entity multiple times
844 if (joins[name] && depth < CRM.vars.explorer.max_joins && !_.countBy(entities)[entity]) {
845 joinable[name].children = {};
846 recurse(getFieldsCache[entity+'get'].values, joinable[name].children, name + '.', depth+1, entities.concat(entity));
847 }
848 }
849 });
850 })(getFieldData, joinable, '', 1, [entity]);
851 if (!_.isEmpty(joinable)) {
852 // Send joinTpl as a param so it can recursively call itself to render children
853 $('#api-join').show().children('div').html(joinTpl({joins: joinable, tpl: joinTpl}));
854 }
855 }
856 }
857
858 /**
859 * When adding or removing a join from an api.get call
860 */
861 function onSelectJoin() {
862 var name = $(this).val(),
863 ent = $(this).data('entity');
864 fields = [];
865 $('input', '#api-join').prop('disabled', true);
866 if ($(this).is(':checked')) {
867 joins[name] = ent;
868 $('input.api-param-name, #api-return-value').addClass('loading');
869 getMetadata(ent, 'get').done(function() {
870 renderJoinSelector();
871 populateFields(fields, entity, action, '');
872 $('input.api-param-name, #api-return-value').removeClass('loading');
873 });
874 } else {
875 joins = _.omit(joins, function(entity, n) {
876 return n.indexOf(name) === 0;
877 });
878 renderJoinSelector();
879 populateFields(fields, entity, action, '');
880 }
881 }
882
883 $(document).ready(function() {
884 // Set up tabs - bind active tab to document hash because... it's cool?
885 document.location.hash = document.location.hash || 'explorer';
886 $('#mainTabContainer')
887 .tabs({
888 active: $(document.location.hash + '-tab').index() - 1
889 })
890 .on('tabsactivate', function(e, ui) {
891 if (ui.newPanel) {
892 document.location.hash = ui.newPanel.attr('id').replace('-tab', '');
893 }
894 });
895 $(window).on('hashchange', function() {
896 $('#mainTabContainer').tabs('option', 'active', $(document.location.hash + '-tab').index() - 1);
897 });
898
899 // Initialize widgets
900 $('#api-entity, #example-entity, #doc-entity').crmSelect2({
901 // Add strikethough class to selection to indicate deprecated apis
902 formatSelection: function(option) {
903 return $(option.element).hasClass('strikethrough') ? '<span class="strikethrough">' + option.text + '</span>' : option.text;
904 }
905 });
906 $('form#api-explorer')
907 .on('change', '#api-entity, #api-action', function() {
908 entity = $('#api-entity').val();
909 action = $('#api-action').val();
910 joins = {};
911 if ($(this).is('#api-entity')) {
912 $('#api-action').addClass('loading');
913 }
914 $('#api-params').html('<tr><td colspan="4" class="crm-loading-element"></td></tr>');
915 $('#api-params-table thead').show();
916 onChangeEntityOrAction(this);
917 buildParams();
918 checkBookKeepingEntity(entity, action);
919 })
920 .on('change keyup', 'input.api-input, #api-params select', buildParams)
921 .on('submit', submit);
922
923 $('#api-params')
924 .on('change', 'input.api-param-name, select.api-param-op', renderValueField)
925 .on('select2-selecting', 'input.api-param-name, .api-option-name, #api-return-value', function(e) {
926 if (e.val === '-') {
927 $(this).one('change', function() {
928 $(this)
929 .crmSelect2('destroy')
930 .val('')
931 .focus();
932 });
933 }
934 })
935 .on('click', '.api-param-remove', function(e) {
936 e.preventDefault();
937 $(this).closest('tr').remove();
938 buildParams();
939 })
940 .on('change', 'select.api-chain-entity', getChainedAction);
941 $('#api-join').on('change', 'input', onSelectJoin);
942 $('#example-entity').on('change', getExamples);
943 $('#example-action').on('change', getExample);
944 $('#doc-entity').on('change', getDocEntity);
945 $('#doc-action').on('change', getDocAction);
946 $('#api-params-add').on('click', function(e) {
947 e.preventDefault();
948 addField();
949 });
950 $('#api-option-add').on('click', function(e) {
951 e.preventDefault();
952 addOptionField();
953 });
954 $('#api-chain-add').on('click', function(e) {
955 e.preventDefault();
956 addChainField();
957 });
958 populateActions();
959 });
960 }(CRM.$, CRM._));