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