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