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