Technical Articles
My journey towards using UI5 UploadSet with CAP backend
Introduction
CAP is capable of storing media data. But does it work with UI5 upload controls such as UploadSet(*)?
The answer to above question is yes. However, UploadSet doesn’t work out-of-the-box with CAP so you need to write some code to make it adapt to CAP.
The following are the challenges when you try to integrate UploadSet with CAP.
- CAP expects to receive media data with PUT request, while UploadSet sends media data by POST request by default.
- In order to open a file (picture) in UploadSet, it has to be fetched in blob, which doesn’t happen naturally with UploadSet.
The good news is, you don’t have to develop a custom control to upload / download files to / from CAP.
UploadSet has several “hooks” for your own logic and with the help of those hooks, you can integrate UploadSet with CAP.
In this blog I’m going to develop a CAP service (Odata v4) and a simple UI5 app with UpaloadSet.
The code is available at the following GitHub repository.
https://github.com/miyasuta/cap-media
* UploadSet is a successor of UploadCollection, as UploadCollection was deprecated as of UI5 version 1.88.
Development
- CAP project
- UI5 app
- Uploading files
- Downloading files
- Set meaningful names to downloaded files (optional)
- Show file icons (optional)
* The optional steps are not directly related to UploadSet + CAP integration, but rather to improve the appearance of the app.
1. CAP project
1.1. db/data-model.cds
Important annotations are below.
@Core.MediaType
: Indicates that the element contains media data. The value of this annotation is either a string with the contained MIME type or is a path to the element that contains the MIME type.
@Core.IsMediaType
: Indicates that the element contains a MIME type
namespace miyasuta.media;
using {
cuid,
managed
} from '@sap/cds/common';
entity Files: cuid, managed{
@Core.MediaType: mediaType
content: LargeBinary;
@Core.IsMediaType: true
mediaType: String;
fileName: String;
size: Integer;
url: String;
}
1.2. srv/media-service.cds
There’s nothing special here. Just exposing Files entity to the service.
using { miyasuta.media as db } from '../db/data-model';
service Attachments {
entity Files as projection on db.Files
}
1.3. srv/media-service.js
I’ve implemented create handler for filling download URL (just for convenience of the UI).
module.exports = async function () {
this.before('CREATE', 'Files', req => {
console.log('Create called')
console.log(JSON.stringify(req.data))
req.data.url = `/attachments/Files(${req.data.ID})/content`
})
}
2. UI5 app
2.1. Uploading files
First, let’s focus on how to upload files.
manifest.json
The CAP OData service is added as datasource with path “/attachments”.
{
"_version": "1.32.0",
"sap.app": {
...
"dataSources": {
"mainService": {
"uri": "/attachments/",
"type": "OData",
"settings": {
"odataVersion": "4.0",
"localUri": "localService/metadata.xml"
}
}
},
},
...
"sap.ui5": {
...
"models": {
"i18n": {
"type": "sap.ui.model.resource.ResourceModel",
"settings": {
"bundleName": "miyasuta.attachments.i18n.i18n"
}
},
"": {
"type": "sap.ui.model.odata.v4.ODataModel",
"settings": {
"synchronizationMode": "None",
"operationMode": "Server",
"autoExpandSelect": true,
"earlyRequests": true,
"groupProperties": {
"default": {
"submit": "Auto"
}
}
},
"dataSource": "mainService"
}
},
View
Let’s first examine UploadSet’s default upload behavior. To do that, I’ve created the following view, without event handlers for UploadSet.
<mvc:View
controllerName="miyasuta.attachments.controller.App"
xmlns:mvc="sap.ui.core.mvc"
displayBlock="true"
xmlns="sap.m"
xmlns:upload="sap.m.upload"
>
<App id="app">
<pages>
<Page
id="page"
title="{i18n>title}"
>
<upload:UploadSet
id="uploadSet"
instantUpload="true"
uploadEnabled="true"
uploadUrl="/attachments/Files"
items="{
path: '/Files',
parameters: {
$orderby: 'createdAt desc'
},
templateShareable: false}"
>
<upload:toolbar>
</upload:toolbar>
<upload:items>
<upload:UploadSetItem
fileName="{fileName}"
mediaType="{mediaType}"
url="{url}"
enabledEdit="false"
visibleEdit="false"
openPressed="onOpenPressed"
>
<upload:attributes>
<ObjectAttribute
title="Uploaded By"
text="{createdBy}"
active="false"
/>
<ObjectAttribute
title="Uploaded on"
text="{createdAt}"
active="false"
/>
<ObjectAttribute
title="File Size"
text="{size}"
active="false"
/>
</upload:attributes>
</upload:UploadSetItem>
</upload:items>
</upload:UploadSet>
</Page>
</pages>
</App>
</mvc:View>
The app looks like this.
when you upload a file, you’ll see an error below in the backend console. Here you find that data has been sent by POST request.
[cds] - POST /attachments/Files
[cds] - DeserializationError: No payload deserializer available for resource kind 'ENTITY' and mime type 'image/png'
To fix this, make the following changes to the UploadSet properties.
- Set instantupload to “false” to prevent the default upload behavior
- Remove uploadUrl, because we need to set this dynamically after receiving the entity’s key.
- Add event handler for afterItemAdded event. We’ll be uploading a file here.
<upload:UploadSet
id="uploadSet"
instantUpload="false"
uploadEnabled="true"
afterItemAdded="onAfterItemAdded"
uploadCompleted="onUploadCompleted"
items="{
path: '/Files',
parameters: {
$orderby: 'createdAt desc'
},
templateShareable: false}"
>
Controller
The following is the initial state of the controller.
In the method onAfterItemAdded, we first create a new entity of File (method: _createEntity
).
After receiving the entity’s key, we then construct an URL and upload a file by PUT request (method: _uploadContent
).
sap.ui.define([
"sap/ui/core/mvc/Controller",
"sap/m/MessageToast"
],
function (
Controller,
MessageToast
) {
"use strict";
return Controller.extend("miyasuta.attachments.controller.App", {
onInit: function () {
},
onAfterItemAdded: function (oEvent) {
var item = oEvent.getParameter("item")
this._createEntity(item)
.then((id) => {
this._uploadContent(item, id);
})
.catch((err) => {
console.log(err);
})
},
onUploadCompleted: function (oEvent) {
var oUploadSet = this.byId("uploadSet");
oUploadSet.removeAllIncompleteItems();
oUploadSet.getBinding("items").refresh();
},
onOpenPressed: function (oEvent) {
// to be implemented
},
_createEntity: function (item) {
var data = {
mediaType: item.getMediaType(),
fileName: item.getFileName(),
size: item.getFileObject().size
};
var settings = {
url: "/attachments/Files",
method: "POST",
headers: {
"Content-type": "application/json"
},
data: JSON.stringify(data)
}
return new Promise((resolve, reject) => {
$.ajax(settings)
.done((results, textStatus, request) => {
resolve(results.ID);
})
.fail((err) => {
reject(err);
})
})
},
_uploadContent: function (item, id) {
var url = `/attachments/Files(${id})/content`
item.setUploadUrl(url);
var oUploadSet = this.byId("uploadSet");
oUploadSet.setHttpRequestMethod("PUT")
oUploadSet.uploadItem(item);
}
});
});
Now, if you upload an image file, you’ll see it successfully uploaded.
The screenshot below is the result of GET request from Postman. So far, so good!
URL: http://localhost:4004/attachments/Files(<uuid>)/content
But what if you click the link on the item?
A new browser tab opens with black screen with a small white square in the middle. This is NOT what I’ve uploaded (or, it is supposed to look)!
2.2. Downloading files
To fix above issue, we’ll implement onOpenPressed method and overwrite the default behavior. To open a picture properly, we need to specify response type as blob (see method: _download
).
onOpenPressed: function (oEvent) {
oEvent.preventDefault();
var item = oEvent.getSource();
this._download(item)
.then((blob) => {
var url = window.URL.createObjectURL(blob);
//open in the browser
window.open(url);
})
.catch((err)=> {
console.log(err);
});
},
_download: function (item) {
var settings = {
url: item.getUrl(),
method: "GET",
xhrFields:{
responseType: "blob"
}
}
return new Promise((resolve, reject) => {
$.ajax(settings)
.done((result, textStatus, request) => {
resolve(result);
})
.fail((err) => {
reject(err);
})
});
},
As a result, the image is shown in the browser correctly.
2.3. Set meaningful names to downloaded files (optional)
While images, pdf files and text files are opened in a new browser tab, other types of files such as Word or Excel are downloaded to PC (I haven’t tested all file types).
Downloaded files get random guids as file name. Can we make the file names more meaningful ones?
To achieve this, I’ve changed the onOpenPressed method to download files, instead of using window.open() method. The following is the revised code.
onOpenPressed: function (oEvent) {
oEvent.preventDefault();
var item = oEvent.getSource();
this._fileName = item.getFileName();
this._download(item)
.then((blob) => {
var url = window.URL.createObjectURL(blob);
// //open in the browser
// window.open(url);
//download
var link = document.createElement('a');
link.href = url;
link.setAttribute('download', this._fileName);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
})
.catch((err)=> {
console.log(err);
});
},
As a result, downloaded files get their original names.
2.4. Show file icons (optional)
You might remember, that when you used UploadCollection, you would see file icons to the left of file name as shown below.
Although UploadSet seems to reserve space for icons, icons are not displayed.
I found a similar case here, which hasn’t been resolved yet.
https://answers.sap.com/questions/13293377/uploadset-not-showing-icons.html
My workaround is to use thumbnailUrl property with formatter. In the formatter, I’ve set an icon URL according to mimeType. But I feel icons should be shown without writhing such code.
If someone knows a better way, please let me know in the comment section below.
<upload:UploadSetItem
fileName="{fileName}"
mediaType="{mediaType}"
url="{url}"
thumbnailUrl="{
path: 'mediaType',
formatter: '.formatThumbnailUrl'
}"
...
>
This is formatter code.
formatThumbnailUrl: function (mediaType) {
var iconUrl;
switch (mediaType) {
case "image/png":
iconUrl = "sap-icon://card";
break;
case "text/plain":
iconUrl = "sap-icon://document-text";
break;
case "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":
iconUrl = "sap-icon://excel-attachment";
break;
case "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
iconUrl = "sap-icon://doc-attachment";
break;
case "application/pdf":
iconUrl = "sap-icon://pdf-attachment";
break;
default:
iconUrl = "sap-icon://attachment";
}
return iconUrl;
}
Finally, icons are displayed.
Conclusion
- You can use UploadSet to upload / download files to / from CAP OData service.
- To make UploadSet work with CAP, you need to:
- send contents by PUT request, instead of POST request
- downloaded contents in blob format
References
- The following GitHub issue posted by Gregor Wolf describes issue regarding sap.ui.unified.FileUploader with CAP OData service (v4) as of 2019.
- The following GitHub repository by Volker Buzek helped me implementing upload functionality.
Excellent Blog!!
Where are the files getting stored, is it using BTP object store.
Hi Sarath Raj,
Thanks for your comment.
The files are stored in a HANA Cloud Table. I'm not using Object Store.
Fantastic blog post Mio Yasutake !!
I was in fact looking for a sample of UploadSet with CAP backend because UploadCollection is already deprecated, and your blog post really has done that.
Just a minor note, that the current sample from your GitHub repo didn't work right away, and I have to fix those minor issues before seeing things in action.
Hi Jhodel Cailan,
Thanks for your nice comment !
I've checked the Git repository and updated it.
Hi I am facing issue with .msg files. Does it support .msg ?
Thank you for your blog. It helped me implementing file upload in a CAP application.
While your code is great for a 'Files' entity in the root of the project, I had to tweak it slightly for a 'Files' entity that is an association of a Parent entity (e.g. upload files for a specific object). In that case, the path of the Items in the UploadSet will be relational to the parent object ("path: 'Files' " instead of "path: '/Files' "), but this prohibits the use of the Refresh() command after a successful upload.
The approach I took was to invoke a bound action on the parent object, with a sideEffect to refresh the associated Files entity. I'm just sharing the approach here in case others are looking for a solution to this.
The controller:
With the bound action specifying the sideEffects (the handler function is empty as we only need the side effect):
Hello Willem,
thank you for the solution.
Can you share how you have implemented in the service.cds the refreshFiles() action for reading parent and associated entity to return and which component it is necessary to add in the Controller in order to not get "this.base.editFlow" undefined error message ?
Also, I am facing problems in filtering the list of the files in the UploadSet items. Any kind of filter is not working (for examples /Files?$filter=ID eq 10) and all the files in the Files entity are always returned.
Regards,
Massimiliano.
Hi Massimiliano,
The service handler is just an empty function, as the Side Effect will handle the UI refresh:
For the controller you have to use a Controller Extension.
App Manifest:
Which allows you to extend the controller via the fileUpload.controller.js file:
Hope this helps.
Hello Willem,
I solved by adding the ControllerExtensions, thank you.
Also solved the filtering by implementing the List binding of the UploadSet "items" in the
Bye
Hello Willem PARDAENS. I am doing the same thing but getting issues while downloading the file from the ui5 app.
I was trying the same within the ui5 application using oData v2 and I am using association in my cap service. I am able to successfully upload the file but while downloading it is giving me an empty value. Below is the service after uploading the file. As you can see the media_src is providing me the attachment URL. When I try to open it, I am getting a blank screen in the browser. I am attaching the service and associated entities as well.
Can you suggest what I am missing?
AssociatedEntities
Hi Rohit,
Couple of points:
Hope this helps
Hello Willem,
Thanks for the input. Below is my response
Entites
Thanks
Hi Rohit,
My point about the Key was that you only have a single key, which is the key of the parent. If you were to upload a second photo, this key will no longer be unique so it will fail. I suggest you add a UUID.
The fileSize, ok I understand, though it is a bit weird that your size reads as 0.171 as this represents the number of bytes. Better would be to use ".getFileObject().size"
Regarding the download, i'm using "item.getUrl()" to get the download path, though I see in your sample data the url property is not set.
I suggest you take it step-by-step and do some debugging per line to see where your code deviates from the expectations, and then we can discuss that in specifc.
Sure Willem.
Thanks a lot for your quick and valuable input!