SAPUI5 UploadCollection Example

Das SAPUI5-Framework bietet eine einfach zu implementierende Downloadmöglichkeit für Media-Entities eines OData-Services. Dazu kommt die UploadCollection zum Einsatz. Diese wird in Form der Klasse sap.m.UploadCollection ausgeliefert.

Voraussetzungen:

  1. Ein korrekt eingerichteter OData-Service mit einer Media-Entiy.
  2. Redefinition und korrekte Neu-Implementierung der GET_STREAM, (CREATE_STREAM für Upload) und DEFINE-Methode der Provider-Klassen.
  3. UI5-Version des Beispiels: 1.76

Media-Entity

Angenommen wir haben folgenden Aufbau unserer Media-Entity in ODataV2.

<EntityType Name="Document" m:HasStream="true" sap:content-version="1">
<Key>
<PropertyRef Name="ID"/>
</Key>
<Property Name="ID" Type="Edm.String" Nullable="false" MaxLength="32" sap:unicode="false" sap:label="ID" sap:creatable="false" sap:updatable="false"/>
<Property Name="DocumentName" Type="Edm.String" Nullable="false" MaxLength="254" sap:unicode="false" sap:label="Document Name"/>
<Property Name="DocumentType" Type="Edm.String" Nullable="false" MaxLength="128" sap:unicode="false" sap:label="Document Type"/>
<Property Name="Content" Type="Edm.String" Nullable="false" sap:unicode="false" sap:label="Content"/>
<Property Name="ArchivId" Type="Edm.String" Nullable="false" MaxLength="2" sap:unicode="false" sap:label="Archiv ID"/>
<Property Name="ArchivDocId" Type="Edm.String" Nullable="false" MaxLength="40" sap:unicode="false" sap:label="Archiv Document ID"/>
<Property Name="ArchivDocType" Type="Edm.String" Nullable="false" MaxLength="20" sap:unicode="false" sap:label="Archiv Document Typ"/></EntityType>
			

Die Voraussetzung bei dieser MediaEntity ist, dass die GET_STREAM-, CREATE_STREAM– und die DEFINE-Methode redefiniert und korrekt implementiert wurden.

Nun möchten wir eine Funktionalität in unsere Applikation einbauen, um die Dokumente aus dem DocumentSet zu downloaden und anzuzeigen. Um dies zu bewerkstelligen, benötigen wir das sap.m.UploadCollection-Control.

View-Implementierung

Zunächst muss die UploadCollection samt dazugehörigen UploadCollectionItems implementiert werden.

<UploadCollection id="detail_uploadcollection" items="{fileModel>/files}" mode="MultiSelect" uploadEnabled="false"						uploadButtonInvisible="true" selectionChange="onDocumnetSelectionChange">						<toolbar>
    <OverflowToolbar>
        <ToolbarSpacer/>
        <Button enabled="{viewModel>/downloadEnabled}" text="{i18n>btn_download}" press="onDownloadDocuments" type="Transparent"/>
        <UploadCollectionToolbarPlaceholder/>
        </OverflowToolbar>
</toolbar>
<items>
    <UploadCollectionItem documentId="{ID}" url="{path: 'ID', formatter: '.formatUrl'}" fileName="{DocumentName}" mimeType="{DocumentType}" enableEdit="false" enableDelete="false" visibleDelete="false" visibleEdit="false"/>
</items>
</UploadCollection>

Das UploadCollectionItem hat die Property „url„. Diese Property muss mit einer validen URL befüllt werden. In unserem Fall nehmen wir uns die aktuelle, auf den Service bezogene URL des Dokuments. Diese steht nicht direkt als Property in unserer Entiy, sondern muss über einen Umweg ausgelesen werden.

Wir behelfen uns hier mit einem Custom-Formatter, um die korrekte und valide URL unserer Media-Entity aufzulösen.

Eine valide URL, die auf den Steam unserer Media-Entity zeigt, wäre zum Beispiel:
https://<hostname>:<port>/sap/opu/odata/<servicename>/DocumentSet(ID=’566F112400211EDA9CD176726FCF0230′)/$value

