1 (function(angular
, $, _
, undefined) {
4 var schema
= CRM
.vars
.api4
.schema
;
5 // Cache list of entities
7 // Cache list of actions
10 var fieldOptions
= {};
14 angular
.module('api4Explorer').config(function($routeProvider
) {
15 $routeProvider
.when('/explorer/:api4entity?/:api4action?', {
16 controller
: 'Api4Explorer',
17 templateUrl
: '~/api4Explorer/Explorer.html',
22 angular
.module('api4Explorer').controller('Api4Explorer', function($scope
, $routeParams
, $location
, $timeout
, $http
, crmUiHelp
, crmApi4
, dialogService
) {
23 var ts
= $scope
.ts
= CRM
.ts(),
24 ctrl
= $scope
.$ctrl
= this;
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
= {};
34 params
= $scope
.params
= {};
36 $scope
.selectedTab
= {result
: 'result'};
37 $scope
.crmUrl
= CRM
.url
;
39 accessDebugOutput
: CRM
.checkPerm('access debug output'),
40 editGroups
: CRM
.checkPerm('edit groups')
42 marked
.setOptions({highlight
: prettyPrintOne
});
43 var getMetaParams
= {},
44 objectParams
= {orderBy
: 'ASC', values
: '', defaults
: '', chain
: ['Entity', '', '{}']},
45 docs
= CRM
.vars
.api4
.docs
,
49 $scope
.helpTitle
= '';
50 $scope
.helpContent
= {};
51 $scope
.entity
= $routeParams
.api4entity
;
54 $scope
.status
= 'default';
55 $scope
.loading
= false;
57 $scope
.langs
= ['php', 'js', 'ang', 'cli', 'rest'];
58 $scope
.joinTypes
= [{k
: 'LEFT', v
: 'LEFT JOIN'}, {k
: 'INNER', v
: 'INNER JOIN'}, {k
: 'EXCLUDE', v
: 'EXCLUDE'}];
59 $scope
.bridgeEntities
= _
.filter(schema
, function(entity
) {return _
.includes(entity
.type
, 'EntityBridge');});
62 {name
: 'oop', label
: ts('OOP Style'), code
: ''},
63 {name
: 'php', label
: ts('Traditional'), code
: ''}
66 {name
: 'js', label
: ts('Single Call'), code
: ''},
67 {name
: 'js2', label
: ts('Batch Calls'), code
: ''}
70 {name
: 'ang', label
: ts('Single Call'), code
: ''},
71 {name
: 'ang2', label
: ts('Batch Calls'), code
: ''}
74 {name
: 'short', label
: ts('CV (short)'), code
: ''},
75 {name
: 'long', label
: ts('CV (long)'), code
: ''},
76 {name
: 'pipe', label
: ts('CV (pipe)'), code
: ''}
79 {name
: 'curl', label
: ts('Curl'), code
: ''},
80 {name
: 'restphp', label
: ts('PHP (std)'), code
: ''},
81 {name
: 'guzzle', label
: ts('PHP + Guzzle'), code
: ''}
84 this.resultFormats
= [
87 label
: ts('View as JSON')
91 label
: ts('View as PHP')
94 this.authxEnabled
= CRM
.vars
.api4
.authxEnabled
;
96 if (!entities
.length
) {
97 formatForSelect2(schema
, entities
, 'name', ['description', 'icon']);
100 // Prefix other url args with an underscore to avoid conflicts with param names
101 $scope
.$bindToRoute({
106 $scope
.$bindToRoute({
107 expr
: 'selectedTab.code',
112 $scope
.$bindToRoute({
113 expr
: '$ctrl.resultFormat',
119 function ucfirst(str
) {
120 return str
[0].toUpperCase() + str
.slice(1);
123 function lcfirst(str
) {
124 return str
[0].toLowerCase() + str
.slice(1);
127 function pluralize(str
) {
128 var lastLetter
= str
[str
.length
- 1],
129 lastTwo
= str
[str
.length
- 2] + lastLetter
;
130 if (lastLetter
=== 's' || lastLetter
=== 'x' || lastTwo
=== 'ch') {
133 if (lastLetter
=== 'y' && !_
.includes(['ay', 'ey', 'iy', 'oy', 'uy'], lastTwo
)) {
134 return str
.slice(0, -1) + 'ies';
139 // Reformat an existing array of objects for compatibility with select2
140 function formatForSelect2(input
, container
, key
, extra
, prefix
) {
141 _
.each(input
, function(item
) {
142 var id
= (prefix
|| '') + item
[key
];
143 var formatted
= {id
: id
, text
: id
};
145 _
.merge(formatted
, _
.pick(item
, extra
));
147 container
.push(formatted
);
152 // Replaces contents of fieldList array with current fields formatted for select2
153 function getFieldList(fieldList
, action
, addPseudoconstant
, addWriteJoins
) {
154 var fieldInfo
= _
.cloneDeep(_
.findWhere(getEntity().actions
, {name
: action
}).fields
);
155 fieldList
.length
= 0;
156 if (addPseudoconstant
) {
157 addPseudoconstants(fieldInfo
, addPseudoconstant
);
160 addWriteJoinFields(fieldInfo
);
162 formatForSelect2(fieldInfo
, fieldList
, 'name', ['description', 'required', 'default_value']);
165 // Note: this function expects fieldList to be select2-formatted already
166 function addJoins(fieldList
, addWildcard
, addPseudoconstant
) {
167 // Add entities specified by the join param
168 _
.each(getExplicitJoins(), function(join
) {
169 var wildCard
= addWildcard
? [{id
: join
.alias
+ '.*', text
: join
.alias
+ '.*', 'description': 'All core ' + join
.entity
+ ' fields'}] : [],
170 joinFields
= _
.cloneDeep(entityFields(join
.entity
));
172 // Add fields from bridge entity
174 var bridgeFields
= _
.cloneDeep(entityFields(join
.bridge
)),
175 bridgeEntity
= getEntity(join
.bridge
),
176 joinFieldNames
= _
.pluck(joinFields
, 'name'),
177 // Check if this is a symmetric bridge e.g. RelationshipCache joins Contact to Contact
178 bridgePair
= _
.keys(bridgeEntity
.bridge
),
179 symmetric
= getField(bridgePair
[0], join
.bridge
).entity
=== getField(bridgePair
[1], join
.bridge
).entity
;
180 _
.each(bridgeFields
, function(field
) {
182 // Only include bridge fields that link back to the original entity
183 (!bridgeEntity
.bridge
[field
.name
] || field
.fk_entity
!== join
.entity
|| symmetric
) &&
184 // Exclude fields with the same name as those in the original entity
185 !_
.includes(joinFieldNames
, field
.name
)
187 joinFields
.push(field
);
191 if (addPseudoconstant
) {
192 addPseudoconstants(joinFields
, addPseudoconstant
);
195 text
: join
.entity
+ ' AS ' + join
.alias
,
196 description
: 'Explicit join to ' + join
.entity
,
197 children
: wildCard
.concat(formatForSelect2(joinFields
, [], 'name', ['description'], join
.alias
+ '.'))
201 // Add implicit joins based on schema links
202 _
.each(entityFields($scope
.entity
, $scope
.action
), function(field
) {
203 if (field
.fk_entity
) {
204 var linkFields
= _
.cloneDeep(entityFields(field
.fk_entity
)),
205 wildCard
= addWildcard
? [{id
: field
.name
+ '.*', text
: field
.name
+ '.*', 'description': 'All core ' + field
.fk_entity
+ ' fields'}] : [];
206 if (addPseudoconstant
) {
207 addPseudoconstants(linkFields
, addPseudoconstant
);
211 description
: 'Implicit join to ' + field
.fk_entity
,
212 children
: wildCard
.concat(formatForSelect2(linkFields
, [], 'name', ['description'], field
.name
+ '.'))
218 // Note: this function transforms a raw list a-la getFields; not a select2-formatted list
219 function addPseudoconstants(fieldList
, toAdd
) {
220 var optionFields
= _
.filter(fieldList
, 'options');
221 _
.each(optionFields
, function(field
) {
222 var pos
= _
.findIndex(fieldList
, {name
: field
.name
}) + 1;
223 _
.each(toAdd
, function(suffix
) {
224 var newField
= _
.cloneDeep(field
);
225 newField
.name
+= ':' + suffix
;
226 fieldList
.splice(pos
, 0, newField
);
231 // Adds join fields for create actions
232 // Note: this function transforms a raw list a-la getFields; not a select2-formatted list
233 function addWriteJoinFields(fieldList
) {
234 _
.eachRight(fieldList
, function(field
, pos
) {
235 var fkNameField
= field
.fk_entity
&& getField('name', field
.fk_entity
, $scope
.action
);
237 var newField
= _
.cloneDeep(fkNameField
);
238 newField
.name
= field
.name
+ '.' + newField
.name
;
239 fieldList
.splice(pos
, 0, newField
);
244 $scope
.help = function(title
, content
) {
246 $scope
.helpTitle
= helpTitle
;
247 $scope
.helpContent
= helpContent
;
249 $scope
.helpTitle
= title
;
250 $scope
.helpContent
= formatHelp(content
);
254 // Sets the static help text (which gets overridden by mousing over other elements)
255 function setHelp(title
, content
) {
256 $scope
.helpTitle
= helpTitle
= title
;
257 $scope
.helpContent
= helpContent
= formatHelp(content
);
260 // Format help text with markdown; replace variables and format links
261 function formatHelp(rawContent
) {
262 function formatRefs(see
) {
263 _
.each(see
, function(ref
, idx
) {
264 var match
= ref
.match(/^(\\Civi\\Api4\\)?([a-zA-Z]+)$/);
266 ref
= '#/explorer/' + match
[2];
268 // Link to php classes on GitHub.
269 // Fixme: Only works for files in the core repo
270 if (ref
[0] === '\\' || ref
.indexOf('Civi\\') === 0 || ref
.indexOf('CRM_') === 0) {
271 var classFunction
= _
.trim(ref
, '\\').split('::'),
272 replacement
= new RegExp(classFunction
[0].indexOf('CRM_') === 0 ? '_' : '\\\\', 'g');
273 ref
= 'https://github.com/civicrm/civicrm-core/blob/master/' + classFunction
[0].replace(replacement
, '/') + '.php';
275 see
[idx
] = '<a target="' + (ref
[0] === '#' ? '_self' : '_blank') + '" href="' + ref
+ '">' + see
[idx
] + '</a>';
278 var formatted
= _
.cloneDeep(rawContent
);
279 if (formatted
.description
) {
280 formatted
.description
= marked(formatted
.description
);
282 if (formatted
.comment
) {
283 formatted
.comment
= marked(formatted
.comment
);
285 formatRefs(formatted
.see
);
289 $scope
.fieldHelp = function(fieldName
) {
290 var field
= getField(fieldName
, $scope
.entity
, $scope
.action
);
295 description
: field
.description
,
296 type
: field
.data_type
298 if (field
.default_value
) {
299 info
.default = field
.default_value
;
301 if (field
.required_if
) {
302 info
.required_if
= field
.required_if
;
303 } else if (field
.required
) {
304 info
.required
= 'true';
309 // Returns field list for write params (values, defaults)
310 $scope
.fieldList = function(param
) {
313 getFieldList(fields
, $scope
.action
=== 'getFields' ? ($scope
.params
.action
|| 'get') : $scope
.action
, ['name'], true);
314 // Disable fields that are already in use
315 _
.each($scope
.params
[param
] || [], function(val
) {
316 var usedField
= val
[0].replace(/[:.]name/, '');
317 (_
.findWhere(fields
, {id
: usedField
}) || {}).disabled
= true;
318 (_
.findWhere(fields
, {id
: usedField
+ ':name'}) || {}).disabled
= true;
319 (_
.findWhere(fields
, {id
: usedField
+ '.name'}) || {}).disabled
= true;
321 return {results
: fields
};
325 $scope
.formatSelect2Item = function(row
) {
326 return _
.escape(row
.text
) +
327 (row
.required
? '<span class="crm-marker"> *</span>' : '') +
328 (row
.description
? '<div class="crm-select2-row-description"><p>' + _
.escape(row
.description
) + '</p></div>' : '');
331 $scope
.clearParam = function(name
, idx
) {
332 if (typeof idx
=== 'undefined') {
333 $scope
.params
[name
] = $scope
.availableParams
[name
].default;
335 $scope
.params
[name
].splice(idx
, 1);
339 // Gets params that should be represented as generic input fields in the explorer
340 // This fn doesn't have to be particularly efficient as its output is cached in one-time bindings
341 $scope
.getGenericParams = function(paramType
, defaultNull
) {
342 // Returns undefined if params are not yet set; one-time bindings will stabilize when this function returns a value
343 if (_
.isEmpty($scope
.availableParams
)) {
346 var specialParams
= ['select', 'fields', 'action', 'where', 'values', 'defaults', 'orderBy', 'chain', 'groupBy', 'having', 'join'];
347 if ($scope
.availableParams
.limit
&& $scope
.availableParams
.offset
) {
348 specialParams
.push('limit', 'offset');
350 return _
.transform($scope
.availableParams
, function(genericParams
, param
, name
) {
351 if (!_
.contains(specialParams
, name
) && !param
.deprecated
&&
352 !(typeof paramType
!== 'undefined' && !_
.contains(paramType
, param
.type
[0])) &&
353 !(typeof defaultNull
!== 'undefined' && ((param
.default === null) !== defaultNull
))
355 genericParams
[name
] = param
;
360 $scope
.selectRowCount = function() {
361 var index
= params
.select
.indexOf('row_count');
363 $scope
.params
.select
.push('row_count');
365 $scope
.params
.select
.splice(index
, 1);
369 $scope
.isSelectRowCount = function() {
370 return isSelectRowCount($scope
.params
);
373 $scope
.selectLang = function(lang
) {
374 $scope
.selectedTab
.code
= lang
;
378 function isSelectRowCount(params
) {
379 return params
&& params
.select
&& params
.select
.indexOf('row_count') >= 0;
382 function getEntity(entityName
) {
383 return _
.findWhere(schema
, {name
: entityName
|| $scope
.entity
});
386 // Get name of entity given join alias
387 function entityNameFromAlias(alias
) {
388 var joins
= getExplicitJoins(),
389 entity
= $scope
.entity
,
390 path
= alias
.split('.');
391 // First check explicit joins
393 return joins
[alias
].entity
;
395 // Then lookup implicit joins
396 _
.each(path
, function(node
) {
397 var field
= getField(node
, entity
, $scope
.action
);
398 if (!field
|| !field
.fk_entity
) {
401 entity
= field
.fk_entity
;
406 // Get all params that have been set
407 function getParams() {
409 _
.each($scope
.params
, function(param
, key
) {
410 if (param
!= $scope
.availableParams
[key
].default && !(typeof param
=== 'object' && _
.isEmpty(param
))) {
411 if (_
.contains($scope
.availableParams
[key
].type
, 'array') && (typeof objectParams
[key
] === 'undefined')) {
412 params
[key
] = parseYaml(JSON
.parse(angular
.toJson(param
)));
418 _
.each(params
.join
, function(join
) {
419 // Add alias if not specified
420 if (!_
.contains(join
[0], 'AS')) {
421 join
[0] += ' AS ' + join
[0].toLowerCase();
423 // Remove EntityBridge from join if empty
428 _
.each(objectParams
, function(defaultVal
, key
) {
431 _
.each(params
[key
], function(item
) {
432 var val
= _
.cloneDeep(item
[1]);
433 // Remove blank items from "chain" array
434 if (_
.isArray(val
)) {
435 _
.eachRight(item
[1], function(v
) {
442 newParam
[item
[0]] = parseYaml(val
);
444 params
[key
] = newParam
;
450 function parseYaml(input
) {
451 if (typeof input
=== 'undefined' || input
=== '') {
454 // Return literal quoted string without removing quotes - for the sake of JOIN ON clauses
455 if (_
.isString(input
) && input
[0] === input
[input
.length
- 1] && _
.includes(["'", '"'], input
[0])) {
458 if (_
.isObject(input
) || _
.isArray(input
)) {
459 _
.each(input
, function(item
, index
) {
460 input
[index
] = parseYaml(item
);
465 var output
= (input
=== '>') ? '>' : jsyaml
.safeLoad(input
);
466 // We don't want dates parsed to js objects
467 return _
.isDate(output
) ? input
: output
;
473 this.buildFieldList = function() {
474 var actionInfo
= _
.findWhere(actions
, {id
: $scope
.action
});
475 getFieldList($scope
.fields
, $scope
.action
);
476 getFieldList($scope
.fieldsAndJoins
, $scope
.action
, ['name']);
477 getFieldList($scope
.fieldsAndJoinsAndFunctions
, $scope
.action
);
478 getFieldList($scope
.fieldsAndJoinsAndFunctionsWithSuffixes
, $scope
.action
, ['name', 'label']);
479 getFieldList($scope
.fieldsAndJoinsAndFunctionsAndWildcards
, $scope
.action
, ['name', 'label']);
480 if (_
.contains(['get', 'update', 'delete', 'replace'], $scope
.action
)) {
481 addJoins($scope
.fieldsAndJoins
);
482 // SQL functions are supported if HAVING is
483 if (actionInfo
.params
.having
) {
485 text
: ts('FUNCTION'),
486 description
: ts('Calculate result of a SQL function'),
487 children
: _
.transform(CRM
.vars
.api4
.functions
, function(result
, fn
) {
489 id
: fn
.name
+ '() AS ' + fn
.name
.toLowerCase(),
490 description
: fn
.description
,
491 text
: fn
.name
+ '(' + describeSqlFn(fn
.params
) + ')'
495 $scope
.fieldsAndJoinsAndFunctions
.push(functions
);
496 $scope
.fieldsAndJoinsAndFunctionsWithSuffixes
.push(functions
);
497 $scope
.fieldsAndJoinsAndFunctionsAndWildcards
.push(functions
);
499 addJoins($scope
.fieldsAndJoinsAndFunctions
, true);
500 addJoins($scope
.fieldsAndJoinsAndFunctionsWithSuffixes
, false, ['name', 'label']);
501 addJoins($scope
.fieldsAndJoinsAndFunctionsAndWildcards
, true, ['name', 'label']);
503 // Custom fields are supported if HAVING is
504 if (actionInfo
.params
.having
) {
505 $scope
.fieldsAndJoinsAndFunctionsAndWildcards
.unshift({id
: 'custom.*', text
: 'custom.*', 'description': 'All custom fields'});
507 $scope
.fieldsAndJoinsAndFunctionsAndWildcards
.unshift({id
: '*', text
: '*', 'description': 'All core ' + $scope
.entity
+ ' fields'});
510 // Select2 formatter: Add 'strikethrough' class to deprecated items
511 $scope
.formatResultCssClass = function(result
) {
512 return result
.deprecated
? 'strikethrough' : '';
515 function selectAction() {
516 $scope
.action
= $routeParams
.api4action
;
517 if (!actions
.length
) {
518 formatForSelect2(getEntity().actions
, actions
, 'name', ['description', 'params', 'deprecated']);
521 var actionInfo
= _
.findWhere(actions
, {id
: $scope
.action
});
522 _
.each(actionInfo
.params
, function (param
, name
) {
524 defaultVal
= _
.cloneDeep(param
.default);
526 switch (param
.type
[0]) {
529 format
= param
.type
[0];
540 if (name
=== 'limit' && $scope
.action
=== 'get') {
543 if (name
=== 'debug') {
546 if (name
=== 'values') {
547 defaultVal
= defaultValues(defaultVal
);
549 if (name
=== 'loadOptions' && $scope
.action
=== 'getFields') {
553 ['id', 'name', 'label'],
554 ['id', 'name', 'label', 'abbr', 'description', 'color', 'icon']
558 param
.type
= ['string'];
560 $scope
.$bindToRoute({
561 expr
: 'params["' + name
+ '"]',
565 deep
: format
=== 'json'
568 if (typeof objectParams
[name
] !== 'undefined' && name
!== 'orderBy') {
569 $scope
.$watch('params.' + name
, function (values
) {
570 // Remove empty values
571 _
.each(values
, function (clause
, index
) {
572 if (!clause
|| !clause
[0]) {
573 $scope
.clearParam(name
, index
);
578 if (name
=== 'select' && actionInfo
.params
.having
) {
579 $scope
.$watchCollection('params.select', function(newSelect
) {
580 // Ignore row_count, it can't be used in HAVING clause
581 var select
= _
.without(newSelect
, 'row_count');
582 $scope
.havingOptions
.length
= 0;
583 // An empty select is an implicit *
584 if (!select
.length
) {
587 _
.each(select
, function(item
) {
589 pieces
= item
.split(' AS '),
590 alias
= _
.trim(pieces
[pieces
.length
- 1]).replace(':label', ':name');
592 if (alias
[alias
.length
- 1] === '*') {
593 if (alias
.length
> 1) {
594 joinEntity
= entityNameFromAlias(alias
.slice(0, -2));
596 var fieldList
= _
.filter(getEntity(joinEntity
).fields
, {custom_field_id
: null});
597 formatForSelect2(fieldList
, $scope
.havingOptions
, 'name', ['description', 'required', 'default_value'], alias
.slice(0, -1));
600 $scope
.havingOptions
.push({id
: alias
, text
: alias
});
605 if (typeof objectParams
[name
] !== 'undefined' || name
=== 'groupBy' || name
=== 'select' || name
=== 'join') {
606 $scope
.$watch('controls.' + name
, function(value
) {
608 $timeout(function() {
610 if (name
=== 'join') {
611 $scope
.params
[name
].push([field
+ ' AS ' + _
.snakeCase(field
), 'LEFT']);
612 ctrl
.buildFieldList();
614 else if (typeof objectParams
[name
] === 'undefined') {
615 $scope
.params
[name
].push(field
);
617 var defaultOp
= _
.cloneDeep(objectParams
[name
]);
618 if (name
=== 'chain') {
619 var num
= $scope
.params
.chain
.length
;
620 defaultOp
[0] = field
;
621 field
= 'name_me_' + num
;
623 $scope
.params
[name
].push([field
, defaultOp
]);
625 $scope
.controls
[name
] = null;
631 ctrl
.buildFieldList();
632 $scope
.availableParams
= actionInfo
.params
;
637 function describeSqlFn(params
) {
639 _
.each(params
, function(param
) {
642 desc
+= param
.name
+ ' ';
644 if (!_
.isEmpty(param
.flag_before
)) {
645 desc
+= '[' + _
.filter(param
.name
? [param
.name
] : _
.keys(param
.flag_before
)).join('|') + '] ';
647 if (param
.max_expr
=== 1) {
649 } else if (param
.max_expr
> 1) {
650 desc
+= 'expr, ... ';
652 if (!_
.isEmpty(param
.flag_after
)) {
653 desc
+= ' [' + _
.filter(param
.flag_after
).join('|') + '] ';
656 return desc
.replace(/[ ]+/g, ' ');
659 function defaultValues(defaultVal
) {
660 _
.each($scope
.fields
, function(field
) {
661 if (field
.required
) {
662 defaultVal
.push([field
.id
, '']);
668 function stringify(value
, trim
) {
669 if (typeof value
=== 'undefined') {
672 var str
= JSON
.stringify(value
).replace(/,/g
, ', ');
674 str
= str
.slice(1, -1);
679 // Url-encode suitable for use in a bash script
680 function curlEscape(str
) {
681 return encodeURIComponent(str
).
682 replace(/['()*]/g, function(c
) {
683 return "%" + c
.charCodeAt(0).toString(16);
687 function writeCode() {
689 entity
= $scope
.entity
,
690 action
= $scope
.action
,
691 params
= getParams(),
692 index
= isInt($scope
.index
) ? +$scope
.index
: parseYaml($scope
.index
),
694 if ($scope
.entity
&& $scope
.action
) {
696 if (action
.slice(0, 3) === 'get') {
697 var args
= getEntity(entity
).class_args
|| [];
698 result
= args
[0] ? _
.camelCase(args
[0]) : entity
;
699 result
= lcfirst(action
.replace(/s$/, '').slice(3) || result
);
701 var results
= lcfirst(_
.isNumber(index
) ? result
: pluralize(result
)),
702 paramCount
= _
.size(params
),
705 switch ($scope
.selectedTab
.code
) {
709 var js
= "'" + entity
+ "', '" + action
+ "', {";
710 _
.each(params
, function(param
, key
) {
711 js
+= "\n " + key
+ ': ' + stringify(param
) +
712 (++i
< paramCount
? ',' : '');
713 if (key
=== 'checkPermissions') {
714 js
+= ' // IGNORED: permissions are always enforced from client-side requests';
718 if (index
|| index
=== 0) {
719 js
+= ', ' + JSON
.stringify(index
);
721 code
.js
= "CRM.api4(" + js
+ ").then(function(" + results
+ ") {\n // do something with " + results
+ " array\n}, function(failure) {\n // handle failure\n});";
722 code
.js2
= "CRM.api4({" + results
+ ': [' + js
+ "]}).then(function(batch) {\n // do something with batch." + results
+ " array\n}, function(failure) {\n // handle failure\n});";
723 code
.ang
= "crmApi4(" + js
+ ").then(function(" + results
+ ") {\n // do something with " + results
+ " array\n}, function(failure) {\n // handle failure\n});";
724 code
.ang2
= "crmApi4({" + results
+ ': [' + js
+ "]}).then(function(batch) {\n // do something with batch." + results
+ " array\n}, function(failure) {\n // handle failure\n});";
729 code
.php
= '$' + results
+ " = civicrm_api4('" + entity
+ "', '" + action
+ "', [";
730 _
.each(params
, function(param
, key
) {
731 code
.php
+= "\n '" + key
+ "' => " + phpFormat(param
, 4) + ',';
734 if (index
|| index
=== 0) {
735 code
.php
+= ', ' + phpFormat(index
);
740 code
.oop
= '$' + results
+ " = " + formatOOP(entity
, action
, params
, 2) + "\n ->execute()";
741 if (_
.isNumber(index
)) {
742 code
.oop
+= !index
? '\n ->first()' : (index
=== -1 ? '\n ->last()' : '\n ->itemAt(' + index
+ ')');
744 if (_
.isString(index
) || (_
.isPlainObject(index
) && !index
[0] && !index
['0'])) {
745 code
.oop
+= "\n ->indexBy('" + (_
.isPlainObject(index
) ? _
.keys(index
)[0] : index
) + "')";
747 if (_
.isArray(index
) || _
.isPlainObject(index
)) {
748 code
.oop
+= "\n ->column('" + (_
.isArray(index
) ? index
[0] : _
.values(index
)[0]) + "')";
752 if (!_
.isNumber(index
)) {
753 code
.oop
+= "foreach ($" + results
+ ' as $' + ((_
.isString(index
) && index
) ? index
+ ' => $' : '') + result
+ ') {\n // do something\n}';
758 // Cli code using json input
759 code
.long = 'cv api4 ' + entity
+ '.' + action
+ ' ' + cliFormat(JSON
.stringify(params
));
760 code
.pipe
= 'echo ' + cliFormat(JSON
.stringify(params
)) + ' | cv api4 ' + entity
+ '.' + action
+ ' --in=json';
762 // Cli code using short syntax
763 code
.short = 'cv api4 ' + entity
+ '.' + action
;
764 var limitSet
= false;
765 _
.each(params
, function(param
, key
) {
767 case (key
=== 'select' && !_
.includes(param
.join(), ' ')):
768 code
.short += ' +s ' + cliFormat(param
.join(','));
770 case (key
=== 'where' && !_
.intersection(_
.map(param
, 0), ['AND', 'OR', 'NOT']).length
):
771 _
.each(param
, function(clause
) {
772 code
.short += ' +w ' + cliFormat(clause
[0] + ' ' + clause
[1] + (clause
.length
> 2 ? (' ' + JSON
.stringify(clause
[2])) : ''));
775 case (key
=== 'orderBy'):
776 _
.each(param
, function(dir
, field
) {
777 code
.short += ' +o ' + cliFormat(field
+ ' ' + dir
);
780 case (key
=== 'values'):
781 _
.each(param
, function(val
, field
) {
782 code
.short += ' +v ' + cliFormat(field
+ '=' + val
);
785 case (key
=== 'limit' || key
=== 'offset'):
786 // These 2 get combined
789 code
.short += ' +l ' + (params
.limit
|| '0') + (params
.offset
? ('@' + params
.offset
) : '');
793 code
.short += ' ' + key
+ '=' + (typeof param
=== 'string' ? cliFormat(param
) : cliFormat(JSON
.stringify(param
)));
799 var restUrl
= CRM
.vars
.api4
.restUrl
800 .replace('CRMAPI4ENTITY', entity
)
801 .replace('CRMAPI4ACTION', action
);
803 if (CRM
.vars
.api4
.restUrl
.endsWith('/CRMAPI4ENTITY/CRMAPI4ACTION')) {
804 cleanUrl
= CRM
.vars
.api4
.restUrl
.replace('/CRMAPI4ENTITY/CRMAPI4ACTION', '/');
806 var restCred
= 'Bearer MY_API_KEY';
810 "CRM_URL='" + restUrl
+ "'\n" +
811 "CRM_AUTH='X-Civi-Auth: " + restCred
+ "'\n\n" +
812 'curl -X POST -H "$CRM_AUTH" "$CRM_URL" \\' + "\n" +
813 "-d 'params=" + curlEscape(JSON
.stringify(params
));
814 if (index
|| index
=== 0) {
815 code
.curl
+= '&index=' + curlEscape(JSON
.stringify(index
));
819 var queryParams
= "['params' => json_encode($params)" +
820 ((typeof index
=== 'number') ? ", 'index' => " + JSON
.stringify(index
) : '') +
821 ((index
&& typeof index
!== 'number') ? ", 'index' => json_encode(" + phpFormat(index
) + ')' : '') +
826 "$params = " + phpFormat(params
, 2) + ";\n" +
827 "$client = new \\GuzzleHttp\\Client([\n" +
828 (cleanUrl
? " 'base_uri' => '" + cleanUrl
+ "',\n" : '') +
829 " 'headers' => ['X-Civi-Auth' => " + phpFormat(restCred
) + "],\n" +
831 "$response = $client->get('" + (cleanUrl
? entity
+ '/' + action
: restUrl
) + "', [\n" +
832 " 'form_params' => " + queryParams
+ ",\n" +
834 '$' + results
+ " = json_decode((string) $response->getBody(), TRUE);";
838 "$url = '" + restUrl
+ "';\n" +
839 "$params = " + phpFormat(params
, 2) + ";\n" +
840 "$request = stream_context_create([\n" +
842 " 'method' => 'POST',\n" +
844 " 'Content-Type: application/x-www-form-urlencoded',\n" +
845 " " + phpFormat('X-Civi-Auth: ' + restCred
) + ",\n" +
847 " 'content' => http_build_query(" + queryParams
+ "),\n" +
850 '$' + results
+ " = json_decode(file_get_contents($url, FALSE, $request), TRUE);\n";
853 _
.each($scope
.code
, function(vals
) {
854 _
.each(vals
, function(style
) {
855 style
.code
= code
[style
.name
] ? prettyPrintOne(_
.escape(code
[style
.name
])) : '';
861 function formatOOP(entity
, action
, params
, indent
) {
862 var info
= getEntity(entity
),
863 arrayParams
= ['groupBy', 'records'],
864 newLine
= "\n" + _
.repeat(' ', indent
),
865 code
= '\\' + info
.class + '::' + action
+ '(',
866 args
= _
.cloneDeep(info
.class_args
|| []);
867 if (params
.checkPermissions
=== false) {
870 code
+= _
.map(args
, phpFormat
).join(', ') + ')';
871 _
.each(params
, function(param
, key
) {
873 if (typeof objectParams
[key
] !== 'undefined' && key
!== 'chain') {
874 _
.each(param
, function(item
, index
) {
875 val
= phpFormat(index
) + ', ' + phpFormat(item
, 2 + indent
);
876 code
+= newLine
+ "->add" + ucfirst(key
).replace(/s$/, '') + '(' + val
+ ')';
878 } else if (_
.includes(arrayParams
, key
)) {
879 _
.each(param
, function(item
) {
880 code
+= newLine
+ "->add" + ucfirst(key
).replace(/s$/, '') + '(' + phpFormat(item
, 2 + indent
) + ')';
882 } else if (key
=== 'where') {
883 _
.each(param
, function (clause
) {
884 if (clause
[0] === 'AND' || clause
[0] === 'OR' || clause
[0] === 'NOT') {
885 code
+= newLine
+ "->addClause(" + phpFormat(clause
[0]) + ", " + phpFormat(clause
[1]).slice(1, -1) + ')';
887 code
+= newLine
+ "->addWhere(" + phpFormat(clause
).slice(1, -1) + ")";
890 } else if (key
=== 'select') {
891 // selectRowCount() is a shortcut for addSelect('row_count')
892 if (isSelectRowCount(params
)) {
893 code
+= newLine
+ '->selectRowCount()';
894 param
= _
.without(param
, 'row_count');
896 // addSelect() is a variadic function & can take multiple arguments
898 code
+= newLine
+ '->addSelect(' + phpFormat(param
).slice(1, -1) + ')';
900 } else if (key
=== 'chain') {
901 _
.each(param
, function(chain
, name
) {
902 code
+= newLine
+ "->addChain('" + name
+ "', " + formatOOP(chain
[0], chain
[1], chain
[2], 2 + indent
);
903 code
+= (chain
.length
> 3 ? ',' : '') + (!_
.isEmpty(chain
[2]) ? newLine
: ' ') + (chain
.length
> 3 ? phpFormat(chain
[3]) : '') + ')';
905 } else if (key
=== 'join') {
906 _
.each(param
, function(join
) {
907 code
+= newLine
+ "->addJoin(" + phpFormat(join
).slice(1, -1) + ')';
910 else if (key
!== 'checkPermissions') {
911 code
+= newLine
+ "->set" + ucfirst(key
) + '(' + phpFormat(param
, 2 + indent
) + ')';
917 function isInt(value
) {
918 if (_
.isFinite(value
)) {
921 if (!_
.isString(value
)) {
924 return /^-{0,1}\d+$/.test(value
);
927 function formatMeta(resp
) {
929 _
.each(resp
, function(val
, key
) {
930 if (key
!== 'values' && !_
.isPlainObject(val
) && !_
.isFunction(val
)) {
931 ret
+= (ret
.length
? ', ' : '') + key
+ ': ' + (_
.isArray(val
) ? '[' + val
+ ']' : val
);
934 return prettyPrintOne(_
.escape(ret
));
937 $scope
.execute = function() {
938 $scope
.status
= 'info';
939 $scope
.loading
= true;
940 $http
.post(CRM
.url('civicrm/ajax/api4/' + $scope
.entity
+ '/' + $scope
.action
, {
941 params
: angular
.toJson(getParams()),
942 index
: isInt($scope
.index
) ? +$scope
.index
: parseYaml($scope
.index
)
945 'X-Requested-With': 'XMLHttpRequest'
947 }).then(function(resp
) {
948 $scope
.loading
= false;
949 $scope
.status
= resp
.data
&& resp
.data
.debug
&& resp
.data
.debug
.log
? 'warning' : 'success';
950 $scope
.debug
= debugFormat(resp
.data
);
953 values
: resp
.data
.values
957 $scope
.loading
= false;
958 $scope
.status
= 'danger';
959 $scope
.debug
= debugFormat(resp
.data
);
968 ctrl
.formatResult = function() {
972 $scope
.result
= [formatMeta(response
.meta
)];
973 switch (ctrl
.resultFormat
) {
975 $scope
.result
.push(prettyPrintOne((_
.isArray(response
.values
) ? '(' + response
.values
.length
+ ') ' : '') + _
.escape(JSON
.stringify(response
.values
, null, 2)), 'js', 1));
979 $scope
.result
.push(prettyPrintOne((_
.isArray(response
.values
) ? '(' + response
.values
.length
+ ') ' : '') + _
.escape(phpFormat(response
.values
, 2, 2)), 'php', 1));
984 function debugFormat(data
) {
985 var debug
= data
.debug
? prettyPrintOne(_
.escape(JSON
.stringify(data
.debug
, null, 2)).replace(/\\n/g, "\n")) : null;
991 * Format value to look like php code
993 function phpFormat(val
, indent
, indentChildren
) {
994 if (typeof val
=== 'undefined') {
997 if (val
=== null || val
=== true || val
=== false) {
998 return JSON
.stringify(val
).toUpperCase();
1000 var indentChild
= indentChildren
? indent
+ indentChildren
: null;
1001 indent
= (typeof indent
=== 'number') ? _
.repeat(' ', indent
) : (indent
|| '');
1003 baseLine
= indent
? indent
.slice(0, -2) : '',
1004 newLine
= indent
? '\n' : '',
1005 trailingComma
= indent
? ',' : '';
1006 if ($.isPlainObject(val
)) {
1007 if ($.isEmptyObject(val
)) {
1010 $.each(val
, function(k
, v
) {
1011 ret
+= (ret
? ', ' : '') + newLine
+ indent
+ "'" + k
+ "' => " + phpFormat(v
, indentChild
, indentChildren
);
1013 return '[' + ret
+ trailingComma
+ newLine
+ baseLine
+ ']';
1015 if ($.isArray(val
)) {
1019 $.each(val
, function(k
, v
) {
1020 ret
+= (ret
? ', ' : '') + newLine
+ indent
+ phpFormat(v
, indentChild
, indentChildren
);
1022 return '[' + ret
+ trailingComma
+ newLine
+ baseLine
+ ']';
1024 if (_
.isString(val
) && !_
.contains(val
, "'")) {
1025 return "'" + val
+ "'";
1027 return JSON
.stringify(val
).replace(/\$/g, '\\$');
1030 // Format string to be cli-input-safe
1031 function cliFormat(str
) {
1032 if (!_
.includes(str
, ' ') && !_
.includes(str
, '"') && !_
.includes(str
, "'")) {
1035 if (!_
.includes(str
, "'")) {
1036 return "'" + str
+ "'";
1038 if (!_
.includes(str
, '"')) {
1039 return '"' + str
+ '"';
1041 return "'" + str
.replace(/'/g, "\\'") + "'";
1044 function fetchMeta() {
1045 crmApi4(getMetaParams)
1046 .then(function(data) {
1048 getEntity().actions = data.actions;
1054 // Help for an entity with no action selected
1055 function showEntityHelp(entityName) {
1056 var entityInfo = getEntity(entityName);
1057 setHelp($scope.entity, {
1058 description: entityInfo.description,
1059 comment: entityInfo.comment,
1060 type: entityInfo.type,
1061 since: entityInfo.since,
1066 if (!$scope.entity) {
1067 setHelp(ts('APIv4 Explorer
'), {description: docs.description, comment: docs.comment, see: docs.see});
1068 } else if (!actions.length && !getEntity().actions) {
1069 getMetaParams.actions = [$scope.entity, 'getActions
', {chain: {fields: [$scope.entity, 'getFields
', {action: '$name
'}]}}];
1075 if ($scope.entity) {
1076 showEntityHelp($scope.entity);
1079 // Update route when changing entity
1080 $scope.$watch('entity
', function(newVal, oldVal) {
1081 if (oldVal !== newVal) {
1082 // Flush actions cache to re-fetch for new entity
1084 $location.url('/explorer/' + newVal);
1088 // Update route when changing actions
1089 $scope.$watch('action
', function(newVal, oldVal) {
1090 if ($scope.entity && $routeParams.api4action !== newVal && !_.isUndefined(newVal)) {
1091 $location.url('/explorer/' + $scope.entity + '/' + newVal);
1092 } else if (newVal) {
1093 setHelp($scope.entity + '::' + newVal, _.pick(_.findWhere(getEntity().actions, {name: newVal}), ['description
', 'comment
', 'see
', 'deprecated
']));
1097 $scope.paramDoc = function(name) {
1098 return docs.params[name];
1101 $scope.executeDoc = function() {
1103 description: ts('Runs API call on the CiviCRM database
.'),
1104 comment: ts('Results and debugging info will be displayed below
.')
1106 if ($scope.action === 'delete') {
1107 doc.WARNING = ts('This API call will be executed on the real database
. Deleting data cannot be undone
.');
1109 else if ($scope.action && $scope.action.slice(0, 3) !== 'get') {
1110 doc.WARNING = ts('This API call will be executed on the real database
. It cannot be undone
.');
1115 $scope.saveDoc = function() {
1117 description: ts('Save API call as a smart group
.'),
1118 comment: ts('Create a SavedSearch using these API params to populate a smart group
.') +
1119 '\n\n' + ts('NOTE
: you must select contact id as the only field
.')
1123 $scope.$watch('params
', writeCode, true);
1124 $scope.$watch('index
', writeCode);
1127 $scope.save = function() {
1128 $scope.params.limit = $scope.params.offset = 0;
1129 if ($scope.params.chain.length) {
1130 CRM.alert(ts('Smart groups are not compatible
with API chaining
.'), ts('Error
'), 'error
', {expires: 5000});
1133 if ($scope.params.select.length !== 1 || !_.includes($scope.params.select[0], 'id
')) {
1134 CRM.alert(ts('To create a smart group
, the API must select contact id and no other fields
.'), ts('Error
'), 'error
', {expires: 5000});
1140 visibility: 'User and User Admin Only
',
1143 entity: $scope.entity,
1144 params: JSON.parse(angular.toJson($scope.params))
1146 model.params.version = 4;
1147 delete model.params.chain;
1148 delete model.params.debug;
1149 delete model.params.limit;
1150 delete model.params.offset;
1151 delete model.params.orderBy;
1152 delete model.params.checkPermissions;
1153 var options = CRM.utils.adjustDialogDefaults({
1156 title: ts('Save smart group
')
1158 dialogService.open('saveSearchDialog
', '~/api4Explorer/SaveSearch
.html
', model, options);
1162 angular.module('api4Explorer
').controller('SaveSearchCtrl
', function($scope, crmApi4, dialogService) {
1163 var ts = $scope.ts = CRM.ts(),
1164 model = $scope.model;
1165 $scope.groupEntityRefParams = {
1168 params: {is_hidden: 0, is_active: 1, 'saved_search_id
.api_entity
': model.entity},
1169 extra: ['saved_search_id
', 'description
', 'visibility
', 'group_type
']
1173 minimumInputLength: 0,
1174 placeholder: ts('Select existing group
')
1177 if (!CRM.checkPerm('administer reserved groups
')) {
1178 $scope.groupEntityRefParams.api.params.is_reserved = 0;
1181 administerReservedGroups: CRM.checkPerm('administer reserved groups
')
1183 $scope.options = CRM.vars.api4.groupOptions;
1184 $scope.$watch('model
.id
', function(id) {
1186 _.assign(model, $('#api
-save
-search
-select
-group
').select2('data
').extra);
1189 $scope.cancel = function() {
1190 dialogService.cancel('saveSearchDialog
');
1192 $scope.save = function() {
1193 $('.ui
-dialog
:visible
').block();
1194 var group = model.id ? {id: model.id} : {title: model.title};
1195 group.description = model.description;
1196 group.visibility = model.visibility;
1197 group.group_type = model.group_type;
1198 group.saved_search_id = '$id
';
1200 api_entity: model.entity,
1201 api_params: model.params
1204 savedSearch.id = model.saved_search_id;
1206 crmApi4('SavedSearch
', 'save
', {records: [savedSearch], chain: {group: ['Group
', 'save
', {'records
': [group]}]}})
1207 .then(function(result) {
1208 dialogService.close('saveSearchDialog
', result[0]);
1213 angular.module('api4Explorer
').component('crmApi4Clause
', {
1224 templateUrl: '~/api4Explorer/Clause
.html
',
1225 controller: function ($scope, $element, $timeout) {
1226 var ts = $scope.ts = CRM.ts(),
1228 this.conjunctions = {AND: ts('And
'), OR: ts('Or
'), NOT: ts('Not
')};
1229 this.operators = CRM.vars.api4.operators;
1230 this.sortOptions = {
1232 connectWith: '.api4
-clause
-group
-sortable
',
1233 containment: $element.closest('.api4
-clause
-fieldset
'),
1239 this.$onInit = function() {
1240 ctrl.hasParent = !!$element.attr('delete-group
');
1243 this.addGroup = function(op) {
1244 ctrl.clauses.push([op, []]);
1247 function onSort(event, ui) {
1248 $($element).closest('.api4
-clause
-fieldset
').toggleClass('api4
-sorting
', event.type === 'sortstart
');
1249 $('.api4
-input
.form
-inline
').css('margin
-left
', '');
1252 // Indent clause while dragging between nested groups
1253 function onSortOver(event, ui) {
1256 offset = $(ui.placeholder).offset().left - $(ui.sender).offset().left;
1258 $('.api4
-input
.form
-inline
.ui
-sortable
-helper
').css('margin
-left
', '' + offset + 'px
');
1261 this.addClause = function() {
1262 $timeout(function() {
1263 if (ctrl.newClause) {
1264 if (ctrl.skip && ctrl.clauses.length < ctrl.skip) {
1265 ctrl.clauses.push(null);
1267 ctrl.clauses.push([ctrl.newClause, '=', '']);
1268 ctrl.newClause = null;
1273 this.deleteRow = function(index) {
1274 ctrl.clauses.splice(index, 1);
1277 // Remove empty values
1278 this.changeClauseField = function(clause, index) {
1279 if (clause[0] === '') {
1280 ctrl.deleteRow(index);
1284 // Add/remove value if operator allows for one
1285 this.changeClauseOperator = function(clause) {
1286 if (_.contains(clause[1], 'IS
')) {
1288 } else if (clause.length === 2) {
1295 angular.module('api4Explorer
').directive('api4ExpValue
', function($routeParams, crmApi4) {
1298 data: '=api4ExpValue
'
1301 link: function (scope, element, attrs, ctrl) {
1302 var ts = scope.ts = CRM.ts(),
1303 multi = _.includes(['IN
', 'NOT IN
', 'BETWEEN
', 'NOT BETWEEN
'], scope.data.op),
1304 entity = $routeParams.api4entity,
1305 action = scope.data.action || $routeParams.api4action;
1307 function destroyWidget() {
1308 var $el = $(element);
1309 if ($el.is('.crm
-form
-date
-wrapper
.crm
-hidden
-date
')) {
1310 $el.crmDatepicker('destroy
');
1312 if ($el.is('.select2
-container
+ input
')) {
1313 $el.crmEntityRef('destroy
');
1315 $(element).removeData().removeAttr('type
').removeAttr('placeholder
').show();
1318 function makeWidget(field, op) {
1319 var $el = $(element),
1320 inputType = field.input_type,
1321 dataType = field.data_type;
1323 op = field.serialize || dataType === 'Array
' ? 'IN
' : '=';
1325 multi = _.includes(['IN
', 'NOT IN
', 'BETWEEN
', 'NOT BETWEEN
'], op);
1326 // IS NULL, IS EMPTY, etc.
1327 if (_.contains(op, 'IS
')) {
1331 if (inputType === 'Date
') {
1332 if (_.includes(['=', '!=', '<>', '<', '>=', '<', '<='], op)) {
1333 $el.crmDatepicker({time: (field.input_attrs && field.input_attrs.time) || false});
1335 } else if (_.includes(['=', '!=', '<>', 'IN
', 'NOT IN
'], op) && (field.fk_entity || field.options || dataType === 'Boolean
')) {
1336 if (field.options) {
1337 var id = field.pseudoconstant || 'id
';
1338 $el.addClass('loading
').attr('placeholder
', ts('- select
-')).crmSelect2({multiple: multi, data: [{id: '', text: ''}]});
1339 loadFieldOptions(field.entity || entity).then(function(data) {
1340 var options = _.transform(data[field.name].options, function(options, opt) {
1341 options.push({id: opt[id], text: opt.label, description: opt.description, color: opt.color, icon: opt.icon});
1343 $el.removeClass('loading
').crmSelect2({data: options, multiple: multi});
1345 } else if (field.fk_entity) {
1346 var apiParams = field.id_field ? {id_field: field.id_field} : {};
1347 $el.crmEntityRef({entity: field.fk_entity, api: apiParams, select: {multiple: multi}, static: field.fk_entity === 'Contact
' ? ['user_contact_id
'] : []});
1348 } else if (dataType === 'Boolean
') {
1349 $el.attr('placeholder
', ts('- select
-')).crmSelect2({allowClear: false, multiple: multi, placeholder: ts('- select
-'), data: [
1350 {id: 'true', text: ts('Yes
')},
1351 {id: 'false', text: ts('No
')}
1354 } else if (dataType === 'Integer
' && !multi) {
1355 $el.attr('type
', 'number
');
1359 function loadFieldOptions(entity) {
1360 if (!fieldOptions[entity + action]) {
1361 fieldOptions[entity + action] = crmApi4(entity, 'getFields
', {
1362 loadOptions: ['id
', 'name
', 'label
', 'description
', 'color
', 'icon
'],
1364 where: [['options
', '!=', false]],
1368 return fieldOptions[entity + action];
1371 // Copied from ng-list but applied conditionally if field is multi-valued
1372 var parseList = function(viewValue) {
1373 // If the viewValue is invalid (say required but empty) it will be `undefined`
1374 if (_.isUndefined(viewValue)) return;
1383 _.each(viewValue.split(','), function(value) {
1384 if (value) list.push(_.trim(value));
1391 // Copied from ng-list
1392 ctrl.$parsers.push(parseList);
1393 ctrl.$formatters.push(function(value) {
1394 return _.isArray(value) ? value.join(', ') : value;
1397 // Copied from ng-list
1398 ctrl.$isEmpty = function(value) {
1399 return !value || !value.length;
1402 scope.$watchCollection('data
', function(data) {
1404 var field = getField(data.field, entity, action);
1405 if (field && data.format !== 'plain
') {
1406 makeWidget(field, data.op);
1414 angular.module('api4Explorer
').directive('api4ExpChain
', function(crmApi4) {
1417 chain: '=api4ExpChain
',
1421 templateUrl: '~/api4Explorer/Chain
.html
',
1422 link: function (scope, element, attrs) {
1423 var ts = scope.ts = CRM.ts();
1425 function changeEntity(newEntity, oldEntity) {
1426 // When clearing entity remove this chain
1428 scope.chain[0] = '';
1431 // Reset action && index
1432 if (newEntity !== oldEntity) {
1433 scope.chain[1][1] = scope.chain[1][2] = '';
1435 if (getEntity(newEntity).actions) {
1438 crmApi4(newEntity, 'getActions
', {chain: {fields: [newEntity, 'getFields
', {action: '$name
'}]}})
1439 .then(function(data) {
1440 getEntity(data.entity).actions = data;
1441 if (data.entity === scope.chain[1][0]) {
1448 function setActions() {
1449 scope.actions = [''].concat(_.pluck(getEntity(scope.chain[1][0]).actions, 'name
'));
1452 // Set default params when choosing action
1453 function changeAction(newAction, oldAction) {
1455 // Prepopulate links
1456 if (newAction && newAction !== oldAction) {
1458 scope.chain[1][3] = '';
1459 // Look for links back to main entity
1460 _.each(entityFields(scope.chain[1][0]), function(field) {
1461 if (field.fk_entity === scope.mainEntity) {
1462 link = [field.name, '$id
'];
1465 // Look for links from main entity
1466 if (!link && newAction !== 'create
') {
1467 _.each(entityFields(scope.mainEntity), function(field) {
1468 if (field.fk_entity === scope.chain[1][0]) {
1469 link = ['id
', '$' + field.name];
1470 // Since we're specifying the id
, set index to getsingle
1471 scope
.chain
[1][3] = '0';
1475 if (link
&& _
.contains(['get', 'update', 'replace', 'delete'], newAction
)) {
1476 scope
.chain
[1][2] = '{where: [[' + link
[0] + ', =, ' + link
[1] + ']]}';
1478 else if (link
&& _
.contains(['create'], newAction
)) {
1479 scope
.chain
[1][2] = '{values: {' + link
[0] + ': ' + link
[1] + '}}';
1481 else if (link
&& _
.contains(['save'], newAction
)) {
1482 scope
.chain
[1][2] = '{records: [{' + link
[0] + ': ' + link
[1] + '}]}';
1484 scope
.chain
[1][2] = '{}';
1489 scope
.$watch("chain[1][0]", changeEntity
);
1490 scope
.$watch("chain[1][1]", changeAction
);
1495 function getEntity(entityName
) {
1496 return _
.findWhere(schema
, {name
: entityName
});
1499 function entityFields(entityName
, action
) {
1500 var entity
= getEntity(entityName
);
1501 if (entity
&& action
&& entity
.actions
) {
1502 return _
.findWhere(entity
.actions
, {name
: action
}).fields
;
1504 return _
.result(entity
, 'fields');
1507 function getExplicitJoins() {
1508 return _
.transform(params
.join
, function(joins
, join
) {
1509 // Fix capitalization of AS
1510 join
[0] = join
[0].replace(/ as
/i
, ' AS ');
1511 var j
= join
[0].split(' AS '),
1512 joinEntity
= _
.trim(j
[0]),
1513 joinAlias
= _
.trim(j
[1]) || joinEntity
.toLowerCase();
1514 joins
[joinAlias
] = {
1517 side
: join
[1] || 'LEFT',
1518 bridge
: _
.isString(join
[2]) ? join
[2] : null
1523 function getField(fieldName
, entity
, action
) {
1524 var suffix
= fieldName
.split(':')[1];
1525 fieldName
= fieldName
.split(':')[0];
1526 var fieldNames
= fieldName
.split('.');
1527 var field
= _
.cloneDeep(get(entity
, fieldNames
));
1528 if (field
&& suffix
) {
1529 field
.pseudoconstant
= suffix
;
1531 // When joining to a 'name' field, value fields should render an appropriate entityRef
1532 if (field
&& field
.type
=== 'Field' && field
.name
=== 'name' && _
.includes(fieldName
, '.')) {
1533 field
.fk_entity
= field
.entity
;
1534 field
.id_field
= 'name';
1538 function get(entity
, fieldNames
) {
1539 if (fieldNames
.length
=== 1) {
1540 return _
.findWhere(entityFields(entity
, action
), {name
: fieldNames
[0]});
1542 var comboName
= _
.findWhere(entityFields(entity
, action
), {name
: fieldNames
[0] + '.' + fieldNames
[1]});
1546 var linkName
= fieldNames
.shift(),
1547 join
= getExplicitJoins()[linkName
],
1548 newEntity
= join
? join
.entity
: _
.findWhere(entityFields(entity
, action
), {name
: linkName
}).fk_entity
;
1549 return get(newEntity
, fieldNames
);
1553 // Collapsible optgroups for select2
1556 .on('select2-open', function(e
) {
1557 if ($(e
.target
).hasClass('collapsible-optgroups')) {
1559 .off('.collapseOptionGroup')
1560 .addClass('collapsible-optgroups-enabled')
1561 .on('click.collapseOptionGroup', '.select2-result-with-children > .select2-result-label', function() {
1562 $(this).parent().toggleClass('optgroup-expanded');
1566 .on('select2-close', function() {
1567 $('#select2-drop').off('.collapseOptionGroup').removeClass('collapsible-optgroups-enabled');
1570 })(angular
, CRM
.$, CRM
._
);