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