Skip to content

Commit

Permalink
Merge pull request #61 from cesarParra/editor
Browse files Browse the repository at this point in the history
Playground Editor
  • Loading branch information
cesarParra authored Oct 8, 2023
2 parents 92e539e + e1d240d commit 5a0dae2
Show file tree
Hide file tree
Showing 33 changed files with 38,798 additions and 22 deletions.
29 changes: 25 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,19 +22,19 @@ Powerful formula-syntax evaluator for Apex and LWC.

### Unlocked Package (`expression` namespace)

[![Install Unlocked Package in a Sandbox](assets/btn-install-unlocked-package-sandbox.png)](https://test.salesforce.com/packaging/installPackage.apexp?p0=04tDm0000011MgDIAU)
[![Install Unlocked Package in Production](assets/btn-install-unlocked-package-production.png)](https://login.salesforce.com/packaging/installPackage.apexp?p0=04tDm0000011MgDIAU)
[![Install Unlocked Package in a Sandbox](assets/btn-install-unlocked-package-sandbox.png)](https://test.salesforce.com/packaging/installPackage.apexp?p0=04tDm0000011MgNIAU)
[![Install Unlocked Package in Production](assets/btn-install-unlocked-package-production.png)](https://login.salesforce.com/packaging/installPackage.apexp?p0=04tDm0000011MgNIAU)

Install with SF CLI:

```shell
sf package install --apex-compile package --wait 20 --package 04tDm0000011MgDIAU
sf package install --apex-compile package --wait 20 --package 04tDm0000011MgNIAU
```

Install with SFDX CLI:

```shell
sfdx force:package:install --apexcompile package --wait 20 --package 04tDm0000011MgDIAU
sfdx force:package:install --apexcompile package --wait 20 --package 04tDm0000011MgNIAU
```

### Direct Deployment to Salesforce
Expand All @@ -43,6 +43,27 @@ Clone the repo and deploy the source code, or click the button below to directly

[![Deploy to Salesforce](assets/deploy.png)](https://githubsfdeploy.herokuapp.com/app/githubdeploy/cesarParra/formula-evaluator)

## Setup

Grant the `Expression Admin` permission set to any user that will be configuring and
managing the application.

This permission set grants access to the Expression Playground tab, and the Expression
Function custom metadata type.

## Playground

The Expression Playground tab allows you to test and evaluate formulas in a
visual way.

[![Expression Playground](assets/expression-playground.png)](assets/expression-playground.png)

With it, you can quickly test and validate expressions, and see the results
in real-time. You can also use it to learn about the different operators and
functions available.

To provide a context for the expression you can also specify a record Id (optional).

## Usage

> 📓Code samples use the `expression` namespace, which assumes you are using the
Expand Down
Binary file added assets/expression-playground.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
18 changes: 16 additions & 2 deletions expression-src/main/api/tests/EvaluatorTest.cls
Original file line number Diff line number Diff line change
Expand Up @@ -1286,6 +1286,11 @@ private class EvaluatorTest {
Assert.areEqual(3, Evaluator.run('LIST(1, 2, 3) -> SIZE()'));
}

@IsTest
private static void canPipeFunctionCalls_pipeHasPrecedenceWhenPairedWithOtherExpressions() {
Assert.areEqual(8, Evaluator.run('LIST(1, 2, 3) -> SIZE() + 5'));
}

@IsTest
private static void canPipeFunctionCallsMultipleTimes() {
Object result = Evaluator.run('[1, 2, 3, 4, 5, 6] -> WHERE($current > 2) -> WHERE($current < 5)');
Expand Down Expand Up @@ -1464,8 +1469,8 @@ private class EvaluatorTest {
};

String formula = 'REDUCE(ChildAccounts, ' +
'{"employees": NumberOfEmployees + GET($accumulator, "employees"), "revenue": AnnualRevenue + GET($accumulator, "revenue")}, ' +
'{"employees": 0, "revenue": 0}' +
'{"employees": NumberOfEmployees + GET($accumulator, "employees"), "revenue": AnnualRevenue + GET($accumulator, "revenue")}, ' +
'{"employees": 0, "revenue": 0}' +
')';
Object result = Evaluator.run(formula, parentAccount.Id);

Expand All @@ -1485,4 +1490,13 @@ private class EvaluatorTest {
return 'Value';
}
}

@IsTest
private static void canAccessMapKeysUsingDotNotation() {
String formula = 'MAP([{"keyName": "A"}, {"keyName": "B"}], $current.keyName)';
Object result = Evaluator.run(formula);
Assert.areEqual(2, ((List<Object>) result).size());
Assert.areEqual('A', ((List<Object>) result)[0]);
Assert.areEqual('B', ((List<Object>) result)[1]);
}
}
37 changes: 37 additions & 0 deletions expression-src/main/editor/controllers/PlaygroundController.cls
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
public with sharing class PlaygroundController {
@AuraEnabled(Cacheable=true)
public static List<String> getFunctions() {
Set<String> builtInFunctionNames = ExpressionFunction.FUNCTIONS.keySet();
Set<String> customFunctionNames = Expression_Function__mdt.getAll().keySet();
Set<String> functionNames = new Set<String>();
functionNames.addAll(builtInFunctionNames);
functionNames.addAll(customFunctionNames);
return new List<String>(
functionNames
);
}

@AuraEnabled
public static Result validate(String expr, Id recordId) {
Result toReturn = new Result();
try {
if (recordId != null) {
toReturn.result = Evaluator.run(expr, recordId);
} else {
toReturn.result = Evaluator.run(expr);
}
} catch (Exception e) {
toReturn.error = e.getMessage();
}

return toReturn;
}

public class Result {
@AuraEnabled
public String error;

@AuraEnabled
public Object result;
}
}
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>58.0</apiVersion>
<status>Active</status>
</ApexClass>
8 changes: 8 additions & 0 deletions expression-src/main/editor/lwc/playground/playground.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.lgc-bg {
background-color: rgb(242 242 242);
}
.top-right {
position: absolute;
top: 20px;
right: 20px;
}
29 changes: 29 additions & 0 deletions expression-src/main/editor/lwc/playground/playground.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<template>
<div class="slds-p-around_medium slds-m-bottom_small lgc-bg">
<lightning-input type="text"
value={recordId}
onchange={handleInputChange}
label="Record Id (Optional Context)"></lightning-input>
</div>
<div class="slds-grid slds-gutters">
<div class="slds-col slds-size_6-of-12 slds-is-relative">
<iframe src={iframeUrl}
scrolling="no" style="width: 100%; height: 500px; overflow: hidden; border: none"
onload={iframeLoaded}
></iframe>
<div class="top-right">
<lightning-button
icon-name="utility:play"
variant="brand" label="Validate" onclick={getExpression}>
</lightning-button>
</div>
</div>
<div class="slds-col slds-size_6-of-12">
<lightning-card title="Result" icon-name="custom:custom14">
<div class="slds-p-horizontal_small">
<pre class={resultColor}>{result.payload}</pre>
</div>
</lightning-card>
</div>
</div>
</template>
47 changes: 47 additions & 0 deletions expression-src/main/editor/lwc/playground/playground.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { LightningElement } from 'lwc';
import monaco from '@salesforce/resourceUrl/monaco';
import getFunctions from '@salesforce/apex/PlaygroundController.getFunctions';
import validate from '@salesforce/apex/PlaygroundController.validate';

export default class Monaco extends LightningElement {
recordId;
iframeUrl = `${monaco}/main.html`;
result = {};

async iframeLoaded() {
console.log('iframe loaded');
const functionKeywords = await getFunctions();
this.iframeWindow.postMessage({
name: 'initialize',
keywords: functionKeywords
});
}

async getExpression() {
const expr = this.iframeWindow.editor.getValue();
const result = await validate({expr: expr, recordId: this.recordId});
if (result.error) {
this.result = {
type: "error",
payload: result.error
}
} else {
this.result = {
type: "success",
payload: JSON.stringify(result.result, null, 2)
}
}
}

handleInputChange(event) {
this.recordId = event.detail.value;
}

get iframeWindow() {
return this.template.querySelector('iframe').contentWindow;
}

get resultColor() {
return this.result.type === 'error' ? 'slds-text-color_error' : 'slds-text-color_default';
}
}
10 changes: 10 additions & 0 deletions expression-src/main/editor/lwc/playground/playground.js-meta.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>58.0</apiVersion>
<description>Playground</description>
<isExposed>true</isExposed>
<masterLabel>Playground</masterLabel>
<targets>
<target>lightning__Tab</target>
</targets>
</LightningComponentBundle>
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<StaticResource xmlns="http://soap.sforce.com/2006/04/metadata">
<cacheControl>Public</cacheControl>
<contentType>application/zip</contentType>
<description>monaco2</description>
</StaticResource>
74 changes: 74 additions & 0 deletions expression-src/main/editor/staticresources/monaco/main.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html;charset=utf-8"/>
<link
rel="stylesheet"
data-name="vs/editor/editor.main"
href="min/vs/editor/editor.main.css"
/>
</head>
<body style="margin: 0;">
<div id="container" style="width: 800px; height: 500px;"></div>

<script>
var require = {paths: {vs: 'min/vs'}};
</script>
<script src="min/vs/loader.js"></script>
<script src="min/vs/editor/editor.main.nls.js"></script>
<script src="min/vs/editor/editor.main.js"></script>

<script>
window.addEventListener("message", onMessage, false);

function onMessage(event) {
console.log(JSON.stringify(event.data));
if (event.data.name === 'initialize') {
initialize(event.data.keywords);
}
}

function initialize(functionKeywords) {
monaco.languages.register({id: 'expression'});
let keywords = functionKeywords;
monaco.languages.setMonarchTokensProvider('expression', {
keywords,
tokenizer: {
root: [
// identifiers and keywords
[/[a-zA-Z_$][\w$]*/, {
cases: {
'@keywords': 'keyword',
}
}],
[/".*?"/, 'string'],
[/[{}()\[\]]/, '@brackets'],
],
},
ignoreCase: true
});
monaco.languages.registerCompletionItemProvider('expression', {
provideCompletionItems(model, position, context, token) {
const suggestions = [
...keywords.map(
k => {
return {
label: k,
kind: monaco.languages.CompletionItemKind.Keyword,
insertText: k,
}
}
)
];
return {suggestions: suggestions};
}
});

window.editor = monaco.editor.create(document.getElementById('container'), {
value: [''].join('\n'),
language: 'expression',
});
}
</script>
</body>
</html>
Binary file not shown.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit 5a0dae2

Please sign in to comment.