cancel
Showing results for 
Search instead for 
Did you mean: 
cchannon

PowerFX Plugins: Part 1 - Monaco Editor for PowerFX

Power FX Plugins: The Monaco Editor

 

Background

cchannon_0-1670356602731.png

 

OK, so I decided a little while ago that I wanted to author my own PowerFX (PFX) plugin authoring utility. The concept was pretty simple: When ProDevs like me author plugins for Dataverse applications, it is sometimes because the business logic is very complex (so C# .net is the right "How"), but more often it is about "Where and When" the logic runs: Plugins always run no matter where the transaction came from, Plugins run server-side, Plugins have access to Pre and Post images...

 

Sometimes, we just want to know that this record will ALWAYS get a name that is a combination of its Part Number and CreatedOn, whether it was made by a person or a system integration... or we want to know that even a web-savvy user who knows how to open a script debugger can't escape our business rules that limit exactly which teams they can assign a record to, or we want to know that we will only do <a thing> if a field was <x> and is now <y>.

 

For cases like these, we use Plugins not because the logic requires any particular sophistication, but because they run server-side, and they are dedicated to run on specific events that are inescapable. But if we know these circumstances exist, and we know they don't require any particular tool complexity, then why not open the use case up for low-code devs to start contributing here too?

 

That takes us to this blog: I have started building out my own way to address this pattern; a PFX Plugin solution that I believe will eventually enable low-code devs to author plugins with nearly equivalent reach and power to their C# .net counterparts.

 

cchannon_0-1670297228139.jpeg

 

Yes, that's right. I am a Pro-Coder, trying to open up our last great bastion--the Sacred Vaults of the Dataverse Plugin--for non-coders to play with. Is there something wrong with me??? No, I am not crazy (or at least I don't think I am 🤔) I just really believe, like the Power Platform product team does, that the more people we empower to solve more complicated problems, the more successful we will all be, together. 🧑‍🤝‍🧑

 

So, with that, let's jump into part 1: how to build our own simple, lightweight PFX editing window...

 

Design

To meet my stated objective, I needed to start with giving users a place to author their PFX commands. I wanted to give them something close to the PFX Command Window in Canvas Apps, so I started with the PFX samples repo on GitHub. There is (at the time of writing this blog) an answer there for how to build that command window, complete with intellisense and autocomplete... but it relies on a server-side component because the JS Evaluator is not (yet) open sourced. 😤

 

This sample works fine, but it is not "the Power Apps Way" and I wanted to avoid complex, Azure-dependent solution steps that would require specialized skills. So, instead of using that sample, I decided to create my own lightweight PCF that would allow for the bare essentials of PFX editing, while not unnecessarily complicating deployment.

 

OK, so we want a PCF and we don't care if we lose some functionality as compared with the Canvas App editing experience so long as it provides us with some bare essentials. But we also don't want to put too much work in here: this isn't the main event, after all, it is just a side trip to make authoring more convenient. Oh, and we need to be able to author multiple such "plugin commands" because we might want to use this in many places in a single Environment.

 

That pretty much gives us our design: we want some ready-to go advantages for code editing (Monaco) we want some bare essentials such as formatting and syntax highlighting, but don't mind if we lose the fancy stuff (for now) and we want to be able to author these things in a Dataverse database and create an arbitrary number of them. To keep things easy, we'll just create a custom table, "Plugin Commands" and we'll start with just one column, "Command". We'll set "Command" as a multiline text column, and we'll use a PCF to replace the text box with a Monaco editor, tailored to the needs of PowerFX.

 

The Code

So let's dive in:

We're going to build a Virtual PCF because it is much lighter-weight and lets us access the same React resources the code Dataverse Model Driven App is using, so we begin with...

 

Init a PCF with:

 

 

 

 

-fw React

 

 

 

 