Und genau diese URL möchten wir per Aggregation-Binding passend für jedes Dokument in die UploadCollection bekommen. Denn mit dem URL-Parameter „$value“ bekommen wir den Media-Stream unseres Dokuments.

Controller-Implementierung

Im Controller brauchen wir zunächst eine Formatter-Funktion, die bei jedem Binding auf die „url„-Property aufgerufen wird. In dieser Funktion holen wir uns die Service-Url des OData-Services, hängen unseren Schlüssel an und zusätzlich den „$value„-Parameter.

formatUrl: function (sSrId, sSeqnr) {
			let sUrl = this.getModel().sServiceUrl;
			sUrl += "/" + this.getModel().createKey("DocumentSet", {
				ID: sSrId
			});
			sUrl += "/$value";
			return sUrl;
		},

Nachdem wir nun das Code-Snippets implementiert haben, bekommen wir schon Items in unserer UploadCollection richtig angezeigt.

Würden wir jetzt auf ein Dokument klicken, würde in einem neuen Tab das Dokument geladen.

Wir möchten aber Dokumente selektieren und per Button downloaden. Hierfür bietet die UploadCollection die passende Funktion „downloadItem„.

Zuerst definieren wir den Eventhandler für das selectionChanged-Event der UploadCollection, um den Download-Button ein/auszublenden wenn wir Items selektieren. Die Sichtbarkeit wird über ein JSONModel namens ViewModel und der Property „downloadEnabled“ gesteuert.

onDocumnetSelectionChange: function (oEvent) {
			let oUploadCollection = oEvent.getSource();
			if (oUploadCollection.getSelectedItems().length > 0) {
				this._oViewModel.setProperty("/downloadEnabled", true);
			} else {
				this._oViewModel.setProperty("/downloadEnabled", true);
			}
		},

Anschließend wird noch der Eventhandler für das press-Event des Buttons benötigt. Hier werden die selektierten Items heruntergeladen.

onDownloadDocuments: function (oEvent) {
			let oUploadCollection = this.getView().byId("detail_uploadcollection"),
				aSelectedItems = oUploadCollection.getSelectedItems();
			for (var i = 0; i < aSelectedItems.length; i++) {
				oUploadCollection.downloadItem(aSelectedItems[i], true);
			}
		},

Anschließend können wir die Applikation ins ABAP-Backend deployen.

Nun können wir die selektierten Dokumente per Button-Press herunterladen und anzeigen. Hierfür nimmt sich die UploadCollection die Property „url“ des jeweiligen UploadCollectionItems um eine Download-URL zu definieren.

Wir sehen also, das UI5-Framework bietet hier schon einen leichten Weg, um den Dokumentendownload zu ermöglichen. Die Voraussetzung hierfür ist jedoch die richtige Implementierung des Backends und des OData-Services. Wenn dies jedoch sauber gemacht wurde, steht unserem Dokumentendownload nichts im Weg.

WizardStep – Nummerierung oder Überschrift ausblenden

Immer wieder stößt man auf Anforderungen im SAPUI5-Umfeld, die nicht immer mit einem „setProperty“ zu lösen sind, so auch diese Anforderung:

Nummerierung ausblenden

Aus welchen Gründen auch immer fordert der Kunde die Nummerierung bei den WizardSteps auszublenden. Ein jeder SAPUI5-Entwickler weiß, dass dies, nicht wie bei den nextStep-Buttons, „out of the box“ funktioniert.

Diese Nummer möchten wir bei den Überschriften ausblenden

Wenn wir jetzt über die DeveloperTools anschauen wie die Übeschriften der einzelnen WizardSteps aufgebaut sind, dann können wir erkennen, dass diese Nummer vom Framework zur Laufzeit berechnet und eingefügt werden:

Ausschnitt aus den DeveloperTools

Da sehen wir, dass in der CSS-Klasse „.sapMWizardStepTitle::before“ im „content“ die Nummerierung eingefügt wird.

Jetzt müssen wir nur mehr diese CSS-Klasse in unserem SAPUI5-Projekt überschreiben (webapp/css/style.css):

.sapMWizardStepTitle::before{
     content: ""!important;
}

