APIv4 Explorer - Support SQL functions
[civicrm-core.git] / ang / api4Explorer / Explorer.js
1 (function(angular, $, _, undefined) {
2
3 // Schema metadata
4 var schema = CRM.vars.api4.schema;
5 // FK schema data
6 var links = CRM.vars.api4.links;
7 // Cache list of entities
8 var entities = [];
9 // Cache list of actions
10 var actions = [];
11 // Field options
12 var fieldOptions = {};
13
14
15 angular.module('api4Explorer').config(function($routeProvider) {
16 $routeProvider.when('/explorer/:api4entity?/:api4action?', {
17 controller: 'Api4Explorer',
18 templateUrl: '~/api4Explorer/Explorer.html',
19 reloadOnSearch: false
20 });
21 });
22
23 angular.module('api4Explorer').controller('Api4Explorer', function($scope, $routeParams, $location, $timeout, $http, crmUiHelp, crmApi4, dialogService) {
24 var ts = $scope.ts = CRM.ts();
25 $scope.entities = entities;
26 $scope.actions = actions;
27 $scope.fields = [];
28 $scope.fieldsAndJoins = [];
29 $scope.fieldsAndJoinsAndFunctions = [];
30 $scope.fieldsAndJoinsAndFunctionsAndWildcards = [];
31 $scope.availableParams = {};
32 $scope.params = {};
33 $scope.index = '';
34 $scope.selectedTab = {result: 'result', code: 'php'};
35 $scope.perm = {
36 accessDebugOutput: CRM.checkPerm('access debug output'),
37 editGroups: CRM.checkPerm('edit groups')
38 };
39 marked.setOptions({highlight: prettyPrintOne});
40 var getMetaParams = {},
41 objectParams = {orderBy: 'ASC', values: '', defaults: '', chain: ['Entity', '', '{}']},
42 docs = CRM.vars.api4.docs,
43 helpTitle = '',
44 helpContent = {};
45 $scope.helpTitle = '';
46 $scope.helpContent = {};
47 $scope.entity = $routeParams.api4entity;
48 $scope.result = [];
49 $scope.debug = null;
50 $scope.status = 'default';
51 $scope.loading = false;
52 $scope.controls = {};
53 $scope.code = [
54 {
55 lang: 'php',
56 style: [
57 {name: 'oop', label: ts('OOP Style'), code: ''},
58 {name: 'php', label: ts('Traditional'), code: ''}
59 ]
60 },
61 {
62 lang: 'js',
63 style: [
64 {name: 'js', label: ts('Single Call'), code: ''},
65 {name: 'js2', label: ts('Batch Calls'), code: ''}
66 ]
67 },
68 {
69 lang: 'ang',
70 style: [
71 {name: 'ang', label: ts('Single Call'), code: ''},
72 {name: 'ang2', label: ts('Batch Calls'), code: ''}
73 ]
74 },
75 {
76 lang: 'cli',
77 style: [
78 {name: 'cv', label: ts('CV'), code: ''}
79 ]
80 },
81 ];
82
83 if (!entities.length) {
84 formatForSelect2(schema, entities, 'name', ['description']);
85 }
86
87 $scope.$bindToRoute({
88 expr: 'index',
89 param: 'index',
90 default: ''
91 });
92
93 function ucfirst(str) {
94 return str[0].toUpperCase() + str.slice(1);
95 }
96
97 function lcfirst(str) {
98 return str[0].toLowerCase() + str.slice(1);
99 }
100
101 function pluralize(str) {
102 switch (str[str.length-1]) {
103 case 's':
104 return str + 'es';
105 case 'y':
106 return str.slice(0, -1) + 'ies';
107 default:
108 return str + 's';
109 }
110 }
111
112 // Turn a flat array into a select2 array
113 function arrayToSelect2(array) {
114 var out = [];
115 _.each(array, function(item) {
116 out.push({id: item, text: item});
117 });
118 return out;
119 }
120
121 // Reformat an existing array of objects for compatibility with select2
122 function formatForSelect2(input, container, key, extra, prefix) {
123 _.each(input, function(item) {
124 var id = (prefix || '') + item[key];
125 var formatted = {id: id, text: id};
126 if (extra) {
127 _.merge(formatted, _.pick(item, extra));
128 }
129 container.push(formatted);
130 });
131 return container;
132 }
133
134 function getFieldList(action) {
135 var fields = [],
136 fieldInfo = _.findWhere(getEntity().actions, {name: action}).fields;
137 formatForSelect2(fieldInfo, fields, 'name', ['description', 'required', 'default_value']);
138 return fields;
139 }
140
141 function addJoins(fieldList, addWildcard) {
142 var fields = _.cloneDeep(fieldList),
143 fks = _.findWhere(links, {entity: $scope.entity}) || {};
144 _.each(fks.links, function(link) {
145 var linkFields = _.cloneDeep(entityFields(link.entity)),
146 wildCard = addWildcard ? [{id: link.alias + '.*', text: link.alias + '.*', 'description': 'All core ' + link.entity + ' fields'}] : [];
147 if (linkFields) {
148 fields.push({
149 text: link.alias,
150 description: 'Join to ' + link.entity,
151 children: wildCard.concat(formatForSelect2(linkFields, [], 'name', ['description'], link.alias + '.'))
152 });
153 }
154 });
155 return fields;
156 }
157
158 $scope.help = function(title, content) {
159 if (!content) {
160 $scope.helpTitle = helpTitle;
161 $scope.helpContent = helpContent;
162 } else {
163 $scope.helpTitle = title;
164 $scope.helpContent = formatHelp(content);
165 }
166 };
167
168 // Sets the static help text (which gets overridden by mousing over other elements)
169 function setHelp(title, content) {
170 $scope.helpTitle = helpTitle = title;
171 $scope.helpContent = helpContent = formatHelp(content);
172 }
173
174 // Convert plain-text help to markdown; replace variables and format links
175 function formatHelp(rawContent) {
176 function formatRefs(see) {
177 _.each(see, function(ref, idx) {
178 var match = ref.match(/^\\Civi\\Api4\\([a-zA-Z]+)$/);
179 if (match) {
180 ref = '#/explorer/' + match[1];
181 }
182 if (ref[0] === '\\') {
183 ref = 'https://github.com/civicrm/civicrm-core/blob/master' + ref.replace(/\\/i, '/') + '.php';
184 }
185 see[idx] = '<a target="' + (ref[0] === '#' ? '_self' : '_blank') + '" href="' + ref + '">' + see[idx] + '</a>';
186 });
187 }
188 var formatted = _.cloneDeep(rawContent);
189 if (formatted.description) {
190 formatted.description = marked(formatted.description);
191 }
192 if (formatted.comment) {
193 formatted.comment = marked(formatted.comment);
194 }
195 formatRefs(formatted.see);
196 return formatted;
197 }
198
199 $scope.fieldHelp = function(fieldName) {
200 var field = getField(fieldName, $scope.entity, $scope.action);
201 if (!field) {
202 return;
203 }
204 var info = {
205 description: field.description,
206 type: field.data_type
207 };
208 if (field.default_value) {
209 info.default = field.default_value;
210 }
211 if (field.required_if) {
212 info.required_if = field.required_if;
213 } else if (field.required) {
214 info.required = 'true';
215 }
216 return info;
217 };
218
219 $scope.fieldList = function(param) {
220 return function() {
221 var fields = _.cloneDeep($scope.action === 'getFields' ? getFieldList($scope.params.action || 'get') : $scope.fields);
222 // Disable fields that are already in use
223 _.each($scope.params[param] || [], function(val) {
224 (_.findWhere(fields, {id: val[0]}) || {}).disabled = true;
225 });
226 return {results: fields};
227 };
228 };
229
230 $scope.formatSelect2Item = function(row) {
231 return _.escape(row.text) +
232 (row.required ? '<span class="crm-marker"> *</span>' : '') +
233 (row.description ? '<div class="crm-select2-row-description"><p>' + _.escape(row.description) + '</p></div>' : '');
234 };
235
236 $scope.clearParam = function(name, idx) {
237 if (typeof idx === 'undefined') {
238 $scope.params[name] = $scope.availableParams[name].default;
239 } else {
240 $scope.params[name].splice(idx, 1);
241 }
242 };
243
244 $scope.isSpecial = function(name) {
245 var specialParams = ['select', 'fields', 'action', 'where', 'values', 'defaults', 'orderBy', 'chain', 'groupBy'];
246 if ($scope.availableParams.limit && $scope.availableParams.offset) {
247 specialParams.push('limit', 'offset');
248 }
249 return _.contains(specialParams, name);
250 };
251
252 $scope.selectRowCount = function() {
253 if ($scope.isSelectRowCount()) {
254 $scope.params.select = [];
255 } else {
256 $scope.params.select = ['row_count'];
257 $scope.index = '';
258 if ($scope.params.limit == 25) {
259 $scope.params.limit = 0;
260 }
261 }
262 };
263
264 $scope.isSelectRowCount = function() {
265 return isSelectRowCount($scope.params);
266 };
267
268 function isSelectRowCount(params) {
269 return params && params.select && params.select.length === 1 && params.select[0] === 'row_count';
270 }
271
272 function getEntity(entityName) {
273 return _.findWhere(schema, {name: entityName || $scope.entity});
274 }
275
276 // Get all params that have been set
277 function getParams() {
278 var params = {};
279 _.each($scope.params, function(param, key) {
280 if (param != $scope.availableParams[key].default && !(typeof param === 'object' && _.isEmpty(param))) {
281 if (_.contains($scope.availableParams[key].type, 'array') && (typeof objectParams[key] === 'undefined')) {
282 params[key] = parseYaml(JSON.parse(angular.toJson(param)));
283 } else {
284 params[key] = param;
285 }
286 }
287 });
288 _.each(objectParams, function(defaultVal, key) {
289 if (params[key]) {
290 var newParam = {};
291 _.each(params[key], function(item) {
292 var val = _.cloneDeep(item[1]);
293 // Remove blank items from "chain" array
294 if (_.isArray(val)) {
295 _.eachRight(item[1], function(v, k) {
296 if (v) {
297 return false;
298 }
299 val.length--;
300 });
301 }
302 newParam[item[0]] = parseYaml(val);
303 });
304 params[key] = newParam;
305 }
306 });
307 return params;
308 }
309
310 function parseYaml(input) {
311 if (typeof input === 'undefined') {
312 return undefined;
313 }
314 if (input === '') {
315 return '';
316 }
317 if (_.isObject(input) || _.isArray(input)) {
318 _.each(input, function(item, index) {
319 input[index] = parseYaml(item);
320 });
321 return input;
322 }
323 try {
324 var output = (input === '>') ? '>' : jsyaml.safeLoad(input);
325 // We don't want dates parsed to js objects
326 return _.isDate(output) ? input : output;
327 } catch (e) {
328 return input;
329 }
330 }
331
332 function selectAction() {
333 $scope.action = $routeParams.api4action;
334 $scope.fieldsAndJoins.length = 0;
335 $scope.fieldsAndJoinsAndFunctions.length = 0;
336 $scope.fieldsAndJoinsAndFunctionsAndWildcards.length = 0;
337 if (!actions.length) {
338 formatForSelect2(getEntity().actions, actions, 'name', ['description', 'params']);
339 }
340 if ($scope.action) {
341 var actionInfo = _.findWhere(actions, {id: $scope.action});
342 $scope.fields = getFieldList($scope.action);
343 if (_.contains(['get', 'update', 'delete', 'replace'], $scope.action)) {
344 $scope.fieldsAndJoins = addJoins($scope.fields);
345 var fieldsAndFunctions = _.cloneDeep($scope.fields);
346 // SQL functions are supported if HAVING is
347 if (actionInfo.params.having) {
348 fieldsAndFunctions.push({
349 text: ts('FUNCTION'),
350 description: ts('Calculate result of a SQL function'),
351 children: _.transform(CRM.vars.api4.functions, function(result, fn) {
352 result.push({
353 id: fn.name + '() AS ' + fn.name.toLowerCase(),
354 text: fn.name + '()',
355 description: fn.name + '(' + describeSqlFn(fn.params) + ')'
356 });
357 })
358 });
359 }
360 $scope.fieldsAndJoinsAndFunctions = addJoins(fieldsAndFunctions, true);
361 $scope.fieldsAndJoinsAndFunctionsAndWildcards = addJoins(fieldsAndFunctions, true);
362 } else {
363 $scope.fieldsAndJoins = $scope.fields;
364 $scope.fieldsAndJoinsAndFunctions = $scope.fields;
365 $scope.fieldsAndJoinsAndFunctionsAndWildcards = _.cloneDeep($scope.fields);
366 }
367 $scope.fieldsAndJoinsAndFunctionsAndWildcards.unshift({id: '*', text: '*', 'description': 'All core ' + $scope.entity + ' fields'});
368 _.each(actionInfo.params, function (param, name) {
369 var format,
370 defaultVal = _.cloneDeep(param.default);
371 if (param.type) {
372 switch (param.type[0]) {
373 case 'int':
374 case 'bool':
375 format = param.type[0];
376 break;
377
378 case 'array':
379 case 'object':
380 format = 'json';
381 break;
382
383 default:
384 format = 'raw';
385 }
386 if (name === 'limit') {
387 defaultVal = 25;
388 }
389 if (name === 'debug') {
390 defaultVal = true;
391 }
392 if (name === 'values') {
393 defaultVal = defaultValues(defaultVal);
394 }
395 $scope.$bindToRoute({
396 expr: 'params["' + name + '"]',
397 param: name,
398 format: format,
399 default: defaultVal,
400 deep: format === 'json'
401 });
402 }
403 if (typeof objectParams[name] !== 'undefined' && name !== 'orderBy') {
404 $scope.$watch('params.' + name, function (values) {
405 // Remove empty values
406 _.each(values, function (clause, index) {
407 if (!clause || !clause[0]) {
408 $scope.clearParam(name, index);
409 }
410 });
411 }, true);
412 }
413 if (typeof objectParams[name] !== 'undefined' || name === 'groupBy' || name === 'select') {
414 $scope.$watch('controls.' + name, function(value) {
415 var field = value;
416 $timeout(function() {
417 if (field) {
418 if (typeof objectParams[name] === 'undefined') {
419 $scope.params[name].push(field);
420 } else {
421 var defaultOp = _.cloneDeep(objectParams[name]);
422 if (name === 'chain') {
423 var num = $scope.params.chain.length;
424 defaultOp[0] = field;
425 field = 'name_me_' + num;
426 }
427 $scope.params[name].push([field, defaultOp]);
428 }
429 $scope.controls[name] = null;
430 }
431 });
432 });
433 }
434 });
435 $scope.availableParams = actionInfo.params;
436 }
437 writeCode();
438 }
439
440 function describeSqlFn(params) {
441 var desc = ' ';
442 _.each(params, function(param) {
443 desc += ' ';
444 if (param.prefix) {
445 desc += _.filter(param.prefix).join('|') + ' ';
446 }
447 if (param.expr === 1) {
448 desc += 'expr ';
449 } else if (param.expr > 1) {
450 desc += 'expr, ... ';
451 }
452 if (param.suffix) {
453 desc += ' ' + _.filter(param.suffix).join('|') + ' ';
454 }
455 });
456 return desc.replace(/[ ]+/g, ' ');
457 }
458
459 function defaultValues(defaultVal) {
460 _.each($scope.fields, function(field) {
461 if (field.required) {
462 defaultVal.push([field.id, '']);
463 }
464 });
465 return defaultVal;
466 }
467
468 function stringify(value, trim) {
469 if (typeof value === 'undefined') {
470 return '';
471 }
472 var str = JSON.stringify(value).replace(/,/g, ', ');
473 if (trim) {
474 str = str.slice(1, -1);
475 }
476 return str.trim();
477 }
478
479 function writeCode() {
480 var code = {},
481 entity = $scope.entity,
482 action = $scope.action,
483 params = getParams(),
484 index = isInt($scope.index) ? +$scope.index : parseYaml($scope.index),
485 result = 'result';
486 if ($scope.entity && $scope.action) {
487 delete params.debug;
488 if (action.slice(0, 3) === 'get') {
489 result = entity.substr(0, 7) === 'Custom_' ? _.camelCase(entity.substr(7)) : entity;
490 result = lcfirst(action.replace(/s$/, '').slice(3) || result);
491 }
492 var results = lcfirst(_.isNumber(index) ? result : pluralize(result)),
493 paramCount = _.size(params),
494 i = 0;
495
496 if (isSelectRowCount(params)) {
497 results = result + 'Count';
498 }
499
500 // Write javascript
501 var js = "'" + entity + "', '" + action + "', {";
502 _.each(params, function(param, key) {
503 js += "\n " + key + ': ' + stringify(param) +
504 (++i < paramCount ? ',' : '');
505 if (key === 'checkPermissions') {
506 js += ' // IGNORED: permissions are always enforced from client-side requests';
507 }
508 });
509 js += "\n}";
510 if (index || index === 0) {
511 js += ', ' + JSON.stringify(index);
512 }
513 code.js = "CRM.api4(" + js + ").then(function(" + results + ") {\n // do something with " + results + " array\n}, function(failure) {\n // handle failure\n});";
514 code.js2 = "CRM.api4({" + results + ': [' + js + "]}).then(function(batch) {\n // do something with batch." + results + " array\n}, function(failure) {\n // handle failure\n});";
515 code.ang = "crmApi4(" + js + ").then(function(" + results + ") {\n // do something with " + results + " array\n}, function(failure) {\n // handle failure\n});";
516 code.ang2 = "crmApi4({" + results + ': [' + js + "]}).then(function(batch) {\n // do something with batch." + results + " array\n}, function(failure) {\n // handle failure\n});";
517
518 // Write php code
519 code.php = '$' + results + " = civicrm_api4('" + entity + "', '" + action + "', [";
520 _.each(params, function(param, key) {
521 code.php += "\n '" + key + "' => " + phpFormat(param, 4) + ',';
522 });
523 code.php += "\n]";
524 if (index || index === 0) {
525 code.php += ', ' + phpFormat(index);
526 }
527 code.php += ");";
528
529 // Write oop code
530 code.oop = '$' + results + " = " + formatOOP(entity, action, params, 2) + "\n ->execute()";
531 if (isSelectRowCount(params)) {
532 code.oop += "\n ->count()";
533 } else if (_.isNumber(index)) {
534 code.oop += !index ? '\n ->first()' : (index === -1 ? '\n ->last()' : '\n ->itemAt(' + index + ')');
535 } else if (index) {
536 if (_.isString(index) || (_.isPlainObject(index) && !index[0] && !index['0'])) {
537 code.oop += "\n ->indexBy('" + (_.isPlainObject(index) ? _.keys(index)[0] : index) + "')";
538 }
539 if (_.isArray(index) || _.isPlainObject(index)) {
540 code.oop += "\n ->column('" + (_.isArray(index) ? index[0] : _.values(index)[0]) + "')";
541 }
542 }
543 code.oop += ";\n";
544 if (!_.isNumber(index) && !isSelectRowCount(params)) {
545 code.oop += "foreach ($" + results + ' as $' + ((_.isString(index) && index) ? index + ' => $' : '') + result + ') {\n // do something\n}';
546 }
547
548 // Write cli code
549 code.cv = 'cv api4 ' + entity + '.' + action + " '" + stringify(params) + "'";
550 }
551 _.each($scope.code, function(vals) {
552 _.each(vals.style, function(style) {
553 style.code = code[style.name] ? prettyPrintOne(code[style.name]) : '';
554 });
555 });
556 }
557
558 // Format oop params
559 function formatOOP(entity, action, params, indent) {
560 var code = '',
561 newLine = "\n" + _.repeat(' ', indent);
562 if (entity.substr(0, 7) !== 'Custom_') {
563 code = "\\Civi\\Api4\\" + entity + '::' + action + '()';
564 } else {
565 code = "\\Civi\\Api4\\CustomValue::" + action + "('" + entity.substr(7) + "')";
566 }
567 _.each(params, function(param, key) {
568 var val = '';
569 if (typeof objectParams[key] !== 'undefined' && key !== 'chain') {
570 _.each(param, function(item, index) {
571 val = phpFormat(index) + ', ' + phpFormat(item, 2 + indent);
572 code += newLine + "->add" + ucfirst(key).replace(/s$/, '') + '(' + val + ')';
573 });
574 } else if (key === 'where') {
575 _.each(param, function (clause) {
576 if (clause[0] === 'AND' || clause[0] === 'OR' || clause[0] === 'NOT') {
577 code += newLine + "->addClause(" + phpFormat(clause[0]) + ", " + phpFormat(clause[1]).slice(1, -1) + ')';
578 } else {
579 code += newLine + "->addWhere(" + phpFormat(clause).slice(1, -1) + ")";
580 }
581 });
582 } else if (key === 'select') {
583 code += newLine;
584 // addSelect() is a variadic function & can take multiple arguments; selectRowCount() is a shortcut for addSelect('row_count')
585 code += isSelectRowCount(params) ? '->selectRowCount()' : '->addSelect(' + phpFormat(param).slice(1, -1) + ')';
586 } else if (key === 'chain') {
587 _.each(param, function(chain, name) {
588 code += newLine + "->addChain('" + name + "', " + formatOOP(chain[0], chain[1], chain[2], 2 + indent);
589 code += (chain.length > 3 ? ',' : '') + (!_.isEmpty(chain[2]) ? newLine : ' ') + (chain.length > 3 ? phpFormat(chain[3]) : '') + ')';
590 });
591 }
592 else {
593 code += newLine + "->set" + ucfirst(key) + '(' + phpFormat(param, 2 + indent) + ')';
594 }
595 });
596 return code;
597 }
598
599 function isInt(value) {
600 if (_.isFinite(value)) {
601 return true;
602 }
603 if (!_.isString(value)) {
604 return false;
605 }
606 return /^-{0,1}\d+$/.test(value);
607 }
608
609 function formatMeta(resp) {
610 var ret = '';
611 _.each(resp, function(val, key) {
612 if (key !== 'values' && !_.isPlainObject(val) && !_.isFunction(val)) {
613 ret += (ret.length ? ', ' : '') + key + ': ' + (_.isArray(val) ? '[' + val + ']' : val);
614 }
615 });
616 return prettyPrintOne(_.escape(ret));
617 }
618
619 $scope.execute = function() {
620 $scope.status = 'warning';
621 $scope.loading = true;
622 $http.post(CRM.url('civicrm/ajax/api4/' + $scope.entity + '/' + $scope.action, {
623 params: angular.toJson(getParams()),
624 index: isInt($scope.index) ? +$scope.index : parseYaml($scope.index)
625 }), null, {
626 headers: {
627 'X-Requested-With': 'XMLHttpRequest'
628 }
629 }).then(function(resp) {
630 $scope.loading = false;
631 $scope.status = 'success';
632 $scope.debug = debugFormat(resp.data);
633 $scope.result = [formatMeta(resp.data), prettyPrintOne(_.escape(JSON.stringify(resp.data.values, null, 2)), 'js', 1)];
634 }, function(resp) {
635 $scope.loading = false;
636 $scope.status = 'danger';
637 $scope.debug = debugFormat(resp.data);
638 $scope.result = [formatMeta(resp), prettyPrintOne(_.escape(JSON.stringify(resp.data, null, 2)))];
639 });
640 };
641
642 function debugFormat(data) {
643 var debug = data.debug ? prettyPrintOne(_.escape(JSON.stringify(data.debug, null, 2)).replace(/\\n/g, "\n")) : null;
644 delete data.debug;
645 return debug;
646 }
647
648 /**
649 * Format value to look like php code
650 */
651 function phpFormat(val, indent) {
652 if (typeof val === 'undefined') {
653 return '';
654 }
655 if (val === null || val === true || val === false) {
656 return JSON.stringify(val).toUpperCase();
657 }
658 indent = (typeof indent === 'number') ? _.repeat(' ', indent) : (indent || '');
659 var ret = '',
660 baseLine = indent ? indent.slice(0, -2) : '',
661 newLine = indent ? '\n' : '',
662 trailingComma = indent ? ',' : '';
663 if ($.isPlainObject(val)) {
664 $.each(val, function(k, v) {
665 ret += (ret ? ', ' : '') + newLine + indent + "'" + k + "' => " + phpFormat(v);
666 });
667 return '[' + ret + trailingComma + newLine + baseLine + ']';
668 }
669 if ($.isArray(val)) {
670 $.each(val, function(k, v) {
671 ret += (ret ? ', ' : '') + newLine + indent + phpFormat(v);
672 });
673 return '[' + ret + trailingComma + newLine + baseLine + ']';
674 }
675 if (_.isString(val) && !_.contains(val, "'")) {
676 return "'" + val + "'";
677 }
678 return JSON.stringify(val).replace(/\$/g, '\\$');
679 }
680
681 function fetchMeta() {
682 crmApi4(getMetaParams)
683 .then(function(data) {
684 if (data.actions) {
685 getEntity().actions = data.actions;
686 selectAction();
687 }
688 });
689 }
690
691 // Help for an entity with no action selected
692 function showEntityHelp(entityName) {
693 var entityInfo = getEntity(entityName);
694 setHelp($scope.entity, {
695 description: entityInfo.description,
696 comment: entityInfo.comment,
697 see: entityInfo.see
698 });
699 }
700
701 if (!$scope.entity) {
702 setHelp(ts('APIv4 Explorer'), {description: docs.description, comment: docs.comment, see: docs.see});
703 } else if (!actions.length && !getEntity().actions) {
704 getMetaParams.actions = [$scope.entity, 'getActions', {chain: {fields: [$scope.entity, 'getFields', {action: '$name'}]}}];
705 fetchMeta();
706 } else {
707 selectAction();
708 }
709
710 if ($scope.entity) {
711 showEntityHelp($scope.entity);
712 }
713
714 // Update route when changing entity
715 $scope.$watch('entity', function(newVal, oldVal) {
716 if (oldVal !== newVal) {
717 // Flush actions cache to re-fetch for new entity
718 actions = [];
719 $location.url('/explorer/' + newVal);
720 }
721 });
722
723 // Update route when changing actions
724 $scope.$watch('action', function(newVal, oldVal) {
725 if ($scope.entity && $routeParams.api4action !== newVal && !_.isUndefined(newVal)) {
726 $location.url('/explorer/' + $scope.entity + '/' + newVal);
727 } else if (newVal) {
728 setHelp($scope.entity + '::' + newVal, _.pick(_.findWhere(getEntity().actions, {name: newVal}), ['description', 'comment', 'see']));
729 }
730 });
731
732 $scope.paramDoc = function(name) {
733 return docs.params[name];
734 };
735
736 $scope.executeDoc = function() {
737 var doc = {
738 description: ts('Runs API call on the CiviCRM database.'),
739 comment: ts('Results and debugging info will be displayed below.')
740 };
741 if ($scope.action === 'delete') {
742 doc.WARNING = ts('This API call will be executed on the real database. Deleting data cannot be undone.');
743 }
744 else if ($scope.action && $scope.action.slice(0, 3) !== 'get') {
745 doc.WARNING = ts('This API call will be executed on the real database. It cannot be undone.');
746 }
747 return doc;
748 };
749
750 $scope.saveDoc = function() {
751 return {
752 description: ts('Save API call as a smart group.'),
753 comment: ts('Allows you to create a SavedSearch containing the WHERE clause of this API call.'),
754 };
755 };
756
757 $scope.$watch('params', writeCode, true);
758 $scope.$watch('index', writeCode);
759 writeCode();
760
761 $scope.save = function() {
762 var model = {
763 title: '',
764 description: '',
765 visibility: 'User and User Admin Only',
766 group_type: [],
767 id: null,
768 entity: $scope.entity,
769 params: JSON.parse(angular.toJson($scope.params))
770 };
771 model.params.version = 4;
772 delete model.params.select;
773 delete model.params.chain;
774 delete model.params.debug;
775 delete model.params.limit;
776 delete model.params.checkPermissions;
777 var options = CRM.utils.adjustDialogDefaults({
778 width: '500px',
779 autoOpen: false,
780 title: ts('Save smart group')
781 });
782 dialogService.open('saveSearchDialog', '~/api4Explorer/SaveSearch.html', model, options);
783 };
784 });
785
786 angular.module('api4Explorer').controller('SaveSearchCtrl', function($scope, crmApi4, dialogService) {
787 var ts = $scope.ts = CRM.ts(),
788 model = $scope.model;
789 $scope.groupEntityRefParams = {
790 entity: 'Group',
791 api: {
792 params: {is_hidden: 0, is_active: 1, 'saved_search_id.api_entity': model.entity},
793 extra: ['saved_search_id', 'description', 'visibility', 'group_type']
794 },
795 select: {
796 allowClear: true,
797 minimumInputLength: 0,
798 placeholder: ts('Select existing group')
799 }
800 };
801 if (!CRM.checkPerm('administer reserved groups')) {
802 $scope.groupEntityRefParams.api.params.is_reserved = 0;
803 }
804 $scope.perm = {
805 administerReservedGroups: CRM.checkPerm('administer reserved groups')
806 };
807 $scope.options = CRM.vars.api4.groupOptions;
808 $scope.$watch('model.id', function(id) {
809 if (id) {
810 _.assign(model, $('#api-save-search-select-group').select2('data').extra);
811 }
812 });
813 $scope.cancel = function() {
814 dialogService.cancel('saveSearchDialog');
815 };
816 $scope.save = function() {
817 $('.ui-dialog:visible').block();
818 var group = model.id ? {id: model.id} : {title: model.title};
819 group.description = model.description;
820 group.visibility = model.visibility;
821 group.group_type = model.group_type;
822 group.saved_search_id = '$id';
823 var savedSearch = {
824 api_entity: model.entity,
825 api_params: model.params
826 };
827 if (group.id) {
828 savedSearch.id = model.saved_search_id;
829 }
830 crmApi4('SavedSearch', 'save', {records: [savedSearch], chain: {group: ['Group', 'save', {'records': [group]}]}})
831 .then(function(result) {
832 dialogService.close('saveSearchDialog', result[0]);
833 });
834 };
835 });
836
837 angular.module('api4Explorer').directive('crmApi4Clause', function($timeout) {
838 return {
839 scope: {
840 data: '=crmApi4Clause'
841 },
842 templateUrl: '~/api4Explorer/Clause.html',
843 link: function (scope, element, attrs) {
844 var ts = scope.ts = CRM.ts();
845 scope.newClause = '';
846 scope.conjunctions = ['AND', 'OR', 'NOT'];
847 scope.operators = CRM.vars.api4.operators;
848
849 scope.addGroup = function(op) {
850 scope.data.clauses.push([op, []]);
851 };
852
853 scope.removeGroup = function() {
854 scope.data.groupParent.splice(scope.data.groupIndex, 1);
855 };
856
857 scope.onSort = function(event, ui) {
858 $(element).closest('.api4-clause-fieldset').toggleClass('api4-sorting', event.type === 'sortstart');
859 $('.api4-input.form-inline').css('margin-left', '');
860 };
861
862 // Indent clause while dragging between nested groups
863 scope.onSortOver = function(event, ui) {
864 var offset = 0;
865 if (ui.sender) {
866 offset = $(ui.placeholder).offset().left - $(ui.sender).offset().left;
867 }
868 $('.api4-input.form-inline.ui-sortable-helper').css('margin-left', '' + offset + 'px');
869 };
870
871 scope.$watch('newClause', function(value) {
872 var field = value;
873 $timeout(function() {
874 if (field) {
875 scope.data.clauses.push([field, '=', '']);
876 scope.newClause = null;
877 }
878 });
879 });
880 scope.$watch('data.clauses', function(values) {
881 // Remove empty values
882 _.each(values, function(clause, index) {
883 if (typeof clause !== 'undefined' && !clause[0]) {
884 values.splice(index, 1);
885 }
886 if (typeof clause[1] === 'string' && _.contains(clause[1], 'NULL')) {
887 clause.length = 2;
888 } else if (typeof clause[1] === 'string' && clause.length == 2) {
889 clause.push('');
890 }
891 });
892 }, true);
893 }
894 };
895 });
896
897 angular.module('api4Explorer').directive('api4ExpValue', function($routeParams, crmApi4) {
898 return {
899 scope: {
900 data: '=api4ExpValue'
901 },
902 require: 'ngModel',
903 link: function (scope, element, attrs, ctrl) {
904 var ts = scope.ts = CRM.ts(),
905 multi = _.includes(['IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN'], scope.data.op),
906 entity = $routeParams.api4entity,
907 action = scope.data.action || $routeParams.api4action;
908
909 function destroyWidget() {
910 var $el = $(element);
911 if ($el.is('.crm-form-date-wrapper .crm-hidden-date')) {
912 $el.crmDatepicker('destroy');
913 }
914 if ($el.is('.select2-container + input')) {
915 $el.crmEntityRef('destroy');
916 }
917 $(element).removeData().removeAttr('type').removeAttr('placeholder').show();
918 }
919
920 function makeWidget(field, op) {
921 var $el = $(element),
922 inputType = field.input_type,
923 dataType = field.data_type;
924 if (!op) {
925 op = field.serialize || dataType === 'Array' ? 'IN' : '=';
926 }
927 multi = _.includes(['IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN'], op);
928 if (op === 'IS NULL' || op === 'IS NOT NULL') {
929 $el.hide();
930 return;
931 }
932 if (inputType === 'Date') {
933 if (_.includes(['=', '!=', '<>', '<', '>=', '<', '<='], op)) {
934 $el.crmDatepicker({time: (field.input_attrs && field.input_attrs.time) || false});
935 }
936 } else if (_.includes(['=', '!=', '<>', 'IN', 'NOT IN'], op) && (field.fk_entity || field.options || dataType === 'Boolean')) {
937 if (field.fk_entity) {
938 $el.crmEntityRef({entity: field.fk_entity, select:{multiple: multi}});
939 } else if (field.options) {
940 $el.addClass('loading').attr('placeholder', ts('- select -')).crmSelect2({multiple: multi, data: [{id: '', text: ''}]});
941 loadFieldOptions(field.entity || entity).then(function(data) {
942 var options = [];
943 _.each(_.findWhere(data, {name: field.name}).options, function(val, key) {
944 options.push({id: key, text: val});
945 });
946 $el.removeClass('loading').select2({data: options, multiple: multi});
947 });
948 } else if (dataType === 'Boolean') {
949 $el.attr('placeholder', ts('- select -')).crmSelect2({allowClear: false, multiple: multi, placeholder: ts('- select -'), data: [
950 {id: 'true', text: ts('Yes')},
951 {id: 'false', text: ts('No')}
952 ]});
953 }
954 } else if (dataType === 'Integer' && !multi) {
955 $el.attr('type', 'number');
956 }
957 }
958
959 function loadFieldOptions(entity) {
960 if (!fieldOptions[entity + action]) {
961 fieldOptions[entity + action] = crmApi4(entity, 'getFields', {
962 loadOptions: true,
963 action: action,
964 where: [["options", "!=", false]],
965 select: ["name", "options"]
966 });
967 }
968 return fieldOptions[entity + action];
969 }
970
971 // Copied from ng-list but applied conditionally if field is multi-valued
972 var parseList = function(viewValue) {
973 // If the viewValue is invalid (say required but empty) it will be `undefined`
974 if (_.isUndefined(viewValue)) return;
975
976 if (!multi) {
977 return viewValue;
978 }
979
980 var list = [];
981
982 if (viewValue) {
983 _.each(viewValue.split(','), function(value) {
984 if (value) list.push(_.trim(value));
985 });
986 }
987
988 return list;
989 };
990
991 // Copied from ng-list
992 ctrl.$parsers.push(parseList);
993 ctrl.$formatters.push(function(value) {
994 return _.isArray(value) ? value.join(', ') : value;
995 });
996
997 // Copied from ng-list
998 ctrl.$isEmpty = function(value) {
999 return !value || !value.length;
1000 };
1001
1002 scope.$watchCollection('data', function(data) {
1003 destroyWidget();
1004 var field = getField(data.field, entity, action);
1005 if (field) {
1006 makeWidget(field, data.op);
1007 }
1008 });
1009 }
1010 };
1011 });
1012
1013
1014 angular.module('api4Explorer').directive('api4ExpChain', function(crmApi4) {
1015 return {
1016 scope: {
1017 chain: '=api4ExpChain',
1018 mainEntity: '=',
1019 entities: '='
1020 },
1021 templateUrl: '~/api4Explorer/Chain.html',
1022 link: function (scope, element, attrs) {
1023 var ts = scope.ts = CRM.ts();
1024
1025 function changeEntity(newEntity, oldEntity) {
1026 // When clearing entity remove this chain
1027 if (!newEntity) {
1028 scope.chain[0] = '';
1029 return;
1030 }
1031 // Reset action && index
1032 if (newEntity !== oldEntity) {
1033 scope.chain[1][1] = scope.chain[1][2] = '';
1034 }
1035 if (getEntity(newEntity).actions) {
1036 setActions();
1037 } else {
1038 crmApi4(newEntity, 'getActions', {chain: {fields: [newEntity, 'getFields', {action: '$name'}]}})
1039 .then(function(data) {
1040 getEntity(data.entity).actions = data;
1041 if (data.entity === scope.chain[1][0]) {
1042 setActions();
1043 }
1044 });
1045 }
1046 }
1047
1048 function setActions() {
1049 scope.actions = [''].concat(_.pluck(getEntity(scope.chain[1][0]).actions, 'name'));
1050 }
1051
1052 // Set default params when choosing action
1053 function changeAction(newAction, oldAction) {
1054 var link;
1055 // Prepopulate links
1056 if (newAction && newAction !== oldAction) {
1057 // Clear index
1058 scope.chain[1][3] = '';
1059 // Look for links back to main entity
1060 _.each(entityFields(scope.chain[1][0]), function(field) {
1061 if (field.fk_entity === scope.mainEntity) {
1062 link = [field.name, '$id'];
1063 }
1064 });
1065 // Look for links from main entity
1066 if (!link && newAction !== 'create') {
1067 _.each(entityFields(scope.mainEntity), function(field) {
1068 if (field.fk_entity === scope.chain[1][0]) {
1069 link = ['id', '$' + field.name];
1070 // Since we're specifying the id, set index to getsingle
1071 scope.chain[1][3] = '0';
1072 }
1073 });
1074 }
1075 if (link && _.contains(['get', 'update', 'replace', 'delete'], newAction)) {
1076 scope.chain[1][2] = '{where: [[' + link[0] + ', =, ' + link[1] + ']]}';
1077 }
1078 else if (link && _.contains(['create'], newAction)) {
1079 scope.chain[1][2] = '{values: {' + link[0] + ': ' + link[1] + '}}';
1080 }
1081 else if (link && _.contains(['save'], newAction)) {
1082 scope.chain[1][2] = '{records: [{' + link[0] + ': ' + link[1] + '}]}';
1083 } else {
1084 scope.chain[1][2] = '{}';
1085 }
1086 }
1087 }
1088
1089 scope.$watch("chain[1][0]", changeEntity);
1090 scope.$watch("chain[1][1]", changeAction);
1091 }
1092 };
1093 });
1094
1095 function getEntity(entityName) {
1096 return _.findWhere(schema, {name: entityName});
1097 }
1098
1099 function entityFields(entityName, action) {
1100 var entity = getEntity(entityName);
1101 if (entity && action && entity.actions) {
1102 return _.findWhere(entity.actions, {name: action}).fields;
1103 }
1104 return _.result(entity, 'fields');
1105 }
1106
1107 function getField(fieldName, entity, action) {
1108 var fieldNames = fieldName.split('.');
1109 return get(entity, fieldNames);
1110
1111 function get(entity, fieldNames) {
1112 if (fieldNames.length === 1) {
1113 return _.findWhere(entityFields(entity, action), {name: fieldNames[0]});
1114 }
1115 var comboName = _.findWhere(entityFields(entity, action), {name: fieldNames[0] + '.' + fieldNames[1]});
1116 if (comboName) {
1117 return comboName;
1118 }
1119 var linkName = fieldNames.shift(),
1120 entityLinks = _.findWhere(links, {entity: entity}).links,
1121 newEntity = _.findWhere(entityLinks, {alias: linkName}).entity;
1122 return get(newEntity, fieldNames);
1123 }
1124 }
1125
1126 // Collapsible optgroups for select2
1127 $(function() {
1128 $('body')
1129 .on('select2-open', function(e) {
1130 if ($(e.target).hasClass('collapsible-optgroups')) {
1131 $('#select2-drop')
1132 .off('.collapseOptionGroup')
1133 .addClass('collapsible-optgroups-enabled')
1134 .on('click.collapseOptionGroup', '.select2-result-with-children > .select2-result-label', function() {
1135 $(this).parent().toggleClass('optgroup-expanded');
1136 });
1137 }
1138 })
1139 .on('select2-close', function() {
1140 $('#select2-drop').off('.collapseOptionGroup').removeClass('collapsible-optgroups-enabled');
1141 });
1142 });
1143 })(angular, CRM.$, CRM._);