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