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