| 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 | // Api params |
| 14 | var params; |
| 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 | |
| 25 | angular.module('api4Explorer').controller('Api4Explorer', function($scope, $routeParams, $location, $timeout, $http, crmUiHelp, crmApi4, dialogService) { |
| 26 | var ts = $scope.ts = CRM.ts(), |
| 27 | ctrl = $scope.$ctrl = this; |
| 28 | $scope.entities = entities; |
| 29 | $scope.actions = actions; |
| 30 | $scope.fields = []; |
| 31 | $scope.havingOptions = []; |
| 32 | $scope.fieldsAndJoins = []; |
| 33 | $scope.fieldsAndJoinsAndFunctions = []; |
| 34 | $scope.fieldsAndJoinsAndFunctionsWithSuffixes = []; |
| 35 | $scope.fieldsAndJoinsAndFunctionsAndWildcards = []; |
| 36 | $scope.availableParams = {}; |
| 37 | params = $scope.params = {}; |
| 38 | $scope.index = ''; |
| 39 | $scope.selectedTab = {result: 'result', code: 'php'}; |
| 40 | $scope.perm = { |
| 41 | accessDebugOutput: CRM.checkPerm('access debug output'), |
| 42 | editGroups: CRM.checkPerm('edit groups') |
| 43 | }; |
| 44 | marked.setOptions({highlight: prettyPrintOne}); |
| 45 | var getMetaParams = {}, |
| 46 | objectParams = {orderBy: 'ASC', values: '', defaults: '', chain: ['Entity', '', '{}']}, |
| 47 | docs = CRM.vars.api4.docs, |
| 48 | helpTitle = '', |
| 49 | helpContent = {}; |
| 50 | $scope.helpTitle = ''; |
| 51 | $scope.helpContent = {}; |
| 52 | $scope.entity = $routeParams.api4entity; |
| 53 | $scope.result = []; |
| 54 | $scope.debug = null; |
| 55 | $scope.status = 'default'; |
| 56 | $scope.loading = false; |
| 57 | $scope.controls = {}; |
| 58 | $scope.langs = ['php', 'js', 'ang', 'cli']; |
| 59 | $scope.joinTypes = [{k: 'LEFT', v: 'LEFT JOIN'}, {k: 'INNER', v: 'INNER JOIN'}, {k: 'EXCLUDE', v: 'EXCLUDE'}]; |
| 60 | $scope.bridgeEntities = _.filter(schema, function(entity) {return _.includes(entity.type, 'EntityBridge');}); |
| 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: [ |
| 75 | {name: 'short', label: ts('CV (short)'), code: ''}, |
| 76 | {name: 'long', label: ts('CV (long)'), code: ''}, |
| 77 | {name: 'pipe', label: ts('CV (pipe)'), code: ''} |
| 78 | ] |
| 79 | }; |
| 80 | |
| 81 | if (!entities.length) { |
| 82 | formatForSelect2(schema, entities, 'name', ['description', 'icon']); |
| 83 | } |
| 84 | |
| 85 | $scope.$bindToRoute({ |
| 86 | expr: 'index', |
| 87 | param: 'index', |
| 88 | default: '' |
| 89 | }); |
| 90 | |
| 91 | function ucfirst(str) { |
| 92 | return str[0].toUpperCase() + str.slice(1); |
| 93 | } |
| 94 | |
| 95 | function lcfirst(str) { |
| 96 | return str[0].toLowerCase() + str.slice(1); |
| 97 | } |
| 98 | |
| 99 | function pluralize(str) { |
| 100 | var lastLetter = str[str.length - 1], |
| 101 | lastTwo = str[str.length - 2] + lastLetter; |
| 102 | if (lastLetter === 's' || lastLetter === 'x' || lastTwo === 'ch') { |
| 103 | return str + 'es'; |
| 104 | } |
| 105 | if (lastLetter === 'y' && !_.includes(['ay', 'ey', 'iy', 'oy', 'uy'], lastTwo)) { |
| 106 | return str.slice(0, -1) + 'ies'; |
| 107 | } |
| 108 | return str + 's'; |
| 109 | } |
| 110 | |
| 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 | |
| 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; |
| 128 | if (addPseudoconstant) { |
| 129 | addPseudoconstants(fieldInfo, addPseudoconstant); |
| 130 | } |
| 131 | formatForSelect2(fieldInfo, fieldList, 'name', ['description', 'required', 'default_value']); |
| 132 | } |
| 133 | |
| 134 | // Note: this function expects fieldList to be select2-formatted already |
| 135 | function addJoins(fieldList, addWildcard, addPseudoconstant) { |
| 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 |
| 152 | _.each(links[$scope.entity], function(link) { |
| 153 | var linkFields = _.cloneDeep(entityFields(link.entity)), |
| 154 | wildCard = addWildcard ? [{id: link.alias + '.*', text: link.alias + '.*', 'description': 'All core ' + link.entity + ' fields'}] : []; |
| 155 | if (linkFields) { |
| 156 | if (addPseudoconstant) { |
| 157 | addPseudoconstants(linkFields, addPseudoconstant); |
| 158 | } |
| 159 | fieldList.push({ |
| 160 | text: link.alias, |
| 161 | description: 'Implicit join to ' + link.entity, |
| 162 | children: wildCard.concat(formatForSelect2(linkFields, [], 'name', ['description'], link.alias + '.')) |
| 163 | }); |
| 164 | } |
| 165 | }); |
| 166 | } |
| 167 | |
| 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 | |
| 181 | $scope.help = function(title, content) { |
| 182 | if (!content) { |
| 183 | $scope.helpTitle = helpTitle; |
| 184 | $scope.helpContent = helpContent; |
| 185 | } else { |
| 186 | $scope.helpTitle = title; |
| 187 | $scope.helpContent = formatHelp(content); |
| 188 | } |
| 189 | }; |
| 190 | |
| 191 | // Sets the static help text (which gets overridden by mousing over other elements) |
| 192 | function setHelp(title, content) { |
| 193 | $scope.helpTitle = helpTitle = title; |
| 194 | $scope.helpContent = helpContent = formatHelp(content); |
| 195 | } |
| 196 | |
| 197 | // Format help text with markdown; replace variables and format links |
| 198 | function formatHelp(rawContent) { |
| 199 | function formatRefs(see) { |
| 200 | _.each(see, function(ref, idx) { |
| 201 | var match = ref.match(/^(\\Civi\\Api4\\)?([a-zA-Z]+)$/); |
| 202 | if (match) { |
| 203 | ref = '#/explorer/' + match[2]; |
| 204 | } |
| 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'; |
| 211 | } |
| 212 | see[idx] = '<a target="' + (ref[0] === '#' ? '_self' : '_blank') + '" href="' + ref + '">' + see[idx] + '</a>'; |
| 213 | }); |
| 214 | } |
| 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 | } |
| 222 | formatRefs(formatted.see); |
| 223 | return formatted; |
| 224 | } |
| 225 | |
| 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 | |
| 246 | // Returns field list for write params (values, defaults) |
| 247 | $scope.fieldList = function(param) { |
| 248 | return function() { |
| 249 | var fields = []; |
| 250 | getFieldList(fields, $scope.action === 'getFields' ? ($scope.params.action || 'get') : $scope.action, ['name']); |
| 251 | // Disable fields that are already in use |
| 252 | _.each($scope.params[param] || [], function(val) { |
| 253 | var usedField = val[0].replace(':name', ''); |
| 254 | (_.findWhere(fields, {id: usedField}) || {}).disabled = true; |
| 255 | (_.findWhere(fields, {id: usedField + ':name'}) || {}).disabled = true; |
| 256 | }); |
| 257 | return {results: fields}; |
| 258 | }; |
| 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 | |
| 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 | } |
| 273 | }; |
| 274 | |
| 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 | } |
| 282 | var specialParams = ['select', 'fields', 'action', 'where', 'values', 'defaults', 'orderBy', 'chain', 'groupBy', 'having', 'join']; |
| 283 | if ($scope.availableParams.limit && $scope.availableParams.offset) { |
| 284 | specialParams.push('limit', 'offset'); |
| 285 | } |
| 286 | return _.transform($scope.availableParams, function(genericParams, param, name) { |
| 287 | if (!_.contains(specialParams, name) && !param.deprecated && |
| 288 | !(typeof paramType !== 'undefined' && !_.contains(paramType, param.type[0])) && |
| 289 | !(typeof defaultNull !== 'undefined' && ((param.default === null) !== defaultNull)) |
| 290 | ) { |
| 291 | genericParams[name] = param; |
| 292 | } |
| 293 | }); |
| 294 | }; |
| 295 | |
| 296 | $scope.selectRowCount = function() { |
| 297 | var index = params.select.indexOf('row_count'); |
| 298 | if (index < 0) { |
| 299 | $scope.params.select.push('row_count'); |
| 300 | } else { |
| 301 | $scope.params.select.splice(index, 1); |
| 302 | } |
| 303 | }; |
| 304 | |
| 305 | $scope.isSelectRowCount = function() { |
| 306 | return isSelectRowCount($scope.params); |
| 307 | }; |
| 308 | |
| 309 | $scope.selectLang = function(lang) { |
| 310 | $scope.selectedTab.code = lang; |
| 311 | writeCode(); |
| 312 | }; |
| 313 | |
| 314 | function isSelectRowCount(params) { |
| 315 | return params && params.select && params.select.indexOf('row_count') >= 0; |
| 316 | } |
| 317 | |
| 318 | function getEntity(entityName) { |
| 319 | return _.findWhere(schema, {name: entityName || $scope.entity}); |
| 320 | } |
| 321 | |
| 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) { |
| 333 | var link = _.find(links[entity], {alias: node}); |
| 334 | if (!link) { |
| 335 | return false; |
| 336 | } |
| 337 | entity = link.entity; |
| 338 | }); |
| 339 | return entity; |
| 340 | } |
| 341 | |
| 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')) { |
| 348 | params[key] = parseYaml(JSON.parse(angular.toJson(param))); |
| 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) { |
| 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); |
| 369 | }); |
| 370 | params[key] = newParam; |
| 371 | } |
| 372 | }); |
| 373 | return params; |
| 374 | } |
| 375 | |
| 376 | function parseYaml(input) { |
| 377 | if (typeof input === 'undefined' || input === '') { |
| 378 | return input; |
| 379 | } |
| 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; |
| 383 | } |
| 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 | |
| 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 | } |
| 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 | } |
| 433 | $scope.fieldsAndJoinsAndFunctionsAndWildcards.unshift({id: '*', text: '*', 'description': 'All core ' + $scope.entity + ' fields'}); |
| 434 | }; |
| 435 | |
| 436 | function selectAction() { |
| 437 | $scope.action = $routeParams.api4action; |
| 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}); |
| 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 | } |
| 461 | if (name === 'limit') { |
| 462 | defaultVal = 25; |
| 463 | } |
| 464 | if (name === 'debug') { |
| 465 | defaultVal = true; |
| 466 | } |
| 467 | if (name === 'values') { |
| 468 | defaultVal = defaultValues(defaultVal); |
| 469 | } |
| 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 | } |
| 481 | $scope.$bindToRoute({ |
| 482 | expr: 'params["' + name + '"]', |
| 483 | param: name, |
| 484 | format: format, |
| 485 | default: defaultVal, |
| 486 | deep: format === 'json' |
| 487 | }); |
| 488 | } |
| 489 | if (typeof objectParams[name] !== 'undefined' && name !== 'orderBy') { |
| 490 | $scope.$watch('params.' + name, function (values) { |
| 491 | // Remove empty values |
| 492 | _.each(values, function (clause, index) { |
| 493 | if (!clause || !clause[0]) { |
| 494 | $scope.clearParam(name, index); |
| 495 | } |
| 496 | }); |
| 497 | }, true); |
| 498 | } |
| 499 | if (name === 'select' && actionInfo.params.having) { |
| 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'); |
| 503 | $scope.havingOptions.length = 0; |
| 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 '), |
| 511 | alias = _.trim(pieces[pieces.length - 1]).replace(':label', ':name'); |
| 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 | } |
| 523 | }); |
| 524 | }); |
| 525 | } |
| 526 | if (typeof objectParams[name] !== 'undefined' || name === 'groupBy' || name === 'select' || name === 'join') { |
| 527 | $scope.$watch('controls.' + name, function(value) { |
| 528 | var field = value; |
| 529 | $timeout(function() { |
| 530 | if (field) { |
| 531 | if (name === 'join') { |
| 532 | $scope.params[name].push([field + ' AS ' + _.snakeCase(field), 'LEFT']); |
| 533 | ctrl.buildFieldList(); |
| 534 | } |
| 535 | else if (typeof objectParams[name] === 'undefined') { |
| 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]); |
| 545 | } |
| 546 | $scope.controls[name] = null; |
| 547 | } |
| 548 | }); |
| 549 | }); |
| 550 | } |
| 551 | }); |
| 552 | ctrl.buildFieldList(); |
| 553 | $scope.availableParams = actionInfo.params; |
| 554 | } |
| 555 | writeCode(); |
| 556 | } |
| 557 | |
| 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 | |
| 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() { |
| 598 | var code = {}, |
| 599 | entity = $scope.entity, |
| 600 | action = $scope.action, |
| 601 | params = getParams(), |
| 602 | index = isInt($scope.index) ? +$scope.index : parseYaml($scope.index), |
| 603 | result = 'result'; |
| 604 | if ($scope.entity && $scope.action) { |
| 605 | delete params.debug; |
| 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), |
| 612 | i = 0; |
| 613 | |
| 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()"; |
| 650 | if (_.isNumber(index)) { |
| 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"; |
| 661 | if (!_.isNumber(index)) { |
| 662 | code.oop += "foreach ($" + results + ' as $' + ((_.isString(index) && index) ? index + ' => $' : '') + result + ') {\n // do something\n}'; |
| 663 | } |
| 664 | break; |
| 665 | |
| 666 | case 'cli': |
| 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 | }); |
| 705 | } |
| 706 | } |
| 707 | _.each($scope.code, function(vals) { |
| 708 | _.each(vals, function(style) { |
| 709 | style.code = code[style.name] ? prettyPrintOne(_.escape(code[style.name])) : ''; |
| 710 | }); |
| 711 | }); |
| 712 | } |
| 713 | |
| 714 | // Format oop params |
| 715 | function formatOOP(entity, action, params, indent) { |
| 716 | var info = getEntity(entity), |
| 717 | newLine = "\n" + _.repeat(' ', indent), |
| 718 | code = '\\' + info.class + '::' + action + '(', |
| 719 | perm = params.checkPermissions === false ? 'FALSE' : ''; |
| 720 | if (entity.substr(0, 7) !== 'Custom_') { |
| 721 | code += perm + ')'; |
| 722 | } else { |
| 723 | code += "'" + entity.substr(7) + "'" + (perm ? ', ' : '') + perm + ")"; |
| 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') { |
| 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 | } |
| 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 | } |
| 756 | else if (key !== 'checkPermissions') { |
| 757 | code += newLine + "->set" + ucfirst(key) + '(' + phpFormat(param, 2 + indent) + ')'; |
| 758 | } |
| 759 | }); |
| 760 | return code; |
| 761 | } |
| 762 | |
| 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 | }); |
| 780 | return prettyPrintOne(_.escape(ret)); |
| 781 | } |
| 782 | |
| 783 | $scope.execute = function() { |
| 784 | $scope.status = 'info'; |
| 785 | $scope.loading = true; |
| 786 | $http.post(CRM.url('civicrm/ajax/api4/' + $scope.entity + '/' + $scope.action, { |
| 787 | params: angular.toJson(getParams()), |
| 788 | index: isInt($scope.index) ? +$scope.index : parseYaml($scope.index) |
| 789 | }), null, { |
| 790 | headers: { |
| 791 | 'X-Requested-With': 'XMLHttpRequest' |
| 792 | } |
| 793 | }).then(function(resp) { |
| 794 | $scope.loading = false; |
| 795 | $scope.status = resp.data && resp.data.debug && resp.data.debug.log ? 'warning' : 'success'; |
| 796 | $scope.debug = debugFormat(resp.data); |
| 797 | $scope.result = [ |
| 798 | formatMeta(resp.data), |
| 799 | prettyPrintOne((_.isArray(resp.data.values) ? '(' + resp.data.values.length + ') ' : '') + _.escape(JSON.stringify(resp.data.values, null, 2)), 'js', 1) |
| 800 | ]; |
| 801 | }, function(resp) { |
| 802 | $scope.loading = false; |
| 803 | $scope.status = 'danger'; |
| 804 | $scope.debug = debugFormat(resp.data); |
| 805 | $scope.result = [ |
| 806 | formatMeta(resp), |
| 807 | prettyPrintOne(_.escape(JSON.stringify(resp.data, null, 2))) |
| 808 | ]; |
| 809 | }); |
| 810 | }; |
| 811 | |
| 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 | |
| 818 | /** |
| 819 | * Format value to look like php code |
| 820 | */ |
| 821 | function phpFormat(val, indent) { |
| 822 | if (typeof val === 'undefined') { |
| 823 | return ''; |
| 824 | } |
| 825 | if (val === null || val === true || val === false) { |
| 826 | return JSON.stringify(val).toUpperCase(); |
| 827 | } |
| 828 | indent = (typeof indent === 'number') ? _.repeat(' ', indent) : (indent || ''); |
| 829 | var ret = '', |
| 830 | baseLine = indent ? indent.slice(0, -2) : '', |
| 831 | newLine = indent ? '\n' : '', |
| 832 | trailingComma = indent ? ',' : ''; |
| 833 | if ($.isPlainObject(val)) { |
| 834 | $.each(val, function(k, v) { |
| 835 | ret += (ret ? ', ' : '') + newLine + indent + "'" + k + "' => " + phpFormat(v); |
| 836 | }); |
| 837 | return '[' + ret + trailingComma + newLine + baseLine + ']'; |
| 838 | } |
| 839 | if ($.isArray(val)) { |
| 840 | $.each(val, function(k, v) { |
| 841 | ret += (ret ? ', ' : '') + newLine + indent + phpFormat(v); |
| 842 | }); |
| 843 | return '[' + ret + trailingComma + newLine + baseLine + ']'; |
| 844 | } |
| 845 | if (_.isString(val) && !_.contains(val, "'")) { |
| 846 | return "'" + val + "'"; |
| 847 | } |
| 848 | return JSON.stringify(val).replace(/\$/g, '\\$'); |
| 849 | } |
| 850 | |
| 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 | |
| 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); |
| 878 | setHelp($scope.entity, { |
| 879 | description: entityInfo.description, |
| 880 | comment: entityInfo.comment, |
| 881 | type: entityInfo.type, |
| 882 | since: entityInfo.since, |
| 883 | see: entityInfo.see |
| 884 | }); |
| 885 | } |
| 886 | |
| 887 | if (!$scope.entity) { |
| 888 | setHelp(ts('APIv4 Explorer'), {description: docs.description, comment: docs.comment, see: docs.see}); |
| 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) { |
| 914 | setHelp($scope.entity + '::' + newVal, _.pick(_.findWhere(getEntity().actions, {name: newVal}), ['description', 'comment', 'see'])); |
| 915 | } |
| 916 | }); |
| 917 | |
| 918 | $scope.paramDoc = function(name) { |
| 919 | return docs.params[name]; |
| 920 | }; |
| 921 | |
| 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.'), |
| 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.') |
| 941 | }; |
| 942 | }; |
| 943 | |
| 944 | $scope.$watch('params', writeCode, true); |
| 945 | $scope.$watch('index', writeCode); |
| 946 | writeCode(); |
| 947 | |
| 948 | $scope.save = function() { |
| 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 | } |
| 958 | var model = { |
| 959 | title: '', |
| 960 | description: '', |
| 961 | visibility: 'User and User Admin Only', |
| 962 | group_type: [], |
| 963 | id: null, |
| 964 | entity: $scope.entity, |
| 965 | params: JSON.parse(angular.toJson($scope.params)) |
| 966 | }; |
| 967 | model.params.version = 4; |
| 968 | delete model.params.chain; |
| 969 | delete model.params.debug; |
| 970 | delete model.params.limit; |
| 971 | delete model.params.offset; |
| 972 | delete model.params.orderBy; |
| 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; |
| 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; |
| 1005 | $scope.$watch('model.id', function(id) { |
| 1006 | if (id) { |
| 1007 | _.assign(model, $('#api-save-search-select-group').select2('data').extra); |
| 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; |
| 1017 | group.visibility = model.visibility; |
| 1018 | group.group_type = model.group_type; |
| 1019 | group.saved_search_id = '$id'; |
| 1020 | var savedSearch = { |
| 1021 | api_entity: model.entity, |
| 1022 | api_params: model.params |
| 1023 | }; |
| 1024 | if (group.id) { |
| 1025 | savedSearch.id = model.saved_search_id; |
| 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 | }; |
| 1032 | }); |
| 1033 | |
| 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 | }; |
| 1059 | |
| 1060 | this.$onInit = function() { |
| 1061 | ctrl.hasParent = !!$element.attr('delete-group'); |
| 1062 | }; |
| 1063 | |
| 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 | } |
| 1072 | |
| 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; |
| 1078 | } |
| 1079 | $('.api4-input.form-inline.ui-sortable-helper').css('margin-left', '' + offset + 'px'); |
| 1080 | } |
| 1081 | |
| 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; |
| 1090 | } |
| 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); |
| 1102 | } |
| 1103 | }; |
| 1104 | |
| 1105 | // Add/remove value if operator allows for one |
| 1106 | this.changeClauseOperator = function(clause) { |
| 1107 | if (_.contains(clause[1], 'IS ')) { |
| 1108 | clause.length = 2; |
| 1109 | } else if (clause.length === 2) { |
| 1110 | clause.push(''); |
| 1111 | } |
| 1112 | }; |
| 1113 | } |
| 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) { |
| 1123 | var ts = scope.ts = CRM.ts(), |
| 1124 | multi = _.includes(['IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN'], scope.data.op), |
| 1125 | entity = $routeParams.api4entity, |
| 1126 | action = scope.data.action || $routeParams.api4action; |
| 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), |
| 1141 | inputType = field.input_type, |
| 1142 | dataType = field.data_type; |
| 1143 | if (!op) { |
| 1144 | op = field.serialize || dataType === 'Array' ? 'IN' : '='; |
| 1145 | } |
| 1146 | multi = _.includes(['IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN'], op); |
| 1147 | // IS NULL, IS EMPTY, etc. |
| 1148 | if (_.contains(op, 'IS ')) { |
| 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')) { |
| 1157 | if (field.options) { |
| 1158 | var id = field.pseudoconstant || 'id'; |
| 1159 | $el.addClass('loading').attr('placeholder', ts('- select -')).crmSelect2({multiple: multi, data: [{id: '', text: ''}]}); |
| 1160 | loadFieldOptions(field.entity || entity).then(function(data) { |
| 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}); |
| 1165 | }); |
| 1166 | } else if (field.fk_entity) { |
| 1167 | $el.crmEntityRef({entity: field.fk_entity, select:{multiple: multi}, static: field.fk_entity === 'Contact' ? ['user_contact_id'] : []}); |
| 1168 | } else if (dataType === 'Boolean') { |
| 1169 | $el.attr('placeholder', ts('- select -')).crmSelect2({allowClear: false, multiple: multi, placeholder: ts('- select -'), data: [ |
| 1170 | {id: 'true', text: ts('Yes')}, |
| 1171 | {id: 'false', text: ts('No')} |
| 1172 | ]}); |
| 1173 | } |
| 1174 | } else if (dataType === 'Integer' && !multi) { |
| 1175 | $el.attr('type', 'number'); |
| 1176 | } |
| 1177 | } |
| 1178 | |
| 1179 | function loadFieldOptions(entity) { |
| 1180 | if (!fieldOptions[entity + action]) { |
| 1181 | fieldOptions[entity + action] = crmApi4(entity, 'getFields', { |
| 1182 | loadOptions: ['id', 'name', 'label', 'description', 'color', 'icon'], |
| 1183 | action: action, |
| 1184 | where: [['options', '!=', false]], |
| 1185 | select: ['options'] |
| 1186 | }, 'name'); |
| 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); |
| 1225 | if (field && data.format !== 'plain') { |
| 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) { |
| 1243 | var ts = scope.ts = CRM.ts(); |
| 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] + '}}'; |
| 1300 | } |
| 1301 | else if (link && _.contains(['save'], newAction)) { |
| 1302 | scope.chain[1][2] = '{records: [{' + link[0] + ': ' + link[1] + '}]}'; |
| 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 | |
| 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 | |
| 1336 | function getField(fieldName, entity, action) { |
| 1337 | var suffix = fieldName.split(':')[1]; |
| 1338 | fieldName = fieldName.split(':')[0]; |
| 1339 | var fieldNames = fieldName.split('.'); |
| 1340 | var field = get(entity, fieldNames); |
| 1341 | if (field && suffix) { |
| 1342 | field.pseudoconstant = suffix; |
| 1343 | } |
| 1344 | return field; |
| 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(), |
| 1355 | newEntity = getExplicitJoins()[linkName] || _.findWhere(links[entity], {alias: linkName}).entity; |
| 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._); |