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