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