Mit Erschrecken musste ich feststellen, dass ich, nun bereits seit mehr als einem halben Jahr, das bloggen hier ein wenig vernachlässigt habe. Wenngleich die Umsetzung des heutigen Themas auch schon ein wenig länger her ist, so ist es meiner Meinung nach trotzdem noch aktuell. Allerdings möchte ich dazu aber noch ein wenig weiter ausholen (war ja so klar!) und einige Jahre zurück gehen:
Es begab sich nämlich zu einer Zeit, vor ungefähr 7-8 Jahren, als ich noch mit der Web-Entwicklung zu tun hatte und mich mehr als regelmäßig über JavaScript ärgern musste (und auch wollte!), so dass ich mir schwor, diese Programmiersprache danach niemals wieder auch nur anzusehen… Ob es nun an meiner vielleicht in dem Punkt beschränkten Sicht auf die Dinge oder “simpel” an der fehlenden Typgebundenheit lag, möchte ich nicht weiter ergünden. Aber: Manchmal – wenn man grauer und hoffentlich auch weiser wird - muss man seine Meinung ändern, Überzeugungen überdenken oder einfach etwas besseres benutzen, ohne die gebotenen Möglichkeiten zu verlieren.
Die Schritte zur Überzeugung
Ob nun au den Gefühl heraus, immer das Neuste haben zu müssen oder der Tatsache, dass im Update 2 für Visual Studio 2013 ein Feature Namens “TypeScript” enthalten ist, soll jeder für sich selbst entscheiden. Auf mich jedenfalls passen beide Varianten.
Wichtig in dem Zusammenhang ist allerdings primär TypeScript: Nutzung der Features von JavaScript, ohne die Probleme von JavaScript.
Ich werde allerdings nur am Rande auf TypeScript eingehen, wo ich es für sinnvoll erachte. Eigentlich sprechen die eingebundenen Quellcodes bereits Bände. Die Vorteile überwiegen meiner Meinung nach, wenn diese auch in Teilen mit ein klein wenig mehr Arbeit einhergehen, um beispielsweise die Bibliotheken aus dem Microsoft.Dynamics.NAV Namensraum einzubinden. Diese Arbeit habe ich mir nicht gemacht, sondern beschränke mich darauf, für diesen das Typsystem außer Kraft zu setzen (cast nach any). Mehr dazu im angehängten Quellcode.
Rückblickend kann ich sagen, dass die Nutzung von TypeScript in der Tat wesentlich mehr Vor- als Nachteile brachte. Die Syntax muss man sich natürlich zunächst verinnerlichen. Sicherlich ist es dazu auch notwendig, ein wenig in der Bibliothek libd.ts zu stöbern und sich auf der TypeScript Webseite umzuschauen. Aber dann geht einem alles recht schnell von der Hand und bezüglich Typfehlern gibt es keine Probleme mehr. Bevor ich aber wieder ins schwafeln komme, zurück zu Dynamics NAV.
Alle Clients bedienen
Mit den obigen Werkzeugen bewaffnet, begebe ich mich in die Welt der Dynamics NAV Control Add-Ins, welche seit Dynamics NAV 2013 R2 auf JavaScript und HTML basieren. Näheres dazu auch unter Extending Any Microsoft Dynamics NAV Client Using Control Add-ins. Wie Sie am Titel der verlinkten Webseite erkennen, können mit dem neuen Konzept sowohl der Windows- als auch der Web Client ein solches Control hosten. Ihnen stehen, unabhängig vom verwendeten Client, nun alle Möglichkeiten offen! Dazu ein Hinweis: Das alte Konzept existiert natürlich weiterhin.
Add-Ins scheinen insgesamt sehr beliebt zu sein. Speziell Drag & Drop Add-Ins schossen vor einiger Zeit wie Pilze aus dem Boden. Was liegt da näher, als genau dieses “klassische Genre” in diesem Post zu bedienen? Die Inspiration holte ich mir auf folgender Webseite: http://html5demos.com/file-api. Es handelt sich dabei um eine simple Implementierung der Browser File API. Diese erlaubt es, eine Bilddatei im Browser fallen zu lassen, läd diese hoch und zeigt sie im Anschluss an. Mit dieser Basis bewaffnet machte ich mich daran, dies auch für Dynamics NAV 2013 R2 umzusetzen.
Butter bei die Fische
Das Ziel wird sein, per Drag & Drop Dateien im Browser fallenzulassen, diese automatisch auf den Dynamics NAV Server hochzuladen und in einer Tabelle in einem BLOB-Feld zu speichern. Klingt bis dahin recht einfach, ist es aber nicht Als Startpunkt dient für die Control Add-Ins eine Manifest-Datei. Bei dieser handelt es sich um eine XML-Datei, in der alle Informationen zum Add-In zusammengeführt sind. Eine Übersicht dazu finden Sie hier: Manifest Overview.
<?xml version="1.0" encoding="utf-8"?>
<Manifest>
<Resources>
<Script>droparea.js</Script>
<StyleSheet>droparea.css</StyleSheet>
<Image>BackgroundImage.png</Image>
</Resources>
<ScriptUrls>
<ScriptUrl>http://ajax.aspnetcdn.com/ajax/jQuery/jquery-1.9.1.min.js</ScriptUrl>
</ScriptUrls>
<Script>
<![CDATA[
$(document).ready(function()
{
initializeControlAddIn('controlAddIn', Microsoft.Dynamics.NAV.GetImageResource('BackgroundImage.png'));
Microsoft.Dynamics.NAV.InvokeExtensibilityMethod('ControlAddInReady', null);
});
]]>
</Script>
<RequestedHeight>150</RequestedHeight>
<RequestedWidth>150</RequestedWidth>
<VerticalStretch>true</VerticalStretch>
<HorizontalStretch>true</HorizontalStretch>
</Manifest>
Die Manifest-Datei ist in mehrere Bereiche unterteilt. Zum einen die Ressourcen (Resources-Tag), in dem Referenzen auf JavaScript-, StyleSheet- und Bilddateien hinterlegt werden. Diese müssen in einer vorgegebenen Struktur innerhalb eines ZIP-Archivs vorliegen. Neben der Manifest.xml selbst, sollten dort die Verzeichnisse Image, Script und StyleSheet enthalten sein. In diesen liegen dann auch die referenzierten Dateien, in diesem Fall droparea.js, droparea.cs und BackgroundImage.png. Bilder müssen übrigens zwingend als PNG-Datei (Portable Network Graphics) vorliegen.
Weiterhin enthält die Manifest.xml noch eine Referenz auf JQuery (ScriptUrl) und einen Bereich “Script”, der als Einstiegspunkt für den Aufruf fungiert und zur Initialisierung dient. Alles weitere ist in obigem Link zu finden.
In diesem Fall wird die JavaScript- (bzw. TypeScript-) Funktion initializeControlAddIn aufgerufen, sobald das gesamte HTML-Dokument geladen ist. Diese erhält neben der ID des eigentlichen Add-In (controlAddIn) auch eine Referenz auf ein Bild. Bild-Referenzen werden zur Laufzeit über GetImageResource ermittelt.
function initializeControlAddIn(id: string, imageUrl: string) {
var controlAddIn: HTMLElement = document.getElementById(id);
var imageTag: string = "";
if (imageUrl != "") {
imageTag = '<p class="centeredImage"><img src="' + imageUrl + '"></p>';
}
controlAddIn.innerHTML =
'<section id="container">' +
'<article>' +
'<p id="status">Not supported</p>' +
'<div id="droparea">' + imageTag + '</div>' +
'<div id="progressbar"><div class="percent">0%</div></div>' +
'</article>' +
'</section>';
pageLoaded();
}
function pageLoaded() {
var dropArea: HTMLElement = document.getElementById('droparea');
if (typeof (<any>window).FileReader === 'undefined') {
updateStatus('', 'fail');
} else {
updateStatus('Ready!', 'success');
}
dropArea.ondragover = onDragOver;
dropArea.ondragleave = onDragLeave;
dropArea.ondragend = onDragEnd;
dropArea.ondrop = onDrop;
}
function onDragOver(e: DragEvent) {
this.className = 'hover';
returnfalse;
}
function onDragLeave(e: DragEvent) {
this.className = '';
returnfalse;
}
function onDragEnd(e: DragEvent) {
this.className = '';
returnfalse;
}
function onDrop(e: DragEvent) {
this.className = 'drop';
e.preventDefault();
// Reset progress indicator on new file selection.
updateProgress('', '', 0);
var type: string = e.dataTransfer.types[0];
if (type === "Text") {
var content: string = e.dataTransfer.getData(type);
alert(content);
}
elseif (type === "Files") {
var files: FileList = e.dataTransfer.files;
for (var i: number = 0, file: File; file = files[i]; i++) {
var upload: Upload = new Upload(file.name, file);
uploadQueue.Push(upload);
}
}
if (!uploadQueue.uploadInProgress) {
uploadQueue.StartSendData();
}
this.className = '';
returnfalse;
}
Demnach wird in der Methode initializeControlAddIn() das Element geholt, ein eventuell übergebenes Bild eingebettet und die DropArea gezeichnet. Gefolgt davon, wird die Methode pageLoaded() aufgerufen, die u.a. prüft, ob der Browser die File API unterstützt und abhängig davon den Status des Add-In setzt. Abschließend werden noch 4 Events registriert, die beim Überfahren mit der Maus das Add-In jeweils farblich anpassen, um dem Benutzer auch visuelles Feedback zu geben.
Der Upload selbst startet im Event onDrop(). Auf diese Methode möchte ich nun etwas genauer eingehen, da hier alles beginnt…
Los geht’s
Im Fall, dass Datei(en) fallen gelassen werden, lautet e.dataTransfer.types[0] == “Files”. In dem Fall wird für jede der Dateien ein neues Objekt von Typ Upload() erzeugt und dieses dem definierten uploadQueue() hinzugefügt. Handelt es sich dabei um den ersten Upload bzw. ist noch kein Upload im Gange, dann wird per uploadQueue.StartSendData() die Übertragung gestartet.
StartSendData() {
if (this.uploadQueue.length > 0) {
this.uploadInProgress = true;
var upload: Upload = this.uploadQueue[0];
updateStatus(upload.name + ' (' + this.uploadQueue.length + ')', 'success');
var reader: FileReader = new FileReader();
// Save file and upload reference for use in events
(<any>reader).upload = upload;
reader.onabort = function (event: any) {
alert('File read cancelled');
};
reader.onloadstart = function (event: any) {
updateProgress('Loading', 'loading', -1);
};
reader.onprogress = function (event: ProgressEvent) {
if (event.lengthComputable) {
var percentLoaded: number = Math.round((event.loaded / event.total) * 100);
if (percentLoaded < 100) {
updateProgress('Loading', 'loading', percentLoaded);
}
}
}
reader.onloadend = function (event: ProgressEvent) {
var data: string = (<any>event.target).result;
if (data != null) {
this.upload.data = data;
this.upload.length = data.length;
this.upload.totalLength = data.length;
}
// Ensure that the progress bar displays 100% at the end.
updateProgress('', '', 100);
Microsoft.Dynamics.NAV.InvokeExtensibilityMethod('FileDropBegin', [this.upload.name]);
}
reader.onload = function (event: any) {
};
reader.readAsDataURL(upload.file);
}
else {
this.uploadInProgress = false;
updateStatus('Ready!', 'success');
}
}
Nach dem Start wird natürlich zunächst geprüft, ob der uploadQueue überhaupt etwas zur Übertragung enthält. Ist dies der Fall, wird dies notiert (uploadInProgress = true), das erste Upload-Objekt gelesen und der Status des Controls aktualisiert. Zum eigentlichen Lesen der Dateien fungiert der FileReader(). Für die erzeugte Instanz werden (inline) die Events onabort, onloadstart, onprogress und onload definiert, die primär zur Aktualisierung der Oberfläche dienen. Das onloadend-Event selbst kümmert sich um den eigentlichen Upload. Nachdem der FileReader per reader.readAsDataURL() ausgeführt wurde, wird nach dem vollständigen Upload das onloadend-Event ausgeführt und es werden die eigentlichen Daten und auch die Länge der Daten ermittelt. Um diese Daten dann auch an Dynamics NAV zu schicken, wird InvokeExtensibilityMethod() aufgerufen. Diese Methode ist der “direkte” Rückweg zum Aufruf von Dynamics NAV-Funktionen im C/AL auf dem Server. “Direkt” steht hier in Anführungszeichen, da alle Erweiterungsmethoden jeweils asynchron verarbeitet werden.
Dazu aber später mehr. An dieser Stelle ist es notwendig, dass wir die Erläuterungen kurz unterbrechen, und klären, wie genau denn im C/AL die Erweiterungsmethoden definiert werden und wie auch die Entwicklungsumgebung davon Wind bekommt. Denn diese, wie Sie sich sicherlich denken, hat mit HTML5 und JavaScript nicht wirklich viel am Hut
Wie sag ich’s meiner Entwicklungsumgebung – Schnittstellendefinition
Die Antwort: Wie damals. Wobei “damals” dann hier Dynamics NAV 2013 meint. Es wird ein Interface definiert, in dem alle im eigentlichen JavaScript-Add-In definierten Methoden und Events beschrieben sind. Die Implementierung finden Sie im Projekt in der Datei ControlAddIn.cs.
// Name: DropAreaControl
// Token: b33780e0a1cf8256
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Microsoft.Dynamics.Framework.UI.Extensibility;
namespace DropAreaControlAddIn
{
publicdelegatevoid FileDropBeginEventHandler(string filename);
publicdelegatevoid FileDropEventHandler(string data);
publicdelegatevoid FileDropEndEventHandler();
[ControlAddInExport("DropAreaControl")]
publicinterface IDropAreaControlAddIn
{
[ApplicationVisible]
event ApplicationEventHandler ControlAddInReady;
[ApplicationVisible]
event FileDropBeginEventHandler FileDropBegin;
[ApplicationVisible]
event FileDropEventHandler FileDrop;
[ApplicationVisible]
event FileDropEndEventHandler FileDropEnd;
[ApplicationVisible]
string ReadyForData(string filename);
}
}
Das Interface beschreibt die in JavaScript implementierten Methoden (ReadyForData()) und alle im C/AL zu nutzenden Events. Und genau diese Events sind es, die Sie bei Nutzung der Methode InvokeExtensibilityMethod() dann aus dem Web auf dem Dynamics NAV Server aufrufen.
Weiter geht’s
Und hier ist es wichtig sich bewusst zu sein, dass die Events im Dynamics NAV (also FileDropBegin(), FileDrop() und FileDropEnd()) asynchron aufgerufen werden! Und genau hier, und jetzt fahre ich mit meiner Erläuterung zu InvokeExtensibilityMethod('FileDropBegin', [this.upload.name]) fort, kann es spannend werden, wenn die Datei, welche Sie fallengelassen haben, etwas größer als sehr wenige KB ist. Gehen wir davon aus, dass die Datei 5 MB groß ist und Sie alle Daten, gerne schön in kleineren Häppchen, auf den Server übertragen. Senden, senden, senden, senden…
Und dabei kommt es zum Problem, da dann zu viele Daten vom Browser, über den Web Client (auf dem Internet Information Server) an den Dynamics NAV Server übertragen werden sollen. Es ist aufgrund der Asynchronität aber nicht zwingend so, dass diese Daten auch in Echtzeit verarbeitet und gesendet werden. Dabei kann es dann passieren, dass es letztendlich an einer Stelle einfach “überläuft”.
Dabei fällt mir eine meiner Lieblingsszenen aus “Das Boot” ein: “Irgendwann is’ natürlich Schluss. Das is’ klar. Hält der Druckkörper nich’ mehr. Dann wird das Boot - vom Wasserdruck - zerquetscht.”. Nicht so bildlich vielleicht, aber genau das passiert in den Fall. Es gilt also sicherzustellen, dass wir die folgende Daten erst senden, sobald die vorhergehenden Daten vom Dynamics NAV Server erfolgreich verarbeitet wurden.
Deshalb wird das C/AL-Event DropArea::FileDropBegin(filename : Text) nur einmalig aufgerufen und an dieser Stelle auch nur der Dateiname gespeichert. Daraufhin wird in den Client (JavaScript) per ReadyForData() zurückgerufen, um zu Signalisieren, dass der Server für weitere Datenpakete bereit ist. Der Name wird im JavaScript verwendet, um den korrekten Upload zu ermitteln.
ReadyForData(name: string) {
var upload: Upload = this.FindUpload(name);
if (upload != null) {
if (upload.length > 0) {
var currentLength: number = (upload.length > 16384) ? 16384 : upload.length;
this.currentData = upload.data.substr(0, currentLength);
upload.data = (upload.length > currentLength) ? upload.data.substr(currentLength) : "";
upload.length -= currentLength;
var percentUploaded: number = Math.round(((upload.totalLength - upload.length) / upload.totalLength) * 100);
updateProgress('Uploading', 'loading', percentUploaded);
setTimeout("uploadQueue.SendData();", 10);
return;
}
}
this.currentData = null;
this.RemoveUpload(name);
updateProgress('', '', 0);
setTimeout("uploadQueue.SendData();", 10);
}
Wird dieser gefunden (FindUpload()), dann werden die nächsten 16 KB der zum Upload anstehenden Datei ermittelt und uploadQueue.SendData() erneut aufgerufen. Allerdings nicht direkt, sondern indirekt über setTimeout(), also mit einer Verzögerung von 10 Millisekunden. Aber warum? Wir müssen den Aufruf entkoppeln, damit ReadyForData() Gelegenheit hat, wieder zum Aufrufer Dynamics NAV zurückzukehren. Ansonsten könnte es (bzw. wird es) eine Aufrufkette geben, FileDropBegin() (NAV) –> ReadyForData() (JS) –> SendData() (JS) –> FileDrop() (NAV), die wiederum unerwünschte Effekte auslösen kann. Durch setTimeout() wird also entkoppelt, ReadyForData() kehrt zurück und beendet sich und einige Millisekunden später wird, ausgelöst/getriggert vom JavaScript, SendData() aufgerufen, welches wiederum das aktuelle Datenpaket von max. 16 KB Größe an Dynamics NAV sendet.
SendData() {
if (this.currentData != null) {
Microsoft.Dynamics.NAV.InvokeExtensibilityMethod('FileDrop', [this.currentData]);
}
else {
Microsoft.Dynamics.NAV.InvokeExtensibilityMethod('FileDropEnd', null);
this.StartSendData();
}
}
Das geschieht so lange, wie noch Daten vorhanden sind, also currentData != null ist. Eine 160 KB große Datei führt demnach zu 10 Aufrufen von SendData(), bis letztendlich die Methode bzw. das Dynamics NAV Event FileDropEnd() aufgerufen wird, welches die Dekodierung der empfangenen Daten und die weitere Verarbeitung durchführt. Während der Übertragung werden die ankommenden Daten in eine DotNet Variable vom Typ MemoryStream (FromMemoryStream) geschrieben. Dieser wurde beim Aufruf von FileDropBegin() geöffnet und bekommt bei jedem Aufruf die gesendeten Textdaten, gewandelt in ein Byte[]-Array angehängt. Die gesendeten Daten enthalten am Anfang einen kurzen Header der mit “data:” beginnt, welcher in der Funktion FileDrop() geprüft und eventuell herausgefiltert wird.
Nachdem die Datei komplett übertragen ist und von JavaScript aus, im Dynamics NAV dann das Event FileDropEnd() aufgerufen wird, werden alle bisher im FromMemoryStream gespeicherten Daten Base64-Dekodiert in eine neue MemoryStream-Variable namens ToMemoryStream geschrieben. Die eigentliche Dekodierung geschieht dabei in der C/AL-Funktion Base64Decode(). FileDropEnd() letztendlich, speichert den dekodierten binären Datenstrom in einem BLOB-Feld der Tabelle Drop Area File und räumt danach ein wenig auf.
Voila! Von der lokalen Festplatte, über den Browser, gesendet an den Internet Information Server (Web Client), weitergeleitet an den Dynamics NAV Server, landet die übertragene Datei letztendlich in den Untiefen einer SQL Server Datenbank. Beinahe wie von Geisterhand
Ich will selber ran
Den kompletten Quellcode inkl. Dynamics NAV-Objekten und dem Visual Studio 2013-Solution habe ich an diesen Beitrag angehängt.
Die Solution besteht aus zwei Teilen. Zum Einen das Projekt "ControlAddIn", welche auch die TypeScript-Datei (.ts) enthält. Diese wird beim kompilieren in eine JavaScript-Datei (.js) umgewandelt. Weiterhin der C#-Teil (DropAreaControlAddIn), aus welchem die DLL hervorgeht, die dann das Interface für die Entwicklungsumgebung enthält.
Um alles zu testen, entpacken Sie bitte das Archiv in ein Verzeichnis Ihrer Wahl. Zur simplen Installation, gehen Sie dann bitte wie in den folgenden Punkten beschrieben vor:
- Kopieren Sie die Datei DropAreaControlAddIn\bin\Debug\DropAreaControlAddIn.dll in das Verzeichnis C:\Program Files (x86)\Microsoft Dynamics NAV\71\RoleTailored Client\Add-ins oder alternativ in ein dort angelegtes neues Unterverzeichnis. Diese DLL enthält das oben beschriebene Interface, um die Funktionen und Events in der Dynamics NAV Entwicklungsumgebung verfügbar zu machen. Danach muss die finsql.exe neu gestartet werden.
- Markieren Sie alle Verzeichnisse und die Datei Manifest.xml in Verzeichnis ControlAddIn\AddIn. Erstellen Sie dann (beispielsweise über “Senden an”) ein neues ZIP-Archiv.
- Öffnen Sie Dynamics NAV 2013 R2, suchen Sie nach “Steuerelement-Add-Ins” und tragen Sie die folgenden Daten ein:
- Name: DropAreaControl
- Token: b33780e0a1cf8256
- Klicken Sie auf die Action “Importieren”, um die erstellte Archivdatei mit dem Add-in zu importieren.
- In der neu gestarteten Entwicklungsumgebung, öffnen Sie den Object Designer und importieren die Datei DropAreaControlAddInObjects.fob oder DropAreaControlAddInObjects.txt.
- Kompilieren Sie danach alle Objekte mit der Version “DropArea”.
Aus dem Object Designer der Entwicklungsumgebung, starten Sie nun die Page 50001 “Drop Area Files”. Alternativ öffnen Sie den Web Client: http://server:port/DynamicsNAV71/WebClient/List.aspx?company=CRONUS%20AG&mode=View&page=50001
Ich wünsche Ihnen viel Spaß mit der Implementierung und hoffe auf interessante Weiterentwicklungen!
Carsten Scholling
Microsoft Dynamics Germany
Microsoft Global Business Support (GBS) EMEA
Microsoft Connect: http://connect.microsoft.com
Online Support: http://www.microsoft.com/support
Sicherheitsupdates: http://www.microsoft.de/sicherheit
Microsoft Deutschland GmbH
Konrad-Zuse-Straße 1
D-85716 Unterschleißheim
http://www.microsoft.de