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