Api explorer - limit multiselect to certain actions
[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 actions = {values: ['get']},
8 fields = [],
9 getFieldData = {},
10 options = {},
11 params = {},
12 smartyStub,
13 entityDoc,
14 fieldTpl = _.template($('#api-param-tpl').html()),
15 optionsTpl = _.template($('#api-options-tpl').html()),
16 returnTpl = _.template($('#api-return-tpl').html()),
17 chainTpl = _.template($('#api-chain-tpl').html()),
18 docCodeTpl = _.template($('#doc-code-tpl').html()),
19
20 // These types of entityRef don't require any input to open
21 OPEN_IMMEDIATELY = ['RelationshipType', 'Event', 'Group', 'Tag'],
22
23 // Actions that don't support fancy operators
24 NO_OPERATORS = ['create', 'update', 'delete', 'setvalue', 'getoptions', 'getactions', 'getfields'],
25
26 // Actions that don't support multiple values
27 NO_MULTI = ['delete', 'getoptions', 'getactions', 'getfields'],
28
29 // Operators with special properties
30 BOOL = ['IS NULL', 'IS NOT NULL'],
31 TEXT = ['LIKE', 'NOT LIKE'],
32 MULTI = ['IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN'];
33
34 /**
35 * Call prettyPrint function and perform additional formatting
36 * @param ele
37 */
38 function prettyPrint(ele) {
39 if (typeof window.prettyPrint === 'function') {
40 $(ele).removeClass('prettyprinted').addClass('prettyprint');
41
42 window.prettyPrint();
43
44 // Highlight errors in api result
45 $('span:contains("error_code"),span:contains("error_message")', '#api-result')
46 .siblings('span.str').css('color', '#B00');
47 }
48 }
49
50 /**
51 * Add a "fields" row
52 * @param name
53 */
54 function addField(name) {
55 $('#api-params').append($(fieldTpl({name: name || '', noOps: _.includes(NO_OPERATORS, action)})));
56 var $row = $('tr:last-child', '#api-params');
57 $('input.api-param-name', $row).crmSelect2({
58 data: fields.concat({id: '-', text: ts('Other') + '...', description: ts('Choose a field not in this list')}),
59 formatResult: function(field) {
60 return field.text + '<div class="api-field-desc">' + field.description + '</div>';
61 }
62 }).change();
63 }
64
65 /**
66 * Add a new "options" row
67 */
68 function addOptionField() {
69 if ($('.api-options-row', '#api-params').length) {
70 $('.api-options-row:last', '#api-params').after($(optionsTpl({})));
71 } else {
72 $('#api-params').append($(optionsTpl({})));
73 }
74 var $row = $('.api-options-row:last', '#api-params');
75 $('.api-option-name', $row).crmSelect2({data: [
76 {id: 'limit', text: 'limit'},
77 {id: 'offset', text: 'offset'},
78 {id: 'sort', text: 'sort'},
79 {id: 'metadata', text: 'metadata'},
80 {id: '-', text: ts('Other') + '...'}
81 ]});
82 }
83
84 /**
85 * Add an "api chain" row
86 */
87 function addChainField() {
88 $('#api-params').append($(chainTpl({})));
89 var $row = $('tr:last-child', '#api-params');
90 $('.api-chain-entity', $row).crmSelect2({
91 formatSelection: function(item) {
92 return '<span class="icon ui-icon-link"></span> API ' +
93 ($(item.element).hasClass('strikethrough') ? '<span class="strikethrough">' + item.text + '</span>' : item.text);
94 },
95 placeholder: '<span class="icon ui-icon-link"></span> ' + ts('Entity'),
96 escapeMarkup: function(m) {return m;}
97 });
98 }
99
100 /**
101 * Fetch available actions for selected chained entity
102 */
103 function getChainedAction() {
104 var
105 $selector = $(this),
106 entity = $selector.val(),
107 $row = $selector.closest('tr');
108 if (entity) {
109 $selector.prop('disabled', true);
110 CRM.api3(entity, 'getactions')
111 .done(function(actions) {
112 $selector.prop('disabled', false);
113 CRM.utils.setOptions($('.api-chain-action', $row), _.transform(actions.values, function(ret, item) {ret.push({value: item, key: item});}));
114 });
115 }
116 }
117
118 /**
119 * Fetch fields for entity+action
120 */
121 function getFields(changedElement) {
122 var required = [];
123 fields = [];
124 options = getFieldData = {};
125 // Special case for getfields
126 if (action === 'getfields') {
127 fields.push({
128 id: 'api_action',
129 text: 'Action'
130 });
131 options.api_action = _.reduce(actions.values, function(ret, item) {
132 ret[item] = item;
133 return ret;
134 }, {});
135 showFields(['api_action']);
136 return;
137 }
138 CRM.api3(entity, 'getFields', {'api_action': action, options: {get_options: 'all'}}).done(function(data) {
139 getFieldData = data.values;
140 _.each(data.values, function(field) {
141 if (field.name) {
142 fields.push({
143 id: field.name,
144 text: field.title || field.name,
145 multi: !!field['api.multiple'],
146 description: field.description || '',
147 required: !(!field['api.required'] || field['api.required'] === '0')
148 });
149 if (field['api.required'] && field['api.required'] !== '0') {
150 required.push(field.name);
151 }
152 if (field.options) {
153 options[field.name] = field.options;
154 }
155 }
156 });
157 if ($(changedElement).is('#api-entity') && data.deprecated) {
158 CRM.alert(data.deprecated, entity + ' Deprecated');
159 }
160 showFields(required);
161 if (action === 'get' || action === 'getsingle' || action === 'getstat') {
162 showReturn(action === 'getstat' ? ts('Group by') : ts('Fields to return'));
163 }
164 });
165 }
166
167 /**
168 * For "get" actions show the "return" options
169 */
170 function showReturn(title) {
171 $('#api-params').prepend($(returnTpl({title: title})));
172 $('#api-return-value').crmSelect2({data: fields, multiple: true});
173 }
174
175 /**
176 * Fetch actions for entity
177 */
178 function getActions() {
179 if (entity) {
180 $('#api-action').addClass('loading');
181 CRM.api3(entity, 'getactions').done(function(data) {
182 actions = data;
183 populateActions();
184 });
185 } else {
186 actions = {values: ['get']};
187 populateActions();
188 }
189 }
190
191 /**
192 * Test whether an action is deprecated
193 * @param action
194 * @returns {boolean}
195 */
196 function isActionDeprecated(action) {
197 return !!(typeof actions.deprecated === 'object' && actions.deprecated[action]);
198 }
199
200 /**
201 * Render action text depending on deprecation status
202 * @param option
203 * @returns {string}
204 */
205 function renderAction(option) {
206 return isActionDeprecated(option.id) ? '<span class="strikethrough">' + option.text + '</span>' : option.text;
207 }
208
209 /**
210 * Called after getActions to populate action list
211 */
212 function populateActions() {
213 var val = $('#api-action').val();
214 $('#api-action').removeClass('loading').select2({
215 data: _.transform(actions.values, function(ret, item) {ret.push({text: item, id: item});}),
216 formatSelection: renderAction,
217 formatResult: renderAction
218 });
219 // If previously selected action is not available, set it to 'get' if possible
220 if (!_.includes(actions.values, val)) {
221 $('#api-action').select2('val', !_.includes(actions.values, 'get') ? actions.values[0] : 'get', true);
222 }
223 }
224
225 /**
226 * Check for and display action-specific deprecation notices
227 * @param action
228 */
229 function onChangeAction(action) {
230 if (isActionDeprecated(action)) {
231 CRM.alert(actions.deprecated[action], action + ' deprecated');
232 }
233 }
234
235 /**
236 * Called after getfields to show buttons and required fields
237 * @param required
238 */
239 function showFields(required) {
240 $('#api-params').empty();
241 $('#api-param-buttons').show();
242 if (required.length) {
243 _.each(required, addField);
244 } else {
245 addField();
246 }
247 }
248
249 function isYesNo(fieldName) {
250 return getFieldData[fieldName] && getFieldData[fieldName].type === 16;
251 }
252
253 /**
254 * Should we render a select or textfield?
255 *
256 * @param fieldName
257 * @param operator
258 * @returns boolean
259 */
260 function isSelect(fieldName, operator) {
261 return (isYesNo(fieldName) || options[fieldName] || (getFieldData[fieldName] && getFieldData[fieldName].FKApiName)) && !_.includes(TEXT, operator);
262 }
263
264 /**
265 * Should we render a select as single or multi?
266 *
267 * @param fieldName
268 * @param operator
269 * @returns boolean
270 */
271 function isMultiSelect(fieldName, operator) {
272 if (isYesNo(fieldName) || _.includes(NO_MULTI, action)) {
273 return false;
274 }
275 if (_.includes(MULTI, operator)) {
276 return true;
277 }
278 // The = operator is ambiguous but all others can be safely assumed to be single
279 if (operator !== '=') {
280 return false;
281 }
282 return true;
283 /*
284 * Attempt to resolve the ambiguity of the = operator using metadata
285 * commented out because there is not enough metadata in the api at this time
286 * to accurately figure it out.
287 */
288 // var field = fieldName && _.find(fields, 'id', fieldName);
289 // return field && field.multi;
290 }
291
292 /**
293 * Render value input as a textfield, option list, entityRef, or hidden,
294 * Depending on selected param name and operator
295 */
296 function renderValueField() {
297 var $row = $(this).closest('tr'),
298 name = $('input.api-param-name', $row).val(),
299 operator = $('.api-param-op', $row).val(),
300 $valField = $('input.api-param-value', $row),
301 multiSelect = isMultiSelect(name, operator),
302 currentVal = $valField.val();
303 // Boolean fields only have 1 possible value
304 if (_.includes(BOOL, operator)) {
305 if ($valField.data('select2')) {
306 $valField.select2('destroy');
307 }
308 $valField.css('visibility', 'hidden').val('1');
309 return;
310 }
311 $valField.css('visibility', '');
312 // Option list or entityRef input
313 if (isSelect(name, operator)) {
314 // Reset value before switching to a select from something else
315 if ($(this).is('.api-param-name') || !$valField.data('select2')) {
316 $valField.val('');
317 }
318 // When switching from multi-select to single select
319 else if (!multiSelect && _.includes(currentVal, ',')) {
320 $valField.val(currentVal.split(',')[0]);
321 }
322 // Yes-No options
323 if (isYesNo(name)) {
324 $valField.select2({
325 data: [{id: 1, text: ts('Yes')}, {id: 0, text: ts('No')}]
326 });
327 }
328 // Select options
329 else if (options[name]) {
330 $valField.select2({
331 multiple: multiSelect,
332 data: _.map(options[name], function (value, key) {
333 return {id: key, text: value};
334 })
335 });
336 }
337 // EntityRef
338 else {
339 $valField.crmEntityRef({
340 entity: getFieldData[name].FKApiName,
341 select: {
342 multiple: multiSelect,
343 minimumInputLength: _.includes(OPEN_IMMEDIATELY, getFieldData[name].FKApiName) ? 0 : 1
344 }
345 });
346 }
347 return;
348 }
349 // Plain text input
350 if ($valField.data('select2')) {
351 $valField.select2('destroy');
352 }
353 }
354
355 /**
356 * Attempt to parse a string into a value of the intended type
357 * @param val string
358 * @param makeArray bool
359 */
360 function evaluate(val, makeArray) {
361 try {
362 if (!val.length) {
363 return makeArray ? [] : '';
364 }
365 var first = val.charAt(0),
366 last = val.slice(-1);
367 // Simple types
368 if (val === 'true' || val === 'false' || val === 'null') {
369 /* jshint evil: true */
370 return eval(val);
371 }
372 // Quoted strings
373 if ((first === '"' || first === "'") && last === first) {
374 return val.slice(1, -1);
375 }
376 // Parse json - use eval rather than $.parseJSON because it's less strict about formatting
377 if ((first === '[' && last === ']') || (first === '{' && last === '}')) {
378 return eval('(' + val + ')');
379 }
380 // Transform csv to array
381 if (makeArray) {
382 var result = [];
383 $.each(val.split(','), function(k, v) {
384 result.push(evaluate($.trim(v)) || v);
385 });
386 return result;
387 }
388 // Integers - skip any multidigit number that starts with 0 to avoid oddities (it will be treated as a string below)
389 if (!isNaN(val) && val.search(/[^\d]/) < 0 && (val.length === 1 || first !== '0')) {
390 return parseInt(val, 10);
391 }
392 // Ok ok it's really a string
393 return val;
394 } catch(e) {
395 // If eval crashed return undefined
396 return undefined;
397 }
398 }
399
400 /**
401 * Format value to look like php code
402 * @param val
403 */
404 function phpFormat(val) {
405 var ret = '';
406 if ($.isPlainObject(val)) {
407 $.each(val, function(k, v) {
408 ret += (ret ? ', ' : '') + "'" + k + "' => " + phpFormat(v);
409 });
410 return 'array(' + ret + ')';
411 }
412 if ($.isArray(val)) {
413 $.each(val, function(k, v) {
414 ret += (ret ? ', ' : '') + phpFormat(v);
415 });
416 return 'array(' + ret + ')';
417 }
418 return JSON.stringify(val).replace(/\$/g, '\\$');
419 }
420
421 /**
422 * Smarty doesn't support array literals so we provide a stub
423 * @param js string
424 * @param key string
425 */
426 function smartyFormat(js, key) {
427 if (_.includes(js, '[') || _.includes(js, '{')) {
428 smartyStub = true;
429 return '$' + key.replace(/[. -]/g, '_');
430 }
431 return js;
432 }
433
434 /**
435 * Create the params array from user input
436 * @param e
437 */
438 function buildParams(e) {
439 params = {};
440 $('.api-param-checkbox:checked').each(function() {
441 params[this.name] = 1;
442 });
443 $('input.api-param-value, input.api-option-value').each(function() {
444 var $row = $(this).closest('tr'),
445 input = $(this).val(),
446 op = $('select.api-param-op', $row).val() || '=',
447 name = $('input.api-param-name', $row).val(),
448 // Workaround for ambiguity of the = operator
449 makeArray = (op === '=' && isSelect(name, op)) ? _.includes(input, ',') : op !== '=' && isMultiSelect(name, op),
450 val = evaluate(input, makeArray);
451
452 // Ignore blank values for the return field
453 if ($(this).is('#api-return-value') && !val) {
454 return;
455 }
456 // Special syntax for api chaining
457 if (!name && $('select.api-chain-entity', $row).val()) {
458 name = 'api.' + $('select.api-chain-entity', $row).val() + '.' + $('select.api-chain-action', $row).val();
459 }
460 // Special handling for options
461 if ($(this).is('.api-option-value')) {
462 op = $('input.api-option-name', $row).val();
463 if (op) {
464 name = 'options';
465 }
466 }
467 if (name && val !== undefined) {
468 params[name] = op === '=' ? val : (params[name] || {});
469 if (op !== '=') {
470 params[name][op] = val;
471 }
472 if ($(this).hasClass('crm-error')) {
473 clearError(this);
474 }
475 }
476 else if (name && (!e || e.type !== 'keyup')) {
477 setError(this);
478 }
479 });
480 if (entity && action) {
481 formatQuery();
482 }
483 }
484
485 /**
486 * Display error message on incorrectly-formatted params
487 * @param el
488 */
489 function setError(el) {
490 if (!$(el).hasClass('crm-error')) {
491 var msg = ts('Syntax error: input should be valid JSON or a quoted string.');
492 $(el)
493 .addClass('crm-error')
494 .css('width', '82%')
495 .attr('title', msg)
496 .before('<div class="icon red-icon ui-icon-alert" title="'+msg+'"/>')
497 .tooltip();
498 }
499 }
500
501 /**
502 * Remove error message
503 * @param el
504 */
505 function clearError(el) {
506 $(el)
507 .removeClass('crm-error')
508 .attr('title', '')
509 .css('width', '85%')
510 .tooltip('destroy')
511 .siblings('.ui-icon-alert').remove();
512 }
513
514 /**
515 * Render the api request in various formats
516 */
517 function formatQuery() {
518 var i = 0, q = {
519 smarty: "{crmAPI var='result' entity='" + entity + "' action='" + action + "'",
520 php: "$result = civicrm_api3('" + entity + "', '" + action + "'",
521 json: "CRM.api3('" + entity + "', '" + action + "'",
522 drush: "drush cvapi " + entity + '.' + action + ' ',
523 wpcli: "wp cv api " + entity + '.' + action + ' ',
524 rest: CRM.config.resourceBase + "extern/rest.php?entity=" + entity + "&action=" + action + "&json=" + JSON.stringify(params) + "&api_key=yourkey&key=sitekey"
525 };
526 smartyStub = false;
527 $.each(params, function(key, value) {
528 var js = JSON.stringify(value);
529 if (!(i++)) {
530 q.php += ", array(\n";
531 q.json += ", {\n";
532 } else {
533 q.json += ",\n";
534 }
535 q.php += " '" + key + "' => " + phpFormat(value) + ",\n";
536 q.json += " \"" + key + '": ' + js;
537 q.smarty += ' ' + key + '=' + smartyFormat(js, key);
538 // FIXME: This is not totally correct cli syntax
539 q.drush += key + '=' + js + ' ';
540 q.wpcli += key + '=' + js + ' ';
541 });
542 if (i) {
543 q.php += ")";
544 q.json += "\n}";
545 }
546 q.php += ");";
547 q.json += ").done(function(result) {\n // do something\n});";
548 q.smarty += "}\n{foreach from=$result.values item=" + entity.toLowerCase() + "}\n {$" + entity.toLowerCase() + ".some_field}\n{/foreach}";
549 if (!_.includes(action, 'get')) {
550 q.smarty = '{* Smarty API only works with get actions *}';
551 } else if (smartyStub) {
552 q.smarty = "{* Smarty does not have a syntax for array literals; assign complex variables from php *}\n" + q.smarty;
553 }
554 $.each(q, function(type, val) {
555 $('#api-' + type).text(val);
556 });
557 prettyPrint('#api-generated pre');
558 }
559
560 /**
561 * Submit button handler
562 * @param e
563 */
564 function submit(e) {
565 e.preventDefault();
566 if (!entity || !action) {
567 alert(ts('Select an entity.'));
568 return;
569 }
570 if (!_.includes(action, 'get') && action != 'check') {
571 var msg = action === 'delete' ? ts('This will delete data from CiviCRM. Are you sure?') : ts('This will write to the database. Continue?');
572 CRM.confirm({title: ts('Confirm %1', {1: action}), message: msg}).on('crmConfirm:yes', execute);
573 } else {
574 execute();
575 }
576 }
577
578 /**
579 * Execute api call and display the results
580 * Note: We have to manually execute the ajax in order to add the secret extra "prettyprint" param
581 */
582 function execute() {
583 $('#api-result').html('<div class="crm-loading-element"></div>');
584 $.ajax({
585 url: CRM.url('civicrm/ajax/rest'),
586 data: {
587 entity: entity,
588 action: action,
589 prettyprint: 1,
590 json: JSON.stringify(params)
591 },
592 type: _.includes(action, 'get') ? 'GET' : 'POST',
593 dataType: 'text'
594 }).done(function(text) {
595 $('#api-result').text(text);
596 prettyPrint('#api-result');
597 });
598 }
599
600 /**
601 * Fetch list of example files for a given entity
602 */
603 function getExamples() {
604 CRM.utils.setOptions($('#example-action').prop('disabled', true).addClass('loading'), []);
605 $.getJSON(CRM.url('civicrm/ajax/apiexample', {entity: $(this).val()}))
606 .done(function(result) {
607 CRM.utils.setOptions($('#example-action').prop('disabled', false).removeClass('loading'), result);
608 });
609 }
610
611 /**
612 * Fetch and display an example file
613 */
614 function getExample() {
615 var
616 entity = $('#example-entity').val(),
617 action = $('#example-action').val();
618 if (entity && action) {
619 $('#example-result').block();
620 $.get(CRM.url('civicrm/ajax/apiexample', {file: entity + '/' + action}))
621 .done(function(result) {
622 $('#example-result').unblock().text(result);
623 prettyPrint('#example-result');
624 });
625 } else {
626 $('#example-result').text($('#example-result').attr('placeholder'));
627 }
628 }
629
630 /**
631 * Fetch entity docs & actions
632 */
633 function getDocEntity() {
634 CRM.utils.setOptions($('#doc-action').prop('disabled', true).addClass('loading'), []);
635 $.getJSON(CRM.url('civicrm/ajax/apidoc', {entity: $(this).val()}))
636 .done(function(result) {
637 entityDoc = result.doc;
638 CRM.utils.setOptions($('#doc-action').prop('disabled', false).removeClass('loading'), result.actions);
639 $('#doc-result').html(result.doc);
640 prettyPrint('#doc-result pre');
641 });
642 }
643
644 /**
645 * Fetch entity+action docs & code
646 */
647 function getDocAction() {
648 var
649 entity = $('#doc-entity').val(),
650 action = $('#doc-action').val();
651 if (entity && action) {
652 $('#doc-result').block();
653 $.get(CRM.url('civicrm/ajax/apidoc', {entity: entity, action: action}))
654 .done(function(result) {
655 $('#doc-result').unblock().html(result.doc);
656 if (result.code) {
657 $('#doc-result').append(docCodeTpl(result));
658 }
659 prettyPrint('#doc-result pre');
660 });
661 } else {
662 $('#doc-result').html(entityDoc);
663 prettyPrint('#doc-result pre');
664 }
665 }
666
667 $(document).ready(function() {
668 // Set up tabs - bind active tab to document hash because... it's cool?
669 document.location.hash = document.location.hash || 'explorer';
670 $('#mainTabContainer')
671 .tabs({
672 active: $(document.location.hash + '-tab').index() - 1
673 })
674 .on('tabsactivate', function(e, ui) {
675 if (ui.newPanel) {
676 document.location.hash = ui.newPanel.attr('id').replace('-tab', '');
677 }
678 });
679 $(window).on('hashchange', function() {
680 $('#mainTabContainer').tabs('option', 'active', $(document.location.hash + '-tab').index() - 1);
681 });
682
683 // Initialize widgets
684 $('#api-entity, #example-entity, #doc-entity').crmSelect2({
685 // Add strikethough class to selection to indicate deprecated apis
686 formatSelection: function(option) {
687 return $(option.element).hasClass('strikethrough') ? '<span class="strikethrough">' + option.text + '</span>' : option.text;
688 }
689 });
690 $('form#api-explorer')
691 .on('change', '#api-entity, #api-action', function() {
692 entity = $('#api-entity').val();
693 action = $('#api-action').val();
694 if ($(this).is('#api-entity')) {
695 getActions();
696 } else {
697 onChangeAction(action);
698 }
699 if (entity && action) {
700 $('#api-params').html('<tr><td colspan="4" class="crm-loading-element"></td></tr>');
701 $('#api-params-table thead').show();
702 getFields(this);
703 buildParams();
704 } else {
705 $('#api-params, #api-generated pre').empty();
706 $('#api-param-buttons, #api-params-table thead').hide();
707 }
708 })
709 .on('change keyup', 'input.api-input, #api-params select', buildParams)
710 .on('submit', submit);
711 $('#api-params')
712 .on('change', 'input.api-param-name, select.api-param-op', renderValueField)
713 .on('change', 'input.api-param-name, .api-option-name', function() {
714 if ($(this).val() === '-' && $(this).data('select2')) {
715 $(this).select2('destroy');
716 $(this).val('').focus();
717 }
718 })
719 .on('click', '.api-param-remove', function(e) {
720 e.preventDefault();
721 $(this).closest('tr').remove();
722 buildParams();
723 })
724 .on('change', 'select.api-chain-entity', getChainedAction);
725 $('#example-entity').on('change', getExamples);
726 $('#example-action').on('change', getExample);
727 $('#doc-entity').on('change', getDocEntity);
728 $('#doc-action').on('change', getDocAction);
729 $('#api-params-add').on('click', function(e) {
730 e.preventDefault();
731 addField();
732 });
733 $('#api-option-add').on('click', function(e) {
734 e.preventDefault();
735 addOptionField();
736 });
737 $('#api-chain-add').on('click', function(e) {
738 e.preventDefault();
739 addChainField();
740 });
741 $('#api-entity').change();
742 });
743 }(CRM.$, CRM._));