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
= {};
15 angular
.module('api4Explorer').config(function($routeProvider
) {
16 $routeProvider
.when('/explorer/:api4entity?/:api4action?', {
17 controller
: 'Api4Explorer',
18 templateUrl
: '~/api4Explorer/Explorer.html',
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
;
28 $scope
.havingOptions
= [];
29 $scope
.fieldsAndJoins
= [];
30 $scope
.fieldsAndJoinsAndFunctions
= [];
31 $scope
.fieldsAndJoinsAndFunctionsWithSuffixes
= [];
32 $scope
.fieldsAndJoinsAndFunctionsAndWildcards
= [];
33 $scope
.availableParams
= {};
36 $scope
.selectedTab
= {result
: 'result', code
: 'php'};
38 accessDebugOutput
: CRM
.checkPerm('access debug output'),
39 editGroups
: CRM
.checkPerm('edit groups')
41 marked
.setOptions({highlight
: prettyPrintOne
});
42 var getMetaParams
= {},
43 objectParams
= {orderBy
: 'ASC', values
: '', defaults
: '', chain
: ['Entity', '', '{}']},
44 docs
= CRM
.vars
.api4
.docs
,
47 $scope
.helpTitle
= '';
48 $scope
.helpContent
= {};
49 $scope
.entity
= $routeParams
.api4entity
;
52 $scope
.status
= 'default';
53 $scope
.loading
= false;
55 $scope
.langs
= ['php', 'js', 'ang', 'cli'];
56 $scope
.joinTypes
= [{k
: false, v
: ts('Optional')}, {k
: true, v
: ts('Required')}];
59 {name
: 'oop', label
: ts('OOP Style'), code
: ''},
60 {name
: 'php', label
: ts('Traditional'), code
: ''}
63 {name
: 'js', label
: ts('Single Call'), code
: ''},
64 {name
: 'js2', label
: ts('Batch Calls'), code
: ''}
67 {name
: 'ang', label
: ts('Single Call'), code
: ''},
68 {name
: 'ang2', label
: ts('Batch Calls'), code
: ''}
71 {name
: 'cv', label
: ts('CV'), code
: ''}
75 if (!entities
.length
) {
76 formatForSelect2(schema
, entities
, 'name', ['description', 'icon']);
85 function ucfirst(str
) {
86 return str
[0].toUpperCase() + str
.slice(1);
89 function lcfirst(str
) {
90 return str
[0].toLowerCase() + str
.slice(1);
93 function pluralize(str
) {
94 switch (str
[str
.length
-1]) {
98 return str
.slice(0, -1) + 'ies';
104 // Reformat an existing array of objects for compatibility with select2
105 function formatForSelect2(input
, container
, key
, extra
, prefix
) {
106 _
.each(input
, function(item
) {
107 var id
= (prefix
|| '') + item
[key
];
108 var formatted
= {id
: id
, text
: id
};
110 _
.merge(formatted
, _
.pick(item
, extra
));
112 container
.push(formatted
);
117 // Returns field list formatted for select2
118 function getFieldList(action
, addPseudoconstant
) {
120 fieldInfo
= _
.findWhere(getEntity().actions
, {name
: action
}).fields
;
121 if (addPseudoconstant
) {
122 fieldInfo
= _
.cloneDeep(fieldInfo
);
123 addPseudoconstants(fieldInfo
, addPseudoconstant
);
125 formatForSelect2(fieldInfo
, fields
, 'name', ['description', 'required', 'default_value']);
129 // Note: this function expects fieldList to be select2-formatted already
130 function addJoins(fieldList
, addWildcard
, addPseudoconstant
) {
131 var fields
= _
.cloneDeep(fieldList
);
132 _
.each(links
[$scope
.entity
], function(link
) {
133 var linkFields
= _
.cloneDeep(entityFields(link
.entity
)),
134 wildCard
= addWildcard
? [{id
: link
.alias
+ '.*', text
: link
.alias
+ '.*', 'description': 'All core ' + link
.entity
+ ' fields'}] : [];
136 if (addPseudoconstant
) {
137 addPseudoconstants(linkFields
, addPseudoconstant
);
141 description
: 'Implicit join to ' + link
.entity
,
142 children
: wildCard
.concat(formatForSelect2(linkFields
, [], 'name', ['description'], link
.alias
+ '.'))
149 // Note: this function transforms a raw list a-la getFields; not a select2-formatted list
150 function addPseudoconstants(fieldList
, toAdd
) {
151 var optionFields
= _
.filter(fieldList
, 'options');
152 _
.each(optionFields
, function(field
) {
153 var pos
= _
.findIndex(fieldList
, {name
: field
.name
}) + 1;
154 _
.each(toAdd
, function(suffix
) {
155 var newField
= _
.cloneDeep(field
);
156 newField
.name
+= ':' + suffix
;
157 fieldList
.splice(pos
, 0, newField
);
162 $scope
.help = function(title
, content
) {
164 $scope
.helpTitle
= helpTitle
;
165 $scope
.helpContent
= helpContent
;
167 $scope
.helpTitle
= title
;
168 $scope
.helpContent
= formatHelp(content
);
172 // Sets the static help text (which gets overridden by mousing over other elements)
173 function setHelp(title
, content
) {
174 $scope
.helpTitle
= helpTitle
= title
;
175 $scope
.helpContent
= helpContent
= formatHelp(content
);
178 // Convert plain-text help to markdown; replace variables and format links
179 function formatHelp(rawContent
) {
180 function formatRefs(see
) {
181 _
.each(see
, function(ref
, idx
) {
182 var match
= ref
.match(/^\\Civi\\Api4\\([a-zA-Z]+)$/);
184 ref
= '#/explorer/' + match
[1];
186 if (ref
[0] === '\\') {
187 ref
= 'https://github.com/civicrm/civicrm-core/blob/master' + ref
.replace(/\\/i, '/') + '.php
';
189 see[idx] = '<a target
="' + (ref[0] === '#' ? '_self' : '_blank') + '" href
="' + ref + '">' + see[idx] + '</a
>';
192 var formatted = _.cloneDeep(rawContent);
193 if (formatted.description) {
194 formatted.description = marked(formatted.description);
196 if (formatted.comment) {
197 formatted.comment = marked(formatted.comment);
199 formatRefs(formatted.see);
203 $scope.fieldHelp = function(fieldName) {
204 var field = getField(fieldName, $scope.entity, $scope.action);
209 description: field.description,
210 type: field.data_type
212 if (field.default_value) {
213 info.default = field.default_value;
215 if (field.required_if) {
216 info.required_if = field.required_if;
217 } else if (field.required) {
218 info.required = 'true';
223 // Returns field list for write params (values, defaults)
224 $scope.fieldList = function(param) {
226 var fields = _.cloneDeep(getFieldList($scope.action === 'getFields
' ? ($scope.params.action || 'get') : $scope.action, ['name
']));
227 // Disable fields that are already in use
228 _.each($scope.params[param] || [], function(val) {
229 var usedField = val[0].replace(':name
', '');
230 (_.findWhere(fields, {id: usedField}) || {}).disabled = true;
231 (_.findWhere(fields, {id: usedField + ':name
'}) || {}).disabled = true;
233 return {results: fields};
237 $scope.formatSelect2Item = function(row) {
238 return _.escape(row.text) +
239 (row.required ? '<span
class="crm-marker"> *</span
>' : '') +
240 (row.description ? '<div
class="crm-select2-row-description"><p
>' + _.escape(row.description) + '</p></div>' : '');
243 $scope.clearParam = function(name, idx) {
244 if (typeof idx === 'undefined') {
245 $scope.params[name] = $scope.availableParams[name].default;
247 $scope.params[name].splice(idx, 1);
251 // Gets params that should be represented as generic input fields in the explorer
252 // This fn doesn't have to be particularly efficient as its output is cached
in one
-time bindings
253 $scope
.getGenericParams = function(paramType
, defaultNull
) {
254 // Returns undefined if params are not yet set; one-time bindings will stabilize when this function returns a value
255 if (_
.isEmpty($scope
.availableParams
)) {
258 var specialParams
= ['select', 'fields', 'action', 'where', 'values', 'defaults', 'orderBy', 'chain', 'groupBy', 'having', 'join'];
259 if ($scope
.availableParams
.limit
&& $scope
.availableParams
.offset
) {
260 specialParams
.push('limit', 'offset');
262 return _
.transform($scope
.availableParams
, function(genericParams
, param
, name
) {
263 if (!_
.contains(specialParams
, name
) &&
264 !(typeof paramType
!== 'undefined' && !_
.contains(paramType
, param
.type
[0])) &&
265 !(typeof defaultNull
!== 'undefined' && ((param
.default === null) !== defaultNull
))
267 genericParams
[name
] = param
;
272 $scope
.selectRowCount = function() {
273 if ($scope
.isSelectRowCount()) {
274 $scope
.params
.select
= [];
276 $scope
.params
.select
= ['row_count'];
278 if ($scope
.params
.limit
== 25) {
279 $scope
.params
.limit
= 0;
284 $scope
.isSelectRowCount = function() {
285 return isSelectRowCount($scope
.params
);
288 $scope
.selectLang = function(lang
) {
289 $scope
.selectedTab
.code
= lang
;
293 function isSelectRowCount(params
) {
294 return params
&& params
.select
&& params
.select
.length
=== 1 && params
.select
[0] === 'row_count';
297 function getEntity(entityName
) {
298 return _
.findWhere(schema
, {name
: entityName
|| $scope
.entity
});
301 // Get all params that have been set
302 function getParams() {
304 _
.each($scope
.params
, function(param
, key
) {
305 if (param
!= $scope
.availableParams
[key
].default && !(typeof param
=== 'object' && _
.isEmpty(param
))) {
306 if (_
.contains($scope
.availableParams
[key
].type
, 'array') && (typeof objectParams
[key
] === 'undefined')) {
307 params
[key
] = parseYaml(JSON
.parse(angular
.toJson(param
)));
313 _
.each(objectParams
, function(defaultVal
, key
) {
316 _
.each(params
[key
], function(item
) {
317 var val
= _
.cloneDeep(item
[1]);
318 // Remove blank items from "chain" array
319 if (_
.isArray(val
)) {
320 _
.eachRight(item
[1], function(v
, k
) {
327 newParam
[item
[0]] = parseYaml(val
);
329 params
[key
] = newParam
;
335 function parseYaml(input
) {
336 if (typeof input
=== 'undefined') {
342 if (_
.isObject(input
) || _
.isArray(input
)) {
343 _
.each(input
, function(item
, index
) {
344 input
[index
] = parseYaml(item
);
349 var output
= (input
=== '>') ? '>' : jsyaml
.safeLoad(input
);
350 // We don't want dates parsed to js objects
351 return _
.isDate(output
) ? input
: output
;
357 function selectAction() {
358 $scope
.action
= $routeParams
.api4action
;
359 $scope
.fieldsAndJoins
.length
= 0;
360 $scope
.fieldsAndJoinsAndFunctions
.length
= 0;
361 $scope
.fieldsAndJoinsAndFunctionsWithSuffixes
.length
= 0;
362 $scope
.fieldsAndJoinsAndFunctionsAndWildcards
.length
= 0;
363 if (!actions
.length
) {
364 formatForSelect2(getEntity().actions
, actions
, 'name', ['description', 'params']);
367 var actionInfo
= _
.findWhere(actions
, {id
: $scope
.action
});
368 $scope
.fields
= getFieldList($scope
.action
);
369 if (_
.contains(['get', 'update', 'delete', 'replace'], $scope
.action
)) {
370 $scope
.fieldsAndJoins
= addJoins(getFieldList($scope
.action
, ['name']));
372 // SQL functions are supported if HAVING is
373 if (actionInfo
.params
.having
) {
375 text
: ts('FUNCTION'),
376 description
: ts('Calculate result of a SQL function'),
377 children
: _
.transform(CRM
.vars
.api4
.functions
, function(result
, fn
) {
379 id
: fn
.name
+ '() AS ' + fn
.name
.toLowerCase(),
380 text
: fn
.name
+ '()',
381 description
: fn
.name
+ '(' + describeSqlFn(fn
.params
) + ')'
386 $scope
.fieldsAndJoinsAndFunctions
= addJoins($scope
.fields
.concat(functions
), true);
387 $scope
.fieldsAndJoinsAndFunctionsWithSuffixes
= addJoins(getFieldList($scope
.action
, ['name', 'label']).concat(functions
), false, ['name', 'label']);
388 $scope
.fieldsAndJoinsAndFunctionsAndWildcards
= addJoins(getFieldList($scope
.action
, ['name', 'label']).concat(functions
), true, ['name', 'label']);
390 $scope
.fieldsAndJoins
= getFieldList($scope
.action
, ['name']);
391 $scope
.fieldsAndJoinsAndFunctions
= $scope
.fields
;
392 $scope
.fieldsAndJoinsAndFunctionsWithSuffixes
= getFieldList($scope
.action
, ['name', 'label']);
393 $scope
.fieldsAndJoinsAndFunctionsAndWildcards
= getFieldList($scope
.action
, ['name', 'label']);
395 $scope
.fieldsAndJoinsAndFunctionsAndWildcards
.unshift({id
: '*', text
: '*', 'description': 'All core ' + $scope
.entity
+ ' fields'});
396 _
.each(actionInfo
.params
, function (param
, name
) {
398 defaultVal
= _
.cloneDeep(param
.default);
400 switch (param
.type
[0]) {
403 format
= param
.type
[0];
414 if (name
=== 'limit') {
417 if (name
=== 'debug') {
420 if (name
=== 'values') {
421 defaultVal
= defaultValues(defaultVal
);
423 if (name
=== 'loadOptions' && $scope
.action
=== 'getFields') {
427 ['id', 'name', 'label'],
428 ['id', 'name', 'label', 'abbr', 'description', 'color', 'icon']
432 param
.type
= ['string'];
434 $scope
.$bindToRoute({
435 expr
: 'params["' + name
+ '"]',
439 deep
: format
=== 'json'
442 if (typeof objectParams
[name
] !== 'undefined' && name
!== 'orderBy') {
443 $scope
.$watch('params.' + name
, function (values
) {
444 // Remove empty values
445 _
.each(values
, function (clause
, index
) {
446 if (!clause
|| !clause
[0]) {
447 $scope
.clearParam(name
, index
);
452 if (name
=== 'select' && actionInfo
.params
.having
) {
453 $scope
.$watchCollection('params.select', function(values
) {
454 $scope
.havingOptions
.length
= 0;
455 _
.each(values
, function(item
) {
456 var pieces
= item
.split(' AS '),
457 alias
= _
.trim(pieces
[pieces
.length
- 1]).replace(':label', ':name');
458 $scope
.havingOptions
.push({id
: alias
, text
: alias
});
462 if (typeof objectParams
[name
] !== 'undefined' || name
=== 'groupBy' || name
=== 'select' || name
=== 'join') {
463 $scope
.$watch('controls.' + name
, function(value
) {
465 $timeout(function() {
467 if (name
=== 'join') {
468 $scope
.params
[name
].push([field
+ ' AS ' + _
.snakeCase(field
), false, '[]']);
470 else if (typeof objectParams
[name
] === 'undefined') {
471 $scope
.params
[name
].push(field
);
473 var defaultOp
= _
.cloneDeep(objectParams
[name
]);
474 if (name
=== 'chain') {
475 var num
= $scope
.params
.chain
.length
;
476 defaultOp
[0] = field
;
477 field
= 'name_me_' + num
;
479 $scope
.params
[name
].push([field
, defaultOp
]);
481 $scope
.controls
[name
] = null;
487 $scope
.availableParams
= actionInfo
.params
;
492 function describeSqlFn(params
) {
494 _
.each(params
, function(param
) {
497 desc
+= _
.filter(param
.prefix
).join('|') + ' ';
499 if (param
.expr
=== 1) {
501 } else if (param
.expr
> 1) {
502 desc
+= 'expr, ... ';
505 desc
+= ' ' + _
.filter(param
.suffix
).join('|') + ' ';
508 return desc
.replace(/[ ]+/g, ' ');
511 function defaultValues(defaultVal
) {
512 _
.each($scope
.fields
, function(field
) {
513 if (field
.required
) {
514 defaultVal
.push([field
.id
, '']);
520 function stringify(value
, trim
) {
521 if (typeof value
=== 'undefined') {
524 var str
= JSON
.stringify(value
).replace(/,/g
, ', ');
526 str
= str
.slice(1, -1);
531 function writeCode() {
533 entity
= $scope
.entity
,
534 action
= $scope
.action
,
535 params
= getParams(),
536 index
= isInt($scope
.index
) ? +$scope
.index
: parseYaml($scope
.index
),
538 if ($scope
.entity
&& $scope
.action
) {
540 if (action
.slice(0, 3) === 'get') {
541 result
= entity
.substr(0, 7) === 'Custom_' ? _
.camelCase(entity
.substr(7)) : entity
;
542 result
= lcfirst(action
.replace(/s$/, '').slice(3) || result
);
544 var results
= lcfirst(_
.isNumber(index
) ? result
: pluralize(result
)),
545 paramCount
= _
.size(params
),
548 if (isSelectRowCount(params
)) {
549 results
= result
+ 'Count';
552 switch ($scope
.selectedTab
.code
) {
556 var js
= "'" + entity
+ "', '" + action
+ "', {";
557 _
.each(params
, function(param
, key
) {
558 js
+= "\n " + key
+ ': ' + stringify(param
) +
559 (++i
< paramCount
? ',' : '');
560 if (key
=== 'checkPermissions') {
561 js
+= ' // IGNORED: permissions are always enforced from client-side requests';
565 if (index
|| index
=== 0) {
566 js
+= ', ' + JSON
.stringify(index
);
568 code
.js
= "CRM.api4(" + js
+ ").then(function(" + results
+ ") {\n // do something with " + results
+ " array\n}, function(failure) {\n // handle failure\n});";
569 code
.js2
= "CRM.api4({" + results
+ ': [' + js
+ "]}).then(function(batch) {\n // do something with batch." + results
+ " array\n}, function(failure) {\n // handle failure\n});";
570 code
.ang
= "crmApi4(" + js
+ ").then(function(" + results
+ ") {\n // do something with " + results
+ " array\n}, function(failure) {\n // handle failure\n});";
571 code
.ang2
= "crmApi4({" + results
+ ': [' + js
+ "]}).then(function(batch) {\n // do something with batch." + results
+ " array\n}, function(failure) {\n // handle failure\n});";
576 code
.php
= '$' + results
+ " = civicrm_api4('" + entity
+ "', '" + action
+ "', [";
577 _
.each(params
, function(param
, key
) {
578 code
.php
+= "\n '" + key
+ "' => " + phpFormat(param
, 4) + ',';
581 if (index
|| index
=== 0) {
582 code
.php
+= ', ' + phpFormat(index
);
587 code
.oop
= '$' + results
+ " = " + formatOOP(entity
, action
, params
, 2) + "\n ->execute()";
588 if (isSelectRowCount(params
)) {
589 code
.oop
+= "\n ->count()";
590 } else if (_
.isNumber(index
)) {
591 code
.oop
+= !index
? '\n ->first()' : (index
=== -1 ? '\n ->last()' : '\n ->itemAt(' + index
+ ')');
593 if (_
.isString(index
) || (_
.isPlainObject(index
) && !index
[0] && !index
['0'])) {
594 code
.oop
+= "\n ->indexBy('" + (_
.isPlainObject(index
) ? _
.keys(index
)[0] : index
) + "')";
596 if (_
.isArray(index
) || _
.isPlainObject(index
)) {
597 code
.oop
+= "\n ->column('" + (_
.isArray(index
) ? index
[0] : _
.values(index
)[0]) + "')";
601 if (!_
.isNumber(index
) && !isSelectRowCount(params
)) {
602 code
.oop
+= "foreach ($" + results
+ ' as $' + ((_
.isString(index
) && index
) ? index
+ ' => $' : '') + result
+ ') {\n // do something\n}';
608 code
.cv
= 'cv api4 ' + entity
+ '.' + action
+ " '" + stringify(params
) + "'";
611 _
.each($scope
.code
, function(vals
) {
612 _
.each(vals
, function(style
) {
613 style
.code
= code
[style
.name
] ? prettyPrintOne(code
[style
.name
]) : '';
619 function formatOOP(entity
, action
, params
, indent
) {
621 newLine
= "\n" + _
.repeat(' ', indent
);
622 if (entity
.substr(0, 7) !== 'Custom_') {
623 code
= "\\Civi\\Api4\\" + entity
+ '::' + action
+ '()';
625 code
= "\\Civi\\Api4\\CustomValue::" + action
+ "('" + entity
.substr(7) + "')";
627 _
.each(params
, function(param
, key
) {
629 if (typeof objectParams
[key
] !== 'undefined' && key
!== 'chain') {
630 _
.each(param
, function(item
, index
) {
631 val
= phpFormat(index
) + ', ' + phpFormat(item
, 2 + indent
);
632 code
+= newLine
+ "->add" + ucfirst(key
).replace(/s$/, '') + '(' + val
+ ')';
634 } else if (key
=== 'where') {
635 _
.each(param
, function (clause
) {
636 if (clause
[0] === 'AND' || clause
[0] === 'OR' || clause
[0] === 'NOT') {
637 code
+= newLine
+ "->addClause(" + phpFormat(clause
[0]) + ", " + phpFormat(clause
[1]).slice(1, -1) + ')';
639 code
+= newLine
+ "->addWhere(" + phpFormat(clause
).slice(1, -1) + ")";
642 } else if (key
=== 'select') {
644 // addSelect() is a variadic function & can take multiple arguments; selectRowCount() is a shortcut for addSelect('row_count')
645 code
+= isSelectRowCount(params
) ? '->selectRowCount()' : '->addSelect(' + phpFormat(param
).slice(1, -1) + ')';
646 } else if (key
=== 'chain') {
647 _
.each(param
, function(chain
, name
) {
648 code
+= newLine
+ "->addChain('" + name
+ "', " + formatOOP(chain
[0], chain
[1], chain
[2], 2 + indent
);
649 code
+= (chain
.length
> 3 ? ',' : '') + (!_
.isEmpty(chain
[2]) ? newLine
: ' ') + (chain
.length
> 3 ? phpFormat(chain
[3]) : '') + ')';
653 code
+= newLine
+ "->set" + ucfirst(key
) + '(' + phpFormat(param
, 2 + indent
) + ')';
659 function isInt(value
) {
660 if (_
.isFinite(value
)) {
663 if (!_
.isString(value
)) {
666 return /^-{0,1}\d+$/.test(value
);
669 function formatMeta(resp
) {
671 _
.each(resp
, function(val
, key
) {
672 if (key
!== 'values' && !_
.isPlainObject(val
) && !_
.isFunction(val
)) {
673 ret
+= (ret
.length
? ', ' : '') + key
+ ': ' + (_
.isArray(val
) ? '[' + val
+ ']' : val
);
676 return prettyPrintOne(_
.escape(ret
));
679 $scope
.execute = function() {
680 $scope
.status
= 'info';
681 $scope
.loading
= true;
682 $http
.post(CRM
.url('civicrm/ajax/api4/' + $scope
.entity
+ '/' + $scope
.action
, {
683 params
: angular
.toJson(getParams()),
684 index
: isInt($scope
.index
) ? +$scope
.index
: parseYaml($scope
.index
)
687 'X-Requested-With': 'XMLHttpRequest'
689 }).then(function(resp
) {
690 $scope
.loading
= false;
691 $scope
.status
= resp
.data
&& resp
.data
.debug
&& resp
.data
.debug
.log
? 'warning' : 'success';
692 $scope
.debug
= debugFormat(resp
.data
);
693 $scope
.result
= [formatMeta(resp
.data
), prettyPrintOne(_
.escape(JSON
.stringify(resp
.data
.values
, null, 2)), 'js', 1)];
695 $scope
.loading
= false;
696 $scope
.status
= 'danger';
697 $scope
.debug
= debugFormat(resp
.data
);
698 $scope
.result
= [formatMeta(resp
), prettyPrintOne(_
.escape(JSON
.stringify(resp
.data
, null, 2)))];
702 function debugFormat(data
) {
703 var debug
= data
.debug
? prettyPrintOne(_
.escape(JSON
.stringify(data
.debug
, null, 2)).replace(/\\n/g, "\n")) : null;
709 * Format value to look like php code
711 function phpFormat(val
, indent
) {
712 if (typeof val
=== 'undefined') {
715 if (val
=== null || val
=== true || val
=== false) {
716 return JSON
.stringify(val
).toUpperCase();
718 indent
= (typeof indent
=== 'number') ? _
.repeat(' ', indent
) : (indent
|| '');
720 baseLine
= indent
? indent
.slice(0, -2) : '',
721 newLine
= indent
? '\n' : '',
722 trailingComma
= indent
? ',' : '';
723 if ($.isPlainObject(val
)) {
724 $.each(val
, function(k
, v
) {
725 ret
+= (ret
? ', ' : '') + newLine
+ indent
+ "'" + k
+ "' => " + phpFormat(v
);
727 return '[' + ret
+ trailingComma
+ newLine
+ baseLine
+ ']';
729 if ($.isArray(val
)) {
730 $.each(val
, function(k
, v
) {
731 ret
+= (ret
? ', ' : '') + newLine
+ indent
+ phpFormat(v
);
733 return '[' + ret
+ trailingComma
+ newLine
+ baseLine
+ ']';
735 if (_
.isString(val
) && !_
.contains(val
, "'")) {
736 return "'" + val
+ "'";
738 return JSON
.stringify(val
).replace(/\$/g, '\\$');
741 function fetchMeta() {
742 crmApi4(getMetaParams
)
743 .then(function(data
) {
745 getEntity().actions
= data
.actions
;
751 // Help for an entity with no action selected
752 function showEntityHelp(entityName
) {
753 var entityInfo
= getEntity(entityName
);
754 setHelp($scope
.entity
, {
755 description
: entityInfo
.description
,
756 comment
: entityInfo
.comment
,
761 if (!$scope
.entity
) {
762 setHelp(ts('APIv4 Explorer'), {description
: docs
.description
, comment
: docs
.comment
, see
: docs
.see
});
763 } else if (!actions
.length
&& !getEntity().actions
) {
764 getMetaParams
.actions
= [$scope
.entity
, 'getActions', {chain
: {fields
: [$scope
.entity
, 'getFields', {action
: '$name'}]}}];
771 showEntityHelp($scope
.entity
);
774 // Update route when changing entity
775 $scope
.$watch('entity', function(newVal
, oldVal
) {
776 if (oldVal
!== newVal
) {
777 // Flush actions cache to re-fetch for new entity
779 $location
.url('/explorer/' + newVal
);
783 // Update route when changing actions
784 $scope
.$watch('action', function(newVal
, oldVal
) {
785 if ($scope
.entity
&& $routeParams
.api4action
!== newVal
&& !_
.isUndefined(newVal
)) {
786 $location
.url('/explorer/' + $scope
.entity
+ '/' + newVal
);
788 setHelp($scope
.entity
+ '::' + newVal
, _
.pick(_
.findWhere(getEntity().actions
, {name
: newVal
}), ['description', 'comment', 'see']));
792 $scope
.paramDoc = function(name
) {
793 return docs
.params
[name
];
796 $scope
.executeDoc = function() {
798 description
: ts('Runs API call on the CiviCRM database.'),
799 comment
: ts('Results and debugging info will be displayed below.')
801 if ($scope
.action
=== 'delete') {
802 doc
.WARNING
= ts('This API call will be executed on the real database. Deleting data cannot be undone.');
804 else if ($scope
.action
&& $scope
.action
.slice(0, 3) !== 'get') {
805 doc
.WARNING
= ts('This API call will be executed on the real database. It cannot be undone.');
810 $scope
.saveDoc = function() {
812 description
: ts('Save API call as a smart group.'),
813 comment
: ts('Create a SavedSearch using these API params to populate a smart group.') +
814 '\n\n' + ts('NOTE: you must select contact id as the only field.')
818 $scope
.$watch('params', writeCode
, true);
819 $scope
.$watch('index', writeCode
);
822 $scope
.save = function() {
823 $scope
.params
.limit
= $scope
.params
.offset
= 0;
824 if ($scope
.params
.chain
.length
) {
825 CRM
.alert(ts('Smart groups are not compatible with API chaining.'), ts('Error'), 'error', {expires
: 5000});
828 if ($scope
.params
.select
.length
!== 1 || !_
.includes($scope
.params
.select
[0], 'id')) {
829 CRM
.alert(ts('To create a smart group, the API must select contact id and no other fields.'), ts('Error'), 'error', {expires
: 5000});
835 visibility
: 'User and User Admin Only',
838 entity
: $scope
.entity
,
839 params
: JSON
.parse(angular
.toJson($scope
.params
))
841 model
.params
.version
= 4;
842 delete model
.params
.chain
;
843 delete model
.params
.debug
;
844 delete model
.params
.limit
;
845 delete model
.params
.offset
;
846 delete model
.params
.orderBy
;
847 delete model
.params
.checkPermissions
;
848 var options
= CRM
.utils
.adjustDialogDefaults({
851 title
: ts('Save smart group')
853 dialogService
.open('saveSearchDialog', '~/api4Explorer/SaveSearch.html', model
, options
);
857 angular
.module('api4Explorer').controller('SaveSearchCtrl', function($scope
, crmApi4
, dialogService
) {
858 var ts
= $scope
.ts
= CRM
.ts(),
859 model
= $scope
.model
;
860 $scope
.groupEntityRefParams
= {
863 params
: {is_hidden
: 0, is_active
: 1, 'saved_search_id.api_entity': model
.entity
},
864 extra
: ['saved_search_id', 'description', 'visibility', 'group_type']
868 minimumInputLength
: 0,
869 placeholder
: ts('Select existing group')
872 if (!CRM
.checkPerm('administer reserved groups')) {
873 $scope
.groupEntityRefParams
.api
.params
.is_reserved
= 0;
876 administerReservedGroups
: CRM
.checkPerm('administer reserved groups')
878 $scope
.options
= CRM
.vars
.api4
.groupOptions
;
879 $scope
.$watch('model.id', function(id
) {
881 _
.assign(model
, $('#api-save-search-select-group').select2('data').extra
);
884 $scope
.cancel = function() {
885 dialogService
.cancel('saveSearchDialog');
887 $scope
.save = function() {
888 $('.ui-dialog:visible').block();
889 var group
= model
.id
? {id
: model
.id
} : {title
: model
.title
};
890 group
.description
= model
.description
;
891 group
.visibility
= model
.visibility
;
892 group
.group_type
= model
.group_type
;
893 group
.saved_search_id
= '$id';
895 api_entity
: model
.entity
,
896 api_params
: model
.params
899 savedSearch
.id
= model
.saved_search_id
;
901 crmApi4('SavedSearch', 'save', {records
: [savedSearch
], chain
: {group
: ['Group', 'save', {'records': [group
]}]}})
902 .then(function(result
) {
903 dialogService
.close('saveSearchDialog', result
[0]);
908 angular
.module('api4Explorer').directive('crmApi4Clause', function($timeout
) {
911 data
: '=crmApi4Clause'
913 templateUrl
: '~/api4Explorer/Clause.html',
914 link: function (scope
, element
, attrs
) {
915 var ts
= scope
.ts
= CRM
.ts();
916 scope
.newClause
= '';
917 scope
.conjunctions
= ['AND', 'OR', 'NOT'];
918 scope
.operators
= CRM
.vars
.api4
.operators
;
920 scope
.addGroup = function(op
) {
921 scope
.data
.clauses
.push([op
, []]);
924 scope
.removeGroup = function() {
925 scope
.data
.groupParent
.splice(scope
.data
.groupIndex
, 1);
928 scope
.onSort = function(event
, ui
) {
929 $(element
).closest('.api4-clause-fieldset').toggleClass('api4-sorting', event
.type
=== 'sortstart');
930 $('.api4-input.form-inline').css('margin-left', '');
933 // Indent clause while dragging between nested groups
934 scope
.onSortOver = function(event
, ui
) {
937 offset
= $(ui
.placeholder
).offset().left
- $(ui
.sender
).offset().left
;
939 $('.api4-input.form-inline.ui-sortable-helper').css('margin-left', '' + offset
+ 'px');
942 scope
.$watch('newClause', function(value
) {
944 $timeout(function() {
946 scope
.data
.clauses
.push([field
, '=', '']);
947 scope
.newClause
= null;
951 scope
.$watch('data.clauses', function(values
) {
952 // Remove empty values
953 _
.each(values
, function(clause
, index
) {
954 if (typeof clause
!== 'undefined' && !clause
[0]) {
955 values
.splice(index
, 1);
957 if (typeof clause
[1] === 'string' && _
.contains(clause
[1], 'NULL')) {
959 } else if (typeof clause
[1] === 'string' && clause
.length
== 2) {
968 angular
.module('api4Explorer').directive('api4ExpValue', function($routeParams
, crmApi4
) {
971 data
: '=api4ExpValue'
974 link: function (scope
, element
, attrs
, ctrl
) {
975 var ts
= scope
.ts
= CRM
.ts(),
976 multi
= _
.includes(['IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN'], scope
.data
.op
),
977 entity
= $routeParams
.api4entity
,
978 action
= scope
.data
.action
|| $routeParams
.api4action
;
980 function destroyWidget() {
981 var $el
= $(element
);
982 if ($el
.is('.crm-form-date-wrapper .crm-hidden-date')) {
983 $el
.crmDatepicker('destroy');
985 if ($el
.is('.select2-container + input')) {
986 $el
.crmEntityRef('destroy');
988 $(element
).removeData().removeAttr('type').removeAttr('placeholder').show();
991 function makeWidget(field
, op
) {
992 var $el
= $(element
),
993 inputType
= field
.input_type
,
994 dataType
= field
.data_type
;
996 op
= field
.serialize
|| dataType
=== 'Array' ? 'IN' : '=';
998 multi
= _
.includes(['IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN'], op
);
999 if (op
=== 'IS NULL' || op
=== 'IS NOT NULL') {
1003 if (inputType
=== 'Date') {
1004 if (_
.includes(['=', '!=', '<>', '<', '>=', '<', '<='], op
)) {
1005 $el
.crmDatepicker({time
: (field
.input_attrs
&& field
.input_attrs
.time
) || false});
1007 } else if (_
.includes(['=', '!=', '<>', 'IN', 'NOT IN'], op
) && (field
.fk_entity
|| field
.options
|| dataType
=== 'Boolean')) {
1008 if (field
.options
) {
1009 var id
= field
.pseudoconstant
|| 'id';
1010 $el
.addClass('loading').attr('placeholder', ts('- select -')).crmSelect2({multiple
: multi
, data
: [{id
: '', text
: ''}]});
1011 loadFieldOptions(field
.entity
|| entity
).then(function(data
) {
1012 var options
= _
.transform(data
[field
.name
].options
, function(options
, opt
) {
1013 options
.push({id
: opt
[id
], text
: opt
.label
, description
: opt
.description
, color
: opt
.color
, icon
: opt
.icon
});
1015 $el
.removeClass('loading').crmSelect2({data
: options
, multiple
: multi
});
1017 } else if (field
.fk_entity
) {
1018 $el
.crmEntityRef({entity
: field
.fk_entity
, select
:{multiple
: multi
}});
1019 } else if (dataType
=== 'Boolean') {
1020 $el
.attr('placeholder', ts('- select -')).crmSelect2({allowClear
: false, multiple
: multi
, placeholder
: ts('- select -'), data
: [
1021 {id
: 'true', text
: ts('Yes')},
1022 {id
: 'false', text
: ts('No')}
1025 } else if (dataType
=== 'Integer' && !multi
) {
1026 $el
.attr('type', 'number');
1030 function loadFieldOptions(entity
) {
1031 if (!fieldOptions
[entity
+ action
]) {
1032 fieldOptions
[entity
+ action
] = crmApi4(entity
, 'getFields', {
1033 loadOptions
: ['id', 'name', 'label', 'description', 'color', 'icon'],
1035 where
: [['options', '!=', false]],
1039 return fieldOptions
[entity
+ action
];
1042 // Copied from ng-list but applied conditionally if field is multi-valued
1043 var parseList = function(viewValue
) {
1044 // If the viewValue is invalid (say required but empty) it will be `undefined`
1045 if (_
.isUndefined(viewValue
)) return;
1054 _
.each(viewValue
.split(','), function(value
) {
1055 if (value
) list
.push(_
.trim(value
));
1062 // Copied from ng-list
1063 ctrl
.$parsers
.push(parseList
);
1064 ctrl
.$formatters
.push(function(value
) {
1065 return _
.isArray(value
) ? value
.join(', ') : value
;
1068 // Copied from ng-list
1069 ctrl
.$isEmpty = function(value
) {
1070 return !value
|| !value
.length
;
1073 scope
.$watchCollection('data', function(data
) {
1075 var field
= getField(data
.field
, entity
, action
);
1077 makeWidget(field
, data
.op
);
1085 angular
.module('api4Explorer').directive('api4ExpChain', function(crmApi4
) {
1088 chain
: '=api4ExpChain',
1092 templateUrl
: '~/api4Explorer/Chain.html',
1093 link: function (scope
, element
, attrs
) {
1094 var ts
= scope
.ts
= CRM
.ts();
1096 function changeEntity(newEntity
, oldEntity
) {
1097 // When clearing entity remove this chain
1099 scope
.chain
[0] = '';
1102 // Reset action && index
1103 if (newEntity
!== oldEntity
) {
1104 scope
.chain
[1][1] = scope
.chain
[1][2] = '';
1106 if (getEntity(newEntity
).actions
) {
1109 crmApi4(newEntity
, 'getActions', {chain
: {fields
: [newEntity
, 'getFields', {action
: '$name'}]}})
1110 .then(function(data
) {
1111 getEntity(data
.entity
).actions
= data
;
1112 if (data
.entity
=== scope
.chain
[1][0]) {
1119 function setActions() {
1120 scope
.actions
= [''].concat(_
.pluck(getEntity(scope
.chain
[1][0]).actions
, 'name'));
1123 // Set default params when choosing action
1124 function changeAction(newAction
, oldAction
) {
1126 // Prepopulate links
1127 if (newAction
&& newAction
!== oldAction
) {
1129 scope
.chain
[1][3] = '';
1130 // Look for links back to main entity
1131 _
.each(entityFields(scope
.chain
[1][0]), function(field
) {
1132 if (field
.fk_entity
=== scope
.mainEntity
) {
1133 link
= [field
.name
, '$id'];
1136 // Look for links from main entity
1137 if (!link
&& newAction
!== 'create') {
1138 _
.each(entityFields(scope
.mainEntity
), function(field
) {
1139 if (field
.fk_entity
=== scope
.chain
[1][0]) {
1140 link
= ['id', '$' + field
.name
];
1141 // Since we're specifying the id, set index to getsingle
1142 scope
.chain
[1][3] = '0';
1146 if (link
&& _
.contains(['get', 'update', 'replace', 'delete'], newAction
)) {
1147 scope
.chain
[1][2] = '{where: [[' + link
[0] + ', =, ' + link
[1] + ']]}';
1149 else if (link
&& _
.contains(['create'], newAction
)) {
1150 scope
.chain
[1][2] = '{values: {' + link
[0] + ': ' + link
[1] + '}}';
1152 else if (link
&& _
.contains(['save'], newAction
)) {
1153 scope
.chain
[1][2] = '{records: [{' + link
[0] + ': ' + link
[1] + '}]}';
1155 scope
.chain
[1][2] = '{}';
1160 scope
.$watch("chain[1][0]", changeEntity
);
1161 scope
.$watch("chain[1][1]", changeAction
);
1166 function getEntity(entityName
) {
1167 return _
.findWhere(schema
, {name
: entityName
});
1170 function entityFields(entityName
, action
) {
1171 var entity
= getEntity(entityName
);
1172 if (entity
&& action
&& entity
.actions
) {
1173 return _
.findWhere(entity
.actions
, {name
: action
}).fields
;
1175 return _
.result(entity
, 'fields');
1178 function getField(fieldName
, entity
, action
) {
1179 var suffix
= fieldName
.split(':')[1];
1180 fieldName
= fieldName
.split(':')[0];
1181 var fieldNames
= fieldName
.split('.');
1182 var field
= get(entity
, fieldNames
);
1183 if (field
&& suffix
) {
1184 field
.pseudoconstant
= suffix
;
1188 function get(entity
, fieldNames
) {
1189 if (fieldNames
.length
=== 1) {
1190 return _
.findWhere(entityFields(entity
, action
), {name
: fieldNames
[0]});
1192 var comboName
= _
.findWhere(entityFields(entity
, action
), {name
: fieldNames
[0] + '.' + fieldNames
[1]});
1196 var linkName
= fieldNames
.shift(),
1197 newEntity
= _
.findWhere(links
[entity
], {alias
: linkName
}).entity
;
1198 return get(newEntity
, fieldNames
);
1202 // Collapsible optgroups for select2
1205 .on('select2-open', function(e
) {
1206 if ($(e
.target
).hasClass('collapsible-optgroups')) {
1208 .off('.collapseOptionGroup')
1209 .addClass('collapsible-optgroups-enabled')
1210 .on('click.collapseOptionGroup', '.select2-result-with-children > .select2-result-label', function() {
1211 $(this).parent().toggleClass('optgroup-expanded');
1215 .on('select2-close', function() {
1216 $('#select2-drop').off('.collapseOptionGroup').removeClass('collapsible-optgroups-enabled');
1219 })(angular
, CRM
.$, CRM
._
);