├── .eslintignore ├── .eslintrc.js ├── .forceignore ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── LICENSE ├── README.md ├── config └── project-scratch-def.json ├── force-app └── main │ └── default │ ├── aura │ ├── jsButton │ │ ├── jsButton.cmp │ │ ├── jsButton.cmp-meta.xml │ │ ├── jsButton.design │ │ ├── jsButtonController.js │ │ └── jsButtonHelper.js │ └── jsButtonQuickAction │ │ ├── jsButtonQuickAction.cmp │ │ ├── jsButtonQuickAction.cmp-meta.xml │ │ └── jsButtonQuickActionController.js │ ├── classes │ ├── APICallController.cls │ ├── APICallController.cls-meta.xml │ ├── APICallControllerTest.cls │ ├── APICallControllerTest.cls-meta.xml │ ├── DynamicSOQLDMLController.cls │ ├── DynamicSOQLDMLController.cls-meta.xml │ ├── DynamicSOQLDMLControllerTest.cls │ ├── DynamicSOQLDMLControllerTest.cls-meta.xml │ ├── GetSessionIdController.cls │ ├── GetSessionIdController.cls-meta.xml │ ├── HttpResponseWrapper.cls │ └── HttpResponseWrapper.cls-meta.xml │ ├── customMetadata │ ├── JS_Button.Account.md-meta.xml │ └── JS_Button.Contact.md-meta.xml │ ├── layouts │ └── JS_Button__mdt-JS Button Layout.layout-meta.xml │ ├── lwc │ ├── .eslintrc.json │ ├── apiService │ │ ├── apiService.js │ │ └── apiService.js-meta.xml │ ├── dmlService │ │ ├── dmlService.js │ │ └── dmlService.js-meta.xml │ ├── httpRequest │ │ ├── httpRequest.js │ │ └── httpRequest.js-meta.xml │ ├── jsButtonLwc │ │ ├── jsButtonLwc.html │ │ ├── jsButtonLwc.js │ │ ├── jsButtonLwc.js-meta.xml │ │ └── jsButtonLwc.svg │ ├── soqlService │ │ ├── soqlService.js │ │ └── soqlService.js-meta.xml │ └── toastService │ │ ├── toastService.js │ │ └── toastService.js-meta.xml │ ├── objects │ └── JS_Button__mdt │ │ ├── JS_Button__mdt.object-meta.xml │ │ └── fields │ │ └── Script__c.field-meta.xml │ ├── pages │ ├── GetSessionId.page │ └── GetSessionId.page-meta.xml │ └── quickActions │ ├── Account.Add_Employees.quickAction-meta.xml │ └── Contact.Add_Files.quickAction-meta.xml ├── package-lock.json ├── package.json ├── scripts └── jsButton │ ├── README.md │ ├── addDefaultOpportunityLineItems.js │ ├── compositeApiExample.js │ ├── copyData.js │ ├── createContactFiles.js │ ├── createContactsUsingCallout.js │ ├── createNewJSButton.js │ ├── deleteInactiveFlowVersions.js │ ├── fetchAndAddFile.js │ └── fetchAndCreateContacts.js └── sfdx-project.json /.eslintignore: -------------------------------------------------------------------------------- 1 | **/lwc/**/*.css 2 | **/lwc/**/*.html 3 | **/lwc/**/*.json 4 | **/lwc/**/*.svg 5 | **/lwc/**/*.xml 6 | .sfdx -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es6: true 5 | }, 6 | extends: "eslint:recommended", 7 | globals: { 8 | Atomics: "readonly", 9 | SharedArrayBuffer: "readonly" 10 | }, 11 | parserOptions: { 12 | ecmaVersion: 2018, 13 | sourceType: "module" 14 | }, 15 | rules: {} 16 | }; 17 | -------------------------------------------------------------------------------- /.forceignore: -------------------------------------------------------------------------------- 1 | # List files or directories below to ignore them when running force:source:push, force:source:pull, and force:source:status 2 | # More information: https://developer.salesforce.com/docs/atlas.en-us.sfdx_dev.meta/sfdx_dev/sfdx_dev_exclude_source.htm 3 | # 4 | 5 | package.xml 6 | 7 | # LWC configuration files 8 | **/jsconfig.json 9 | **/.eslintrc.json 10 | 11 | # LWC Jest 12 | **/__tests__/** 13 | **/profiles/** 14 | **/flexipages/** 15 | **/layouts/Account* 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # This file is used for Git repositories to specify intentionally untracked files that Git should ignore. 2 | # If you are not using git, you can delete this file. For more information see: https://git-scm.com/docs/gitignore 3 | # For useful gitignore templates see: https://github.com/github/gitignore 4 | 5 | # Salesforce cache 6 | .sfdx/ 7 | .localdevserver/ 8 | 9 | # LWC VSCode autocomplete 10 | **/lwc/jsconfig.json 11 | 12 | # LWC Jest coverage reports 13 | coverage/ 14 | 15 | # Logs 16 | logs 17 | *.log 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | 22 | # Dependency directories 23 | node_modules/ 24 | 25 | # Eslint cache 26 | .eslintcache 27 | 28 | # MacOS system files 29 | .DS_Store 30 | 31 | # Windows system files 32 | Thumbs.db 33 | ehthumbs.db 34 | [Dd]esktop.ini 35 | $RECYCLE.BIN/ 36 | flexipages/ 37 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # List files or directories below to ignore them when running prettier 2 | # More information: https://prettier.io/docs/en/ignore.html 3 | # 4 | 5 | **/staticresources/** 6 | .localdevserver 7 | .sfdx 8 | 9 | coverage/ -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "none", 3 | "printWidth": 120, 4 | "overrides": [ 5 | { 6 | "files": "**/lwc/**/*.html", 7 | "options": { "parser": "lwc" } 8 | }, 9 | { 10 | "files": "*.{cmp,page,component}", 11 | "options": { "parser": "html"} 12 | }, 13 | { 14 | "files": "*.{cls,trigger,apex}", 15 | "options": { "parser": "apex"} 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "salesforce.salesforcedx-vscode", 4 | "redhat.vscode-xml", 5 | "dbaeumer.vscode-eslint", 6 | "esbenp.prettier-vscode" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Launch Apex Replay Debugger", 9 | "type": "apex-replay", 10 | "request": "launch", 11 | "logFile": "${command:AskForLogFileName}", 12 | "stopOnEntry": true, 13 | "trace": true 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "search.exclude": { 3 | "**/node_modules": true, 4 | "**/bower_components": true, 5 | "**/.sfdx": true 6 | } 7 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Suraj Pillai 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pure JS Buttons in Lightning 2 | 3 | JS buttons are back in Lightning! (For now, at least) And they are even more 4 | powerful than JS buttons in classic. Run SOQL and DML statements seamlessly. 5 | Make callouts to APIs, including Salesforce APIs directly from JavaScript! 6 | This would allow you to build buttons that do amazing things, just using 7 | JavaScript. Check out the [scripts](./scripts/jsButton) folder for examples. 8 | Feel free to raise a PR to contribute your own scripts. 9 | 10 | ### The Setup 11 | 12 | The button can be made available to users via a quick action powered by the 13 | `jsButtonQuickAction` component. The actual JavaScript should be entered into a 14 | `JS_Button__mdt` custom metadata record, into the `Script__c` field with the 15 | same name as the name of the SObject. The repo contains a couple of samples 16 | for `Account` and `Contact`. The corollary is that, out of the box, only one 17 | button per SObjectType may be supported, for quick actions. You can add any 18 | number of buttons on the flexipage, with the underlying JS added using the 19 | flexipage builder. 20 | 21 | ### APIs 22 | 23 | The library supports the following apis 24 | 25 | - soql 26 | - dml (dml.insert, dml.update, dml.upsert and dml.del ) // `delete` is a resrved keyword :( 27 | - callout ( used for calling external services through Apex. Named credentials are supported! ) 28 | - sfapi ( used for calling Salesforce APIs from the same org. Requires CORS and 29 | CSP Trusted Sites setup. Details below) 30 | - toast ( show a platform toast message ) 31 | 32 | ### The Syntax 33 | 34 | This is the fun part. I haven't, obviously, explored all possible scenarios and 35 | the information may still be incomplete. Please raise an issue if you come 36 | across something I haven't covered. 37 | 38 | - Simple examples (no soql/dml) 39 | 40 | ```js 41 | alert("hello,world"); 42 | ``` 43 | 44 | ```js 45 | toast( 46 | Array(5) 47 | .fill(0) 48 | .map((e, i) => "Hello, " + i) 49 | ); /* `toast` service to show message toasts */ 50 | ``` 51 | 52 | - Fetch 100 of the latest Accounts and for upto 10 of the ones without a Contact, add a Contact 53 | 54 | ```js 55 | let accts = await soql( 56 | `Select Name,(Select Id from Contacts) from Account order by createddate desc 57 | limit 100` 58 | ); /* Querying child records is supported */ 59 | let contacts = accts 60 | .filter((a) => !a.Contacts || a.Contacts.length === 0) 61 | .slice(0, 10) 62 | .map((a) => ({ LastName: a.Name + "-Contact", AccountId: a.Id })); 63 | let contactIds = await dml.insert( 64 | contacts, 65 | "Contact" 66 | ); /*Note how the SObjectType has been specified. This is required for insert and upsert*/ 67 | $A.get("e.force:refreshView").fire(); /* $A is supported!*/ 68 | ``` 69 | 70 | - Act in the context of the current record 71 | 72 | ```js 73 | let acct = await soql( 74 | `Select NumberOfEmployees from Account where Id='${recordId}'` 75 | ); /* Note the use of template literal syntax to resolve 76 | variable values in the query */ 77 | acct[0].NumberOfEmployees = (acct[0].NumberOfEmployees || 0) + 10; 78 | let acctId = await dml.update(acct); 79 | acct = await soql(`Select NumberOfEmployees from Account where Id='${acctId}'`); 80 | toast(acct[0].NumberOfEmployees, "success"); 81 | $A.get("e.force:refreshView").fire(); 82 | ``` 83 | 84 | - Add a 'File' to the current record 85 | 86 | ```js 87 | let fileContent = btoa("Hello World"); 88 | /* convert your file content to base64 data before uploading */ 89 | let cv = { 90 | VersionData: fileContent, 91 | Title: "My Awesome File", 92 | PathOnClient: "MyFile.txt", 93 | FirstPublishLocationId: recordId 94 | }; 95 | let cvId = await dml.insert(cv, "ContentVersion"); 96 | toast("New file Added", "success"); 97 | $A.get("e.force:refreshView").fire(); 98 | ``` 99 | 100 | ### About the Syntax 101 | 102 | - Note how the syntax is linear for SOQL and DML. Coupled with JavaScript's 103 | support for manipulating arrays, this makes it easier to manipulate data, 104 | even compared to Apex in several instances. 105 | - `dml.insert` and `dml.upsert` expect the SObjectType as the second argument. 106 | Thus `dml.insert(acct,"Account")` 107 | - Statements with contextual arguments such as `recordId` 108 | are best expressed using [template literals](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals). 109 | - All statements must be strictly terminated by a semicolon. 110 | 111 | ### Known Limitations 112 | 113 | - Single-line comments are not supported. 114 | - Haven't tested DML with date, datetime, boolean, geolocation and other 115 | compound fields. I will update this section as I do so. 116 | - To insert `ContentVersion` make sure to set `VersionData` to base64 data. 117 | Refer to the example [here](./scripts/jsButton/createContactFiles.js) for details. 118 | - The maximum size of files I was able to upload was around 2 MB. 119 | Anything larger will fail silently due to heap size limits in Apex 120 | 121 | ### Using Salesforce (and other) APIs in your script 122 | 123 | To use Salesforce APIs from your org, using the `sfapi` method, take the following steps: 124 | 125 | - Add your lightning domain (ends with `lightning.force.com`) to the `CORS` list under `Setup`. 126 | - Add your classic domain to `CSP Trusted Sites` list under `Setup`. 127 | 128 | This allows you to write scripts for admins to perform tasks like [deleting inactive versions of flows](./scripts/jsButton/deleteInactiveFlowVersions.js) or [use composite api](./scripts/jsButton/compositeApiExample.js) 129 | for creating parent and child records. 130 | To access protected APIs such as those from other Salesforce orgs, use a named credential and the `callout` api. For Public APIs, you can use `fetch` directly. 131 | -------------------------------------------------------------------------------- /config/project-scratch-def.json: -------------------------------------------------------------------------------- 1 | { 2 | "orgName": "Demo company", 3 | "edition": "Developer", 4 | "features": [], 5 | "settings": { 6 | "lightningExperienceSettings": { 7 | "enableS1DesktopEnabled": true 8 | }, 9 | "securitySettings": { 10 | "passwordPolicies": { 11 | "enableSetPasswordInApi": true 12 | } 13 | }, 14 | "mobileSettings": { 15 | "enableS1EncryptedStoragePref2": false 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /force-app/main/default/aura/jsButton/jsButton.cmp: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /force-app/main/default/aura/jsButton/jsButton.cmp-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 48.0 4 | A Lightning Component Bundle 5 | -------------------------------------------------------------------------------- /force-app/main/default/aura/jsButton/jsButton.design: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /force-app/main/default/aura/jsButton/jsButtonController.js: -------------------------------------------------------------------------------- 1 | ({ 2 | invoke: function (component, event, helper) { 3 | return new Promise( 4 | $A.getCallback((resolve, reject) => { 5 | let js = component.get("v.js"); 6 | if (!js && component.get("v.cmdtName")) { 7 | helper 8 | .fetchJSFromCmdt(component) 9 | .then(() => { 10 | helper.runJS(component, resolve, reject); 11 | }) 12 | .catch((err) => { 13 | reject(err); 14 | }); 15 | } else if (js) { 16 | helper.runJS(component, resolve, reject); 17 | } 18 | }) 19 | ); 20 | } 21 | }); 22 | -------------------------------------------------------------------------------- /force-app/main/default/aura/jsButton/jsButtonHelper.js: -------------------------------------------------------------------------------- 1 | ({ 2 | /* 3 | regexp: { 4 | SOQL: new RegExp(/\|\|\s?(select\s+[^|]+)\s?\|\|/, "gi") 5 | }, 6 | */ 7 | REGEX_SOQL: "\\|\\|\\s?(select\\s+[^|]+)\\s?\\|\\|", 8 | REGEX_UPDATE: "\\|\\|\\s?update\\s([^|;]+);?\\s*\\|\\|", 9 | REGEX_INSERT_UPSERT: 10 | "\\|\\|\\s?(insert|upsert)\\s([\\w\\d_]+)\\s?\\(\\s?(\\w+).*\\|\\|", 11 | 12 | callApexAction: function (component, action) { 13 | return new Promise( 14 | $A.getCallback((resolve, reject) => { 15 | action.setCallback(this, (result) => { 16 | if (component.isValid() && result.getState() === "SUCCESS") { 17 | resolve(result.getReturnValue()); 18 | } else { 19 | reject( 20 | result.getError() ? result.getError()[0].message : "Unknown error" 21 | ); 22 | } 23 | }); 24 | $A.enqueueAction(action); 25 | }) 26 | ); 27 | }, 28 | 29 | runJS: function (component, resolve, reject) { 30 | let helper = this; 31 | let recordId = component.get("v.recordId"); 32 | let js = component.get("v.js"); 33 | //replace consecutive spaces 34 | js = js.replace(/\s+/g, " "); 35 | 36 | //parse soql 37 | js = js.replace( 38 | new RegExp(helper.REGEX_SOQL, "gi"), 39 | "await helper.executeSoql(cmp,`$1`);" 40 | ); 41 | 42 | //parse updates 43 | js = js.replace( 44 | new RegExp(helper.REGEX_UPDATE, "gi"), 45 | "await helper.executeDml(cmp,'update',$1);" 46 | ); 47 | 48 | //parse inserts 49 | js = js.replace( 50 | new RegExp(helper.REGEX_INSERT_UPSERT, "gi"), 51 | "await helper.executeDml(cmp,'$1',$3,'$2');" 52 | ); 53 | 54 | Function( 55 | "recordId", 56 | "cmp", 57 | "helper", 58 | `return (async (recordId,cmp,helper)=>{${js}})(recordId,cmp,helper)` 59 | )(recordId, component, helper) 60 | .then((op) => { 61 | if (Array.isArray(op)) { 62 | if (op[0] && typeof op[0] !== "string") 63 | op = op.map((row) => JSON.stringify(row)); 64 | component.set("v.outputArray", op); 65 | } else component.set("v.output", op); 66 | resolve(); 67 | }) 68 | .catch((err) => { 69 | reject(err); 70 | }); 71 | }, 72 | 73 | fetchJSFromCmdt: function (cmp) { 74 | let helper = this; 75 | return new Promise( 76 | $A.getCallback((resolve, reject) => { 77 | let cmdtAction = cmp.get("c.getJSFromCmdt"); 78 | cmdtAction.setParams({ cmdtName: cmp.get("v.cmdtName") }); 79 | helper 80 | .callApexAction(cmp, cmdtAction) 81 | .then((js) => { 82 | cmp.set("v.js", js); 83 | resolve(); 84 | }) 85 | .catch((err) => { 86 | reject(err); 87 | }); 88 | }) 89 | ); 90 | }, 91 | 92 | executeSoql: function (cmp, soql) { 93 | return new Promise( 94 | $A.getCallback((resolve, reject) => { 95 | let soqlAction = cmp.get("c.executeSoqlQuery"); 96 | soqlAction.setParams({ query: soql }); 97 | soqlAction.setCallback(this, (result) => { 98 | resolve(result.getReturnValue()); 99 | }); 100 | $A.enqueueAction(soqlAction); 101 | }) 102 | ); 103 | }, 104 | 105 | getSObjectType: function (cmp, record, sobjectType) { 106 | let helper = this; 107 | return new Promise( 108 | $A.getCallback((resolve, reject) => { 109 | if (!sobjectType) { 110 | let getSobjectTypeAction = cmp.get("c.getSObjectTypeFromId"); 111 | getSobjectTypeAction.setParams({ recordId: record.Id }); 112 | helper 113 | .callApexAction(cmp, getSobjectTypeAction) 114 | .then((type) => resolve(type)) 115 | .catch((err) => reject(err)); 116 | } else resolve(sobjectType); 117 | }) 118 | ); 119 | }, 120 | 121 | executeDml: function (cmp, dmlType, records, sobjectType) { 122 | let helper = this; 123 | return new Promise( 124 | $A.getCallback((resolve, reject) => { 125 | if (!Array.isArray(records)) records = [records]; 126 | helper 127 | .getSObjectType(cmp, records[0], sobjectType) 128 | .then((sobjectType) => { 129 | records.forEach((rec) => 130 | Object.assign(rec, { attributes: { type: sobjectType } }) 131 | ); 132 | let dmlAction = cmp.get("c.executeDml"); 133 | dmlAction.setParams({ 134 | operation: dmlType, 135 | strData: sobjectType 136 | ? JSON.stringify(records, (k, v) => 137 | typeof v === "number" ? "" + v : v 138 | ) 139 | : null, 140 | sObjectType: sobjectType 141 | }); 142 | helper 143 | .callApexAction(cmp, dmlAction) 144 | .then((res) => resolve(res)) 145 | .catch((err) => reject(err)); 146 | }) 147 | .catch((err) => reject(err)); 148 | }) 149 | ); 150 | } 151 | }); 152 | -------------------------------------------------------------------------------- /force-app/main/default/aura/jsButtonQuickAction/jsButtonQuickAction.cmp: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | -------------------------------------------------------------------------------- /force-app/main/default/aura/jsButtonQuickAction/jsButtonQuickAction.cmp-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 48.0 4 | A Lightning Component Bundle 5 | 6 | -------------------------------------------------------------------------------- /force-app/main/default/aura/jsButtonQuickAction/jsButtonQuickActionController.js: -------------------------------------------------------------------------------- 1 | ({ 2 | doInit: function (component) { 3 | component 4 | .find("jsbutton") 5 | .invoke() 6 | .then( 7 | $A.getCallback((resp) => { 8 | console.log(">> resp " + JSON.stringify(resp)); 9 | $A.get("e.force:closeQuickAction").fire(); 10 | }) 11 | ) 12 | .catch( 13 | $A.getCallback((err) => { 14 | $A.get("e.force:closeQuickAction").fire(); 15 | }) 16 | ); 17 | } 18 | }); 19 | -------------------------------------------------------------------------------- /force-app/main/default/classes/APICallController.cls: -------------------------------------------------------------------------------- 1 | /************************************************************ 2 | 3 | *** @author Suraj Pillai 4 | *** @group Controller 5 | *** @description Controller for making API calls and sending the response back 6 | *** 7 | **/ 8 | public with sharing class APICallController { 9 | /** 10 | * @description Given an endpoint, request params and headers, callout the api and return the response 11 | * @param endPoint The endpoint to callout to 12 | * @param method The http method to use 13 | * @param bodyStr The request body string. 14 | * @param headers Map of string key and value for request headers 15 | * @return The response for the http request 16 | * 17 | */ 18 | @AuraEnabled 19 | public static HttpResponseWrapper makeApiCall( 20 | String endPoint, 21 | String method, 22 | String bodyStr, 23 | Map headers 24 | ) { 25 | HttpRequest req = new HttpRequest(); 26 | req.setEndpoint(endPoint); 27 | req.setMethod(method); 28 | if (method != 'GET') { 29 | req.setBody(bodyStr); 30 | } 31 | if (headers != null) { 32 | for (String key : headers.keySet()) { 33 | req.setHeader(key, headers.get(key)); 34 | } 35 | } 36 | HttpResponse resp = new Http().send(req); 37 | Map respHeaders = new Map(); 38 | for (String key : resp.getHeaderKeys()) { 39 | respHeaders.put(key, String.valueOf(resp.getHeader(key))); 40 | } 41 | return new HttpResponseWrapper(resp.getBody(), resp.getStatusCode(), respHeaders); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /force-app/main/default/classes/APICallController.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 48.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /force-app/main/default/classes/APICallControllerTest.cls: -------------------------------------------------------------------------------- 1 | @isTest 2 | public with sharing class APICallControllerTest { 3 | @isTest 4 | public static void testAPICall() { 5 | Test.setMock(HttpCalloutMock.class, new APICallMock()); 6 | HttpResponseWrapper resp = APICallController.makeApiCall( 7 | 'https://api.example.com', 8 | 'POST', 9 | '{"message":"sample_request"}', 10 | new Map{ 'Accept' => 'application/json', 'Content-Type' => 'application/json' } 11 | ); 12 | system.assertEquals('{"message": "sample response"}', resp.body, 'Unexpected Response'); 13 | system.assertEquals(200, resp.statusCode, 'Incorrect value for status code'); 14 | system.assertEquals(2, resp.headers.size(), 'Mismatch in the number of response headers expected'); 15 | system.assertEquals('sample_value1', resp.headers.get('custom_header1'), 'Incorrect value for first header'); 16 | system.assertEquals('sample_value2', resp.headers.get('custom_header2'), 'Incorrect value for second header'); 17 | } 18 | 19 | class APICallMock implements HttpCalloutMock { 20 | public HttpResponse respond(HttpRequest req) { 21 | HttpResponse resp = new HttpResponse(); 22 | if ( 23 | req.getBody() == '{"message":"sample_request"}' && 24 | req.getHeader('Accept') == 'application/json' && 25 | req.getHeader('Content-Type') == 'application/json' 26 | ) { 27 | resp.setBody('{"message": "sample response"}'); 28 | resp.setHeader('custom_header1', 'sample_value1'); 29 | resp.setHeader('custom_header2', 'sample_value2'); 30 | resp.setStatusCode(200); 31 | } else { 32 | resp.setStatusCode(400); 33 | resp.setBody('{"message":"Bad Request"}'); 34 | } 35 | return resp; 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /force-app/main/default/classes/APICallControllerTest.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 48.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /force-app/main/default/classes/DynamicSOQLDMLController.cls: -------------------------------------------------------------------------------- 1 | public with sharing class DynamicSOQLDMLController { 2 | /** 3 | * Execute a Soql Query 4 | * @param query The soql query to execute 5 | * 6 | * @return SObject[] 7 | **/ 8 | @AuraEnabled 9 | public static SObject[] executeSoqlQuery(String query) { 10 | return Database.query(query); 11 | } 12 | 13 | /** 14 | * Get JavaScript from a Custom Metadata with a given name 15 | * @param cmdtName The name of Custom Metadata record to fetch the script from 16 | * 17 | * @return String 18 | **/ 19 | @AuraEnabled(cacheable=true) 20 | public static String getJSFromCmdt(String cmdtName) { 21 | JS_Button__mdt[] jsButton = [SELECT Script__c FROM JS_Button__mdt WHERE DeveloperName = :cmdtName LIMIT 1]; 22 | if (jsButton.size() == 1) 23 | return jsButton[0].Script__c; 24 | else 25 | return ''; 26 | } 27 | 28 | /** 29 | * Short Description 30 | * @param recordId Get the SObject Type given a record Id 31 | * 32 | * @return String 33 | **/ 34 | @AuraEnabled(cacheable=true) 35 | public static String getSObjectTypeFromId(Id recordId) { 36 | return recordId.getSObjectType().getDescribe().getName(); 37 | } 38 | 39 | private static List deserializeContentVersion(String strData) { 40 | List deserializedRecords = (List) JSON.deserializeUntyped(strData); 41 | List recordsList = new List(); 42 | for (Object objRec : deserializedRecords) { 43 | Map record = (Map) objRec; 44 | ContentVersion cv = new ContentVersion(); 45 | String vData = String.valueOf(record.remove('VersionData')); 46 | cv = (ContentVersion) JSON.deserialize(JSON.serialize(record), ContentVersion.class); 47 | cv.put('VersionData', EncodingUtil.base64Decode(vData)); 48 | recordsList.add(cv); 49 | } 50 | return recordsList; 51 | } 52 | 53 | /** 54 | * @description Execute a DML statement 55 | * @param operation 'Insert','Update' or 'Upsert' 56 | * @param strData The records to update, stringified 57 | * @param sObjectType The SObject type to perform the DML on 58 | * @return Id[] 59 | **/ 60 | @AuraEnabled 61 | public static List executeDml(String operation, String strData, String sObjectType) { 62 | List records = null; 63 | 64 | if (sObjectType.equalsIgnoreCase('ContentVersion')) { 65 | records = deserializeContentVersion(strData); 66 | } else { 67 | records = (SObject[]) JSON.deserialize(strData, Type.forName('List<' + sObjectType + '>')); 68 | } 69 | 70 | if (operation == 'insert') { 71 | insert records; 72 | } else if (operation == 'update') { 73 | update records; 74 | } else if (operation == 'upsert') { 75 | upsert records; 76 | } else if (operation == 'delete') { 77 | delete records; 78 | } else { 79 | return null; 80 | } 81 | return new List(new Map(records).keySet()); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /force-app/main/default/classes/DynamicSOQLDMLController.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 48.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /force-app/main/default/classes/DynamicSOQLDMLControllerTest.cls: -------------------------------------------------------------------------------- 1 | @isTest 2 | public with sharing class DynamicSOQLDMLControllerTest { 3 | @isTest 4 | public static void testUpdate() { 5 | Account a = new Account(Name = 'Test Account'); 6 | insert a; 7 | a.Phone = '432424'; 8 | Account[] recordsToUpdate = new List{ a }; 9 | DynamicSOQLDMLController.executeDml('update', JSON.serialize(recordsToUpdate), 'Account'); 10 | a = [SELECT Phone FROM Account WHERE Id = :a.Id]; 11 | System.assertEquals('432424', a.Phone); 12 | } 13 | 14 | @isTest 15 | public static void testInsert() { 16 | // we won't test fetching cmdt 17 | DynamicSOQLDMLController.getJSFromCmdt('Account'); 18 | String acctString = '[{"attributes":{"type":"Account"},"Name":"Test Account"}]'; 19 | DynamicSOQLDMLController.executeDml('insert', acctString, 'Account'); 20 | System.assertEquals(1, [SELECT ID FROM Account WHERE Name = 'Test Account'].size()); 21 | } 22 | 23 | @isTest 24 | public static void testUpsertInsert() { 25 | Account a = new Account(Name = 'Test Account'); 26 | insert a; 27 | a.Phone = '432343'; 28 | Account a1 = new Account(Name = 'Test Account 2'); 29 | Account[] accountsToUpdate = new List{}; 30 | accountsToUpdate.add(a); 31 | accountsToUpdate.add(a1); 32 | 33 | String acctString = JSON.serialize(accountsToUpdate); 34 | DynamicSOQLDMLController.executeDml('upsert', acctString, 'Account'); 35 | System.assertEquals(2, [SELECT ID FROM Account].size()); 36 | a = [SELECT Phone FROM Account WHERE Id = :a.Id]; 37 | System.assertEquals('432343', a.Phone); 38 | } 39 | 40 | @isTest 41 | public static void testSoql() { 42 | Account a = new Account(Name = 'Test Account'); 43 | insert a; 44 | Account[] acctsResult = DynamicSOQLDMLController.executeSoqlQuery('Select Name from Account'); 45 | System.assertEquals(1, acctsResult.size()); 46 | System.assertEquals('Test Account', acctsResult[0].Name); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /force-app/main/default/classes/DynamicSOQLDMLControllerTest.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 48.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /force-app/main/default/classes/GetSessionIdController.cls: -------------------------------------------------------------------------------- 1 | /************************************************************ 2 | 3 | *** Copyright (c) Vertex Computer Systems Inc. All rights reserved. 4 | 5 | *** @author Suraj Pillai 6 | *** @group Controller 7 | *** @date 01/2021 8 | *** @description Get API-ready session id of the current users 9 | *** 10 | **/ 11 | public with sharing class GetSessionIdController { 12 | /**** 13 | ** @description Returns the current user's session id that may be used for calling Salesforce APIs 14 | ** @return the current user's api-ready session id 15 | **/ 16 | @AuraEnabled(cacheable=true) 17 | public static String getSessionId() { 18 | String content = Page.GetSessionId.getContent().toString(); 19 | return getSessionIdFromPage(content); 20 | } 21 | 22 | private static String getSessionIdFromPage(String content) { 23 | Integer s = content.indexOf('Start_Of_Session_Id') + 'Start_Of_Session_Id'.length(), 24 | e = content.indexOf('End_Of_Session_Id'); 25 | return content.substring(s, e); 26 | } 27 | 28 | @AuraEnabled(cacheable=true) 29 | public static String getRestAPIBaseUrl() { 30 | return URL.getSalesforceBaseUrl().toExternalForm() + '/services/data/v51.0'; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /force-app/main/default/classes/GetSessionIdController.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 49.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /force-app/main/default/classes/HttpResponseWrapper.cls: -------------------------------------------------------------------------------- 1 | public with sharing class HttpResponseWrapper { 2 | @AuraEnabled 3 | public String body; 4 | @AuraEnabled 5 | public Integer statusCode; 6 | @AuraEnabled 7 | public Map headers; 8 | 9 | public HttpResponseWrapper(String body, Integer statusCode, Map headers) { 10 | this.body = body; 11 | this.statusCode = statusCode; 12 | this.headers = headers; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /force-app/main/default/classes/HttpResponseWrapper.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 48.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /force-app/main/default/customMetadata/JS_Button.Account.md-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | false 5 | 6 | Script__c 7 | let acct = await soql(`Select NumberOfEmployees from Account where Id='${recordId}'`); 8 | 9 | acct[0].NumberOfEmployees = (acct[0].NumberOfEmployees || 0) + 10; 10 | 11 | let acctId = await dml.update(acct); 12 | 13 | acct = await soql(`Select NumberOfEmployees from Account where Id='${acctId}'`); 14 | 15 | toast(`Number of employees updated to ${acct[0].NumberOfEmployees}`,"success"); 16 | 17 | $A.get('e.force:refreshView').fire(); 18 | 19 | 20 | -------------------------------------------------------------------------------- /force-app/main/default/customMetadata/JS_Button.Contact.md-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | false 5 | 6 | Script__c 7 | /* Creates 5 files related to the current Contact record */ 8 | let con = await soql(`select LastName from Contact where Id='${recordId}'`); 9 | let files = Array(5) 10 | .fill(0) 11 | .map((e, i) => ({ 12 | VersionData: btoa(con[0].LastName + "-" + i), 13 | PathOnClient: "file.txt", 14 | Title: con[0].LastName + "-File-" + i, 15 | FirstPublishLocationId: recordId 16 | })); 17 | let fileIds = await dml.insert(files, "ContentVersion"); 18 | toast("done", "success"); 19 | $A.get('e.force:refreshView').fire(); 20 | 21 | 22 | -------------------------------------------------------------------------------- /force-app/main/default/layouts/JS_Button__mdt-JS Button Layout.layout-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | false 5 | false 6 | true 7 | 8 | 9 | 10 | Required 11 | MasterLabel 12 | 13 | 14 | Required 15 | DeveloperName 16 | 17 | 18 | Edit 19 | Script__c 20 | 21 | 22 | 23 | 24 | Edit 25 | IsProtected 26 | 27 | 28 | Required 29 | NamespacePrefix 30 | 31 | 32 | 33 | 34 | 35 | false 36 | false 37 | true 38 | 39 | 40 | 41 | Readonly 42 | CreatedById 43 | 44 | 45 | 46 | 47 | Readonly 48 | LastModifiedById 49 | 50 | 51 | 52 | 53 | 54 | false 55 | false 56 | false 57 | 58 | 59 | 60 | false 61 | false 62 | false 63 | false 64 | false 65 | 66 | -------------------------------------------------------------------------------- /force-app/main/default/lwc/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@salesforce/eslint-config-lwc/recommended"] 3 | } 4 | -------------------------------------------------------------------------------- /force-app/main/default/lwc/apiService/apiService.js: -------------------------------------------------------------------------------- 1 | import getSessionId from "@salesforce/apex/GetSessionIdController.getSessionId"; 2 | import getRestAPIBaseUrl from "@salesforce/apex/GetSessionIdController.getRestAPIBaseUrl"; 3 | 4 | export default async function sfapi( 5 | endPoint, 6 | method = "GET", 7 | headers = { "Content-Type": "application/json" }, 8 | body = null 9 | ) { 10 | if (endPoint.toLowerCase().indexOf("salesforce.com") === -1) { 11 | const baseUrl = await getRestAPIBaseUrl(); 12 | endPoint = baseUrl + endPoint; 13 | } 14 | const sessionId = await getSessionId(); 15 | headers = Object.assign(headers, { 16 | "Content-Type": "application/json", 17 | Accept: "application/json", 18 | Authorization: `Bearer ${sessionId}` 19 | }); 20 | const result = await fetch(endPoint, { 21 | mode: "cors", 22 | method, 23 | body, 24 | headers 25 | }); 26 | return result.json(); 27 | } 28 | -------------------------------------------------------------------------------- /force-app/main/default/lwc/apiService/apiService.js-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 49.0 4 | false 5 | -------------------------------------------------------------------------------- /force-app/main/default/lwc/dmlService/dmlService.js: -------------------------------------------------------------------------------- 1 | import executeDml from "@salesforce/apex/DynamicSOQLDMLController.executeDml"; 2 | import getSObjectType from "@salesforce/apex/DynamicSOQLDMLController.getSObjectTypeFromId"; 3 | import toast from "c/toastService"; 4 | 5 | async function dml(dmlType, records, sObjectType) { 6 | try { 7 | if (records && !Array.isArray(records)) { 8 | records = [records]; 9 | } 10 | 11 | /* If sobjecType is not specified, we try to deduce it from the record id */ 12 | if (!sObjectType) sObjectType = await getSObjectType({ recordId: records[0].Id }); 13 | 14 | records = records.map((rec) => ({ 15 | ...rec, 16 | attributes: { type: sObjectType } 17 | })); 18 | 19 | let results = await executeDml({ 20 | operation: dmlType, 21 | strData: sObjectType 22 | ? JSON.stringify(records, (k, v) => { 23 | return typeof v === "number" ? "" + v : v; 24 | }) 25 | : null, 26 | sObjectType 27 | }); 28 | return results; 29 | } catch (err) { 30 | toast(err, "error"); 31 | } 32 | return null; 33 | } 34 | 35 | const insert = dml.bind(null, "insert"); 36 | const update = dml.bind(null, "update"); 37 | const upsert = dml.bind(null, "upsert"); 38 | const del = dml.bind(null, "delete"); 39 | 40 | export { insert, update, upsert, del }; 41 | -------------------------------------------------------------------------------- /force-app/main/default/lwc/dmlService/dmlService.js-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 50.0 4 | false 5 | -------------------------------------------------------------------------------- /force-app/main/default/lwc/httpRequest/httpRequest.js: -------------------------------------------------------------------------------- 1 | import makeApiCall from "@salesforce/apex/APICallController.makeApiCall"; 2 | 3 | export default async function callout( 4 | endPoint, 5 | method = "GET", 6 | headers = { "Content-Type": "application/json" }, 7 | body = null 8 | ) { 9 | return makeApiCall({ 10 | endPoint, 11 | method, 12 | bodyStr: body ? JSON.stringify(body) : "", 13 | headers 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /force-app/main/default/lwc/httpRequest/httpRequest.js-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 48.0 4 | false 5 | -------------------------------------------------------------------------------- /force-app/main/default/lwc/jsButtonLwc/jsButtonLwc.html: -------------------------------------------------------------------------------- 1 | 19 | -------------------------------------------------------------------------------- /force-app/main/default/lwc/jsButtonLwc/jsButtonLwc.js: -------------------------------------------------------------------------------- 1 | import { LightningElement, api } from "lwc"; 2 | import fetchJSFromCmdt from "@salesforce/apex/DynamicSOQLDMLController.getJSFromCmdt"; 3 | import { ShowToastEvent } from "lightning/platformShowToastEvent"; 4 | import callout from "c/httpRequest"; 5 | import sfapi from "c/apiService"; 6 | import soql from "c/soqlService"; 7 | import * as dml from "c/dmlService"; 8 | import toast from "c/toastService"; 9 | 10 | export default class JsButtonLwc extends LightningElement { 11 | @api js; 12 | @api cmdtName; 13 | @api recordId; 14 | _notifiedParent = false; 15 | 16 | @api 17 | buttonLabel = "JS Button"; 18 | 19 | @api 20 | buttonVariant = "brand"; 21 | 22 | isScriptLoaded = false; 23 | _isRunning = false; 24 | 25 | get spinnerClass() { 26 | return this._isRunning ? "" : "slds-hide"; 27 | } 28 | 29 | renderedCallback() { 30 | if (!this._notifiedParent) this.dispatchEvent(new CustomEvent("initcomplete")); 31 | this._notifiedParent = true; 32 | } 33 | 34 | @api 35 | async invoke() { 36 | if (!this.js && this.cmdtName) { 37 | let js = await fetchJSFromCmdt({ cmdtName: this.cmdtName }); 38 | await this.runJS(js); 39 | } else if (this.js) { 40 | await this.runJS(this.js); 41 | } 42 | } 43 | 44 | _showError(message) { 45 | this.dispatchEvent(new ShowToastEvent({ message, variant: "error" })); 46 | } 47 | 48 | async runJS(js) { 49 | this._isRunning = true; 50 | //replace consecutive spaces 51 | //don't replace consecutive spaces 52 | //js = js.replace(/\s+/g, " "); 53 | 54 | try { 55 | //eslint-disable-next-line 56 | let op = await Function( 57 | "recordId", 58 | "soql", 59 | "dml", 60 | "callout", 61 | "sfapi", 62 | "toast", 63 | `return (async ()=>{${js}})()` 64 | ).bind(this)(this.recordId, soql, dml, callout, sfapi, toast); 65 | return op; 66 | } catch (err) { 67 | console.error("An error occurred ", err); 68 | alert("Unhandled error in script " + err.message ? err.message : err); 69 | } finally { 70 | this._isRunning = false; 71 | } 72 | return null; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /force-app/main/default/lwc/jsButtonLwc/jsButtonLwc.js-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 49.0 4 | true 5 | Lightning Javascript Button 6 | 7 | lightning__RecordPage 8 | lightning__AppPage 9 | lightning__HomePage 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /force-app/main/default/lwc/jsButtonLwc/jsButtonLwc.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /force-app/main/default/lwc/soqlService/soqlService.js: -------------------------------------------------------------------------------- 1 | import executeSoql from "@salesforce/apex/DynamicSOQLDMLController.executeSoqlQuery"; 2 | import toast from "c/toastService"; 3 | 4 | export default async function soql(query) { 5 | try { 6 | let results = await executeSoql({ query }); 7 | return results; 8 | } catch (err) { 9 | toast(err, "error"); 10 | } 11 | return null; 12 | } 13 | -------------------------------------------------------------------------------- /force-app/main/default/lwc/soqlService/soqlService.js-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 50.0 4 | false 5 | -------------------------------------------------------------------------------- /force-app/main/default/lwc/toastService/toastService.js: -------------------------------------------------------------------------------- 1 | import { ShowToastEvent } from "lightning/platformShowToastEvent"; 2 | 3 | export default function toast(message, variant = "info") { 4 | document.body.dispatchEvent(new ShowToastEvent({ message, variant })); 5 | } 6 | -------------------------------------------------------------------------------- /force-app/main/default/lwc/toastService/toastService.js-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 50.0 4 | false 5 | -------------------------------------------------------------------------------- /force-app/main/default/objects/JS_Button__mdt/JS_Button__mdt.object-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Store javascript scripts to be executed for custom JS buttons in lightning 4 | 5 | JS Buttons 6 | Public 7 | 8 | -------------------------------------------------------------------------------- /force-app/main/default/objects/JS_Button__mdt/fields/Script__c.field-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Script__c 4 | The script to be executed 5 | false 6 | SubscriberControlled 7 | The script to be executed 8 | 9 | 50000 10 | LongTextArea 11 | 5 12 | 13 | -------------------------------------------------------------------------------- /force-app/main/default/pages/GetSessionId.page: -------------------------------------------------------------------------------- 1 | 2 | Start_Of_Session_Id{!$Api.Session_ID}End_Of_Session_Id 3 | 4 | -------------------------------------------------------------------------------- /force-app/main/default/pages/GetSessionId.page-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 50.0 4 | 5 | -------------------------------------------------------------------------------- /force-app/main/default/quickActions/Account.Add_Employees.quickAction-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 250 4 | 5 | jsButtonQuickAction 6 | false 7 | LightningComponent 8 | -100 9 | 10 | -------------------------------------------------------------------------------- /force-app/main/default/quickActions/Contact.Add_Files.quickAction-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 250 4 | 5 | jsButtonQuickAction 6 | false 7 | LightningComponent 8 | -100 9 | 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "salesforce-app", 3 | "private": true, 4 | "version": "1.0.0", 5 | "description": "Salesforce App", 6 | "scripts": { 7 | "lint": "npm run lint:lwc", 8 | "lint:lwc": "eslint force-app/main/default/lwc", 9 | "test": "npm run test:unit", 10 | "test:unit": "sfdx-lwc-jest", 11 | "test:unit:watch": "sfdx-lwc-jest --watch", 12 | "test:unit:debug": "sfdx-lwc-jest --debug", 13 | "test:unit:coverage": "sfdx-lwc-jest --coverage", 14 | "prettier": "prettier --write \"**/*.{cls,cmp,component,css,html,js,json,md,page,trigger,xml,yaml,yml}\"", 15 | "prettier:verify": "prettier --list-different \"**/*.{cls,cmp,component,css,html,js,json,md,page,trigger,xml,yaml,yml}\"" 16 | }, 17 | "devDependencies": { 18 | "@prettier/plugin-xml": "^0.7.2", 19 | "@salesforce/eslint-config-lwc": "^0.5.0", 20 | "@salesforce/sfdx-lwc-jest": "^0.11.0", 21 | "eslint": "^6.8.0", 22 | "prettier": "^2.0.5", 23 | "prettier-plugin-apex": "^1.8.0" 24 | }, 25 | "main": ".eslintrc.js", 26 | "repository": { 27 | "type": "git", 28 | "url": "git@suraj.github.com:surajp/lightning-js-button.git" 29 | }, 30 | "keywords": [], 31 | "author": "", 32 | "license": "ISC" 33 | } 34 | -------------------------------------------------------------------------------- /scripts/jsButton/README.md: -------------------------------------------------------------------------------- 1 | ### Note: 2 | 3 | Before using `sfapi`, take the following steps 4 | 5 | - Add your lightning domain (ends with `lightning.force.com`) to the `CORS` list under `Setup`. 6 | - Add your classic domain to `CSP Trusted Sites` list under `Setup`. 7 | -------------------------------------------------------------------------------- /scripts/jsButton/addDefaultOpportunityLineItems.js: -------------------------------------------------------------------------------- 1 | let family = prompt("What family of products would you like to add?"); 2 | if (!family) return; 3 | try { 4 | let items = await soql( 5 | `select Id,UnitPrice from PricebookEntry where Pricebook2.Name='Standard Price Book' and IsActive=true and Product2.Family='${family}` 6 | ); 7 | if (!items || items.length === 0) { 8 | toast("No Products found"); 9 | } 10 | let oli = items.map((item) => ({ 11 | PriceBookEntryId: item.Id, 12 | OpportunityId: recordId, 13 | Quantity: 1, 14 | ListPrice: item.UnitPrice, 15 | TotalPrice: item.UnitPrice 16 | })); 17 | await dml.insert(oli, "OpportunityLineItem"); 18 | $A.get("e.force:refreshView").fire(); 19 | } catch (e) { 20 | toast(JSON.stringify(e), "error"); 21 | } 22 | -------------------------------------------------------------------------------- /scripts/jsButton/compositeApiExample.js: -------------------------------------------------------------------------------- 1 | /* Create multiple accounts and related contacts in a single api call */ 2 | let accts = [], 3 | cntcs = []; 4 | accts.push({ body: { Name: "Amazon", Industry: "E-commerce" }, referenceId: "Amazon" }); 5 | accts.push({ body: { Name: "Facebook", Industry: "Social Media" }, referenceId: "Facebook" }); 6 | accts.push({ body: { Name: "Google", Industry: "Search" }, referenceId: "Google" }); 7 | accts.push({ body: { Name: "Netflix", Industry: "Entertainment" }, referenceId: "Netflix" }); 8 | 9 | /* create one contact for each Account */ 10 | cntcs.push({ 11 | body: { 12 | LastName: "Bezos", 13 | FirstName: "Jeff", 14 | Email: "bezos@amazon.example.com", 15 | Title: "CEO of Amazon", 16 | AccountId: "@{Amazon.id}" 17 | }, 18 | referenceId: "Jeff" 19 | }); 20 | 21 | cntcs.push({ 22 | body: { 23 | LastName: "Zuckerberg", 24 | FirstName: "Marc", 25 | Email: "marc@facebook.example.com", 26 | Title: "CEO of Facebook", 27 | AccountId: "@{Facebook.id}" 28 | }, 29 | referenceId: "Marc" 30 | }); 31 | 32 | cntcs.push({ 33 | body: { 34 | LastName: "Pichai", 35 | FirstName: "Sundar", 36 | Email: "pichai@google.example.com", 37 | Title: "CEO of Google", 38 | AccountId: "@{Google.id}" 39 | }, 40 | referenceId: "Sundar" 41 | }); 42 | 43 | cntcs.push({ 44 | body: { 45 | LastName: "Hastings", 46 | FirstName: "Reed", 47 | Email: "reed@netflix.example.com", 48 | Title: "CEO of Netflix", 49 | AccountId: "@{Netflix.id}" 50 | }, 51 | referenceId: "Reed" 52 | }); 53 | 54 | /* create subrequests for Account and Contact by adding `method` and `url` properties */ 55 | accts = accts.map((a) => ({ 56 | ...a, 57 | method: "POST", 58 | url: "/services/data/v51.0/sobjects/Account" 59 | })); 60 | cntcs = cntcs.map((c) => ({ 61 | ...c, 62 | method: "POST", 63 | url: "/services/data/v51.0/sobjects/Contact" 64 | })); 65 | 66 | /*setup and make composite api request */ 67 | let compositeReq = { allOrNone: true, compositeRequest: [...accts, ...cntcs] }; 68 | let response = await sfapi( 69 | "/composite/" /*path excluding base url*/, 70 | "POST" /*method*/, 71 | {} /* additional headers */, 72 | JSON.stringify(compositeReq) /* request body */ 73 | ); 74 | alert(JSON.stringify(response)); 75 | -------------------------------------------------------------------------------- /scripts/jsButton/copyData.js: -------------------------------------------------------------------------------- 1 | /*** 2 | * This script moves Account, Contact and Opportunity data from a source instance to the current SF instance. 3 | * The source instance is expected to be configured using a named credential named 'source' 4 | */ 5 | let baseurl = "callout:source/services/data/v48.0/query?q="; 6 | let resp = await callout(baseurl + "Select+Id,Name+from+Account", "GET", { 7 | Accept: "application/json" 8 | }); 9 | resp = JSON.parse(resp.body); 10 | 11 | if (!resp.done) { 12 | toast("Request to fetch Accounts failed", "error"); 13 | } 14 | let accts = resp.records; 15 | resp = await callout(baseurl + "Select+Id,LastName,FirstName,Email,AccountId+from+Contact", "GET", { 16 | Accept: "application/json" 17 | }); 18 | resp = JSON.parse(resp.body); 19 | if (!resp.done) { 20 | toast("Request to fetch Contacts failed", "error"); 21 | } 22 | let contacts = resp.records; 23 | 24 | resp = await callout(baseurl + "Select+Id,Name,CloseDate,StageName,AccountId,ContactId+from+Opportunity", "GET", { 25 | Accept: "application/json" 26 | }); 27 | resp = JSON.parse(resp.body); 28 | if (!resp.done) { 29 | toast("Request to fetch Opportunities failed", "error"); 30 | } 31 | let opps = resp.records; 32 | 33 | let acctsToInsert = accts.map((a) => { 34 | let b = { ...a }; 35 | delete b.Id; 36 | delete b.attributes; 37 | return b; 38 | }); 39 | 40 | let newAcctIds = []; 41 | try { 42 | newAcctIds = await dml.insert(acctsToInsert, "Account"); 43 | } catch (err) { 44 | toast(JSON.stringify(err), "error"); 45 | } 46 | let acctIdMap = accts.reduce((obj, a, i) => ({ ...obj, [a.Id]: newAcctIds[i] }), {}); 47 | 48 | let contactsToInsert = contacts 49 | .map((c) => { 50 | let d = { ...c, AccountId: acctIdMap[c.AccountId] }; 51 | delete d.Id; 52 | delete d.attributes; 53 | return d; 54 | }) 55 | .filter((c) => c.AccountId); 56 | 57 | let newCtctIds = []; 58 | try { 59 | newCtctIds = await dml.insert(contactsToInsert, "Contact"); 60 | } catch (err) { 61 | toast(JSON.stringify(err), "error"); 62 | } 63 | let ctctIdMap = contacts.reduce((obj, c, i) => ({ ...obj, [c.Id]: newCtctIds[i] }), {}); 64 | 65 | let oppsToInsert = opps 66 | .map((o) => { 67 | let p = { 68 | ...o, 69 | AccountId: acctIdMap[o.AccountId], 70 | ContactId: ctctIdMap[o.ContactId] 71 | }; 72 | delete p.Id; 73 | delete p.attributes; 74 | return p; 75 | }) 76 | .filter((o) => o.AccountId); 77 | 78 | let newOppIds = []; 79 | try { 80 | newOppIds = dml.insert(oppsToInsert, "Opportunity"); 81 | } catch (err) { 82 | toast(JSON.stringify(err), "error"); 83 | } 84 | toast("Data transfer complete", "success"); 85 | -------------------------------------------------------------------------------- /scripts/jsButton/createContactFiles.js: -------------------------------------------------------------------------------- 1 | /* Creates 5 files related to the current Contact record */ 2 | let con = await soql(`select LastName from Contact where Id='${recordId}'`); 3 | let files = Array(5) 4 | .fill(0) 5 | .map((e, i) => ({ 6 | VersionData: btoa(con[0].LastName + "-" + i), 7 | PathOnClient: "file.txt", 8 | Title: con[0].LastName + "-File-" + i, 9 | FirstPublishLocationId: recordId 10 | })); 11 | let fileIds = await dml.insert(files, "ContentVersion"); 12 | toast("done", "success"); 13 | -------------------------------------------------------------------------------- /scripts/jsButton/createContactsUsingCallout.js: -------------------------------------------------------------------------------- 1 | let records = []; 2 | for (let i = 0; i < 10; i++) { 3 | let contact = undefined; 4 | try { 5 | contact = JSON.parse((await callout("callout:random_user/api")).body); 6 | } catch (err) { 7 | toast(err.body.message, "error"); 8 | } 9 | let record = { 10 | FirstName: contact.results[0].name.first, 11 | LastName: contact.results[0].name.last, 12 | Email: contact.results[0].email, 13 | Phone: contact.results[0].phone, 14 | AccountId: recordId 15 | }; 16 | records.push(record); 17 | } 18 | await dml.insert(records, "Contact"); 19 | $A.get("e.force:refreshView").fire(); 20 | -------------------------------------------------------------------------------- /scripts/jsButton/createNewJSButton.js: -------------------------------------------------------------------------------- 1 | try { 2 | let cmpName = prompt( 3 | "Enter the name for your aura bundle. This will be the name of the custom metadata record backing this bundle as well" 4 | ); 5 | if (!cmpName) return; 6 | let body = { 7 | MasterLabel: cmpName, 8 | Description: "created by js button", 9 | ApiVersion: 48.0, 10 | DeveloperName: cmpName 11 | }; 12 | let resp = await callout( 13 | "callout:salesforce/services/data/v48.0/tooling/sobjects/AuraDefinitionBundle/", 14 | "POST", 15 | { "Content-Type": "application/json" }, 16 | body 17 | ); 18 | let auraBundleId = JSON.parse(resp.body).id; 19 | alert(auraBundleId); 20 | let source = ` `; 21 | body = { 22 | AuraDefinitionBundleId: auraBundleId, 23 | DefType: "COMPONENT", 24 | Format: "XML", 25 | Source: source 26 | }; 27 | resp = await callout( 28 | "callout:salesforce/services/data/v48.0/tooling/sobjects/AuraDefinition/", 29 | "POST", 30 | { "Content-Type": "application/json" }, 31 | body 32 | ); 33 | alert(resp.statusCode); 34 | source = ` ({ doInit: function (component) { component .find("jsbutton") .invoke() .then( $A.getCallback((resp) => { $A.get("e.force:closeQuickAction").fire(); })) .catch( $A.getCallback((err) => { $A.get("e.force:closeQuickAction").fire(); })); } });`; 35 | body = { 36 | AuraDefinitionBundleId: auraBundleId, 37 | DefType: "CONTROLLER", 38 | Format: "JS", 39 | Source: source 40 | }; 41 | resp = await callout( 42 | "callout:salesforce/services/data/v48.0/tooling/sobjects/AuraDefinition/", 43 | "POST", 44 | { "Content-Type": "application/json" }, 45 | body 46 | ); 47 | toast(resp.statusCode, "success"); 48 | } catch (e) { 49 | toast(JSON.stringify(e), "error"); 50 | } 51 | -------------------------------------------------------------------------------- /scripts/jsButton/deleteInactiveFlowVersions.js: -------------------------------------------------------------------------------- 1 | try { 2 | let resp = await callout( 3 | "callout:salesforce/services/data/v50.0/tooling/query/?q=Select+Id,FullName+from+Flow+where+status+!=+'Active'" 4 | ); 5 | let respJson = JSON.parse(resp.body); 6 | 7 | let results = await Promise.all( 8 | respJson.records.map(async (rec) => { 9 | let flowId = rec.Id; 10 | resp = await callout( 11 | "callout:salesforce/services/data/v50.0/tooling/sobjects/Flow/" + 12 | flowId + 13 | "/", 14 | "DELETE" 15 | ); 16 | return resp.statusCode; 17 | }) 18 | ); 19 | 20 | toast(JSON.stringify(results), "success"); 21 | } catch (e) { 22 | toast(JSON.stringify(e), "error"); 23 | } 24 | -------------------------------------------------------------------------------- /scripts/jsButton/fetchAndAddFile.js: -------------------------------------------------------------------------------- 1 | /* Go to https://cors-anywhere.herokuapp.com/ and request access to the demo for the below `fetch` statement to work */ 2 | /* Also add https://cors-anywhere.herokuapp.com/ to CSP trusted sites list */ 3 | let respBlob = await fetch( 4 | "https://cors-anywhere.herokuapp.com/https://www.eurofound.europa.eu/sites/default/files/ef_publication/field_ef_document/ef1663en.pdf" 5 | ).then((resp) => resp.blob()); 6 | 7 | const blobToBase64 = (blob) => { 8 | return new Promise((resolve, reject) => { 9 | try { 10 | const reader = new FileReader(); 11 | reader.readAsDataURL(blob); 12 | reader.onloadend = function () { 13 | let base64data = reader.result; 14 | debugger; 15 | resolve(base64data.substring(base64data.indexOf(",") + 1)); 16 | }; 17 | } catch (e) { 18 | reject(e); 19 | } 20 | }); 21 | }; 22 | 23 | let base64data = await blobToBase64(respBlob); 24 | let cv = [ 25 | { 26 | Title: "SF.pdf", 27 | VersionData: base64data, 28 | PathOnClient: "Salesforce.pdf", 29 | FirstPublishLocationId: recordId 30 | } 31 | ]; 32 | let fileIds = await dml.insert(cv, "ContentVersion"); 33 | console.log("file ids", fileIds); 34 | toast("File added", "success"); 35 | $A.get("e.force:refreshView").fire(); 36 | -------------------------------------------------------------------------------- /scripts/jsButton/fetchAndCreateContacts.js: -------------------------------------------------------------------------------- 1 | let records = []; 2 | for (let i = 0; i < 10; i++) { 3 | let contact = undefined; 4 | try { 5 | contact = await ( 6 | await fetch("https://randomuser.me/api/", { 7 | headers: { "Content-Type": "application/json" } 8 | }) 9 | ).json(); 10 | } catch (err) { 11 | toast(err, "error"); 12 | } 13 | let record = { 14 | FirstName: contact.results[0].name.first, 15 | LastName: contact.results[0].name.last, 16 | Email: contact.results[0].email, 17 | Phone: contact.results[0].phone, 18 | AccountId: recordId 19 | }; 20 | records.push(record); 21 | } 22 | await dml.insert(records, "Contact"); 23 | toast("Contacts created", "success"); 24 | $A.get("e.force:refreshView").fire(); 25 | -------------------------------------------------------------------------------- /sfdx-project.json: -------------------------------------------------------------------------------- 1 | { 2 | "packageDirectories": [ 3 | { 4 | "path": "force-app", 5 | "default": true 6 | } 7 | ], 8 | "namespace": "", 9 | "sfdcLoginUrl": "https://login.salesforce.com", 10 | "sourceApiVersion": "48.0" 11 | } --------------------------------------------------------------------------------