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