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