Effektive Anwendung von Angular in Dynamics 365
Web Ressourcen in Dynamics 365 bestehen in der Regel aus HTML und JavaScript Dateien. Meistens sind für Anwendungsfälle simple Oberflächen mit wenig komplexem JavaScript und HTML ausreichend, um eine Anwendung bereitzustellen.
In anspruchsvolleren Projekten wird das klassische Arbeiten mit reinem HTML und JavaScript Dateien sehr schnell sehr unübersichtlich. Ein Framework wie z.B. Angular ist in diesem Fall sinnvoll, um komplexere Anforderungen effizient umzusetzen.
Im Folgendem wird dargestellt, wie eine Angular Anwendung (SPA) mit DevExtreme und Routing über Parameter effektiv in Dynamics 365 genutzt werden kann. In diesem Deep-Dive wird davon ausgegangen, dass der Leser bereits Erfahrungen mit Angular und DevExtreme besitzt.
Vorweg zwei Beispiele:
Beispiel 1 – einfaches DevExtreme Formular
In diesem Beispiel ist ersichtlich, dass auch ein Angular Menü verwendet werden kann, um innerhalb der Ressource zu navigieren.
Ein DevExtreme DataGrid mit Command Column
Außerdem ein DevExtreme Tab (oben). Die Filterspalte ist hier nicht aktiviert, ein Einsatz ist aber selbstverständlich möglich.
Anlegen eines Angular Projektes
Legen Sie ein Angular Projekt an und fügen Sie DevExtreme hinzu. In diesem Beispiel wurde ein Angular Projekt mit dem Standard von Visual Studio 2019 angelegt und DevExtreme hinzugefügt. Dies ist jedoch keine Voraussetzung für das weitere Vorgehen (siehe auch hier).
Nach dem Anlegen fügen Sie eine DevExtreme Checkbox zur Hauptseite hinzu (warum dies relevant ist wird im Folgenden erläutert)
Einzufügender Code:
home.component.ts:
export class HomeComponent {
public checkboxValue: boolean = false;
}
home.component.html:
DevExtreme Button:
appmodule.ts:
import { DxCheckBoxModule } from 'devextreme-angular';
@NgModule({
declarations: [
AppComponent,
NavMenuComponent,
HomeComponent,
CounterComponent,
FetchDataComponent
],
imports: [
BrowserModule.withServerTransition({ appId: 'ng-cli-universal' }),
HttpClientModule,
FormsModule,
RouterModule.forRoot([
{ path: '', component: HomeComponent, pathMatch: 'full' },
{ path: 'counter', component: CounterComponent },
{ path: 'fetch-data', component: FetchDataComponent },
]),
DxCheckBoxModule
],
providers: [],
bootstrap: [AppComponent]
})
Die Anwendung sollte nun wie folgt aussehen:
Zu erkennen ist die erfolgreiche Ausführung am blauen Haken in der Checkbox „DevExtreme Button“.
Routing ermöglichen
Ein funktionierendes Routing ist von Haus aus in Dynamics zunächst nicht möglich, da die Web Ressourcen nicht auf Single Page Applications ausgelegt sind. Dies ist aber durch zwei Erweiterungen zu bewerkstelligen.
Der Standard-Basispfad von Angular funktioniert in Dynamics Web Ressourcen nicht. Nach vielen versuchen hat sich die Methode aus dieser Quelle am Praktischsten erwiesen:
https://github.com/kip-dk/angular-xrm-Web Ressource
Hierzu fügen wir folgendes JavaScript in die index.html Datei hinzu:
document["ANG_BASE_URL"] = "/";
var url = window.location.href;
if (url.toLowerCase().indexOf('Web Ressources') >= 0) {
var spl = url.split('?')[0].split('/');
if (spl.length > 3) {
url = '/';
for (var i = 3; i < (spl.length); i++) {
if (spl[i] != null && spl[i] != '') {
url += spl[i] + '/'
}
}
document["ANG_BASE_URL"] = url;
}
}
Außerdem löschen wir die Zeile:
In der app.module.ts fügen wir den dynamischen Basispfad nun noch als Provider hinzu:
providers: [{ provide: APP_BASE_HREF, useValue: document["ANG_BASE_URL"] }]
Hiermit kann das Routing innerhalb von Angular (z.B. über das Menü) erfolgen. Leider funktioniert dadurch noch kein Aufruf der Routen von außerhalb. Hierzu gibt es aber auch eine Lösung.
Externes Routing über den Data Parameter
Schritt 1: lege ein neues Model für den Data Parameter an. In diesem Fall wird dies ein einfaches Model mit einer route und einem param (der an die route weitergeleitet wird)
dataParam.model.ts:
export interface dataParamModel {
route: string;
param: string;
}
In der app.component.ts fügen wir eine ngOnInit Methode hinzu. Außerdem die erforderlichen import Anforderungen sowie Konstruktorparameter:
import { dataParamModel } from './model/dataParamModel';
…
export class AppComponent implements OnInit {
…
constructor(private route: ActivatedRoute, private router: Router)
{
…
}
…
ngOnInit() {
this.route.queryParams
.subscribe(params => {
if (params != null && params.Data != null) {
console.log(params.Data);
let myParams: dataParamModel = JSON.parse(params.Data);
this.router.navigateByUrl(
this.router.createUrlTree(
[myParams.route], { queryParams: { param: myParams.param } }
)
);
}
});
}
Diese Methode wird den Data Parameter als JSON String aufnehmen und in ein dataParamModel umwandeln. Hieraus ergibt sich dann die neue Route sowie der Parameter der an die Route weitergeleitet wird.
Für einen Test werden wir nun eine Weiterleitung an die CounterComponent testen und den currentCount entsprechend setzen. Hierfür erweitern wir die counter.component.ts:
import { ActivatedRoute, Router } from '@angular/router';
…
export class CounterComponent implements OnInit {
…
constructor(private route: ActivatedRoute, private router: Router) { }
…
ngOnInit() {
this.route.queryParams
.subscribe(params => {
console.log("paramsCounter", params);
if (params != null && params.param != null) {
this.currentCount = Number(params.param);
}
});
}
…
Ob der Router sich korrekt verhält können wir nun im Debugger testen. Hierfür rufen wir folgenden link auf:
https://localhost:44332?Data={%22route%22:%22counter%22,%20%22param%22:%225%22}
(ersetzen sie gegebenenfalls den Port durch Ihren eigenen Port)
Erwartetes Ergebnis:
XRM SDK verwenden
Um nun in den Web Ressourcen auch XRM Context Funktionen wie den Zugriff auf die API zu verwenden müssen wir diesen Typen durch den Typescriptcompiler verfügbar machen.
Quelle:
https://www.inogic.com/blog/2019/01/using-angular-dynamics-365-crm-part-ii/
Wir fügen zu Index.html folgendes im Header hinzu:
""
In der package.json fügen wir folgende dependency hinzu:
"@types/xrm": "9.0.21"
Und die tsconfig.app.json ergänzen wir um folgenden Eintrag in den compileroptions:
"types": [
"xrm"
]
Nun können wir den XRM Context nutzen. Wir verwenden ihn nun, um alle Kontakte mit dem Anfangbuchstaben ‚A‘ abzurufen. Hierzu wird die FetchdataComponent modifiziert:
fetch-data.component.html:
fetch-data.component.ts:
import { Component, Inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { environment } from '../../environments/environment';
import { Contacts, ContactsReponse } from '../model/crmContactsModel';
@Component({
selector: 'app-fetch-data',
templateUrl: './fetch-data.component.html'
})
export class FetchDataComponent {
public contacts: Contacts[];
public organizationUrl: string;
constructor(private http: HttpClient) {
if (environment.production) {
this.organizationUrl = Xrm.Page.context.getClientUrl();
}
}
ngOnInit(){
if (environment.production) {
var urlToCall = this.organizationUrl + "/api/data/v8.2/contacts?$filter=startswith(fullname,%27A%27)";
this.http.get(urlToCall).subscribe(
(response: ContactsReponse) => {
this.contacts = response.value;
}
);
} else {
var data = JSON.parse(
`{
"value": [
{
"customertypecode": 1,
"address2_addresstypecode": 1,
"birthdate": "1985-02-25",
"merged": false,
"gendercode": 1,
"territorycode": 1,
"emailaddress1": "Adrian@adventure-works.com",
"haschildrencode": 1,
"preferredappointmenttimecode": 1,
"isbackofficecustomer": false,
"modifiedon": "2020-01-25T11:43:32Z",
"msdyn_orgchangestatus": 0,
"_owninguser_value": "c8215b0f-1d72-410c-a9b1-8a03a6ce83f9",
"importsequencenumber": 104,
"address1_composite": "11111 William Nicol DriveJohannesburg 2100South Africa",
"lastname": "Dumitrascu",
"donotpostalmail": false,
"marketingonly": false,
"donotphone": false,
"preferredcontactmethodcode": 1,
"educationcode": 1,
"_ownerid_value": "c8215b0f-1d72-410c-a9b1-8a03a6ce83f9",
"customersizecode": 1,
"firstname": "Adrian",
"jobtitle": "Purchasing Manager",
"yomifullname": "Adrian Dumitrascu",
"donotemail": false,
"address2_shippingmethodcode": 1,
"fullname": "Adrian Dumitrascu",
"timezoneruleversionnumber": 0,
"address1_addressid": "07f55886-efe3-4d74-bc6b-d5cce5f57b7b",
"msdyn_gdproptout": false,
"address2_freighttermscode": 1,
"statuscode": 1,
"createdon": "2017-01-20T22:39:39Z",
"versionnumber": 2287670,
"donotsendmm": false,
"donotfax": false,
"leadsourcecode": 1,
"address1_country": "South Africa",
"address1_line1": "11111 William Nicol Drive",
"creditonhold": false,
"telephone1": "768-555-0156",
"_owningbusinessunit_value": "1b13d491-373f-ea11-a813-000d3a265c7e",
"address3_addressid": "1dce3eb5-8a2a-4b5d-b85e-3fb39ad66540",
"donotbulkemail": false,
"_modifiedby_value": "c8215b0f-1d72-410c-a9b1-8a03a6ce83f9",
"followemail": true,
"shippingmethodcode": 1,
"_createdby_value": "c8215b0f-1d72-410c-a9b1-8a03a6ce83f9",
"address1_city": "Johannesburg",
"donotbulkpostalmail": false,
"contactid": "49a0e5b9-88df-e311-b8e5-6c3be5a8b200",
"participatesinworkflow": false,
"statecode": 0,
"overriddencreatedon": "2020-01-25T11:43:32Z",
"address2_addressid": "dccef157-a97a-49e0-b016-7d1102567493",
"address1_postalcode": "2100",
"spousesname": null,
"emailaddress3": null,
"address3_telephone3": null,
"mobilephone": null,
"utcconversiontimezonecode": null,
"_preferredserviceid_value": null,
"address3_shippingmethodcode": null,
"address3_postalcode": null,
"annualincome": null,
"stageid": null,
"telephone3": null,
"address1_primarycontactname": null,
"address3_city": null,
"lastonholdtime": null,
"address2_stateorprovince": null,
"lastusedincampaign": null,
"address3_freighttermscode": null,
"pager": null,
"employeeid": null,
"_parentcustomerid_value": null,
"managername": null,
"address1_name": null,
"department": null,
"address3_country": null,
"address2_telephone1": null,
"address2_primarycontactname": null,
"address2_latitude": null,
"address2_postalcode": null,
"home2": null,
"entityimage_timestamp": null,
"_originatingleadid_value": null,
"_masterid_value": null,
"_createdonbehalfby_value": null,
"address3_longitude": null,
"subscriptionid": null,
"business2": null,
"address3_county": null,
"address1_latitude": null,
"address1_freighttermscode": null,
"address3_addresstypecode": null,
"address1_longitude": null,
"address1_addresstypecode": null,
"aging90_base": null,
"address3_primarycontactname": null,
"familystatuscode": null,
"address2_utcoffset": null,
"company": null,
"telephone2": null,
"yomimiddlename": null,
"_modifiedonbehalfby_value": null,
"address3_utcoffset": null,
"aging30_base": null,
"address1_telephone3": null,
"address2_line2": null,
"creditlimit_base": null,
"address3_line1": null,
"address1_county": null,
"_createdbyexternalparty_value": null,
"entityimageid": null,
"processid": null,
"address1_telephone2": null,
"description": null,
"address1_fax": null,
"address3_line2": null,
"externaluseridentifier": null,
"callback": null,
"emailaddress2": null,
"address2_line3": null,
"managerphone": null,
"preferredappointmentdaycode": null,
"websiteurl": null,
"exchangerate": null,
"address1_telephone1": null,
"address3_composite": null,
"address3_fax": null,
"childrensnames": null,
"_owningteam_value": null,
"numberofchildren": null,
"address2_postofficebox": null,
"aging90": null,
"address3_latitude": null,
"aging60_base": null,
"_transactioncurrencyid_value": null,
"entityimage": null,
"_modifiedbyexternalparty_value": null,
"paymenttermscode": null,
"address3_name": null,
"ftpsiteurl": null,
"anniversary": null,
"address1_shippingmethodcode": null,
"_preferredsystemuserid_value": null,
"address2_telephone2": null,
"_slainvokedid_value": null,
"address3_telephone1": null,
"nickname": null,
"address1_postofficebox": null,
"_preferredequipmentid_value": null,
"assistantphone": null,
"assistantname": null,
"address2_country": null,
"aging60": null,
"_accountid_value": null,
"address2_name": null,
"onholdtime": null,
"address2_telephone3": null,
"address3_upszone": null,
"aging30": null,
"address2_upszone": null,
"address1_upszone": null,
"creditlimit": null,
"accountrolecode": null,
"salutation": null,
"suffix": null,
"traversedpath": null,
"address1_utcoffset": null,
"governmentid": null,
"address1_stateorprovince": null,
"annualincome_base": null,
"address3_stateorprovince": null,
"address2_city": null,
"address3_postofficebox": null,
"address1_line2": null,
"address2_longitude": null,
"address3_telephone2": null,
"yomifirstname": null,
"address2_line1": null,
"address2_composite": null,
"address2_county": null,
"_parentcontactid_value": null,
"address2_fax": null,
"yomilastname": null,
"fax": null,
"entityimage_url": null,
"address1_line3": null,
"_defaultpricelevelid_value": null,
"_slaid_value": null,
"middlename": null,
"address3_line3": null,
"timespentbymeonemailandmeetings": null
}
]
}`);
this.contacts = (data as ContactsReponse).value;
}
}
}
Und zu guter Letzt fügen wir bei app.module.ts noch den Import für das Datagrid hinzu:
DxDataGridModule
Nun sollte unsere FetchdataComponente im Debugger (mit Fakedaten) wie folgt aussehen:
Ob alles in Dynamics korrekt funktioniert sehen wir im nächsten Kapitel.
Bereitstellung in Dynamics 365
Zunächst müssen wir in der angular.json das Outputhashing deaktivieren. Hierfür setzen wir den wert „outputHashing“: „none“ (standard ist „all“)
Danach bauen wir die Angular Anwendung für die Produktion
Heraus kommen folgende Dateien:
Nun müssen wir noch einen kleinen Trick anwenden, um ttf und woff Dateien in Dynamics hochzuladen. Da wir diese Dateien jedoch für die Icons (wie z.B. den blauen Haken in der Checkbox auf unserer Startseite) benötigen, hängen wir an diese Dateien einfach ein .css an:
Auf make.powerapps.com und legen wir nun eine neue Solution an:
(alternativ kann eine Bestehende verwendet werden)
Innerhalb der Solution fügen wir nun eine neue Web Ressource hinzu:
Hierbei gibt es folgendes zu beachten:
- Bei allen Dateien muss der gleiche PREFIX (hier deepDive) gefolgt von einem / gefolgt von dem Dateinamen stehen.
- Bei der index.html ist das html wegzulassen
Bei den Dateien, an denen wir die Endung .css angehängt haben (ttf, woff), ist diese Endung im Dateinamen nun wegzulassen.
Bei allen anderen Dateien den exakten Namen verwenden (z.B. main-es5.js)
Wenn alle Dateien gespeichert sind und alle Änderungen veröffentlicht sind sollte die Solution nun wie folgt aussehen:
Die Web-Ressource kann nun geöffnet werden. Bedenken Sie, dass die Web Ressource ohne den letzten Slash „/“ geöffnet wird, ansonsten wird eine leere Seite angezeigt („cannot match any route“)
Also:
https://mididev.crm4.dynamics.com/Web Ressources/new_deepDive/index
Statt:
https://mididev.crm4.dynamics.com/Web Ressources/new_deepDive/index/
Achten Sie darauf, dass der Haken in der Checkbox korrekt dargestellt wird:
Nun können wir noch das externe Routing testen:
https://mididev.crm4.dynamics.com/Web Ressources/new_deepDive/index?Data={„route“:“counter“,“param“:“5″}
Und die FetchdataComponent mit den Livedaten aus Dynamics:
Wenn alles funktioniert, dann herzlichen Glückwunsch! Sie haben Ihre erste Angular Web Ressource erstellt. Nun haben Sie die Möglichkeit, das ganze Potenzial der Angular/DevExtreme Entwicklung mit den Daten aus Dynamics zu kombinieren.
Da Sie sich im Context des angemeldeten Benutzers befinden brauchen Sie sich auch weniger Gedanken um die Sicherheit machen. Der Benutzer wird in der FetchdataComponent nur Kontakte sehen, auf die er auch in Dynamics Zugriff gehabt hätte. Und das, ohne dass sich unsere Anwendung darum weiter kümmern müsste.