Wir machen jetzt nichts anderes, als die Nummer mit einem leeren String zu überschreiben. Das ist das Ergebnis:

WizardStep ohne Nummerierung

Überschriften ausblenden

Genau das gleiche wollen wir auch mit den Überschriften der einzelnen WizardSteps machen.

Wenn wir jetzt einfach die „title-Properties“ in den einzelnen WizardSteps leer lassen würden, dann würde man im Header auch keine Überschriften sehen:

„title-Property“ in WizardSteps leer setzen
Keine Überschriften in den Steps, aber auch keine im WizardHeader

Das wollen wir natürlich verhindern, wir möchten lediglich die Überschriften in den einzelnen Steps ausblenden.

Da wir jetzt wissen, dass es eine CSS-Klasse „.sapMWizardStepTitle“ gibt, so können wir diese einfach ausblenden:

.sapMWizardStepTitle{
     display: none!important;
}

Und als Ergebnis haben wir jetzt unsere Überschriften in den einzelnen Steps ausgeblendet, aber im Header sind diese noch da:

Überschriften ausgeblendet

SAPUI5 Fragment als Dialog öffnen

In SAPUI5 dient ein Fragement der Kapselung und Erhöhung der Wiederverwendbarkeit von Sourcecode. Die Fragment Klasse liegt im sap.ui.core Namespace. Daher muss dieser in der FragmentDefinition deklariert werden. Das Fragment wird in eine eigene Datei mit der Endung .fragment.xml gepackt.

<core:FragmentDefinition xmlns="sap.m" xmlns:core="sap.ui.core" xmlns:smartFilterBar="sap.ui.comp.smartfilterbar"
	xmlns:smartTable="sap.ui.comp.smarttable" xmlns:html="http://www.w3.org/1999/xhtml"
	xmlns:app="http://schemas.sap.com/sapui5/extension/sap.ui.core.CustomData/1">
	<Dialog id="employeeDialog" title="{i18n>employee.fragment.title}" contentWidth="100%" contentHeight="100%">
		<content>
			<smartTable:SmartTable id="LineItemsSmartTable" entitySet="Employee" smartFilterId="smartFilterBar" tableType="ResponsiveTable"
				useExportToExcel="false" useVariantManagement="false" useTablePersonalisation="true" showRowCount="true"
				persistencyKey="SmartTableAnalytical_Explored" enableAutoBinding="true" app:useSmartField="true" vclass="sapUiResponsiveContentPadding"></smartTable:SmartTable>
		</content>
		<endButton>
			<Button text="{i18n>employee.fragment.btnClose}" press="onCloseDialog"/>
		</endButton>
	</Dialog>
</core:FragmentDefinition>

Das Fragment kann im JavaScript, wie in folgendem Code Snippet dargestellt, im Dialog per lazy-loading geladen und anschließend geöffnet werden.

1. Variante

onOpenDialog: function(){
	var oView = this.getView();
	if (!this.byId("employeeDialog")) {
		sap.ui.core.Fragment.load({
			id: oView.getId(),
			name: "at.clouddna.demo.DemoApp.view.EmployeeFragment",
			controller: this
		}).then(function (oDialog) {
			jQuery.sap.syncStyleClass(oView.getController().getContentDensityClass(), oView, oDialog);
			oView.addDependent(oDialog);
			oDialog.open();
		}.bind(this));
	} else {
		this.byId("employeeDialog").open();
	}
}
onCloseDialog: function () {
	this.byId("employeeDialog").close();
	//this.byId("employeeDialog").destroy();
}

2. Variante

_getDialog: function () {
	// create dialog lazily
	if (!this._oDialog) {
		// create dialog via fragment factory
		this._oDialog = sap.ui.xmlfragment("dlgEmployee", "at.clouddna.demo.DemoApp.view.EmployeeFragment", this);
		// connect dialog to view (models, lifecycle)
		this.getView().addDependent(this._oDialog);
	}
	return this._oDialog;
},
onOpenDialog: function () {
	this._getDialog().open();
},
onCloseDialog: function () {
	this._getDialog().close();
}

