1 (function(angular
, $, _
, undefined) {
4 var schema
= CRM
.vars
.api4
.schema
;
6 var links
= CRM
.vars
.api4
.links
;
7 // Cache list of entities
9 // Cache list of actions
12 var fieldOptions
= {};
17 angular
.module('api4Explorer').config(function($routeProvider
) {
18 $routeProvider
.when('/explorer/:api4entity?/:api4action?', {
19 controller
: 'Api4Explorer',
20 templateUrl
: '~/api4Explorer/Explorer.html',
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
;
31 $scope
.havingOptions
= [];
32 $scope
.fieldsAndJoins
= [];
33 $scope
.fieldsAndJoinsAndFunctions
= [];
34 $scope
.fieldsAndJoinsAndFunctionsWithSuffixes
= [];
35 $scope
.fieldsAndJoinsAndFunctionsAndWildcards
= [];
36 $scope
.availableParams
= {};
37 params
= $scope
.params
= {};
39 $scope
.selectedTab
= {result
: 'result', code
: 'php'};
41 accessDebugOutput
: CRM
.checkPerm('access debug output'),
42 editGroups
: CRM
.checkPerm('edit groups')
44 marked
.setOptions({highlight
: prettyPrintOne
});
45 var getMetaParams
= {},
46 objectParams
= {orderBy
: 'ASC', values
: '', defaults
: '', chain
: ['Entity', '', '{}']},
47 docs
= CRM
.vars
.api4
.docs
,
50 $scope
.helpTitle
= '';
51 $scope
.helpContent
= {};
52 $scope
.entity
= $routeParams
.api4entity
;
55 $scope
.status
= 'default';
56 $scope
.loading
= false;
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'});
63 {name
: 'oop', label
: ts('OOP Style'), code
: ''},
64 {name
: 'php', label
: ts('Traditional'), code
: ''}
67 {name
: 'js', label
: ts('Single Call'), code
: ''},
68 {name
: 'js2', label
: ts('Batch Calls'), code
: ''}
71 {name
: 'ang', label
: ts('Single Call'), code
: ''},
72 {name
: 'ang2', label
: ts('Batch Calls'), code
: ''}
75 {name
: 'short', label
: ts('CV (short)'), code
: ''},
76 {name
: 'long', label
: ts('CV (long)'), code
: ''},
77 {name
: 'pipe', label
: ts('CV (pipe)'), code
: ''}
81 if (!entities
.length
) {
82 formatForSelect2(schema
, entities
, 'name', ['description', 'icon']);
91 function ucfirst(str
) {
92 return str
[0].toUpperCase() + str
.slice(1);
95 function lcfirst(str
) {
96 return str
[0].toLowerCase() + str
.slice(1);
99 function pluralize(str
) {
100 var lastLetter
= str
[str
.length
- 1],
101 lastTwo
= str
[str
.length
- 2] + lastLetter
;
102 if (lastLetter
=== 's' || lastLetter
=== 'x' || lastTwo
=== 'ch') {
105 if (lastLetter
=== 'y' && lastTwo
!== 'ey') {
106 return str
.slice(0, -1) + 'ies';
111 // Reformat an existing array of objects for compatibility with select2
112 function formatForSelect2(input
, container
, key
, extra
, prefix
) {
113 _
.each(input
, function(item
) {
114 var id
= (prefix
|| '') + item
[key
];
115 var formatted
= {id
: id
, text
: id
};
117 _
.merge(formatted
, _
.pick(item
, extra
));
119 container
.push(formatted
);
124 // Replaces contents of fieldList array with current fields formatted for select2
125 function getFieldList(fieldList
, action
, addPseudoconstant
) {
126 var fieldInfo
= _
.cloneDeep(_
.findWhere(getEntity().actions
, {name
: action
}).fields
);
127 fieldList
.length
= 0;
128 if (addPseudoconstant
) {
129 addPseudoconstants(fieldInfo
, addPseudoconstant
);
131 formatForSelect2(fieldInfo
, fieldList
, 'name', ['description', 'required', 'default_value']);
134 // Note: this function expects fieldList to be select2-formatted already
135 function addJoins(fieldList
, addWildcard
, addPseudoconstant
) {
136 // Add entities specified by the join param
137 _
.each(getExplicitJoins(), function(joinEntity
, joinAlias
) {
138 var wildCard
= addWildcard
? [{id
: joinAlias
+ '.*', text
: joinAlias
+ '.*', 'description': 'All core ' + joinEntity
+ ' fields'}] : [],
139 joinFields
= _
.cloneDeep(entityFields(joinEntity
));
141 if (addPseudoconstant
) {
142 addPseudoconstants(joinFields
, addPseudoconstant
);
145 text
: joinEntity
+ ' AS ' + joinAlias
,
146 description
: 'Explicit join to ' + joinEntity
,
147 children
: wildCard
.concat(formatForSelect2(joinFields
, [], 'name', ['description'], joinAlias
+ '.'))
151 // Add implicit joins based on schema links
152 _
.each(links
[$scope
.entity
], function(link
) {
153 var linkFields
= _
.cloneDeep(entityFields(link
.entity
)),
154 wildCard
= addWildcard
? [{id
: link
.alias
+ '.*', text
: link
.alias
+ '.*', 'description': 'All core ' + link
.entity
+ ' fields'}] : [];
156 if (addPseudoconstant
) {
157 addPseudoconstants(linkFields
, addPseudoconstant
);
161 description
: 'Implicit join to ' + link
.entity
,
162 children
: wildCard
.concat(formatForSelect2(linkFields
, [], 'name', ['description'], link
.alias
+ '.'))
168 // Note: this function transforms a raw list a-la getFields; not a select2-formatted list
169 function addPseudoconstants(fieldList
, toAdd
) {
170 var optionFields
= _
.filter(fieldList
, 'options');
171 _
.each(optionFields
, function(field
) {
172 var pos
= _
.findIndex(fieldList
, {name
: field
.name
}) + 1;
173 _
.each(toAdd
, function(suffix
) {
174 var newField
= _
.cloneDeep(field
);
175 newField
.name
+= ':' + suffix
;
176 fieldList
.splice(pos
, 0, newField
);
181 $scope
.help = function(title
, content
) {
183 $scope
.helpTitle
= helpTitle
;
184 $scope
.helpContent
= helpContent
;
186 $scope
.helpTitle
= title
;
187 $scope
.helpContent
= formatHelp(content
);
191 // Sets the static help text (which gets overridden by mousing over other elements)
192 function setHelp(title
, content
) {
193 $scope
.helpTitle
= helpTitle
= title
;
194 $scope
.helpContent
= helpContent
= formatHelp(content
);
197 // Convert plain-text help to markdown; replace variables and format links
198 function formatHelp(rawContent
) {
199 function formatRefs(see
) {
200 _
.each(see
, function(ref
, idx
) {
201 var match
= ref
.match(/^\\Civi\\Api4\\([a-zA-Z]+)$/);
203 ref
= '#/explorer/' + match
[1];
205 if (ref
[0] === '\\') {
206 ref
= 'https://github.com/civicrm/civicrm-core/blob/master' + ref
.replace(/\\/i, '/') + '.php
';
208 see[idx] = '<a target
="' + (ref[0] === '#' ? '_self' : '_blank') + '" href
="' + ref + '">' + see[idx] + '</a
>';
211 var formatted = _.cloneDeep(rawContent);
212 if (formatted.description) {
213 formatted.description = marked(formatted.description);
215 if (formatted.comment) {
216 formatted.comment = marked(formatted.comment);
218 formatRefs(formatted.see);
222 $scope.fieldHelp = function(fieldName) {
223 var field = getField(fieldName, $scope.entity, $scope.action);
228 description: field.description,
229 type: field.data_type
231 if (field.default_value) {
232 info.default = field.default_value;
234 if (field.required_if) {
235 info.required_if = field.required_if;
236 } else if (field.required) {
237 info.required = 'true';
242 // Returns field list for write params (values, defaults)
243 $scope.fieldList = function(param) {
246 getFieldList(fields, $scope.action === 'getFields
' ? ($scope.params.action || 'get') : $scope.action, ['name
']);
247 // Disable fields that are already in use
248 _.each($scope.params[param] || [], function(val) {
249 var usedField = val[0].replace(':name
', '');
250 (_.findWhere(fields, {id: usedField}) || {}).disabled = true;
251 (_.findWhere(fields, {id: usedField + ':name
'}) || {}).disabled = true;
253 return {results: fields};
257 $scope.formatSelect2Item = function(row) {
258 return _.escape(row.text) +
259 (row.required ? '<span
class="crm-marker"> *</span
>' : '') +
260 (row.description ? '<div
class="crm-select2-row-description"><p
>' + _.escape(row.description) + '</p></div>' : '');
263 $scope.clearParam = function(name, idx) {
264 if (typeof idx === 'undefined') {
265 $scope.params[name] = $scope.availableParams[name].default;
267 $scope.params[name].splice(idx, 1);
271 // Gets params that should be represented as generic input fields in the explorer
272 // This fn doesn't have to be particularly efficient as its output is cached
in one
-time bindings
273 $scope
.getGenericParams = function(paramType
, defaultNull
) {
274 // Returns undefined if params are not yet set; one-time bindings will stabilize when this function returns a value
275 if (_
.isEmpty($scope
.availableParams
)) {
278 var specialParams
= ['select', 'fields', 'action', 'where', 'values', 'defaults', 'orderBy', 'chain', 'groupBy', 'having', 'join'];
279 if ($scope
.availableParams
.limit
&& $scope
.availableParams
.offset
) {
280 specialParams
.push('limit', 'offset');
282 return _
.transform($scope
.availableParams
, function(genericParams
, param
, name
) {
283 if (!_
.contains(specialParams
, name
) &&
284 !(typeof paramType
!== 'undefined' && !_
.contains(paramType
, param
.type
[0])) &&
285 !(typeof defaultNull
!== 'undefined' && ((param
.default === null) !== defaultNull
))
287 genericParams
[name
] = param
;
292 $scope
.selectRowCount = function() {
293 var index
= params
.select
.indexOf('row_count');
295 $scope
.params
.select
.push('row_count');
297 $scope
.params
.select
.splice(index
, 1);
301 $scope
.isSelectRowCount = function() {
302 return isSelectRowCount($scope
.params
);
305 $scope
.selectLang = function(lang
) {
306 $scope
.selectedTab
.code
= lang
;
310 function isSelectRowCount(params
) {
311 return params
&& params
.select
&& params
.select
.indexOf('row_count') >= 0;
314 function getEntity(entityName
) {
315 return _
.findWhere(schema
, {name
: entityName
|| $scope
.entity
});
318 // Get name of entity given join alias
319 function entityNameFromAlias(alias
) {
320 var joins
= getExplicitJoins(),
321 entity
= $scope
.entity
,
322 path
= alias
.split('.');
323 // First check explicit joins
327 // Then lookup implicit links
328 _
.each(path
, function(node
) {
329 var link
= _
.find(links
[entity
], {alias
: node
});
333 entity
= link
.entity
;
338 // Get all params that have been set
339 function getParams() {
341 _
.each($scope
.params
, function(param
, key
) {
342 if (param
!= $scope
.availableParams
[key
].default && !(typeof param
=== 'object' && _
.isEmpty(param
))) {
343 if (_
.contains($scope
.availableParams
[key
].type
, 'array') && (typeof objectParams
[key
] === 'undefined')) {
344 params
[key
] = parseYaml(JSON
.parse(angular
.toJson(param
)));
350 _
.each(objectParams
, function(defaultVal
, key
) {
353 _
.each(params
[key
], function(item
) {
354 var val
= _
.cloneDeep(item
[1]);
355 // Remove blank items from "chain" array
356 if (_
.isArray(val
)) {
357 _
.eachRight(item
[1], function(v
, k
) {
364 newParam
[item
[0]] = parseYaml(val
);
366 params
[key
] = newParam
;
372 function parseYaml(input
) {
373 if (typeof input
=== 'undefined' || input
=== '') {
376 // Return literal quoted string without removing quotes - for the sake of JOIN ON clauses
377 if (_
.isString(input
) && input
[0] === input
[input
.length
- 1] && _
.includes(["'", '"'], input
[0])) {
380 if (_
.isObject(input
) || _
.isArray(input
)) {
381 _
.each(input
, function(item
, index
) {
382 input
[index
] = parseYaml(item
);
387 var output
= (input
=== '>') ? '>' : jsyaml
.safeLoad(input
);
388 // We don't want dates parsed to js objects
389 return _
.isDate(output
) ? input
: output
;
395 this.buildFieldList = function() {
396 var actionInfo
= _
.findWhere(actions
, {id
: $scope
.action
});
397 getFieldList($scope
.fields
, $scope
.action
);
398 getFieldList($scope
.fieldsAndJoins
, $scope
.action
, ['name']);
399 getFieldList($scope
.fieldsAndJoinsAndFunctions
, $scope
.action
);
400 getFieldList($scope
.fieldsAndJoinsAndFunctionsWithSuffixes
, $scope
.action
, ['name', 'label']);
401 getFieldList($scope
.fieldsAndJoinsAndFunctionsAndWildcards
, $scope
.action
, ['name', 'label']);
402 if (_
.contains(['get', 'update', 'delete', 'replace'], $scope
.action
)) {
403 addJoins($scope
.fieldsAndJoins
);
404 // SQL functions are supported if HAVING is
405 if (actionInfo
.params
.having
) {
407 text
: ts('FUNCTION'),
408 description
: ts('Calculate result of a SQL function'),
409 children
: _
.transform(CRM
.vars
.api4
.functions
, function(result
, fn
) {
411 id
: fn
.name
+ '() AS ' + fn
.name
.toLowerCase(),
412 text
: fn
.name
+ '()',
413 description
: fn
.name
+ '(' + describeSqlFn(fn
.params
) + ')'
417 $scope
.fieldsAndJoinsAndFunctions
.push(functions
);
418 $scope
.fieldsAndJoinsAndFunctionsWithSuffixes
.push(functions
);
419 $scope
.fieldsAndJoinsAndFunctionsAndWildcards
.push(functions
);
421 addJoins($scope
.fieldsAndJoinsAndFunctions
, true);
422 addJoins($scope
.fieldsAndJoinsAndFunctionsWithSuffixes
, false, ['name', 'label']);
423 addJoins($scope
.fieldsAndJoinsAndFunctionsAndWildcards
, true, ['name', 'label']);
425 // Custom fields are supported if HAVING is
426 if (actionInfo
.params
.having
) {
427 $scope
.fieldsAndJoinsAndFunctionsAndWildcards
.unshift({id
: 'custom.*', text
: 'custom.*', 'description': 'All custom fields'});
429 $scope
.fieldsAndJoinsAndFunctionsAndWildcards
.unshift({id
: '*', text
: '*', 'description': 'All core ' + $scope
.entity
+ ' fields'});
432 function selectAction() {
433 $scope
.action
= $routeParams
.api4action
;
434 if (!actions
.length
) {
435 formatForSelect2(getEntity().actions
, actions
, 'name', ['description', 'params']);
438 var actionInfo
= _
.findWhere(actions
, {id
: $scope
.action
});
439 _
.each(actionInfo
.params
, function (param
, name
) {
441 defaultVal
= _
.cloneDeep(param
.default);
443 switch (param
.type
[0]) {
446 format
= param
.type
[0];
457 if (name
=== 'limit') {
460 if (name
=== 'debug') {
463 if (name
=== 'values') {
464 defaultVal
= defaultValues(defaultVal
);
466 if (name
=== 'loadOptions' && $scope
.action
=== 'getFields') {
470 ['id', 'name', 'label'],
471 ['id', 'name', 'label', 'abbr', 'description', 'color', 'icon']
475 param
.type
= ['string'];
477 $scope
.$bindToRoute({
478 expr
: 'params["' + name
+ '"]',
482 deep
: format
=== 'json'
485 if (typeof objectParams
[name
] !== 'undefined' && name
!== 'orderBy') {
486 $scope
.$watch('params.' + name
, function (values
) {
487 // Remove empty values
488 _
.each(values
, function (clause
, index
) {
489 if (!clause
|| !clause
[0]) {
490 $scope
.clearParam(name
, index
);
495 if (name
=== 'select' && actionInfo
.params
.having
) {
496 $scope
.$watchCollection('params.select', function(newSelect
) {
497 // Ignore row_count, it can't be used in HAVING clause
498 var select
= _
.without(newSelect
, 'row_count');
499 $scope
.havingOptions
.length
= 0;
500 // An empty select is an implicit *
501 if (!select
.length
) {
504 _
.each(select
, function(item
) {
506 pieces
= item
.split(' AS '),
507 alias
= _
.trim(pieces
[pieces
.length
- 1]).replace(':label', ':name');
509 if (alias
[alias
.length
- 1] === '*') {
510 if (alias
.length
> 1) {
511 joinEntity
= entityNameFromAlias(alias
.slice(0, -2));
513 var fieldList
= _
.filter(getEntity(joinEntity
).fields
, {custom_field_id
: null});
514 formatForSelect2(fieldList
, $scope
.havingOptions
, 'name', ['description', 'required', 'default_value'], alias
.slice(0, -1));
517 $scope
.havingOptions
.push({id
: alias
, text
: alias
});
522 if (typeof objectParams
[name
] !== 'undefined' || name
=== 'groupBy' || name
=== 'select' || name
=== 'join') {
523 $scope
.$watch('controls.' + name
, function(value
) {
525 $timeout(function() {
527 if (name
=== 'join') {
528 $scope
.params
[name
].push([field
+ ' AS ' + _
.snakeCase(field
), false]);
529 ctrl
.buildFieldList();
531 else if (typeof objectParams
[name
] === 'undefined') {
532 $scope
.params
[name
].push(field
);
534 var defaultOp
= _
.cloneDeep(objectParams
[name
]);
535 if (name
=== 'chain') {
536 var num
= $scope
.params
.chain
.length
;
537 defaultOp
[0] = field
;
538 field
= 'name_me_' + num
;
540 $scope
.params
[name
].push([field
, defaultOp
]);
542 $scope
.controls
[name
] = null;
548 ctrl
.buildFieldList();
549 $scope
.availableParams
= actionInfo
.params
;
554 function describeSqlFn(params
) {
556 _
.each(params
, function(param
) {
559 desc
+= _
.filter(param
.prefix
).join('|') + ' ';
561 if (param
.expr
=== 1) {
563 } else if (param
.expr
> 1) {
564 desc
+= 'expr, ... ';
567 desc
+= ' ' + _
.filter(param
.suffix
).join('|') + ' ';
570 return desc
.replace(/[ ]+/g, ' ');
573 function defaultValues(defaultVal
) {
574 _
.each($scope
.fields
, function(field
) {
575 if (field
.required
) {
576 defaultVal
.push([field
.id
, '']);
582 function stringify(value
, trim
) {
583 if (typeof value
=== 'undefined') {
586 var str
= JSON
.stringify(value
).replace(/,/g
, ', ');
588 str
= str
.slice(1, -1);
593 function writeCode() {
595 entity
= $scope
.entity
,
596 action
= $scope
.action
,
597 params
= getParams(),
598 index
= isInt($scope
.index
) ? +$scope
.index
: parseYaml($scope
.index
),
600 if ($scope
.entity
&& $scope
.action
) {
602 if (action
.slice(0, 3) === 'get') {
603 result
= entity
.substr(0, 7) === 'Custom_' ? _
.camelCase(entity
.substr(7)) : entity
;
604 result
= lcfirst(action
.replace(/s$/, '').slice(3) || result
);
606 var results
= lcfirst(_
.isNumber(index
) ? result
: pluralize(result
)),
607 paramCount
= _
.size(params
),
610 switch ($scope
.selectedTab
.code
) {
614 var js
= "'" + entity
+ "', '" + action
+ "', {";
615 _
.each(params
, function(param
, key
) {
616 js
+= "\n " + key
+ ': ' + stringify(param
) +
617 (++i
< paramCount
? ',' : '');
618 if (key
=== 'checkPermissions') {
619 js
+= ' // IGNORED: permissions are always enforced from client-side requests';
623 if (index
|| index
=== 0) {
624 js
+= ', ' + JSON
.stringify(index
);
626 code
.js
= "CRM.api4(" + js
+ ").then(function(" + results
+ ") {\n // do something with " + results
+ " array\n}, function(failure) {\n // handle failure\n});";
627 code
.js2
= "CRM.api4({" + results
+ ': [' + js
+ "]}).then(function(batch) {\n // do something with batch." + results
+ " array\n}, function(failure) {\n // handle failure\n});";
628 code
.ang
= "crmApi4(" + js
+ ").then(function(" + results
+ ") {\n // do something with " + results
+ " array\n}, function(failure) {\n // handle failure\n});";
629 code
.ang2
= "crmApi4({" + results
+ ': [' + js
+ "]}).then(function(batch) {\n // do something with batch." + results
+ " array\n}, function(failure) {\n // handle failure\n});";
634 code
.php
= '$' + results
+ " = civicrm_api4('" + entity
+ "', '" + action
+ "', [";
635 _
.each(params
, function(param
, key
) {
636 code
.php
+= "\n '" + key
+ "' => " + phpFormat(param
, 4) + ',';
639 if (index
|| index
=== 0) {
640 code
.php
+= ', ' + phpFormat(index
);
645 code
.oop
= '$' + results
+ " = " + formatOOP(entity
, action
, params
, 2) + "\n ->execute()";
646 if (_
.isNumber(index
)) {
647 code
.oop
+= !index
? '\n ->first()' : (index
=== -1 ? '\n ->last()' : '\n ->itemAt(' + index
+ ')');
649 if (_
.isString(index
) || (_
.isPlainObject(index
) && !index
[0] && !index
['0'])) {
650 code
.oop
+= "\n ->indexBy('" + (_
.isPlainObject(index
) ? _
.keys(index
)[0] : index
) + "')";
652 if (_
.isArray(index
) || _
.isPlainObject(index
)) {
653 code
.oop
+= "\n ->column('" + (_
.isArray(index
) ? index
[0] : _
.values(index
)[0]) + "')";
657 if (!_
.isNumber(index
)) {
658 code
.oop
+= "foreach ($" + results
+ ' as $' + ((_
.isString(index
) && index
) ? index
+ ' => $' : '') + result
+ ') {\n // do something\n}';
663 // Cli code using json input
664 code
.long = 'cv api4 ' + entity
+ '.' + action
+ ' ' + cliFormat(JSON
.stringify(params
));
665 code
.pipe
= 'echo ' + cliFormat(JSON
.stringify(params
)) + ' | cv api4 ' + entity
+ '.' + action
+ ' --in=json';
667 // Cli code using short syntax
668 code
.short = 'cv api4 ' + entity
+ '.' + action
;
669 var limitSet
= false;
670 _
.each(params
, function(param
, key
) {
672 case (key
=== 'select' && !_
.includes(param
.join(), ' ')):
673 code
.short += ' +s ' + cliFormat(param
.join(','));
675 case (key
=== 'where' && !_
.intersection(_
.map(param
, 0), ['AND', 'OR', 'NOT']).length
):
676 _
.each(param
, function(clause
) {
677 code
.short += ' +w ' + cliFormat(clause
[0] + ' ' + clause
[1] + (clause
.length
> 2 ? (' ' + JSON
.stringify(clause
[2])) : ''));
680 case (key
=== 'orderBy'):
681 _
.each(param
, function(dir
, field
) {
682 code
.short += ' +o ' + cliFormat(field
+ ' ' + dir
);
685 case (key
=== 'values'):
686 _
.each(param
, function(val
, field
) {
687 code
.short += ' +v ' + cliFormat(field
+ '=' + val
);
690 case (key
=== 'limit' || key
=== 'offset'):
691 // These 2 get combined
694 code
.short += ' +l ' + (params
.limit
|| '0') + (params
.offset
? ('@' + params
.offset
) : '');
698 code
.short += ' ' + key
+ '=' + (typeof param
=== 'string' ? cliFormat(param
) : cliFormat(JSON
.stringify(param
)));
703 _
.each($scope
.code
, function(vals
) {
704 _
.each(vals
, function(style
) {
705 style
.code
= code
[style
.name
] ? prettyPrintOne(code
[style
.name
]) : '';
711 function formatOOP(entity
, action
, params
, indent
) {
713 newLine
= "\n" + _
.repeat(' ', indent
),
714 perm
= params
.checkPermissions
=== false ? 'FALSE' : '';
715 if (entity
.substr(0, 7) !== 'Custom_') {
716 code
= "\\Civi\\Api4\\" + entity
+ '::' + action
+ '(' + perm
+ ')';
718 code
= "\\Civi\\Api4\\CustomValue::" + action
+ "('" + entity
.substr(7) + "'" + (perm
? ', ' : '') + perm
+ ")";
720 _
.each(params
, function(param
, key
) {
722 if (typeof objectParams
[key
] !== 'undefined' && key
!== 'chain') {
723 _
.each(param
, function(item
, index
) {
724 val
= phpFormat(index
) + ', ' + phpFormat(item
, 2 + indent
);
725 code
+= newLine
+ "->add" + ucfirst(key
).replace(/s$/, '') + '(' + val
+ ')';
727 } else if (key
=== 'where') {
728 _
.each(param
, function (clause
) {
729 if (clause
[0] === 'AND' || clause
[0] === 'OR' || clause
[0] === 'NOT') {
730 code
+= newLine
+ "->addClause(" + phpFormat(clause
[0]) + ", " + phpFormat(clause
[1]).slice(1, -1) + ')';
732 code
+= newLine
+ "->addWhere(" + phpFormat(clause
).slice(1, -1) + ")";
735 } else if (key
=== 'select') {
736 // selectRowCount() is a shortcut for addSelect('row_count')
737 if (isSelectRowCount(params
)) {
738 code
+= newLine
+ '->selectRowCount()';
739 param
= _
.without(param
, 'row_count');
741 // addSelect() is a variadic function & can take multiple arguments
743 code
+= newLine
+ '->addSelect(' + phpFormat(param
).slice(1, -1) + ')';
745 } else if (key
=== 'chain') {
746 _
.each(param
, function(chain
, name
) {
747 code
+= newLine
+ "->addChain('" + name
+ "', " + formatOOP(chain
[0], chain
[1], chain
[2], 2 + indent
);
748 code
+= (chain
.length
> 3 ? ',' : '') + (!_
.isEmpty(chain
[2]) ? newLine
: ' ') + (chain
.length
> 3 ? phpFormat(chain
[3]) : '') + ')';
751 else if (key
!== 'checkPermissions') {
752 code
+= newLine
+ "->set" + ucfirst(key
) + '(' + phpFormat(param
, 2 + indent
) + ')';
758 function isInt(value
) {
759 if (_
.isFinite(value
)) {
762 if (!_
.isString(value
)) {
765 return /^-{0,1}\d+$/.test(value
);
768 function formatMeta(resp
) {
770 _
.each(resp
, function(val
, key
) {
771 if (key
!== 'values' && !_
.isPlainObject(val
) && !_
.isFunction(val
)) {
772 ret
+= (ret
.length
? ', ' : '') + key
+ ': ' + (_
.isArray(val
) ? '[' + val
+ ']' : val
);
775 return prettyPrintOne(_
.escape(ret
));
778 $scope
.execute = function() {
779 $scope
.status
= 'info';
780 $scope
.loading
= true;
781 $http
.post(CRM
.url('civicrm/ajax/api4/' + $scope
.entity
+ '/' + $scope
.action
, {
782 params
: angular
.toJson(getParams()),
783 index
: isInt($scope
.index
) ? +$scope
.index
: parseYaml($scope
.index
)
786 'X-Requested-With': 'XMLHttpRequest'
788 }).then(function(resp
) {
789 $scope
.loading
= false;
790 $scope
.status
= resp
.data
&& resp
.data
.debug
&& resp
.data
.debug
.log
? 'warning' : 'success';
791 $scope
.debug
= debugFormat(resp
.data
);
793 formatMeta(resp
.data
),
794 prettyPrintOne('(' + resp
.data
.values
.length
+ ') ' + _
.escape(JSON
.stringify(resp
.data
.values
, null, 2)), 'js', 1)
797 $scope
.loading
= false;
798 $scope
.status
= 'danger';
799 $scope
.debug
= debugFormat(resp
.data
);
802 prettyPrintOne(_
.escape(JSON
.stringify(resp
.data
, null, 2)))
807 function debugFormat(data
) {
808 var debug
= data
.debug
? prettyPrintOne(_
.escape(JSON
.stringify(data
.debug
, null, 2)).replace(/\\n/g, "\n")) : null;
814 * Format value to look like php code
816 function phpFormat(val
, indent
) {
817 if (typeof val
=== 'undefined') {
820 if (val
=== null || val
=== true || val
=== false) {
821 return JSON
.stringify(val
).toUpperCase();
823 indent
= (typeof indent
=== 'number') ? _
.repeat(' ', indent
) : (indent
|| '');
825 baseLine
= indent
? indent
.slice(0, -2) : '',
826 newLine
= indent
? '\n' : '',
827 trailingComma
= indent
? ',' : '';
828 if ($.isPlainObject(val
)) {
829 $.each(val
, function(k
, v
) {
830 ret
+= (ret
? ', ' : '') + newLine
+ indent
+ "'" + k
+ "' => " + phpFormat(v
);
832 return '[' + ret
+ trailingComma
+ newLine
+ baseLine
+ ']';
834 if ($.isArray(val
)) {
835 $.each(val
, function(k
, v
) {
836 ret
+= (ret
? ', ' : '') + newLine
+ indent
+ phpFormat(v
);
838 return '[' + ret
+ trailingComma
+ newLine
+ baseLine
+ ']';
840 if (_
.isString(val
) && !_
.contains(val
, "'")) {
841 return "'" + val
+ "'";
843 return JSON
.stringify(val
).replace(/\$/g, '\\$');
846 // Format string to be cli-input-safe
847 function cliFormat(str
) {
848 if (!_
.includes(str
, ' ') && !_
.includes(str
, '"') && !_
.includes(str
, "'")) {
851 if (!_
.includes(str
, "'")) {
852 return "'" + str
+ "'";
854 if (!_
.includes(str
, '"')) {
855 return '"' + str
+ '"';
857 return "'" + str
.replace(/'/g, "\\'") + "'";
860 function fetchMeta() {
861 crmApi4(getMetaParams)
862 .then(function(data) {
864 getEntity().actions = data.actions;
870 // Help for an entity with no action selected
871 function showEntityHelp(entityName) {
872 var entityInfo = getEntity(entityName);
873 setHelp($scope.entity, {
874 description: entityInfo.description,
875 comment: entityInfo.comment,
880 if (!$scope.entity) {
881 setHelp(ts('APIv4 Explorer
'), {description: docs.description, comment: docs.comment, see: docs.see});
882 } else if (!actions.length && !getEntity().actions) {
883 getMetaParams.actions = [$scope.entity, 'getActions
', {chain: {fields: [$scope.entity, 'getFields
', {action: '$name
'}]}}];
890 showEntityHelp($scope.entity);
893 // Update route when changing entity
894 $scope.$watch('entity
', function(newVal, oldVal) {
895 if (oldVal !== newVal) {
896 // Flush actions cache to re-fetch for new entity
898 $location.url('/explorer/' + newVal);
902 // Update route when changing actions
903 $scope.$watch('action
', function(newVal, oldVal) {
904 if ($scope.entity && $routeParams.api4action !== newVal && !_.isUndefined(newVal)) {
905 $location.url('/explorer/' + $scope.entity + '/' + newVal);
907 setHelp($scope.entity + '::' + newVal, _.pick(_.findWhere(getEntity().actions, {name: newVal}), ['description
', 'comment
', 'see
']));
911 $scope.paramDoc = function(name) {
912 return docs.params[name];
915 $scope.executeDoc = function() {
917 description: ts('Runs API call on the CiviCRM database
.'),
918 comment: ts('Results and debugging info will be displayed below
.')
920 if ($scope.action === 'delete') {
921 doc.WARNING = ts('This API call will be executed on the real database
. Deleting data cannot be undone
.');
923 else if ($scope.action && $scope.action.slice(0, 3) !== 'get') {
924 doc.WARNING = ts('This API call will be executed on the real database
. It cannot be undone
.');
929 $scope.saveDoc = function() {
931 description: ts('Save API call as a smart group
.'),
932 comment: ts('Create a SavedSearch using these API params to populate a smart group
.') +
933 '\n\n' + ts('NOTE
: you must select contact id as the only field
.')
937 $scope.$watch('params
', writeCode, true);
938 $scope.$watch('index
', writeCode);
941 $scope.save = function() {
942 $scope.params.limit = $scope.params.offset = 0;
943 if ($scope.params.chain.length) {
944 CRM.alert(ts('Smart groups are not compatible
with API chaining
.'), ts('Error
'), 'error
', {expires: 5000});
947 if ($scope.params.select.length !== 1 || !_.includes($scope.params.select[0], 'id
')) {
948 CRM.alert(ts('To create a smart group
, the API must select contact id and no other fields
.'), ts('Error
'), 'error
', {expires: 5000});
954 visibility: 'User and User Admin Only
',
957 entity: $scope.entity,
958 params: JSON.parse(angular.toJson($scope.params))
960 model.params.version = 4;
961 delete model.params.chain;
962 delete model.params.debug;
963 delete model.params.limit;
964 delete model.params.offset;
965 delete model.params.orderBy;
966 delete model.params.checkPermissions;
967 var options = CRM.utils.adjustDialogDefaults({
970 title: ts('Save smart group
')
972 dialogService.open('saveSearchDialog
', '~/api4Explorer/SaveSearch
.html
', model, options);
976 angular.module('api4Explorer
').controller('SaveSearchCtrl
', function($scope, crmApi4, dialogService) {
977 var ts = $scope.ts = CRM.ts(),
978 model = $scope.model;
979 $scope.groupEntityRefParams = {
982 params: {is_hidden: 0, is_active: 1, 'saved_search_id
.api_entity
': model.entity},
983 extra: ['saved_search_id
', 'description
', 'visibility
', 'group_type
']
987 minimumInputLength: 0,
988 placeholder: ts('Select existing group
')
991 if (!CRM.checkPerm('administer reserved groups
')) {
992 $scope.groupEntityRefParams.api.params.is_reserved = 0;
995 administerReservedGroups: CRM.checkPerm('administer reserved groups
')
997 $scope.options = CRM.vars.api4.groupOptions;
998 $scope.$watch('model
.id
', function(id) {
1000 _.assign(model, $('#api
-save
-search
-select
-group
').select2('data
').extra);
1003 $scope.cancel = function() {
1004 dialogService.cancel('saveSearchDialog
');
1006 $scope.save = function() {
1007 $('.ui
-dialog
:visible
').block();
1008 var group = model.id ? {id: model.id} : {title: model.title};
1009 group.description = model.description;
1010 group.visibility = model.visibility;
1011 group.group_type = model.group_type;
1012 group.saved_search_id = '$id
';
1014 api_entity: model.entity,
1015 api_params: model.params
1018 savedSearch.id = model.saved_search_id;
1020 crmApi4('SavedSearch
', 'save
', {records: [savedSearch], chain: {group: ['Group
', 'save
', {'records
': [group]}]}})
1021 .then(function(result) {
1022 dialogService.close('saveSearchDialog
', result[0]);
1027 angular.module('api4Explorer
').directive('crmApi4Clause
', function() {
1030 data: '<crmApi4Clause
'
1032 templateUrl: '~/api4Explorer/Clause
.html
',
1033 controller: function ($scope, $element, $timeout) {
1034 var ts = $scope.ts = CRM.ts(),
1035 ctrl = $scope.$ctrl = this;
1036 this.conjunctions = {AND: ts('And
'), OR: ts('Or
'), NOT: ts('Not
')};
1037 this.operators = CRM.vars.api4.operators;
1038 this.sortOptions = {
1040 connectWith: '.api4
-clause
-group
-sortable
',
1041 containment: $element.closest('.api4
-clause
-fieldset
'),
1047 this.addGroup = function(op) {
1048 $scope.data.clauses.push([op, []]);
1051 this.removeGroup = function() {
1052 $scope.data.groupParent.splice($scope.data.groupIndex, 1);
1055 function onSort(event, ui) {
1056 $($element).closest('.api4
-clause
-fieldset
').toggleClass('api4
-sorting
', event.type === 'sortstart
');
1057 $('.api4
-input
.form
-inline
').css('margin
-left
', '');
1060 // Indent clause while dragging between nested groups
1061 function onSortOver(event, ui) {
1064 offset = $(ui.placeholder).offset().left - $(ui.sender).offset().left;
1066 $('.api4
-input
.form
-inline
.ui
-sortable
-helper
').css('margin
-left
', '' + offset + 'px
');
1069 this.addClause = function() {
1070 $timeout(function() {
1071 if (ctrl.newClause) {
1072 $scope.data.clauses.push([ctrl.newClause, '=', '']);
1073 ctrl.newClause = null;
1077 $scope.$watch('data
.clauses
', function(values) {
1078 // Iterate in reverse order so index doesn't
get out
-of-sync during splice
1079 _
.forEachRight(values
, function(clause
, index
) {
1080 // Remove empty values
1081 if (index
>= ($scope
.data
.skip
|| 0)) {
1082 if (typeof clause
!== 'undefined' && !clause
[0]) {
1083 values
.splice(index
, 1);
1085 // Add/remove value if operator allows for one
1086 else if (typeof clause
[1] === 'string' && _
.contains(clause
[1], 'NULL')) {
1088 } else if (typeof clause
[1] === 'string' && clause
.length
=== 2) {
1098 angular
.module('api4Explorer').directive('api4ExpValue', function($routeParams
, crmApi4
) {
1101 data
: '=api4ExpValue'
1104 link: function (scope
, element
, attrs
, ctrl
) {
1105 var ts
= scope
.ts
= CRM
.ts(),
1106 multi
= _
.includes(['IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN'], scope
.data
.op
),
1107 entity
= $routeParams
.api4entity
,
1108 action
= scope
.data
.action
|| $routeParams
.api4action
;
1110 function destroyWidget() {
1111 var $el
= $(element
);
1112 if ($el
.is('.crm-form-date-wrapper .crm-hidden-date')) {
1113 $el
.crmDatepicker('destroy');
1115 if ($el
.is('.select2-container + input')) {
1116 $el
.crmEntityRef('destroy');
1118 $(element
).removeData().removeAttr('type').removeAttr('placeholder').show();
1121 function makeWidget(field
, op
) {
1122 var $el
= $(element
),
1123 inputType
= field
.input_type
,
1124 dataType
= field
.data_type
;
1126 op
= field
.serialize
|| dataType
=== 'Array' ? 'IN' : '=';
1128 multi
= _
.includes(['IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN'], op
);
1129 if (op
=== 'IS NULL' || op
=== 'IS NOT NULL') {
1133 if (inputType
=== 'Date') {
1134 if (_
.includes(['=', '!=', '<>', '<', '>=', '<', '<='], op
)) {
1135 $el
.crmDatepicker({time
: (field
.input_attrs
&& field
.input_attrs
.time
) || false});
1137 } else if (_
.includes(['=', '!=', '<>', 'IN', 'NOT IN'], op
) && (field
.fk_entity
|| field
.options
|| dataType
=== 'Boolean')) {
1138 if (field
.options
) {
1139 var id
= field
.pseudoconstant
|| 'id';
1140 $el
.addClass('loading').attr('placeholder', ts('- select -')).crmSelect2({multiple
: multi
, data
: [{id
: '', text
: ''}]});
1141 loadFieldOptions(field
.entity
|| entity
).then(function(data
) {
1142 var options
= _
.transform(data
[field
.name
].options
, function(options
, opt
) {
1143 options
.push({id
: opt
[id
], text
: opt
.label
, description
: opt
.description
, color
: opt
.color
, icon
: opt
.icon
});
1145 $el
.removeClass('loading').crmSelect2({data
: options
, multiple
: multi
});
1147 } else if (field
.fk_entity
) {
1148 $el
.crmEntityRef({entity
: field
.fk_entity
, select
:{multiple
: multi
}});
1149 } else if (dataType
=== 'Boolean') {
1150 $el
.attr('placeholder', ts('- select -')).crmSelect2({allowClear
: false, multiple
: multi
, placeholder
: ts('- select -'), data
: [
1151 {id
: 'true', text
: ts('Yes')},
1152 {id
: 'false', text
: ts('No')}
1155 } else if (dataType
=== 'Integer' && !multi
) {
1156 $el
.attr('type', 'number');
1160 function loadFieldOptions(entity
) {
1161 if (!fieldOptions
[entity
+ action
]) {
1162 fieldOptions
[entity
+ action
] = crmApi4(entity
, 'getFields', {
1163 loadOptions
: ['id', 'name', 'label', 'description', 'color', 'icon'],
1165 where
: [['options', '!=', false]],
1169 return fieldOptions
[entity
+ action
];
1172 // Copied from ng-list but applied conditionally if field is multi-valued
1173 var parseList = function(viewValue
) {
1174 // If the viewValue is invalid (say required but empty) it will be `undefined`
1175 if (_
.isUndefined(viewValue
)) return;
1184 _
.each(viewValue
.split(','), function(value
) {
1185 if (value
) list
.push(_
.trim(value
));
1192 // Copied from ng-list
1193 ctrl
.$parsers
.push(parseList
);
1194 ctrl
.$formatters
.push(function(value
) {
1195 return _
.isArray(value
) ? value
.join(', ') : value
;
1198 // Copied from ng-list
1199 ctrl
.$isEmpty = function(value
) {
1200 return !value
|| !value
.length
;
1203 scope
.$watchCollection('data', function(data
) {
1205 var field
= getField(data
.field
, entity
, action
);
1206 if (field
&& data
.format
!== 'plain') {
1207 makeWidget(field
, data
.op
);
1215 angular
.module('api4Explorer').directive('api4ExpChain', function(crmApi4
) {
1218 chain
: '=api4ExpChain',
1222 templateUrl
: '~/api4Explorer/Chain.html',
1223 link: function (scope
, element
, attrs
) {
1224 var ts
= scope
.ts
= CRM
.ts();
1226 function changeEntity(newEntity
, oldEntity
) {
1227 // When clearing entity remove this chain
1229 scope
.chain
[0] = '';
1232 // Reset action && index
1233 if (newEntity
!== oldEntity
) {
1234 scope
.chain
[1][1] = scope
.chain
[1][2] = '';
1236 if (getEntity(newEntity
).actions
) {
1239 crmApi4(newEntity
, 'getActions', {chain
: {fields
: [newEntity
, 'getFields', {action
: '$name'}]}})
1240 .then(function(data
) {
1241 getEntity(data
.entity
).actions
= data
;
1242 if (data
.entity
=== scope
.chain
[1][0]) {
1249 function setActions() {
1250 scope
.actions
= [''].concat(_
.pluck(getEntity(scope
.chain
[1][0]).actions
, 'name'));
1253 // Set default params when choosing action
1254 function changeAction(newAction
, oldAction
) {
1256 // Prepopulate links
1257 if (newAction
&& newAction
!== oldAction
) {
1259 scope
.chain
[1][3] = '';
1260 // Look for links back to main entity
1261 _
.each(entityFields(scope
.chain
[1][0]), function(field
) {
1262 if (field
.fk_entity
=== scope
.mainEntity
) {
1263 link
= [field
.name
, '$id'];
1266 // Look for links from main entity
1267 if (!link
&& newAction
!== 'create') {
1268 _
.each(entityFields(scope
.mainEntity
), function(field
) {
1269 if (field
.fk_entity
=== scope
.chain
[1][0]) {
1270 link
= ['id', '$' + field
.name
];
1271 // Since we're specifying the id, set index to getsingle
1272 scope
.chain
[1][3] = '0';
1276 if (link
&& _
.contains(['get', 'update', 'replace', 'delete'], newAction
)) {
1277 scope
.chain
[1][2] = '{where: [[' + link
[0] + ', =, ' + link
[1] + ']]}';
1279 else if (link
&& _
.contains(['create'], newAction
)) {
1280 scope
.chain
[1][2] = '{values: {' + link
[0] + ': ' + link
[1] + '}}';
1282 else if (link
&& _
.contains(['save'], newAction
)) {
1283 scope
.chain
[1][2] = '{records: [{' + link
[0] + ': ' + link
[1] + '}]}';
1285 scope
.chain
[1][2] = '{}';
1290 scope
.$watch("chain[1][0]", changeEntity
);
1291 scope
.$watch("chain[1][1]", changeAction
);
1296 function getEntity(entityName
) {
1297 return _
.findWhere(schema
, {name
: entityName
});
1300 function entityFields(entityName
, action
) {
1301 var entity
= getEntity(entityName
);
1302 if (entity
&& action
&& entity
.actions
) {
1303 return _
.findWhere(entity
.actions
, {name
: action
}).fields
;
1305 return _
.result(entity
, 'fields');
1308 function getExplicitJoins() {
1309 return _
.transform(params
.join
, function(joins
, join
) {
1310 var j
= join
[0].split(' AS '),
1311 joinEntity
= _
.trim(j
[0]),
1312 joinAlias
= _
.trim(j
[1]) || joinEntity
.toLowerCase();
1313 joins
[joinAlias
] = joinEntity
;
1317 function getField(fieldName
, entity
, action
) {
1318 var suffix
= fieldName
.split(':')[1];
1319 fieldName
= fieldName
.split(':')[0];
1320 var fieldNames
= fieldName
.split('.');
1321 var field
= get(entity
, fieldNames
);
1322 if (field
&& suffix
) {
1323 field
.pseudoconstant
= suffix
;
1327 function get(entity
, fieldNames
) {
1328 if (fieldNames
.length
=== 1) {
1329 return _
.findWhere(entityFields(entity
, action
), {name
: fieldNames
[0]});
1331 var comboName
= _
.findWhere(entityFields(entity
, action
), {name
: fieldNames
[0] + '.' + fieldNames
[1]});
1335 var linkName
= fieldNames
.shift(),
1336 newEntity
= getExplicitJoins()[linkName
] || _
.findWhere(links
[entity
], {alias
: linkName
}).entity
;
1337 return get(newEntity
, fieldNames
);
1341 // Collapsible optgroups for select2
1344 .on('select2-open', function(e
) {
1345 if ($(e
.target
).hasClass('collapsible-optgroups')) {
1347 .off('.collapseOptionGroup')
1348 .addClass('collapsible-optgroups-enabled')
1349 .on('click.collapseOptionGroup', '.select2-result-with-children > .select2-result-label', function() {
1350 $(this).parent().toggleClass('optgroup-expanded');
1354 .on('select2-close', function() {
1355 $('#select2-drop').off('.collapseOptionGroup').removeClass('collapsible-optgroups-enabled');
1358 })(angular
, CRM
.$, CRM
._
);