(I am skipping lots of early steps for PCFs here on the assumption that if you're still reading, then you probably already know these, but if you need more info on how to get started, just go ahead and DM me!)

 

ControlManifest.Input.xml

This is a PCF, after all, so let's start with the Manifest. We only need one col input, but it could be of any of the main Text types, so let's support them all with a type-group:

 

 

 

 

<type-group name="strings">
  <type>SingleLine.Text</type>
  <type>SingleLine.TextArea</type>
  <type>Multiple</type>
</type-group>

 

 

 

 

Then we'll feed that group in for the single input property:

 

 

 

 

<?xml version="1.0" encoding="utf-8" ?>
<manifest>
  <control namespace="ktcs" constructor="monacoForPFX" version="0.0.1" display-name-key="Monaco For PFX" description-key="A basic monaco code editor for PowerFX" control-type="virtual" >
    <external-service-usage enabled="false">
    </external-service-usage>
    <type-group name="strings">
      <type>SingleLine.Text</type>
      <type>SingleLine.TextArea</type>
      <type>Multiple</type>
    </type-group>
    <property name="stringPFX" display-name-key="PFX Body" description-key="The text representing the body of PFX" of-type-group="strings" usage="bound" required="true" />
    <resources>
      <code path="index.ts" order="1"/>
      <css path="editor.css" order="1"/>
      <platform-library name="React" version="16.8.6" />
      <platform-library name="Fluent" version="8.29.0" />
    </resources>
  </control>
</manifest>

 

 

 

 

Index.ts

Our index will also be pretty simple. We'll set aside our notifyOutputChanged into a global during init:

 

 

 

 

public init(
    context: ComponentFramework.Context<IInputs>,
    notifyOutputChanged: () => void,
    state: ComponentFramework.Dictionary
): void {
    this.notifyOutputChanged = notifyOutputChanged;
}

 

 

 

 

and we'll put our main logic into the updateView event, where we'll conditionally build our props and push them to React.createElement to prevent some of the unnecessary re-rendering:

 

 

 

 

public updateView(context: ComponentFramework.Context<IInputs>): React.ReactElement {
    if(!this._isLoaded){
        this._defaultString = context.parameters.stringPFX.raw ? context.parameters.stringPFX.raw : "1+1"
        this._isLoaded = true;
    }
    const props: IEditorProps = { 
        callback: this.callback.bind(this),
        defaultValue: this._defaultString
    };
    return React.createElement(
        Editor, props
    );
}

 

 

 

 

And we'll also add a simple callback function so that the edits made in our Monaco editor also write back to the database:

 

 

 

 

public callback(newString: string): void {
    this.currentValue = newString;
    this.notifyOutputChanged();
}

 

 

 

 

editor.tsx

Now we get into the fun stuff: the React component itself. We start by importing our libraries and exporting our Props Interface:

 

 

 

 

import * as React from 'react';
import * as monaco from 'monaco-editor';

export interface IEditorProps {
    callback: (newvalue: string) => void;
    defaultValue: string;
}

 

 

 

 

Then we declare our MonacoEnvironment (following the Monaco instructions to make it work in a React Component):

 

 

 

 

self.MonacoEnvironment = {
    getWorkerUrl: function (_moduleId: any, label: string) {
	if (label === 'json') {
	    return './json.worker.bundle.js';
	}
	if (label === 'css' || label === 'scss' || label === 'less') {
	    return './css.worker.bundle.js';
	}
	if (label === 'html' || label === 'handlebars' || label === 'razor') {
	    return './html.worker.bundle.js';
	}
	if (label === 'typescript' || label === 'javascript') {
	    return './ts.worker.bundle.js';
	}
	return './editor.worker.bundle.js';
    }
};

 

 

 

 

And we register the language as "PowerFX":

 

 

 

 

monaco.languages.register({ id: 'PowerFX' });

 

 

 

 

Then we feed it a Monarch definition (I borrowed from the Monarch Tutorials and by browsing PowerFX.Core to put this together, but I am by no means 'good' at Monarch definitions. If you see anything in this you could improve on, please send me a Pull Request!)

 

 

 

monaco.languages.setMonarchTokensProvider('PowerFX', {
	// Set defaultToken to invalid to see what you do not tokenize yet
	defaultToken: 'invalid',
	
	booleans: ['true', 'false'],

	operators: [
		'+',
		'-',
		'*',
		'/',
		'^',
		'%',
		'=',
		'>',
		'>=',
		'<',
		'<=',
		'<>',
		'&',
		'&&',
		'||',
		'!',
		'.',
		'@',
		';;'
	  ],

	// we include these common regular expressions
	symbols: /[=><!~?:&|+\-*\/\^%@;]+/,
	escapes: /\\(?:[abfnrtv\\"']|x[0-9A-Fa-f]{1,4}|u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8})/,
	digits: /\d+(_+\d+)*/,

	// The main tokenizer for our languages
	tokenizer: {
		root: [
			[/[{}]/, 'delimiter.bracket'],
			{ include: 'common' }
		],

		common: [
			// identifiers and keywords
			[/[a-z_$][\w$]*/, {
				cases: {
					// '@typeKeywords': 'keyword',
					// '@keywords': 'keyword',
					'@booleans': 'boolean',
					'@default': 'identifier'
				}
			}],
			[/[A-Z][\w\$]*/, 'type.identifier'],  // to show class names nicely
			// [/[A-Z][\w\$]*/, 'identifier'],

			// whitespace
			{ include: '@whitespace' },

			// delimiters and operators
			[/[()\[\]]/, '@brackets'],
			[/[<>](?!@symbols)/, '@brackets'],
			[/@symbols/, {
				cases: {
					'@operators': 'delimiter',
					'@default': ''
				}
			}],

			// numbers
			[/(@digits)[eE]([\-+]?(@digits))?/, 'number.float'],
			[/(@digits)\.(@digits)([eE][\-+]?(@digits))?/, 'number.float'],
			[/(@digits)/, 'number'],

			// delimiter: after number because of .\d floats
			[/[;,.]/, 'delimiter'],

			// strings
			[/"([^"\\]|\\.)*$/, 'string.invalid'],  // non-teminated string
			[/'([^'\\]|\\.)*$/, 'string.invalid'],  // non-teminated string
			[/"/, 'string', '@string_double'],
			[/'/, 'string', '@string_single'],
		],

		whitespace: [
			[/[ \t\r\n]+/, ''],
			[/\/\*\*(?!\/)/, 'comment.doc', '@jsdoc'],
			[/\/\*/, 'comment', '@comment'],
			[/\/\/.*$/, 'comment'],
		],

		comment: [
			[/[^\/*]+/, 'comment'],
			[/\*\//, 'comment', '@pop'],
			[/[\/*]/, 'comment']
		],

		jsdoc: [
			[/[^\/*]+/, 'comment.doc'],
			[/\*\//, 'comment.doc', '@pop'],
			[/[\/*]/, 'comment.doc']
		],

		string_double: [
			[/[^\\"]+/, 'string'],
			[/@escapes/, 'string.escape'],
			[/\\./, 'string.escape.invalid'],
			[/"/, 'string', '@pop']
		],

		string_single: [
			[/[^\\']+/, 'string'],
			[/@escapes/, 'string.escape'],
			[/\\./, 'string.escape.invalid'],
			[/'/, 'string', '@pop']
		],

		bracketCounting: [
			[/\{/, 'delimiter.bracket', '@bracketCounting'],
			[/\}/, 'delimiter.bracket', '@pop'],
			{ include: 'common' }
		],
	},
});

 

 

 

Then we set our bracketing pairs: 

 

 

 

monaco.languages.setLanguageConfiguration('PowerFX', {
	surroundingPairs: [
		{ open: '{', close: '}' },
		{ open: '[', close: ']' },
		{ open: '(', close: ')' }
	],
	brackets: [
		['{', '}'],
		['[', ']'],
		['(', ')']
	]
});

 

 

 

and now we're ready to export our React Functional Component, in which we're going to useEffect to limit the rerendering to only trigger if the "defaultValue" changes--which should only happen when the form first loads.

 

 

 

export const Editor: React.FC<IEditorProps> = (props: IEditorProps) => {
	const editorDiv = React.useRef<HTMLDivElement>(null);
	editorDiv.current?.style.setProperty("maxHeight", "400px");
	let editor: monaco.editor.IStandaloneCodeEditor;
	React.useEffect(() => {
		if (editorDiv.current) {
			editor = monaco.editor.create(editorDiv.current, {
				value: props.defaultValue,
				language: 'PowerFX'
			});
            editor.onDidChangeModelContent(_ => {
                props.callback(editor.getValue());
            })
		}
		return () => {
			editor.dispose();
		};
	}, [props.defaultValue]);
	return <div className="Editor" ref={editorDiv}></div>;
};

 

 

 

and putting all that together:

 

 

 

import * as React from 'react';
import * as monaco from 'monaco-editor';

export interface IEditorProps {
    callback: (newvalue: string) => void;
    defaultValue: string;
}

self.MonacoEnvironment = {
	getWorkerUrl: function (_moduleId: any, label: string) {
		if (label === 'json') {
			return './json.worker.bundle.js';
		}
		if (label === 'css' || label === 'scss' || label === 'less') {
			return './css.worker.bundle.js';
		}
		if (label === 'html' || label === 'handlebars' || label === 'razor') {
			return './html.worker.bundle.js';
		}
		if (label === 'typescript' || label === 'javascript') {
			return './ts.worker.bundle.js';
		}
		return './editor.worker.bundle.js';
	}
};

monaco.languages.register({ id: 'PowerFX' });

// Register a tokens provider for the language
monaco.languages.setMonarchTokensProvider('PowerFX', {
	// Set defaultToken to invalid to see what you do not tokenize yet
	defaultToken: 'invalid',
	
	booleans: ['true', 'false'],

	operators: [
		'+',
		'-',
		'*',
		'/',
		'^',
		'%',
		'=',
		'>',
		'>=',
		'<',
		'<=',
		'<>',
		'&',
		'&&',
		'||',
		'!',
		'.',
		'@',
		';;'
	  ],

	// we include these common regular expressions
	symbols: /[=><!~?:&|+\-*\/\^%@;]+/,
	escapes: /\\(?:[abfnrtv\\"']|x[0-9A-Fa-f]{1,4}|u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8})/,
	digits: /\d+(_+\d+)*/,

	// The main tokenizer for our languages
	tokenizer: {
		root: [
			[/[{}]/, 'delimiter.bracket'],
			{ include: 'common' }
		],

		common: [
			// identifiers and keywords
			[/[a-z_$][\w$]*/, {
				cases: {
					// '@typeKeywords': 'keyword',
					// '@keywords': 'keyword',
					'@booleans': 'boolean',
					'@default': 'identifier'
				}
			}],
			[/[A-Z][\w\$]*/, 'type.identifier'],  // to show class names nicely
			// [/[A-Z][\w\$]*/, 'identifier'],

			// whitespace
			{ include: '@whitespace' },

			// delimiters and operators
			[/[()\[\]]/, '@brackets'],
			[/[<>](?!@symbols)/, '@brackets'],
			[/@symbols/, {
				cases: {
					'@operators': 'delimiter',
					'@default': ''
				}
			}],

			// numbers
			[/(@digits)[eE]([\-+]?(@digits))?/, 'number.float'],
			[/(@digits)\.(@digits)([eE][\-+]?(@digits))?/, 'number.float'],
			[/(@digits)/, 'number'],

			// delimiter: after number because of .\d floats
			[/[;,.]/, 'delimiter'],

			// strings
			[/"([^"\\]|\\.)*$/, 'string.invalid'],  // non-teminated string
			[/'([^'\\]|\\.)*$/, 'string.invalid'],  // non-teminated string
			[/"/, 'string', '@string_double'],
			[/'/, 'string', '@string_single'],
		],

		whitespace: [
			[/[ \t\r\n]+/, ''],
			[/\/\*\*(?!\/)/, 'comment.doc', '@jsdoc'],
			[/\/\*/, 'comment', '@comment'],
			[/\/\/.*$/, 'comment'],
		],

		comment: [
			[/[^\/*]+/, 'comment'],
			[/\*\//, 'comment', '@pop'],
			[/[\/*]/, 'comment']
		],

		jsdoc: [
			[/[^\/*]+/, 'comment.doc'],
			[/\*\//, 'comment.doc', '@pop'],
			[/[\/*]/, 'comment.doc']
		],

		string_double: [
			[/[^\\"]+/, 'string'],
			[/@escapes/, 'string.escape'],
			[/\\./, 'string.escape.invalid'],
			[/"/, 'string', '@pop']
		],

		string_single: [
			[/[^\\']+/, 'string'],
			[/@escapes/, 'string.escape'],
			[/\\./, 'string.escape.invalid'],
			[/'/, 'string', '@pop']
		],

		bracketCounting: [
			[/\{/, 'delimiter.bracket', '@bracketCounting'],
			[/\}/, 'delimiter.bracket', '@pop'],
			{ include: 'common' }
		],
	},
});

monaco.languages.setLanguageConfiguration('PowerFX', {
	surroundingPairs: [
		{ open: '{', close: '}' },
		{ open: '[', close: ']' },
		{ open: '(', close: ')' }
	],
	brackets: [
		['{', '}'],
		['[', ']'],
		['(', ')']
	]
});

export const Editor: React.FC<IEditorProps> = (props: IEditorProps) => {
	const editorDiv = React.useRef<HTMLDivElement>(null);
	editorDiv.current?.style.setProperty("maxHeight", "400px");
	let editor: monaco.editor.IStandaloneCodeEditor;
	React.useEffect(() => {
		if (editorDiv.current) {
			editor = monaco.editor.create(editorDiv.current, {
				value: props.defaultValue,
				language: 'PowerFX'
			});
            editor.onDidChangeModelContent(_ => {
                props.callback(editor.getValue());
            })
		}
		return () => {
			editor.dispose();
		};
	}, [props.defaultValue]);
	return <div className="Editor" ref={editorDiv}></div>;
};

 

 

 

So that's pretty much it! Now we just register this PCF on the "Command" field on our custom table's form and the text we put in there will get nice, clean syntax highlighting and colorization. It won't get us intellisense or autocomplete, but for now this is good enough to keep us going!

cchannon_1-1670355983149.png

Check back here on the community blogs in another week or so; I'll be posting more then about how the actual plugin execution is built, and feel free to check out the GitHub repo where I am building everything in this series!