Controller getriggertes Aggregation Binding

In SAPUI5 / Fiori Apps ist es immer wieder erforderlich dass Tabellen oder Listen initial leer angezeigt werden und erst nachdem beispielsweise eine Selektion durchgeführt wurde die Daten geladen werden. Folgendes Code Snippet zeigt wie sich diese Anforderung anhand einer sap.m.Table umsetzen lässt. In der View muss die items Aggregation auf „{}“ gesetzt werden. Damit werden keine Daten vom Server geladen. Der Sourcecode der View sieht wie folgt aus:

<Table id="employeeTable"  growing="true" growingThreshold="10" items="{}" mode="None">
	<columns>
		<Column id="colName">
			<Text text="{i18n>employeeTable.name.title}"/>
		</Column>
		<Column id="colEmail">
			<Text text="{i18n>employeeTable.email.title}"/>
		</Column>
		<Column id="colAction">
			<Text text="{i18n>employeeTable.action.title}"/>
		</Column>
	</columns>
	<items>
		<ColumnListItem>
			<cells>
				<Label text="{employee>Name}"/>
			</cells>
			<cells>
				<Label text="{employee>Email}"/>
			</cells>
			<cells>
				<Button icon="sap-icon://delete" press="onDeleteEmployee" />
			</cells>
		</ColumnListItem>
	</items>
</Table>

Im Controller laden wir im ersten Schritt den Inhalt der Template Property der items Aggregation. Danach können Filter und Sorter optional gesetzt werden. Es ist zwingend erforderlich die BindingInfo mit Pfad, Template, Filtern und Sortern zu setzen. Abschließend wird das AggregationBinding auf die items Aggregation durchgeführt. Danach werden die Daten in der Tabelle der BindingInfo entsprechend angezeigt. Folgendes Code Snippet zeigt den relevanten Ausschnitt aus der Controller Implementierung:

var sParameter = "SAP";
var _oTable = this.getView().byId("employeeTable");
var oTemplate = _oTable.getBindingInfo("items").template;
var oFilter = new sap.ui.model.Filter({
	path: "Department",
	operator: sap.ui.model.FilterOperator.EQ,
	value1: sParameter
});
var aFilters = [oFilter];
var oBindingInfo = {
	path: "employee>/Employee",
	template: oTemplate,
	filters: aFilters
};
_oTable.bindAggregation("items", oBindingInfo);

Pattern Matched Event

SAP bietet mit Fiori ein geniales Erweiterungskonzept. Dieses setzt jedoch voraus, dass der Entwickler bestimmte Vorgaben einhält. So ist es beispielsweise empfehlenswert keine Logik in die onInit Lifecycle-Methode zu legen. Stattdessen sollte man sich auf den PatternMatched-Event registrieren und im zugehörigen Event-Handler die Initialisierung durchführen. Folgendes Code Snippet zeigt die Umsetzung:

onInit: function () {
	//Register the event handler for the Pattern Matched Event
	this.getRouter().getRoute("Main").attachPatternMatched(this.onPatternMatched, this);
},
onPatternMatched: function (oEventArgs) {
	//Here goes the Source Code doing all the initialization work
},

Es können mehrere Routen auf die gleiche View verweisen und so könnte man auch für jede Route einen eigenen PatternMatched-Event-Handler registrieren.

Ein Beispiel dafür wäre, dass auf Detail-View einmal im Bearbeitungsmodus und einmal im Display-Only-Modus navigiert wird:

onInit: function () {
	this.getRouter().getRoute("DetailDisplay").attachPatternMatched(this.onPatternMatchedDisplay, this);
	this.getRouter().getRoute("DetailChange").attachPatternMatched(this.onPatternMatchedChange, this);
},
onPatternMatchedDisplay: function (oEventArgs) {
	this.bEdit = false;
	this.onPatternMatchedGeneral(oEventArgs);
},
onPatternMatchedChange: function (oEventArgs) {
	this.bEdit = true;
	this.onPatternMatchedGeneral(oEventArgs);
},
onPatternMatchedGeneral: function(oEvent){
	if(this.bEdit){
		...
	}else{
		...
	}
}