APIv4 - Add `@since` annotation to each entity
[civicrm-core.git] / ang / api4Explorer / Explorer.js
CommitLineData
19b53e5b
C
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 = {};
f54beb1e
CW
13 // Api params
14 var params;
19b53e5b
C
15
16
17 angular.module('api4Explorer').config(function($routeProvider) {
18 $routeProvider.when('/explorer/:api4entity?/:api4action?', {
19 controller: 'Api4Explorer',
20 templateUrl: '~/api4Explorer/Explorer.html',
21 reloadOnSearch: false
22 });
23 });
24
4e97c268 25 angular.module('api4Explorer').controller('Api4Explorer', function($scope, $routeParams, $location, $timeout, $http, crmUiHelp, crmApi4, dialogService) {
f54beb1e
CW
26 var ts = $scope.ts = CRM.ts(),
27 ctrl = $scope.$ctrl = this;
19b53e5b
C
28 $scope.entities = entities;
29 $scope.actions = actions;
30 $scope.fields = [];
9f6c0e4f 31 $scope.havingOptions = [];
19b53e5b 32 $scope.fieldsAndJoins = [];
a26e006b 33 $scope.fieldsAndJoinsAndFunctions = [];
a4499ec5 34 $scope.fieldsAndJoinsAndFunctionsWithSuffixes = [];
a26e006b 35 $scope.fieldsAndJoinsAndFunctionsAndWildcards = [];
19b53e5b 36 $scope.availableParams = {};
f54beb1e 37 params = $scope.params = {};
19b53e5b 38 $scope.index = '';
19d01932 39 $scope.selectedTab = {result: 'result', code: 'php'};
b65fa6dc 40 $scope.perm = {
d7507e89
CW
41 accessDebugOutput: CRM.checkPerm('access debug output'),
42 editGroups: CRM.checkPerm('edit groups')
b65fa6dc 43 };
136ca5bb 44 marked.setOptions({highlight: prettyPrintOne});
19b53e5b 45 var getMetaParams = {},
bb3786d2 46 objectParams = {orderBy: 'ASC', values: '', defaults: '', chain: ['Entity', '', '{}']},
9cea3619 47 docs = CRM.vars.api4.docs,
19b53e5b
C
48 helpTitle = '',
49 helpContent = {};
50 $scope.helpTitle = '';
51 $scope.helpContent = {};
52 $scope.entity = $routeParams.api4entity;
53 $scope.result = [];
b65fa6dc 54 $scope.debug = null;
19b53e5b
C
55 $scope.status = 'default';
56 $scope.loading = false;
57 $scope.controls = {};
f28caa2c 58 $scope.langs = ['php', 'js', 'ang', 'cli'];
266e8deb 59 $scope.joinTypes = [{k: 'LEFT', v: 'LEFT JOIN'}, {k: 'INNER', v: 'INNER JOIN'}, {k: 'EXCLUDE', v: 'EXCLUDE'}];
465bc32a 60 $scope.bridgeEntities = _.filter(schema, function(entity) {return _.includes(entity.type, 'EntityBridge');});
f28caa2c
CW
61 $scope.code = {
62 php: [
63 {name: 'oop', label: ts('OOP Style'), code: ''},
64 {name: 'php', label: ts('Traditional'), code: ''}
65 ],
66 js: [
67 {name: 'js', label: ts('Single Call'), code: ''},
68 {name: 'js2', label: ts('Batch Calls'), code: ''}
69 ],
70 ang: [
71 {name: 'ang', label: ts('Single Call'), code: ''},
72 {name: 'ang2', label: ts('Batch Calls'), code: ''}
73 ],
74 cli: [
75ea0350
CW
75 {name: 'short', label: ts('CV (short)'), code: ''},
76 {name: 'long', label: ts('CV (long)'), code: ''},
77 {name: 'pipe', label: ts('CV (pipe)'), code: ''}
f28caa2c
CW
78 ]
79 };
19b53e5b
C
80
81 if (!entities.length) {
449c4e6b 82 formatForSelect2(schema, entities, 'name', ['description', 'icon']);
19b53e5b
C
83 }
84
85 $scope.$bindToRoute({
86 expr: 'index',
87 param: 'index',
88 default: ''
89 });
90
91 function ucfirst(str) {
92 return str[0].toUpperCase() + str.slice(1);
93 }
94
95 function lcfirst(str) {
96 return str[0].toLowerCase() + str.slice(1);
97 }
98
99 function pluralize(str) {
628ae369
CW
100 var lastLetter = str[str.length - 1],
101 lastTwo = str[str.length - 2] + lastLetter;
e8b2a714 102 if (lastLetter === 's' || lastLetter === 'x' || lastTwo === 'ch') {
628ae369 103 return str + 'es';
19b53e5b 104 }
fb5d5e8a 105 if (lastLetter === 'y' && !_.includes(['ay', 'ey', 'iy', 'oy', 'uy'], lastTwo)) {
628ae369
CW
106 return str.slice(0, -1) + 'ies';
107 }
108 return str + 's';
19b53e5b
C
109 }
110
19b53e5b
C
111 // Reformat an existing array of objects for compatibility with select2
112 function formatForSelect2(input, container, key, extra, prefix) {
113 _.each(input, function(item) {
114 var id = (prefix || '') + item[key];
115 var formatted = {id: id, text: id};
116 if (extra) {
117 _.merge(formatted, _.pick(item, extra));
118 }
119 container.push(formatted);
120 });
121 return container;
122 }
123
f54beb1e
CW
124 // Replaces contents of fieldList array with current fields formatted for select2
125 function getFieldList(fieldList, action, addPseudoconstant) {
126 var fieldInfo = _.cloneDeep(_.findWhere(getEntity().actions, {name: action}).fields);
127 fieldList.length = 0;
37d82abe 128 if (addPseudoconstant) {
37d82abe
CW
129 addPseudoconstants(fieldInfo, addPseudoconstant);
130 }
f54beb1e 131 formatForSelect2(fieldInfo, fieldList, 'name', ['description', 'required', 'default_value']);
19b53e5b
C
132 }
133
37d82abe
CW
134 // Note: this function expects fieldList to be select2-formatted already
135 function addJoins(fieldList, addWildcard, addPseudoconstant) {
f54beb1e
CW
136 // Add entities specified by the join param
137 _.each(getExplicitJoins(), function(joinEntity, joinAlias) {
138 var wildCard = addWildcard ? [{id: joinAlias + '.*', text: joinAlias + '.*', 'description': 'All core ' + joinEntity + ' fields'}] : [],
139 joinFields = _.cloneDeep(entityFields(joinEntity));
140 if (joinFields) {
141 if (addPseudoconstant) {
142 addPseudoconstants(joinFields, addPseudoconstant);
143 }
144 fieldList.push({
145 text: joinEntity + ' AS ' + joinAlias,
146 description: 'Explicit join to ' + joinEntity,
147 children: wildCard.concat(formatForSelect2(joinFields, [], 'name', ['description'], joinAlias + '.'))
148 });
149 }
150 });
151 // Add implicit joins based on schema links
7d91265c 152 _.each(links[$scope.entity], function(link) {
39e0f675
CW
153 var linkFields = _.cloneDeep(entityFields(link.entity)),
154 wildCard = addWildcard ? [{id: link.alias + '.*', text: link.alias + '.*', 'description': 'All core ' + link.entity + ' fields'}] : [];
19b53e5b 155 if (linkFields) {
37d82abe
CW
156 if (addPseudoconstant) {
157 addPseudoconstants(linkFields, addPseudoconstant);
158 }
f54beb1e 159 fieldList.push({
19b53e5b 160 text: link.alias,
c2d3af50 161 description: 'Implicit join to ' + link.entity,
39e0f675 162 children: wildCard.concat(formatForSelect2(linkFields, [], 'name', ['description'], link.alias + '.'))
19b53e5b
C
163 });
164 }
165 });
19b53e5b
C
166 }
167
37d82abe
CW
168 // Note: this function transforms a raw list a-la getFields; not a select2-formatted list
169 function addPseudoconstants(fieldList, toAdd) {
170 var optionFields = _.filter(fieldList, 'options');
171 _.each(optionFields, function(field) {
172 var pos = _.findIndex(fieldList, {name: field.name}) + 1;
173 _.each(toAdd, function(suffix) {
174 var newField = _.cloneDeep(field);
175 newField.name += ':' + suffix;
176 fieldList.splice(pos, 0, newField);
177 });
178 });
179 }
180
136ca5bb
CW
181 $scope.help = function(title, content) {
182 if (!content) {
19b53e5b
C
183 $scope.helpTitle = helpTitle;
184 $scope.helpContent = helpContent;
185 } else {
186 $scope.helpTitle = title;
fc95d9a5 187 $scope.helpContent = formatHelp(content);
19b53e5b
C
188 }
189 };
190
136ca5bb
CW
191 // Sets the static help text (which gets overridden by mousing over other elements)
192 function setHelp(title, content) {
193 $scope.helpTitle = helpTitle = title;
fc95d9a5 194 $scope.helpContent = helpContent = formatHelp(content);
136ca5bb
CW
195 }
196
f48d7e65 197 // Format help text with markdown; replace variables and format links
fc95d9a5
CW
198 function formatHelp(rawContent) {
199 function formatRefs(see) {
200 _.each(see, function(ref, idx) {
f48d7e65 201 var match = ref.match(/^(\\Civi\\Api4\\)?([a-zA-Z]+)$/);
fc95d9a5 202 if (match) {
f48d7e65 203 ref = '#/explorer/' + match[2];
fc95d9a5 204 }
f48d7e65
CW
205 // Link to php classes on GitHub.
206 // Fixme: Only works for files in the core repo
207 if (ref[0] === '\\' || ref.indexOf('Civi\\') === 0 || ref.indexOf('CRM_') === 0) {
208 var classFunction = _.trim(ref, '\\').split('::'),
209 replacement = new RegExp(classFunction[0].indexOf('CRM_') === 0 ? '_' : '\\\\', 'g');
210 ref = 'https://github.com/civicrm/civicrm-core/blob/master/' + classFunction[0].replace(replacement, '/') + '.php';
fc95d9a5
CW
211 }
212 see[idx] = '<a target="' + (ref[0] === '#' ? '_self' : '_blank') + '" href="' + ref + '">' + see[idx] + '</a>';
213 });
214 }
136ca5bb
CW
215 var formatted = _.cloneDeep(rawContent);
216 if (formatted.description) {
217 formatted.description = marked(formatted.description);
218 }
219 if (formatted.comment) {
220 formatted.comment = marked(formatted.comment);
221 }
fc95d9a5 222 formatRefs(formatted.see);
136ca5bb
CW
223 return formatted;
224 }
225
19b53e5b
C
226 $scope.fieldHelp = function(fieldName) {
227 var field = getField(fieldName, $scope.entity, $scope.action);
228 if (!field) {
229 return;
230 }
231 var info = {
232 description: field.description,
233 type: field.data_type
234 };
235 if (field.default_value) {
236 info.default = field.default_value;
237 }
238 if (field.required_if) {
239 info.required_if = field.required_if;
240 } else if (field.required) {
241 info.required = 'true';
242 }
243 return info;
244 };
245
37d82abe 246 // Returns field list for write params (values, defaults)
bb3786d2
CW
247 $scope.fieldList = function(param) {
248 return function() {
f54beb1e
CW
249 var fields = [];
250 getFieldList(fields, $scope.action === 'getFields' ? ($scope.params.action || 'get') : $scope.action, ['name']);
bb3786d2
CW
251 // Disable fields that are already in use
252 _.each($scope.params[param] || [], function(val) {
37d82abe
CW
253 var usedField = val[0].replace(':name', '');
254 (_.findWhere(fields, {id: usedField}) || {}).disabled = true;
255 (_.findWhere(fields, {id: usedField + ':name'}) || {}).disabled = true;
bb3786d2
CW
256 });
257 return {results: fields};
258 };
19b53e5b
C
259 };
260
261 $scope.formatSelect2Item = function(row) {
262 return _.escape(row.text) +
263 (row.required ? '<span class="crm-marker"> *</span>' : '') +
264 (row.description ? '<div class="crm-select2-row-description"><p>' + _.escape(row.description) + '</p></div>' : '');
265 };
266
a26e006b
CW
267 $scope.clearParam = function(name, idx) {
268 if (typeof idx === 'undefined') {
269 $scope.params[name] = $scope.availableParams[name].default;
270 } else {
271 $scope.params[name].splice(idx, 1);
272 }
19b53e5b
C
273 };
274
f0acec37
CW
275 // Gets params that should be represented as generic input fields in the explorer
276 // This fn doesn't have to be particularly efficient as its output is cached in one-time bindings
277 $scope.getGenericParams = function(paramType, defaultNull) {
278 // Returns undefined if params are not yet set; one-time bindings will stabilize when this function returns a value
279 if (_.isEmpty($scope.availableParams)) {
280 return;
281 }
c2d3af50 282 var specialParams = ['select', 'fields', 'action', 'where', 'values', 'defaults', 'orderBy', 'chain', 'groupBy', 'having', 'join'];
f0acec37
CW
283 if ($scope.availableParams.limit && $scope.availableParams.offset) {
284 specialParams.push('limit', 'offset');
285 }
286 return _.transform($scope.availableParams, function(genericParams, param, name) {
7430e70d 287 if (!_.contains(specialParams, name) && !param.deprecated &&
f0acec37
CW
288 !(typeof paramType !== 'undefined' && !_.contains(paramType, param.type[0])) &&
289 !(typeof defaultNull !== 'undefined' && ((param.default === null) !== defaultNull))
290 ) {
291 genericParams[name] = param;
292 }
293 });
19b53e5b
C
294 };
295
296 $scope.selectRowCount = function() {
651c4c95
CW
297 var index = params.select.indexOf('row_count');
298 if (index < 0) {
299 $scope.params.select.push('row_count');
19b53e5b 300 } else {
651c4c95 301 $scope.params.select.splice(index, 1);
19b53e5b
C
302 }
303 };
304
305 $scope.isSelectRowCount = function() {
b568c26c 306 return isSelectRowCount($scope.params);
19b53e5b
C
307 };
308
f28caa2c
CW
309 $scope.selectLang = function(lang) {
310 $scope.selectedTab.code = lang;
311 writeCode();
312 };
313
b568c26c 314 function isSelectRowCount(params) {
651c4c95 315 return params && params.select && params.select.indexOf('row_count') >= 0;
b568c26c
CW
316 }
317
19b53e5b
C
318 function getEntity(entityName) {
319 return _.findWhere(schema, {name: entityName || $scope.entity});
320 }
321
caef4ed5
CW
322 // Get name of entity given join alias
323 function entityNameFromAlias(alias) {
324 var joins = getExplicitJoins(),
325 entity = $scope.entity,
326 path = alias.split('.');
327 // First check explicit joins
328 if (joins[alias]) {
329 return joins[alias];
330 }
331 // Then lookup implicit links
332 _.each(path, function(node) {
54d78f6c
CW
333 var link = _.find(links[entity], {alias: node});
334 if (!link) {
335 return false;
336 }
337 entity = link.entity;
caef4ed5
CW
338 });
339 return entity;
340 }
341
19b53e5b
C
342 // Get all params that have been set
343 function getParams() {
344 var params = {};
345 _.each($scope.params, function(param, key) {
346 if (param != $scope.availableParams[key].default && !(typeof param === 'object' && _.isEmpty(param))) {
347 if (_.contains($scope.availableParams[key].type, 'array') && (typeof objectParams[key] === 'undefined')) {
6ba6f2bd 348 params[key] = parseYaml(JSON.parse(angular.toJson(param)));
19b53e5b
C
349 } else {
350 params[key] = param;
351 }
352 }
353 });
354 _.each(objectParams, function(defaultVal, key) {
355 if (params[key]) {
356 var newParam = {};
357 _.each(params[key], function(item) {
cddf293f
CW
358 var val = _.cloneDeep(item[1]);
359 // Remove blank items from "chain" array
360 if (_.isArray(val)) {
361 _.eachRight(item[1], function(v, k) {
362 if (v) {
363 return false;
364 }
365 val.length--;
366 });
367 }
368 newParam[item[0]] = parseYaml(val);
19b53e5b
C
369 });
370 params[key] = newParam;
371 }
372 });
373 return params;
374 }
375
376 function parseYaml(input) {
f54beb1e
CW
377 if (typeof input === 'undefined' || input === '') {
378 return input;
19b53e5b 379 }
f54beb1e
CW
380 // Return literal quoted string without removing quotes - for the sake of JOIN ON clauses
381 if (_.isString(input) && input[0] === input[input.length - 1] && _.includes(["'", '"'], input[0])) {
382 return input;
6ba6f2bd 383 }
19b53e5b
C
384 if (_.isObject(input) || _.isArray(input)) {
385 _.each(input, function(item, index) {
386 input[index] = parseYaml(item);
387 });
388 return input;
389 }
390 try {
391 var output = (input === '>') ? '>' : jsyaml.safeLoad(input);
392 // We don't want dates parsed to js objects
393 return _.isDate(output) ? input : output;
394 } catch (e) {
395 return input;
396 }
397 }
398
f54beb1e
CW
399 this.buildFieldList = function() {
400 var actionInfo = _.findWhere(actions, {id: $scope.action});
401 getFieldList($scope.fields, $scope.action);
402 getFieldList($scope.fieldsAndJoins, $scope.action, ['name']);
403 getFieldList($scope.fieldsAndJoinsAndFunctions, $scope.action);
404 getFieldList($scope.fieldsAndJoinsAndFunctionsWithSuffixes, $scope.action, ['name', 'label']);
405 getFieldList($scope.fieldsAndJoinsAndFunctionsAndWildcards, $scope.action, ['name', 'label']);
406 if (_.contains(['get', 'update', 'delete', 'replace'], $scope.action)) {
407 addJoins($scope.fieldsAndJoins);
408 // SQL functions are supported if HAVING is
409 if (actionInfo.params.having) {
410 var functions = {
411 text: ts('FUNCTION'),
412 description: ts('Calculate result of a SQL function'),
413 children: _.transform(CRM.vars.api4.functions, function(result, fn) {
414 result.push({
415 id: fn.name + '() AS ' + fn.name.toLowerCase(),
416 text: fn.name + '()',
417 description: fn.name + '(' + describeSqlFn(fn.params) + ')'
418 });
419 })
420 };
421 $scope.fieldsAndJoinsAndFunctions.push(functions);
422 $scope.fieldsAndJoinsAndFunctionsWithSuffixes.push(functions);
423 $scope.fieldsAndJoinsAndFunctionsAndWildcards.push(functions);
424 }
425 addJoins($scope.fieldsAndJoinsAndFunctions, true);
426 addJoins($scope.fieldsAndJoinsAndFunctionsWithSuffixes, false, ['name', 'label']);
427 addJoins($scope.fieldsAndJoinsAndFunctionsAndWildcards, true, ['name', 'label']);
428 }
2f69b203
CW
429 // Custom fields are supported if HAVING is
430 if (actionInfo.params.having) {
431 $scope.fieldsAndJoinsAndFunctionsAndWildcards.unshift({id: 'custom.*', text: 'custom.*', 'description': 'All custom fields'});
432 }
f54beb1e
CW
433 $scope.fieldsAndJoinsAndFunctionsAndWildcards.unshift({id: '*', text: '*', 'description': 'All core ' + $scope.entity + ' fields'});
434 };
435
19b53e5b
C
436 function selectAction() {
437 $scope.action = $routeParams.api4action;
19b53e5b
C
438 if (!actions.length) {
439 formatForSelect2(getEntity().actions, actions, 'name', ['description', 'params']);
440 }
441 if ($scope.action) {
442 var actionInfo = _.findWhere(actions, {id: $scope.action});
19b53e5b
C
443 _.each(actionInfo.params, function (param, name) {
444 var format,
445 defaultVal = _.cloneDeep(param.default);
446 if (param.type) {
447 switch (param.type[0]) {
448 case 'int':
449 case 'bool':
450 format = param.type[0];
451 break;
452
453 case 'array':
454 case 'object':
455 format = 'json';
456 break;
457
458 default:
459 format = 'raw';
460 }
9ebb0bb9 461 if (name === 'limit') {
19b53e5b
C
462 defaultVal = 25;
463 }
9ebb0bb9
CW
464 if (name === 'debug') {
465 defaultVal = true;
466 }
19b53e5b
C
467 if (name === 'values') {
468 defaultVal = defaultValues(defaultVal);
469 }
bb6bfd68
CW
470 if (name === 'loadOptions' && $scope.action === 'getFields') {
471 param.options = [
472 false,
473 true,
474 ['id', 'name', 'label'],
475 ['id', 'name', 'label', 'abbr', 'description', 'color', 'icon']
476 ];
477 format = 'json';
478 defaultVal = false;
479 param.type = ['string'];
480 }
19b53e5b
C
481 $scope.$bindToRoute({
482 expr: 'params["' + name + '"]',
483 param: name,
484 format: format,
485 default: defaultVal,
486 deep: format === 'json'
487 });
488 }
f0acec37
CW
489 if (typeof objectParams[name] !== 'undefined' && name !== 'orderBy') {
490 $scope.$watch('params.' + name, function (values) {
19b53e5b 491 // Remove empty values
a26e006b 492 _.each(values, function (clause, index) {
19b53e5b 493 if (!clause || !clause[0]) {
a26e006b 494 $scope.clearParam(name, index);
19b53e5b
C
495 }
496 });
497 }, true);
a26e006b 498 }
9f6c0e4f 499 if (name === 'select' && actionInfo.params.having) {
caef4ed5
CW
500 $scope.$watchCollection('params.select', function(newSelect) {
501 // Ignore row_count, it can't be used in HAVING clause
502 var select = _.without(newSelect, 'row_count');
9f6c0e4f 503 $scope.havingOptions.length = 0;
caef4ed5
CW
504 // An empty select is an implicit *
505 if (!select.length) {
506 select.push('*');
507 }
508 _.each(select, function(item) {
509 var joinEntity,
510 pieces = item.split(' AS '),
37d82abe 511 alias = _.trim(pieces[pieces.length - 1]).replace(':label', ':name');
caef4ed5
CW
512 // Expand wildcards
513 if (alias[alias.length - 1] === '*') {
514 if (alias.length > 1) {
515 joinEntity = entityNameFromAlias(alias.slice(0, -2));
516 }
517 var fieldList = _.filter(getEntity(joinEntity).fields, {custom_field_id: null});
518 formatForSelect2(fieldList, $scope.havingOptions, 'name', ['description', 'required', 'default_value'], alias.slice(0, -1));
519 }
520 else {
521 $scope.havingOptions.push({id: alias, text: alias});
522 }
9f6c0e4f
CW
523 });
524 });
525 }
c2d3af50 526 if (typeof objectParams[name] !== 'undefined' || name === 'groupBy' || name === 'select' || name === 'join') {
19b53e5b
C
527 $scope.$watch('controls.' + name, function(value) {
528 var field = value;
529 $timeout(function() {
530 if (field) {
c2d3af50 531 if (name === 'join') {
266e8deb 532 $scope.params[name].push([field + ' AS ' + _.snakeCase(field), 'LEFT']);
f54beb1e 533 ctrl.buildFieldList();
c2d3af50
CW
534 }
535 else if (typeof objectParams[name] === 'undefined') {
f0acec37
CW
536 $scope.params[name].push(field);
537 } else {
538 var defaultOp = _.cloneDeep(objectParams[name]);
539 if (name === 'chain') {
540 var num = $scope.params.chain.length;
541 defaultOp[0] = field;
542 field = 'name_me_' + num;
543 }
544 $scope.params[name].push([field, defaultOp]);
19b53e5b 545 }
19b53e5b
C
546 $scope.controls[name] = null;
547 }
548 });
549 });
550 }
551 });
f54beb1e 552 ctrl.buildFieldList();
19b53e5b
C
553 $scope.availableParams = actionInfo.params;
554 }
555 writeCode();
556 }
557
a26e006b
CW
558 function describeSqlFn(params) {
559 var desc = ' ';
560 _.each(params, function(param) {
561 desc += ' ';
562 if (param.prefix) {
563 desc += _.filter(param.prefix).join('|') + ' ';
564 }
565 if (param.expr === 1) {
566 desc += 'expr ';
567 } else if (param.expr > 1) {
568 desc += 'expr, ... ';
569 }
570 if (param.suffix) {
571 desc += ' ' + _.filter(param.suffix).join('|') + ' ';
572 }
573 });
574 return desc.replace(/[ ]+/g, ' ');
575 }
576
19b53e5b
C
577 function defaultValues(defaultVal) {
578 _.each($scope.fields, function(field) {
579 if (field.required) {
580 defaultVal.push([field.id, '']);
581 }
582 });
583 return defaultVal;
584 }
585
586 function stringify(value, trim) {
587 if (typeof value === 'undefined') {
588 return '';
589 }
590 var str = JSON.stringify(value).replace(/,/g, ', ');
591 if (trim) {
592 str = str.slice(1, -1);
593 }
594 return str.trim();
595 }
596
597 function writeCode() {
19d01932 598 var code = {},
19b53e5b
C
599 entity = $scope.entity,
600 action = $scope.action,
601 params = getParams(),
2a68b84a 602 index = isInt($scope.index) ? +$scope.index : parseYaml($scope.index),
19b53e5b
C
603 result = 'result';
604 if ($scope.entity && $scope.action) {
9ebb0bb9 605 delete params.debug;
19b53e5b
C
606 if (action.slice(0, 3) === 'get') {
607 result = entity.substr(0, 7) === 'Custom_' ? _.camelCase(entity.substr(7)) : entity;
608 result = lcfirst(action.replace(/s$/, '').slice(3) || result);
609 }
610 var results = lcfirst(_.isNumber(index) ? result : pluralize(result)),
611 paramCount = _.size(params),
19b53e5b
C
612 i = 0;
613
f28caa2c
CW
614 switch ($scope.selectedTab.code) {
615 case 'js':
616 case 'ang':
617 // Write javascript
618 var js = "'" + entity + "', '" + action + "', {";
619 _.each(params, function(param, key) {
620 js += "\n " + key + ': ' + stringify(param) +
621 (++i < paramCount ? ',' : '');
622 if (key === 'checkPermissions') {
623 js += ' // IGNORED: permissions are always enforced from client-side requests';
624 }
625 });
626 js += "\n}";
627 if (index || index === 0) {
628 js += ', ' + JSON.stringify(index);
629 }
630 code.js = "CRM.api4(" + js + ").then(function(" + results + ") {\n // do something with " + results + " array\n}, function(failure) {\n // handle failure\n});";
631 code.js2 = "CRM.api4({" + results + ': [' + js + "]}).then(function(batch) {\n // do something with batch." + results + " array\n}, function(failure) {\n // handle failure\n});";
632 code.ang = "crmApi4(" + js + ").then(function(" + results + ") {\n // do something with " + results + " array\n}, function(failure) {\n // handle failure\n});";
633 code.ang2 = "crmApi4({" + results + ': [' + js + "]}).then(function(batch) {\n // do something with batch." + results + " array\n}, function(failure) {\n // handle failure\n});";
634 break;
635
636 case 'php':
637 // Write php code
638 code.php = '$' + results + " = civicrm_api4('" + entity + "', '" + action + "', [";
639 _.each(params, function(param, key) {
640 code.php += "\n '" + key + "' => " + phpFormat(param, 4) + ',';
641 });
642 code.php += "\n]";
643 if (index || index === 0) {
644 code.php += ', ' + phpFormat(index);
645 }
646 code.php += ");";
647
648 // Write oop code
649 code.oop = '$' + results + " = " + formatOOP(entity, action, params, 2) + "\n ->execute()";
651c4c95 650 if (_.isNumber(index)) {
f28caa2c
CW
651 code.oop += !index ? '\n ->first()' : (index === -1 ? '\n ->last()' : '\n ->itemAt(' + index + ')');
652 } else if (index) {
653 if (_.isString(index) || (_.isPlainObject(index) && !index[0] && !index['0'])) {
654 code.oop += "\n ->indexBy('" + (_.isPlainObject(index) ? _.keys(index)[0] : index) + "')";
655 }
656 if (_.isArray(index) || _.isPlainObject(index)) {
657 code.oop += "\n ->column('" + (_.isArray(index) ? index[0] : _.values(index)[0]) + "')";
658 }
659 }
660 code.oop += ";\n";
651c4c95 661 if (!_.isNumber(index)) {
f28caa2c
CW
662 code.oop += "foreach ($" + results + ' as $' + ((_.isString(index) && index) ? index + ' => $' : '') + result + ') {\n // do something\n}';
663 }
664 break;
3b1f7ce7 665
f28caa2c 666 case 'cli':
75ea0350
CW
667 // Cli code using json input
668 code.long = 'cv api4 ' + entity + '.' + action + ' ' + cliFormat(JSON.stringify(params));
669 code.pipe = 'echo ' + cliFormat(JSON.stringify(params)) + ' | cv api4 ' + entity + '.' + action + ' --in=json';
670
671 // Cli code using short syntax
672 code.short = 'cv api4 ' + entity + '.' + action;
673 var limitSet = false;
674 _.each(params, function(param, key) {
675 switch (true) {
676 case (key === 'select' && !_.includes(param.join(), ' ')):
677 code.short += ' +s ' + cliFormat(param.join(','));
678 break;
679 case (key === 'where' && !_.intersection(_.map(param, 0), ['AND', 'OR', 'NOT']).length):
680 _.each(param, function(clause) {
681 code.short += ' +w ' + cliFormat(clause[0] + ' ' + clause[1] + (clause.length > 2 ? (' ' + JSON.stringify(clause[2])) : ''));
682 });
683 break;
684 case (key === 'orderBy'):
685 _.each(param, function(dir, field) {
686 code.short += ' +o ' + cliFormat(field + ' ' + dir);
687 });
688 break;
689 case (key === 'values'):
690 _.each(param, function(val, field) {
691 code.short += ' +v ' + cliFormat(field + '=' + val);
692 });
693 break;
694 case (key === 'limit' || key === 'offset'):
695 // These 2 get combined
696 if (!limitSet) {
697 limitSet = true;
698 code.short += ' +l ' + (params.limit || '0') + (params.offset ? ('@' + params.offset) : '');
699 }
700 break;
701 default:
702 code.short += ' ' + key + '=' + (typeof param === 'string' ? cliFormat(param) : cliFormat(JSON.stringify(param)));
703 }
704 });
19b53e5b 705 }
19b53e5b 706 }
19d01932 707 _.each($scope.code, function(vals) {
f28caa2c 708 _.each(vals, function(style) {
4279d7d7 709 style.code = code[style.name] ? prettyPrintOne(_.escape(code[style.name])) : '';
19d01932 710 });
19b53e5b
C
711 });
712 }
713
b568c26c
CW
714 // Format oop params
715 function formatOOP(entity, action, params, indent) {
9f338c09 716 var info = getEntity(entity),
6764a9d3 717 newLine = "\n" + _.repeat(' ', indent),
9f338c09 718 code = '\\' + info.class + '::' + action + '(',
6764a9d3 719 perm = params.checkPermissions === false ? 'FALSE' : '';
b568c26c 720 if (entity.substr(0, 7) !== 'Custom_') {
9f338c09 721 code += perm + ')';
b568c26c 722 } else {
9f338c09 723 code += "'" + entity.substr(7) + "'" + (perm ? ', ' : '') + perm + ")";
b568c26c
CW
724 }
725 _.each(params, function(param, key) {
726 var val = '';
727 if (typeof objectParams[key] !== 'undefined' && key !== 'chain') {
728 _.each(param, function(item, index) {
729 val = phpFormat(index) + ', ' + phpFormat(item, 2 + indent);
730 code += newLine + "->add" + ucfirst(key).replace(/s$/, '') + '(' + val + ')';
731 });
732 } else if (key === 'where') {
733 _.each(param, function (clause) {
734 if (clause[0] === 'AND' || clause[0] === 'OR' || clause[0] === 'NOT') {
735 code += newLine + "->addClause(" + phpFormat(clause[0]) + ", " + phpFormat(clause[1]).slice(1, -1) + ')';
736 } else {
737 code += newLine + "->addWhere(" + phpFormat(clause).slice(1, -1) + ")";
738 }
739 });
740 } else if (key === 'select') {
651c4c95
CW
741 // selectRowCount() is a shortcut for addSelect('row_count')
742 if (isSelectRowCount(params)) {
743 code += newLine + '->selectRowCount()';
744 param = _.without(param, 'row_count');
745 }
746 // addSelect() is a variadic function & can take multiple arguments
747 if (param.length) {
748 code += newLine + '->addSelect(' + phpFormat(param).slice(1, -1) + ')';
749 }
b568c26c
CW
750 } else if (key === 'chain') {
751 _.each(param, function(chain, name) {
752 code += newLine + "->addChain('" + name + "', " + formatOOP(chain[0], chain[1], chain[2], 2 + indent);
753 code += (chain.length > 3 ? ',' : '') + (!_.isEmpty(chain[2]) ? newLine : ' ') + (chain.length > 3 ? phpFormat(chain[3]) : '') + ')';
754 });
755 }
6764a9d3 756 else if (key !== 'checkPermissions') {
b568c26c
CW
757 code += newLine + "->set" + ucfirst(key) + '(' + phpFormat(param, 2 + indent) + ')';
758 }
759 });
760 return code;
761 }
762
19b53e5b
C
763 function isInt(value) {
764 if (_.isFinite(value)) {
765 return true;
766 }
767 if (!_.isString(value)) {
768 return false;
769 }
770 return /^-{0,1}\d+$/.test(value);
771 }
772
773 function formatMeta(resp) {
774 var ret = '';
775 _.each(resp, function(val, key) {
776 if (key !== 'values' && !_.isPlainObject(val) && !_.isFunction(val)) {
777 ret += (ret.length ? ', ' : '') + key + ': ' + (_.isArray(val) ? '[' + val + ']' : val);
778 }
779 });
3b1f7ce7 780 return prettyPrintOne(_.escape(ret));
19b53e5b
C
781 }
782
783 $scope.execute = function() {
2aafb0fc 784 $scope.status = 'info';
19b53e5b 785 $scope.loading = true;
2e40130b 786 $http.post(CRM.url('civicrm/ajax/api4/' + $scope.entity + '/' + $scope.action, {
19b53e5b 787 params: angular.toJson(getParams()),
2a68b84a 788 index: isInt($scope.index) ? +$scope.index : parseYaml($scope.index)
2e40130b 789 }), null, {
ea3acfee
SL
790 headers: {
791 'X-Requested-With': 'XMLHttpRequest'
792 }
793 }).then(function(resp) {
19b53e5b 794 $scope.loading = false;
2aafb0fc 795 $scope.status = resp.data && resp.data.debug && resp.data.debug.log ? 'warning' : 'success';
b65fa6dc 796 $scope.debug = debugFormat(resp.data);
651c4c95
CW
797 $scope.result = [
798 formatMeta(resp.data),
3495fe67 799 prettyPrintOne((_.isArray(resp.data.values) ? '(' + resp.data.values.length + ') ' : '') + _.escape(JSON.stringify(resp.data.values, null, 2)), 'js', 1)
651c4c95 800 ];
19b53e5b
C
801 }, function(resp) {
802 $scope.loading = false;
803 $scope.status = 'danger';
b65fa6dc 804 $scope.debug = debugFormat(resp.data);
651c4c95
CW
805 $scope.result = [
806 formatMeta(resp),
807 prettyPrintOne(_.escape(JSON.stringify(resp.data, null, 2)))
808 ];
19b53e5b
C
809 });
810 };
811
b65fa6dc
CW
812 function debugFormat(data) {
813 var debug = data.debug ? prettyPrintOne(_.escape(JSON.stringify(data.debug, null, 2)).replace(/\\n/g, "\n")) : null;
814 delete data.debug;
815 return debug;
816 }
817
19b53e5b
C
818 /**
819 * Format value to look like php code
820 */
821 function phpFormat(val, indent) {
822 if (typeof val === 'undefined') {
823 return '';
824 }
6ba6f2bd
CW
825 if (val === null || val === true || val === false) {
826 return JSON.stringify(val).toUpperCase();
827 }
19b53e5b
C
828 indent = (typeof indent === 'number') ? _.repeat(' ', indent) : (indent || '');
829 var ret = '',
830 baseLine = indent ? indent.slice(0, -2) : '',
cddf293f
CW
831 newLine = indent ? '\n' : '',
832 trailingComma = indent ? ',' : '';
19b53e5b
C
833 if ($.isPlainObject(val)) {
834 $.each(val, function(k, v) {
835 ret += (ret ? ', ' : '') + newLine + indent + "'" + k + "' => " + phpFormat(v);
836 });
cddf293f 837 return '[' + ret + trailingComma + newLine + baseLine + ']';
19b53e5b
C
838 }
839 if ($.isArray(val)) {
840 $.each(val, function(k, v) {
841 ret += (ret ? ', ' : '') + newLine + indent + phpFormat(v);
842 });
cddf293f 843 return '[' + ret + trailingComma + newLine + baseLine + ']';
19b53e5b
C
844 }
845 if (_.isString(val) && !_.contains(val, "'")) {
846 return "'" + val + "'";
847 }
848 return JSON.stringify(val).replace(/\$/g, '\\$');
849 }
850
75ea0350
CW
851 // Format string to be cli-input-safe
852 function cliFormat(str) {
853 if (!_.includes(str, ' ') && !_.includes(str, '"') && !_.includes(str, "'")) {
854 return str;
855 }
856 if (!_.includes(str, "'")) {
857 return "'" + str + "'";
858 }
859 if (!_.includes(str, '"')) {
860 return '"' + str + '"';
861 }
862 return "'" + str.replace(/'/g, "\\'") + "'";
863 }
864
19b53e5b
C
865 function fetchMeta() {
866 crmApi4(getMetaParams)
867 .then(function(data) {
868 if (data.actions) {
869 getEntity().actions = data.actions;
870 selectAction();
871 }
872 });
873 }
874
875 // Help for an entity with no action selected
876 function showEntityHelp(entityName) {
877 var entityInfo = getEntity(entityName);
136ca5bb 878 setHelp($scope.entity, {
19b53e5b 879 description: entityInfo.description,
0493ec47 880 comment: entityInfo.comment,
465bc32a 881 type: entityInfo.type,
d44cc3cb 882 since: entityInfo.since,
0493ec47 883 see: entityInfo.see
136ca5bb 884 });
19b53e5b
C
885 }
886
887 if (!$scope.entity) {
136ca5bb 888 setHelp(ts('APIv4 Explorer'), {description: docs.description, comment: docs.comment, see: docs.see});
19b53e5b
C
889 } else if (!actions.length && !getEntity().actions) {
890 getMetaParams.actions = [$scope.entity, 'getActions', {chain: {fields: [$scope.entity, 'getFields', {action: '$name'}]}}];
891 fetchMeta();
892 } else {
893 selectAction();
894 }
895
896 if ($scope.entity) {
897 showEntityHelp($scope.entity);
898 }
899
900 // Update route when changing entity
901 $scope.$watch('entity', function(newVal, oldVal) {
902 if (oldVal !== newVal) {
903 // Flush actions cache to re-fetch for new entity
904 actions = [];
905 $location.url('/explorer/' + newVal);
906 }
907 });
908
909 // Update route when changing actions
910 $scope.$watch('action', function(newVal, oldVal) {
911 if ($scope.entity && $routeParams.api4action !== newVal && !_.isUndefined(newVal)) {
912 $location.url('/explorer/' + $scope.entity + '/' + newVal);
913 } else if (newVal) {
136ca5bb 914 setHelp($scope.entity + '::' + newVal, _.pick(_.findWhere(getEntity().actions, {name: newVal}), ['description', 'comment', 'see']));
19b53e5b
C
915 }
916 });
917
9cea3619
CW
918 $scope.paramDoc = function(name) {
919 return docs.params[name];
19b53e5b
C
920 };
921
2c5d5bca
CW
922 $scope.executeDoc = function() {
923 var doc = {
924 description: ts('Runs API call on the CiviCRM database.'),
925 comment: ts('Results and debugging info will be displayed below.')
926 };
927 if ($scope.action === 'delete') {
928 doc.WARNING = ts('This API call will be executed on the real database. Deleting data cannot be undone.');
929 }
930 else if ($scope.action && $scope.action.slice(0, 3) !== 'get') {
931 doc.WARNING = ts('This API call will be executed on the real database. It cannot be undone.');
932 }
933 return doc;
934 };
935
936 $scope.saveDoc = function() {
937 return {
938 description: ts('Save API call as a smart group.'),
48102254
CW
939 comment: ts('Create a SavedSearch using these API params to populate a smart group.') +
940 '\n\n' + ts('NOTE: you must select contact id as the only field.')
2c5d5bca
CW
941 };
942 };
943
19b53e5b
C
944 $scope.$watch('params', writeCode, true);
945 $scope.$watch('index', writeCode);
946 writeCode();
947
4e97c268 948 $scope.save = function() {
48102254
CW
949 $scope.params.limit = $scope.params.offset = 0;
950 if ($scope.params.chain.length) {
951 CRM.alert(ts('Smart groups are not compatible with API chaining.'), ts('Error'), 'error', {expires: 5000});
952 return;
953 }
954 if ($scope.params.select.length !== 1 || !_.includes($scope.params.select[0], 'id')) {
955 CRM.alert(ts('To create a smart group, the API must select contact id and no other fields.'), ts('Error'), 'error', {expires: 5000});
956 return;
957 }
4e97c268
CW
958 var model = {
959 title: '',
d7507e89
CW
960 description: '',
961 visibility: 'User and User Admin Only',
962 group_type: [],
4e97c268
CW
963 id: null,
964 entity: $scope.entity,
965 params: JSON.parse(angular.toJson($scope.params))
966 };
967 model.params.version = 4;
4e97c268
CW
968 delete model.params.chain;
969 delete model.params.debug;
970 delete model.params.limit;
48102254
CW
971 delete model.params.offset;
972 delete model.params.orderBy;
4e97c268
CW
973 delete model.params.checkPermissions;
974 var options = CRM.utils.adjustDialogDefaults({
975 width: '500px',
976 autoOpen: false,
977 title: ts('Save smart group')
978 });
979 dialogService.open('saveSearchDialog', '~/api4Explorer/SaveSearch.html', model, options);
980 };
981 });
982
983 angular.module('api4Explorer').controller('SaveSearchCtrl', function($scope, crmApi4, dialogService) {
984 var ts = $scope.ts = CRM.ts(),
985 model = $scope.model;
d7507e89
CW
986 $scope.groupEntityRefParams = {
987 entity: 'Group',
988 api: {
989 params: {is_hidden: 0, is_active: 1, 'saved_search_id.api_entity': model.entity},
990 extra: ['saved_search_id', 'description', 'visibility', 'group_type']
991 },
992 select: {
993 allowClear: true,
994 minimumInputLength: 0,
995 placeholder: ts('Select existing group')
996 }
997 };
998 if (!CRM.checkPerm('administer reserved groups')) {
999 $scope.groupEntityRefParams.api.params.is_reserved = 0;
1000 }
1001 $scope.perm = {
1002 administerReservedGroups: CRM.checkPerm('administer reserved groups')
1003 };
1004 $scope.options = CRM.vars.api4.groupOptions;
4e97c268
CW
1005 $scope.$watch('model.id', function(id) {
1006 if (id) {
d7507e89 1007 _.assign(model, $('#api-save-search-select-group').select2('data').extra);
4e97c268
CW
1008 }
1009 });
1010 $scope.cancel = function() {
1011 dialogService.cancel('saveSearchDialog');
1012 };
1013 $scope.save = function() {
1014 $('.ui-dialog:visible').block();
1015 var group = model.id ? {id: model.id} : {title: model.title};
1016 group.description = model.description;
d7507e89
CW
1017 group.visibility = model.visibility;
1018 group.group_type = model.group_type;
4e97c268
CW
1019 group.saved_search_id = '$id';
1020 var savedSearch = {
1021 api_entity: model.entity,
1022 api_params: model.params
1023 };
1024 if (group.id) {
d7507e89 1025 savedSearch.id = model.saved_search_id;
4e97c268
CW
1026 }
1027 crmApi4('SavedSearch', 'save', {records: [savedSearch], chain: {group: ['Group', 'save', {'records': [group]}]}})
1028 .then(function(result) {
1029 dialogService.close('saveSearchDialog', result[0]);
1030 });
1031 };
19b53e5b
C
1032 });
1033
9b057f1e
CW
1034 angular.module('api4Explorer').component('crmApi4Clause', {
1035 bindings: {
1036 fields: '<',
1037 clauses: '<',
1038 format: '@',
1039 op: '@',
1040 skip: '<',
1041 isRequired: '<',
1042 label: '@',
1043 deleteGroup: '&'
1044 },
1045 templateUrl: '~/api4Explorer/Clause.html',
1046 controller: function ($scope, $element, $timeout) {
1047 var ts = $scope.ts = CRM.ts(),
1048 ctrl = this;
1049 this.conjunctions = {AND: ts('And'), OR: ts('Or'), NOT: ts('Not')};
1050 this.operators = CRM.vars.api4.operators;
1051 this.sortOptions = {
1052 axis: 'y',
1053 connectWith: '.api4-clause-group-sortable',
1054 containment: $element.closest('.api4-clause-fieldset'),
1055 over: onSortOver,
1056 start: onSort,
1057 stop: onSort
1058 };
19b53e5b 1059
9b057f1e
CW
1060 this.$onInit = function() {
1061 ctrl.hasParent = !!$element.attr('delete-group');
1062 };
19b53e5b 1063
9b057f1e
CW
1064 this.addGroup = function(op) {
1065 ctrl.clauses.push([op, []]);
1066 };
1067
1068 function onSort(event, ui) {
1069 $($element).closest('.api4-clause-fieldset').toggleClass('api4-sorting', event.type === 'sortstart');
1070 $('.api4-input.form-inline').css('margin-left', '');
1071 }
19b53e5b 1072
9b057f1e
CW
1073 // Indent clause while dragging between nested groups
1074 function onSortOver(event, ui) {
1075 var offset = 0;
1076 if (ui.sender) {
1077 offset = $(ui.placeholder).offset().left - $(ui.sender).offset().left;
af6f5ac8 1078 }
9b057f1e
CW
1079 $('.api4-input.form-inline.ui-sortable-helper').css('margin-left', '' + offset + 'px');
1080 }
19b53e5b 1081
9b057f1e
CW
1082 this.addClause = function() {
1083 $timeout(function() {
1084 if (ctrl.newClause) {
1085 if (ctrl.skip && ctrl.clauses.length < ctrl.skip) {
1086 ctrl.clauses.push(null);
1087 }
1088 ctrl.clauses.push([ctrl.newClause, '=', '']);
1089 ctrl.newClause = null;
19b53e5b 1090 }
9b057f1e
CW
1091 });
1092 };
1093
1094 this.deleteRow = function(index) {
1095 ctrl.clauses.splice(index, 1);
1096 };
1097
1098 // Remove empty values
1099 this.changeClauseField = function(clause, index) {
1100 if (clause[0] === '') {
1101 ctrl.deleteRow(index);
af6f5ac8 1102 }
9b057f1e 1103 };
19b53e5b 1104
9b057f1e
CW
1105 // Add/remove value if operator allows for one
1106 this.changeClauseOperator = function(clause) {
c0e68893 1107 if (_.contains(clause[1], 'IS ')) {
9b057f1e
CW
1108 clause.length = 2;
1109 } else if (clause.length === 2) {
1110 clause.push('');
1111 }
1112 };
1113 }
19b53e5b
C
1114 });
1115
1116 angular.module('api4Explorer').directive('api4ExpValue', function($routeParams, crmApi4) {
1117 return {
1118 scope: {
1119 data: '=api4ExpValue'
1120 },
1121 require: 'ngModel',
1122 link: function (scope, element, attrs, ctrl) {
6f97b1d9 1123 var ts = scope.ts = CRM.ts(),
6872a653 1124 multi = _.includes(['IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN'], scope.data.op),
19b53e5b 1125 entity = $routeParams.api4entity,
c752d94b 1126 action = scope.data.action || $routeParams.api4action;
19b53e5b
C
1127
1128 function destroyWidget() {
1129 var $el = $(element);
1130 if ($el.is('.crm-form-date-wrapper .crm-hidden-date')) {
1131 $el.crmDatepicker('destroy');
1132 }
1133 if ($el.is('.select2-container + input')) {
1134 $el.crmEntityRef('destroy');
1135 }
1136 $(element).removeData().removeAttr('type').removeAttr('placeholder').show();
1137 }
1138
1139 function makeWidget(field, op) {
1140 var $el = $(element),
bc356925 1141 inputType = field.input_type,
19b53e5b
C
1142 dataType = field.data_type;
1143 if (!op) {
1144 op = field.serialize || dataType === 'Array' ? 'IN' : '=';
1145 }
6872a653 1146 multi = _.includes(['IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN'], op);
c0e68893 1147 // IS NULL, IS EMPTY, etc.
1148 if (_.contains(op, 'IS ')) {
19b53e5b
C
1149 $el.hide();
1150 return;
1151 }
1152 if (inputType === 'Date') {
1153 if (_.includes(['=', '!=', '<>', '<', '>=', '<', '<='], op)) {
1154 $el.crmDatepicker({time: (field.input_attrs && field.input_attrs.time) || false});
1155 }
1156 } else if (_.includes(['=', '!=', '<>', 'IN', 'NOT IN'], op) && (field.fk_entity || field.options || dataType === 'Boolean')) {
37d82abe
CW
1157 if (field.options) {
1158 var id = field.pseudoconstant || 'id';
19b53e5b
C
1159 $el.addClass('loading').attr('placeholder', ts('- select -')).crmSelect2({multiple: multi, data: [{id: '', text: ''}]});
1160 loadFieldOptions(field.entity || entity).then(function(data) {
37d82abe
CW
1161 var options = _.transform(data[field.name].options, function(options, opt) {
1162 options.push({id: opt[id], text: opt.label, description: opt.description, color: opt.color, icon: opt.icon});
1163 }, []);
1164 $el.removeClass('loading').crmSelect2({data: options, multiple: multi});
19b53e5b 1165 });
37d82abe 1166 } else if (field.fk_entity) {
7a4cf127 1167 $el.crmEntityRef({entity: field.fk_entity, select:{multiple: multi}, static: field.fk_entity === 'Contact' ? ['user_contact_id'] : []});
19b53e5b
C
1168 } else if (dataType === 'Boolean') {
1169 $el.attr('placeholder', ts('- select -')).crmSelect2({allowClear: false, multiple: multi, placeholder: ts('- select -'), data: [
2929a8fb
CW
1170 {id: 'true', text: ts('Yes')},
1171 {id: 'false', text: ts('No')}
19b53e5b
C
1172 ]});
1173 }
6872a653 1174 } else if (dataType === 'Integer' && !multi) {
19b53e5b
C
1175 $el.attr('type', 'number');
1176 }
1177 }
1178
1179 function loadFieldOptions(entity) {
1180 if (!fieldOptions[entity + action]) {
1181 fieldOptions[entity + action] = crmApi4(entity, 'getFields', {
37d82abe 1182 loadOptions: ['id', 'name', 'label', 'description', 'color', 'icon'],
19b53e5b 1183 action: action,
37d82abe
CW
1184 where: [['options', '!=', false]],
1185 select: ['options']
1186 }, 'name');
19b53e5b
C
1187 }
1188 return fieldOptions[entity + action];
1189 }
1190
1191 // Copied from ng-list but applied conditionally if field is multi-valued
1192 var parseList = function(viewValue) {
1193 // If the viewValue is invalid (say required but empty) it will be `undefined`
1194 if (_.isUndefined(viewValue)) return;
1195
1196 if (!multi) {
1197 return viewValue;
1198 }
1199
1200 var list = [];
1201
1202 if (viewValue) {
1203 _.each(viewValue.split(','), function(value) {
1204 if (value) list.push(_.trim(value));
1205 });
1206 }
1207
1208 return list;
1209 };
1210
1211 // Copied from ng-list
1212 ctrl.$parsers.push(parseList);
1213 ctrl.$formatters.push(function(value) {
1214 return _.isArray(value) ? value.join(', ') : value;
1215 });
1216
1217 // Copied from ng-list
1218 ctrl.$isEmpty = function(value) {
1219 return !value || !value.length;
1220 };
1221
1222 scope.$watchCollection('data', function(data) {
1223 destroyWidget();
1224 var field = getField(data.field, entity, action);
af6f5ac8 1225 if (field && data.format !== 'plain') {
19b53e5b
C
1226 makeWidget(field, data.op);
1227 }
1228 });
1229 }
1230 };
1231 });
1232
1233
1234 angular.module('api4Explorer').directive('api4ExpChain', function(crmApi4) {
1235 return {
1236 scope: {
1237 chain: '=api4ExpChain',
1238 mainEntity: '=',
1239 entities: '='
1240 },
1241 templateUrl: '~/api4Explorer/Chain.html',
1242 link: function (scope, element, attrs) {
6f97b1d9 1243 var ts = scope.ts = CRM.ts();
19b53e5b
C
1244
1245 function changeEntity(newEntity, oldEntity) {
1246 // When clearing entity remove this chain
1247 if (!newEntity) {
1248 scope.chain[0] = '';
1249 return;
1250 }
1251 // Reset action && index
1252 if (newEntity !== oldEntity) {
1253 scope.chain[1][1] = scope.chain[1][2] = '';
1254 }
1255 if (getEntity(newEntity).actions) {
1256 setActions();
1257 } else {
1258 crmApi4(newEntity, 'getActions', {chain: {fields: [newEntity, 'getFields', {action: '$name'}]}})
1259 .then(function(data) {
1260 getEntity(data.entity).actions = data;
1261 if (data.entity === scope.chain[1][0]) {
1262 setActions();
1263 }
1264 });
1265 }
1266 }
1267
1268 function setActions() {
1269 scope.actions = [''].concat(_.pluck(getEntity(scope.chain[1][0]).actions, 'name'));
1270 }
1271
1272 // Set default params when choosing action
1273 function changeAction(newAction, oldAction) {
1274 var link;
1275 // Prepopulate links
1276 if (newAction && newAction !== oldAction) {
1277 // Clear index
1278 scope.chain[1][3] = '';
1279 // Look for links back to main entity
1280 _.each(entityFields(scope.chain[1][0]), function(field) {
1281 if (field.fk_entity === scope.mainEntity) {
1282 link = [field.name, '$id'];
1283 }
1284 });
1285 // Look for links from main entity
1286 if (!link && newAction !== 'create') {
1287 _.each(entityFields(scope.mainEntity), function(field) {
1288 if (field.fk_entity === scope.chain[1][0]) {
1289 link = ['id', '$' + field.name];
1290 // Since we're specifying the id, set index to getsingle
1291 scope.chain[1][3] = '0';
1292 }
1293 });
1294 }
1295 if (link && _.contains(['get', 'update', 'replace', 'delete'], newAction)) {
1296 scope.chain[1][2] = '{where: [[' + link[0] + ', =, ' + link[1] + ']]}';
1297 }
1298 else if (link && _.contains(['create'], newAction)) {
1299 scope.chain[1][2] = '{values: {' + link[0] + ': ' + link[1] + '}}';
cddf293f
CW
1300 }
1301 else if (link && _.contains(['save'], newAction)) {
1302 scope.chain[1][2] = '{records: [{' + link[0] + ': ' + link[1] + '}]}';
19b53e5b
C
1303 } else {
1304 scope.chain[1][2] = '{}';
1305 }
1306 }
1307 }
1308
1309 scope.$watch("chain[1][0]", changeEntity);
1310 scope.$watch("chain[1][1]", changeAction);
1311 }
1312 };
1313 });
1314
1315 function getEntity(entityName) {
1316 return _.findWhere(schema, {name: entityName});
1317 }
1318
1319 function entityFields(entityName, action) {
1320 var entity = getEntity(entityName);
1321 if (entity && action && entity.actions) {
1322 return _.findWhere(entity.actions, {name: action}).fields;
1323 }
1324 return _.result(entity, 'fields');
1325 }
1326
f54beb1e
CW
1327 function getExplicitJoins() {
1328 return _.transform(params.join, function(joins, join) {
1329 var j = join[0].split(' AS '),
1330 joinEntity = _.trim(j[0]),
1331 joinAlias = _.trim(j[1]) || joinEntity.toLowerCase();
1332 joins[joinAlias] = joinEntity;
1333 }, {});
1334 }
1335
19b53e5b 1336 function getField(fieldName, entity, action) {
37d82abe
CW
1337 var suffix = fieldName.split(':')[1];
1338 fieldName = fieldName.split(':')[0];
19b53e5b 1339 var fieldNames = fieldName.split('.');
37d82abe
CW
1340 var field = get(entity, fieldNames);
1341 if (field && suffix) {
1342 field.pseudoconstant = suffix;
1343 }
1344 return field;
19b53e5b
C
1345
1346 function get(entity, fieldNames) {
1347 if (fieldNames.length === 1) {
1348 return _.findWhere(entityFields(entity, action), {name: fieldNames[0]});
1349 }
1350 var comboName = _.findWhere(entityFields(entity, action), {name: fieldNames[0] + '.' + fieldNames[1]});
1351 if (comboName) {
1352 return comboName;
1353 }
1354 var linkName = fieldNames.shift(),
f54beb1e 1355 newEntity = getExplicitJoins()[linkName] || _.findWhere(links[entity], {alias: linkName}).entity;
19b53e5b
C
1356 return get(newEntity, fieldNames);
1357 }
1358 }
1359
1360 // Collapsible optgroups for select2
1361 $(function() {
1362 $('body')
1363 .on('select2-open', function(e) {
1364 if ($(e.target).hasClass('collapsible-optgroups')) {
1365 $('#select2-drop')
1366 .off('.collapseOptionGroup')
1367 .addClass('collapsible-optgroups-enabled')
1368 .on('click.collapseOptionGroup', '.select2-result-with-children > .select2-result-label', function() {
1369 $(this).parent().toggleClass('optgroup-expanded');
1370 });
1371 }
1372 })
1373 .on('select2-close', function() {
1374 $('#select2-drop').off('.collapseOptionGroup').removeClass('collapsible-optgroups-enabled');
1375 });
1376 });
1377})(angular, CRM.$, CRM._);