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