Basic Newsletter AL Extension
I’m not going to rewrite the guide for setting up your VS Code environment for Extension development, but we’ll go through the basic steps of creating your extension assuming you have a functional NAV/BC environment to work with.
In our VS Code window, Alt-A then Alt-L will trigger the AL Project Creation process, asking us where to put the extension. For sanity, I’m going to be using a folder structure like so:
Our AL Extension does not need the heaping pile of source library files that our Angular App will, so we’ll keep them separate.
After you give the Extension Creation process a folder, it will ask which server you are working with, Cloud or On-Prem. I’m using on prem, so it asks me for some authentication. Odds are pretty high your authentication will fail. You’ll see some errors in the Output Panel:
Mostly I’m getting Reason: Not Found errors and you probably will too. It defaults to looking for an instance on localhost called BC130, not the On-Prem default, DynamicsNAV130. Thankfully it drops you right to your new launch.json file. Change your Server and Server Instance as appropriate.
You may also need to update the Authentication from UserPassword to Windows, depending on your installation.
Since I’m going to be adding the jumping off point in my extension to the Segments List of the system, I’m also going to take the opportunity to change the startupObjectID from 22 (Customer List) to Page 5093 “Segment List”. Tip: If you aren’t 100% sure of the Page ID, you can usually find it in the URL:
When you hit Save on this file, you should get a pop-up that notices you don’t have your Dynamics Symbols from the server. Hit Download symbols to correct that.
If you don’t get this pop-up, press Ctrl+Shift+P and select AL: Download symbols.
You’ll probably want to update the app.json file next with info relevant to you. More details on that here. Make sure you have a strategy around your idRange with your organization/customers.
For our demo, we’ll use object range 73000 to 73050. When I save this change, my HelloWorld.al file will go red in the list, indicating a problem, but that’s ok, we’re going to delete it. Go ahead and do that now.
Quick Aside: Microsoft has some guidelines on the expected standards for Extension development. Waldo has not only useful recommendations that build on top of that, but also VS Code Extensions to support it. I’m new to this, but we’ll try to follow Microsoft’s throughout. If I was making a fully-functional Extension, with logging and setup the way we should, I would absolutely follow Waldo’s.
Now, strangely, Microsoft’s “AL Go!” we ran to create the basic folder/files? That doesn’t setup the folders to match their guidelines. It’s just a nearly empty project.
We’ll create the 3 Microsoft recommended folders, src, res, and test. We’ll create two objects, one a page, one an extension to add the button to Segments. We’re looking at this:
I personally find Pag5093-Ext73001.AddHTMLSend.al just awkward to type or retain, but I presume this is a useful naming schema at some point.
Opening up Pag73000.SendHTML.al, type tp to get the snippets to offer a template to you:
You get a lot of content here (way more than I’ll show):
Skipping ahead, we’ll have this as the content of Pag73000.SendHTML.al:
page 73000 SendHTML
{
PageType = Card;
ApplicationArea = All;
UsageCategory = Administration;
SourceTable = “Segment Header”;
layout
{
area(Content)
{
group(Editor)
{
}
}
}
actions
{
area(Processing)
{
action(SendEmail)
{
ApplicationArea = All;
trigger OnAction()
begin
Message(‘This button would send something.’);
end;
}
}
}
var
rawHTML: Text;
}
And Pag5093-Ext73001.AddHTMLSend.al:
pageextension 73001 AddHTMLSend extends “Segment List” // 5093
{
actions
{
// Add changes to page actions here
addlast(Processing)
{
action(“Send HTML Email”)
{
ApplicationArea = All;
RunPageOnRec = true;
RunObject = page “SendHTML”;
}
}
}
}
Now hitting F5 will run the solution with your extension installed and active. I’ve noticed that here and there I sometimes have to force refresh my browser to see the changes, but that’s no big deal (Ctrl+F5 and it’s fine).
Great, the page exists and is working. Now let’s do more interesting things with it.
Building the Control Add-In Wrapper
Javascript control add-ins in NAV/BC are a little strange to work with. We need a couple of JS files to start with: one that is our Startup script, and one that is our Wrapper around our Angular solution. Angular does a LOT of change detection and all sorts of magic with execution zones that NAV/BC does not know about or need to care about, so this will be a sort of API for AL to talk to Angular. We’ll also need an AL file to define the controladdin for other parts of the AL Extension to interact with.
I found the Javascript Add-In documentation a little sparse, but between a few blog posts, this seems to be the right structure to aim for based on what we have:
Open up our startup.js file and add this extensive pile of code:
init();
Microsoft.Dynamics.NAV.InvokeExtensibilityMethod(“ControlReady”,[]);
init() will be a function in our Wrapper. The 2nd line is a super important concept. This is how JavaScript fires an event to NAV. (I feel less bad about sometimes calling it NAV if they still refer to it that way in their functions.) The params are Name, Param. More on this later, but understand that we have to asynchronously fire an event at NAV from Javascript, and in NAV, we’ll have to listen for it.
Open AngularWrapper.js now and define a basic function:
function init() {
}
Now, a core understanding: Control Addins don’t have any HTML file, only what is generated at runtime by the Web Client. This means we must add HTML with Javascript. This will be a problem soon when we bring in our Angular app, which is going to check the DOM for where it should exist and the DOM is basically empty.
Next, open up HTMLEditor.al and use the tcontrolpanel snippet to get a massive pile of settings. We’ll replace that with
controladdin HTMLEditor
{
RequestedHeight = 300;
MinimumHeight = 300;
MaximumHeight = 300;
RequestedWidth = 700;
MinimumWidth = 700;
MaximumWidth = 700;
VerticalStretch = true;
VerticalShrink = true;
HorizontalStretch = true;
HorizontalShrink = true;
Scripts =
‘res/ControlAddin/Script/AngularWrapper.js’;
StartupScript = ‘res/ControlAddin/Script/startup.js’;
event ControlReady();
}
This won’t do much, but we can test that the Control Addin works by listening for the ControlReady event in AL.
Back in the Pag73000.SendHTML.al file, we can now add our usercontrol to the layout, like so:
layout
{
area(Content)
{
group(Editor)
{
/* Our new code */
usercontrol(“HTMLEditor”;HTMLEditor)
{
trigger ControlReady()
begin
Message(‘Control Addin HTMLEditor Ready’);
end;
}
}
}
}
Now if you hit F5 to Debug the solution, possibly with a browser force-refresh (Javascript is often cached), you should get this message:
Connecting the systems
OK, so, if you skipped ahead, dropped the JS and CSS files from your Angular into the Control Addin folders, added all three JS files to your AL file, ran the Extension, and looked at the Console errors, you’d see this mess:
Or more likely, you’d see this baffling error stack:
Two different errors I’ve mentioned our need to avoid.
In the former, our Angular is looking for <app-root> in the HTML, which won’t exist. The latter is because the three Angular scripts MUST load in the right order, in serial not parallel.
If we just add the following JS to init(), we can solve the first issue, right?
var div = document.getElementById(‘controlAddIn’);
div.innerHTML += ‘<app-root></app-root>’;
(Note: the autogenerated HTML has a DIV with the ID controlAddIn so we can do this sort of DOM modification.)
This adds <app-root>, so we’re done, right? No, you’ll get the same error. All the javascript scripts are run in parallel (or so immediately that it functionally is parallel). So, while init() is busy adding things, Angular is already failing.
Baffling, yes? Here’s the “help” on the Scripts Property (emphasis mine):
Although this property is optional, the control add-in must either specify the StartupScript property or specify one or more scripts. Scripts can be either external resources referenced using a URL or can be embedded within the extension. Embedded script files must be added to the extension project folder in Visual Studio Code and referenced using a relative path. For security and usability reasons, it is recommended to reference any external scripts by using the HTTPS protocol. Scripts are loaded immediately when the control add-in is initialized.
So… what precisely is the difference? One takes a list, one takes a single file – but both are executed immediately. Useless for our needs.
This means we must handle loading the scripts dynamically, in order. But, NAV/BC don’t let us load script resources. We have precisely two methods for getting anything:
- GetEnvironment – this lets us find out information like the platform, company
- GetImageResource – this gives us the URL of an image that was in our Addin Manifest as it can be found by a browser
That’s all we have. A lot of trial error and help from Netronic’s fabulous Blog Series on their similar battles and I found how to hijack the GetImageResources call to load JS files later.
First, we need to put all our files in the right place. This will get annoying to do manually and we’ll have to do this every time we build our Angular solution, so a quick batch file should help.
In VS Code, though, let’s go ahead and just Add Folder to Workspace to bring the Angular solution into the same workspace. Now in our Angular HTMLEditor folder, make a file called angularbuild.bat and add all this, updated to match your paths:
@echo off
call ng build –prod –output-hashing none
xcopy D:\Blog\BasicNewsletter\HTMLEditControl\dist\HTMLEditControl\*.js D:\Blog\BasicNewsletter\BasicNewsletterAL\res\ControlAddin\Images /E /Y /Q
xcopy D:\Blog\BasicNewsletter\HTMLEditControl\dist\HTMLEditControl\styles.css D:\Blog\BasicNewsletter\BasicNewsletterAL\res\ControlAddin\StyleSheet /E /Y /Q
Run that and you should end up with something like this in your AL ControlAddin folder:
Back in HTMLEditor.al, we need to add a weird section:
controladdin HTMLEditor
{
RequestedHeight = 300;
MinimumHeight = 300;
MaximumHeight = 300;
RequestedWidth = 700;
MinimumWidth = 700;
MaximumWidth = 700;
VerticalStretch = true;
VerticalShrink = true;
HorizontalStretch = true;
HorizontalShrink = true;
Scripts = ‘res/ControlAddin/Script/AngularWrapper.js’;
StartupScript = ‘res/ControlAddin/Script/startup.js’;
StyleSheets = ‘res/ControlAddin/StyleSheet/styles.css’,
‘https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.css’; /* far simpler to load Fontawesome from CDN, but does require net access. */
/* Super SUPER weird to have to treat javascript files like images. But here we are */
Images = ‘res/ControlAddin/Images/runtime.js’,
‘res/ControlAddin/Images/polyfills.js’,
‘res/ControlAddin/Images/main.js’;
event ControlReady();
}
Now, we still have ANOTHER weird barrier with GetImageResource – it’s not path aware! I think this may be a bug, so we’ll see if this remains necessary.
If we were to call Microsoft.Dynamics.NAV.GetImageResource(‘runtime.js’), it will return:
https://localhost/dynamicsnav130/Resources/ExtractedResources/EE418FB/ /runtime.js?_v=13.0.24623.0
So, the server extracts the Addin with the folder hierarchy intact, but GetImageResource ignores that entirely. (Aside: I have yet to see the temp folder where addins extract to ever empty out. This could be a large problem. Perhaps a defect?)
Thankfully, AL has string replace() to rescue us. Here’s the crazy nested code you’ll need to load the app in the right order:
function init() {
var div = document.getElementById(‘controlAddIn’);
div.innerHTML += ‘<app-root></app-root>’;
//Dynamically load the 3 angular Dist JS files.
var runtimeUrl = Microsoft.Dynamics.NAV.GetImageResource(‘runtime.js’);
var polyfillUrl = Microsoft.Dynamics.NAV.GetImageResource(‘polyfills.js’);;
var mainUrl = Microsoft.Dynamics.NAV.GetImageResource(‘main.js’);;
//Something is wrong with GetImageResource, so we have to manually add the subfolder name Images
runtimeUrl = runtimeUrl.replace(‘runtime.js’,‘res/ControlAddin/Images/runtime.js’);
polyfillUrl = polyfillUrl.replace(‘polyfills.js’,‘res/ControlAddin/Images/polyfills.js’);
mainUrl = mainUrl.replace(‘main.js’,‘res/ControlAddin/Images/main.js’);
var runtimeScript = document.createElement(‘script’);
runtimeScript.onload = function () {
//waiting for runtime to load beforeloading Poly
var polyfillScript = document.createElement(‘script’);
polyfillScript.onload = function() {
//waiting for poly to load before loading Main
var mainScript = document.createElement(‘script’);
mainScript.onload = function() {
//do stuff with the script as needed
};
mainScript.src = mainUrl;
document.head.appendChild(mainScript);
};
polyfillScript.src = polyfillUrl;
document.head.appendChild(polyfillScript);
};
runtimeScript.src = runtimeUrl;
document.head.appendChild(runtimeScript);
}
Now a reload of everything should finally result in:
It’s not perfect and it doesn’t DO anything yet, but if you’ve made it this far in your copy, you technically have made an Angular application work inside NAV/Business Central.
Great posts so far, would have loved to see this as a tech days session!
Thank you! That’s very kind. It is such an amazing conference, I’d have to seriously up my game to be on that level. 😀
Dear Jeremy. I am working on similar editor, but I am stucked. Can you please share a snippets of this project? Any gitHub library?
Thank you.
Thank you very much.
I’ve tried with Vue https://www.devaim.com/vue-js-en-microsoft-dynamics-nav-business-central/