Skip to content

Commit

Permalink
Refactor to use functions instead of weird syntax (#7)
Browse files Browse the repository at this point in the history
* Refactor to use apis instead of weird syntax

* Update dependencies

* Update README and add scripts

Updated Readme. Added composite api script. Fixed bugs
with GetSessionId. Updated old scripts to use the new api

* Change DML Api

* Update readme

* Update readme

* Minor syntax update

* Update readme

* Update packages

* Update contact add file script

* Update JS button scripts in Account and Contact cmdt

* Update readme
  • Loading branch information
surajp authored Feb 27, 2021
1 parent 1d1c3eb commit e16f04c
Show file tree
Hide file tree
Showing 38 changed files with 23,028 additions and 7,145 deletions.
5 changes: 3 additions & 2 deletions .prettierrc
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
{
"trailingComma": "none",
"printWidth": 120,
"overrides": [
{
"files": "**/lwc/**/*.html",
"options": { "parser": "lwc" }
},
{
"files": "*.{cmp,page,component}",
"options": { "parser": "html","printWidth":120 }
"options": { "parser": "html"}
},
{
"files": "*.{cls,trigger,apex}",
"options": { "parser": "apex","printWidth":120 }
"options": { "parser": "apex"}
}
]
}
133 changes: 97 additions & 36 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,64 +1,125 @@
# Pure JS Buttons in Lightning

JS buttons are back in Lightning! (For now, at least) And they are even more powerful than JS buttons in classic. Run SOQL and DML statements seamlessly. Make callouts to APIs, including Salesforce APIs using named credentials directly from JavaScript! This would allow you to build buttons that do amazing things, just using JavaScript. Check out the `scripts` folder for examples. Feel free to raise a PR to contribute your own scripts.
JS buttons are back in Lightning! (For now, at least) And they are even more
powerful than JS buttons in classic. Run SOQL and DML statements seamlessly.
Make callouts to APIs, including Salesforce APIs directly from JavaScript!
This would allow you to build buttons that do amazing things, just using
JavaScript. Check out the [scripts](./scripts/jsButton) folder for examples.
Feel free to raise a PR to contribute your own scripts.

### The Setup

The button can be made available to users via a quick action powered by the `jsButtonQuickAction` component. The actual JavaScript should be entered into a `JS_Button__mdt` custom metadata record, into the `Script__c` field with the same name as the name of the SObject. The repo contains a couple of samples for Account and Contact. The corollary is that, out of the box, only one button per SObjectType may be supported. Note that the Contact js button intentionally throws an error (by attempting to create a File) to showcase error handling capabilities.
The button can be made available to users via a quick action powered by the
`jsButtonQuickAction` component. The actual JavaScript should be entered into a
`JS_Button__mdt` custom metadata record, into the `Script__c` field with the
same name as the name of the SObject. The repo contains a couple of samples
for `Account` and `Contact`. The corollary is that, out of the box, only one
button per SObjectType may be supported, for quick actions. You can add any
number of buttons on the flexipage, with the underlying JS added using the
flexipage builder.

### APIs

The library supports the following apis

- soql
- dml (dml.insert, dml.update, dml.upsert and dml.del ) // `delete` is a resrved keyword :(
- callout ( used for calling external services through Apex. Named credentials are supported! )
- sfapi ( used for calling Salesforce APIs from the same org. Requires CORS and
CSP Trusted Sites setup. Details below)
- toast ( show a platform toast message )

### The Syntax

This is the fun part. The syntax is quite permissive, with some restrictions, which I will cover below. I haven't, obviously, explored all possible scenarios and the information may still be incomplete. Please raise an issue if you come across something I haven't covered.
This is the fun part. I haven't, obviously, explored all possible scenarios and
the information may still be incomplete. Please raise an issue if you come
across something I haven't covered.

* Simple examples (no soql/dml)
- Simple examples (no soql/dml)

```javascript
alert('hello,world');
```js
alert("hello,world");
```

```javascript
alert(Array(5).fill(0).map((e,i)=>'Hello, '+i));
```js
toast(
Array(5)
.fill(0)
.map((e, i) => "Hello, " + i)
); /* `toast` service to show message toasts */
```

* Fetch 10 of the 100 latest Accounts without a Contact and add a Contact to each of them

```javascript
let accts=|| Select Name,(Select Id from Contacts) from Account order by createddate desc limit 100 ||;
let contacts = accts.filter((a)=>!a.Contacts || a.Contacts.length===0)
.slice(0,10)
.map((a)=>({LastName: a.Name+'-Contact', AccountId: a.Id}));
let contactIds = || insert Contact(contacts) ||; /*Note how the SObjectType has been specified. This is required for insert and upsert*/
$A.get('e.force:refreshView').fire(); /* $A is supported!*/
- Fetch 100 of the latest Accounts and for upto 10 of the ones without a Contact, add a Contact

```js
let accts = await soql(
`Select Name,(Select Id from Contacts) from Account order by createddate desc
limit 100`
); /* Querying child records is supported */
let contacts = accts
.filter((a) => !a.Contacts || a.Contacts.length === 0)
.slice(0, 10)
.map((a) => ({ LastName: a.Name + "-Contact", AccountId: a.Id }));
let contactIds = await dml.insert(
contacts,
"Contact"
); /*Note how the SObjectType has been specified. This is required for insert and upsert*/
$A.get("e.force:refreshView").fire(); /* $A is supported!*/
```

* Act in the context of the current record
- Act in the context of the current record

```javascript
let acct = || Select NumberOfEmployees from Account where Id='${recordId}' ||;
```js
let acct = await soql(
`Select NumberOfEmployees from Account where Id='${recordId}'`
); /* Note the use of template literal syntax to resolve
variable values in the query */
acct[0].NumberOfEmployees = (acct[0].NumberOfEmployees || 0) + 10;
let acctId = || update acct ||;
acct = || Select NumberOfEmployees from Account where Id='${acctId}' ||;
alert(acct[0].NumberOfEmployees);
$A.get('e.force:refreshView').fire();
let acctId = await dml.update(acct);
acct = await soql(`Select NumberOfEmployees from Account where Id='${acctId}'`);
toast(acct[0].NumberOfEmployees, "success");
$A.get("e.force:refreshView").fire();
```

- Upload files to ContentVersion(ContentDocument) records

```js
let fileContent = btoa("Hello World");
/* convert your file content to base64 data before uploading */
let cv = {
VersionData: fileContent,
Title: "My Awesome File",
PathOnClient: "MyFile.txt"
};
let cvId = await dml.insert(cv, "ContentVersion");
```

### About the Syntax

* Note how the syntax is linear for SOQL and DML. Coupled with JavaScript's support for manipulating arrays, this makes it easier to manipulate data, even compared to Apex in several instances.
* SOQL and DML statements must be enclosed in `||`. Semi-colon can be inside or outside the `||`
* Upsert and Update statements must be qualified with the SObjectType thus `|| insert Account(accts) ||;`
* SOQL statements are parsed using template literals. Any arguments should follow the appropriate syntax `${argument}`
* SOQL and DML statements may not be wrapped in a function.
* All statements must be strictly terminated by a semicolon.
- Note how the syntax is linear for SOQL and DML. Coupled with JavaScript's
support for manipulating arrays, this makes it easier to manipulate data,
even compared to Apex in several instances.
- `dml.insert` and `dml.upsert` expect the SObjectType as the second argument.
Thus `dml.insert(acct,"Account")`
- Statements with contextual arguments such as `recordId`
are best expressed using [template literals](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals).
- All statements must be strictly terminated by a semicolon.

### Known Limitations

* Support for delete has been intentionally withheld.
* Single-line comments are not supported.
* Haven't tested DML with date, datetime, boolean, geolocation and other compound fields. I will update this section as I do so.
* SOQL and DML statements should be enclosed in async functions, if they are required to be contained in functions. The program automatically adds `await` to SOQL and DML statements
* DML on Files, Attachments, Documents, etc. is not supported
- Single-line comments are not supported.
- Haven't tested DML with date, datetime, boolean, geolocation and other
compound fields. I will update this section as I do so.
- To insert `ContentVersion` make sure to set `VersionData` to base64 data.
Refer to the example [here](./scripts/jsButton/createContactFiles.js) for details.

### Using Salesforce (and other) APIs in your script

You can use any of Salesforce's APIs (REST, Tooling, Metadata) by setting up a named credential for your own Salesforce instance. This allows you to write scripts for admins to perform tasks like [deleting inactive versions of flows](scripts/jsButton/deleteInactiveFlowVersions.js), or [creating new JS Buttons](scripts/jsButton/createNewJSButton.js)! You can also use named credentials to interact with other APIs as well, of course. Although, for Public APIs, you can just use `fetch` directly. The Salesforce named credential set up would need to have the following scopes (api refresh_token offline_access web). You would need to set up your own Connected App and a Salesforce Auth. Provider that uses this connected app.
To use Salesforce APIs from your org, using the `sfapi` method, take the following steps:

- Add your lightning domain (ends with `lightning.force.com`) to the `CORS` list under `Setup`.
- Add your classic domain to `CSP Trusted Sites` list under `Setup`.

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)
for creating parent and child records.
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.
2 changes: 1 addition & 1 deletion force-app/main/default/aura/jsButton/jsButtonController.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
.catch((err) => {
reject(err);
});
} else {
} else if (js) {
helper.runJS(component, resolve, reject);
}
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
.invoke()
.then(
$A.getCallback((resp) => {
console.log('>> resp '+JSON.stringify(resp));
console.log(">> resp " + JSON.stringify(resp));
$A.get("e.force:closeQuickAction").fire();
})
)
Expand Down
15 changes: 9 additions & 6 deletions force-app/main/default/classes/APICallController.cls
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
/**
* description: Controller for making api calls and sending the response back
**/
/************************************************************
*** @author Suraj Pillai
*** @group Controller
*** @description Controller for making API calls and sending the response back
***
**/
public with sharing class APICallController {
/**
* description Given an endpoint, request params and headers, callout the api and return the response
* @param endpoint The endpoint to callout to
* @description Given an endpoint, request params and headers, callout the api and return the response
* @param endPoint The endpoint to callout to
* @param method The http method to use
* @param bodyStr The request body string.
* @param headers Map of string key and value for request headers
* @return HttpResponseWrapper
* @return The response for the http request
*
*/
@AuraEnabled
Expand Down
41 changes: 31 additions & 10 deletions force-app/main/default/classes/DynamicSOQLDMLController.cls
Original file line number Diff line number Diff line change
Expand Up @@ -36,27 +36,48 @@ public with sharing class DynamicSOQLDMLController {
return recordId.getSObjectType().getDescribe().getName();
}

private static List<ContentVersion> deserializeContentVersion(String strData) {
List<Object> deserializedRecords = (List<Object>) JSON.deserializeUntyped(strData);
List<ContentVersion> recordsList = new List<ContentVersion>();
for (Object objRec : deserializedRecords) {
Map<String, Object> record = (Map<String, Object>) objRec;
ContentVersion cv = new ContentVersion();
String vData = String.valueOf(record.remove('VersionData'));
cv = (ContentVersion) JSON.deserialize(JSON.serialize(record), ContentVersion.class);
cv.put('VersionData', EncodingUtil.base64Decode(vData));
recordsList.add(cv);
}
return recordsList;
}

/**
* Execute a DML statement
* @description Execute a DML statement
* @param operation 'Insert','Update' or 'Upsert'
* @param strdata The records to update, stringified
* @param sobjectType The SObject type to perform the DML on
*
* @param strData The records to update, stringified
* @param sObjectType The SObject type to perform the DML on
* @return Id[]
**/
@AuraEnabled
public static Id[] executeDml(String operation, String strData, String sObjectType) {
SObject[] records = (SObject[]) JSON.deserialize(strData, Type.forName('List<' + sObjectType + '>'));
public static List<Id> executeDml(String operation, String strData, String sObjectType) {
List<SObject> records = null;

if (sObjectType.equalsIgnoreCase('ContentVersion')) {
records = deserializeContentVersion(strData);
} else {
records = (SObject[]) JSON.deserialize(strData, Type.forName('List<' + sObjectType + '>'));
}

if (operation == 'insert') {
insert records;
return new List<Id>(new Map<Id, SObject>(records).keySet());
} else if (operation == 'update') {
update records;
return new List<Id>(new Map<Id, SObject>(records).keySet());
} else if (operation == 'upsert') {
upsert records;
return new List<Id>(new Map<Id, SObject>(records).keySet());
} else if (operation == 'delete') {
delete records;
} else {
return null;
}
return null;
return new List<Id>(new Map<Id, SObject>(records).keySet());
}
}
32 changes: 32 additions & 0 deletions force-app/main/default/classes/GetSessionIdController.cls
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/************************************************************
*** Copyright (c) Vertex Computer Systems Inc. All rights reserved.
*** @author Suraj Pillai
*** @group Controller
*** @date 01/2021
*** @description Get API-ready session id of the current users
***
**/
public with sharing class GetSessionIdController {
/****
** @description Returns the current user's session id that may be used for calling Salesforce APIs
** @return the current user's api-ready session id
**/
@AuraEnabled(cacheable=true)
public static String getSessionId() {
String content = Page.GetSessionId.getContent().toString();
return getSessionIdFromPage(content);
}

private static String getSessionIdFromPage(String content) {
Integer s = content.indexOf('Start_Of_Session_Id') + 'Start_Of_Session_Id'.length(),
e = content.indexOf('End_Of_Session_Id');
return content.substring(s, e);
}

@AuraEnabled(cacheable=true)
public static String getRestAPIBaseUrl() {
return URL.getSalesforceBaseUrl().toExternalForm() + '/services/data/v51.0';
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>49.0</apiVersion>
<status>Active</status>
</ApexClass>
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@
<protected>false</protected>
<values>
<field>Script__c</field>
<value xsi:type="xsd:string">let acct = || Select NumberOfEmployees from Account where Id=&apos;${recordId}&apos; ||;
<value xsi:type="xsd:string">let acct = await soql(`Select NumberOfEmployees from Account where Id=&apos;${recordId}&apos;`);

acct[0].NumberOfEmployees = (acct[0].NumberOfEmployees || 0) + 10;

let acctId = || update acct ||;
let acctId = await dml.update(acct);

acct = || Select NumberOfEmployees from Account where Id=&apos;${acctId}&apos; ||;
acct = await soql(`Select NumberOfEmployees from Account where Id=&apos;${acctId}&apos;`);

alert(acct[0].NumberOfEmployees);
toast(`Number of employees updated to ${acct[0].NumberOfEmployees}`,"success");

$A.get(&apos;e.force:refreshView&apos;).fire();</value>
</values>
Expand Down
26 changes: 13 additions & 13 deletions force-app/main/default/customMetadata/JS_Button.Contact.md-meta.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,18 @@
<protected>false</protected>
<values>
<field>Script__c</field>
<value xsi:type="xsd:string">let con = || select LastName from Contact where Id=&apos;${recordId}&apos; ||;

let files = Array(5).fill(0).map((e,i)=&gt;({ VersionData: btoa(con[0].LastName+&apos;-&apos;+i)), PathOnClient: &apos;file.txt&apos;, Title: con[0].LastName+&apos;-File-&apos;+i }));

let fileIds = || insert ContentVersion(files) ||;

let docIds = || select ContentDocumentId from ContentVersion where Id in (&apos;${fileIds.join(&quot;&apos;,&apos;&quot;)}&apos;) ||;

let linkedEntities = docIds.map((e,i)=&gt;({LinkedEnityId: acct[0].Id, ContentDocumentId: e.Id});

|| insert linkedEntities ||;

alert(&apos;done&apos;);</value>
<value xsi:type="xsd:string">/* Creates 5 files related to the current Contact record */
let con = await soql(`select LastName from Contact where Id=&apos;${recordId}&apos;`);
let files = Array(5)
.fill(0)
.map((e, i) =&gt; ({
VersionData: btoa(con[0].LastName + &quot;-&quot; + i),
PathOnClient: &quot;file.txt&quot;,
Title: con[0].LastName + &quot;-File-&quot; + i,
FirstPublishLocationId: recordId
}));
let fileIds = await dml.insert(files, &quot;ContentVersion&quot;);
toast(&quot;done&quot;, &quot;success&quot;);
$A.get(&apos;e.force:refreshView&apos;).fire();</value>
</values>
</CustomMetadata>
Loading

0 comments on commit e16f04c

Please sign in to comment.