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