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