Mathias Osterkamp

Specialist – focus development Microsoft technology stack

PowerApps TimeTracking for SevDesk Part 2

How to build a custom connector for SevDesk and use it inside a canvas PowerApp.

This article continues with technical details about my first part PowerApps TimeTracking for SevDesk. You have full access to the source code inside Office 365 PowerApps.

Custom connector

In general in PowerApps you need a data source for every app. This can be done by custom entities or with connectors. O365 brings you a lot of default connectors, if you have a third party system like SevDesk, you can create your own connector. Connectors can be created in our solution folder (this solution helps just to package our app and the connector) or directly within Data > Custom Connectors.

You start with title, a description and your host url. To make it easier you can add the api/v1 to your basic url. It is attached to our host url.

General

On next step you choose your security option. SevDesk supports API-Key. You can get an overview here: hilfe.sevdesk.de/knowledge/sevdesk-rest-full-api

We choose the option API-Key and token for query. By running your connector, you are asked for your key. It is stored in a save connection and the connector will append this as hidden parameter to every request.

Security

Your definition hold your request actions. I created the actions from official swagger documentation (app.swaggerhub.com/apis/sevDesk) first with postman. Afterwards i imported the statements from postman into the connector.

You should work with parameters and also add a sample response. PowerApps automatically adds these parameters to your preview and you can work with later.

Definition

A new smart option is to use the Swagger-Editor, you can enable this on top. Every parameter and response is visible here. Here a small sample:

/ContactTimeTracking/Query/getAggregatedContactData:
    get:
      responses:
        default:
          description: default
          schema:
            type: object
            properties:
              fromApiCache: {type: boolean, description: fromApiCache}
              objects:
                type: object
                properties:
                  objects:
                    type: array
                    items:
                      type: object
                      properties:
                        contact:
                          type: object
                          properties:
                            objectName: {type: string, description: objectName}
                            id: {type: string, description: id}
                            name: {type: string, description: name}
                          description: contact
                        date: {type: string, description: date}
                        duration: {type: string, description: duration}
                        quantity: {type: string, description: quantity}
                        trackings: {type: integer, format: int32, description: trackings}
                        used_trackings: {type: integer, format: int32, description: used_trackings}
                        sumGross: {type: string, description: sumGross}
                        sumTax: {type: string, description: sumTax}
                        sumNet: {type: string, description: sumNet}
                    description: objects
                  total: {type: integer, format: int32, description: total}
                  emptyState: {type: boolean, description: emptyState}
                description: objects
      summary: TimeTrackingAggregatedContactData
      parameters:
      - {name: embed, in: query, required: false, type: string, default: 'contact,contact.parent,part'}
      - {name: limit, in: query, required: false, type: integer, default: 50}
      - {name: offset, in: query, required: false, type: integer, default: 0}
      operationId: TimeTrackingAggregatedContactData
      description: TimeTrackingAggregatedContactData

The last step is to create your connector, create a connection and test your operation.

Test

Post requests application/x-www-form-urlencoded

Unfortunately SevDesk does not support post JSON requests for every type of request. We have to make a workaround. You have to create default request with a single body parameter. The body string we will build in our app and we do a post request with this content.

/ContactTimeTracking/Factory/saveTrackedEvents:
    post:
      summary: Create a time tracking
      description: Create a time tracking
      operationId: CreateATimeTracking
      consumes: [application/x-www-form-urlencoded]
      produces: [application/json]
      parameters:
      - name: body
        in: body
        required: false
        schema: {type: string, title: bodycontent}
      responses:

Further more you need to add a special policy. It makes sure, that your request header is Content-Type:application/x-www-form-urlencoded

Test

SevDesk swagger definitions

Here you can find two swagger definitions for SevDesk:

Canvas App

The canvas app is straight forward PowerApps default stuff. We define the data loading (in our case) on the OnVisible of the login/start screen. We just have to add our connector at the data pane and can access it from code. For example SevDesk.Contacts({}).objects, it queries the connector and write the result (with ClearCollect) into a separate collection.

Visible

You can have a look at your collections (after data are loaded)

collections

You will also see on the ScreenForm > btnSave, our application/x-www-form-urlencoded encoded request. It is a little bit ugly but simple creates a content string with all needed parameters.

Set(HttpMessage;Concatenate("trackings%5B0%5D%5Bcreate%5D=";If(isnew;"true";"null");"&trackings%5B0%5D%5Bupdate%5D=";If(isnew;"null";"true");"&trackings%5B0%5D%5BsevClient%5D=null&trackings%5B0%5D%5Bcontact%5D%5Bid%5D=";cbContacts.Selected.id;"&trackings%5B0%5D%5Bcontact%5D%5BobjectName%5D=Contact";If(IsBlank(cbProjects.Selected.id);"&trackings%5B0%5D%5Bproject%5D=null";Concatenate("&trackings%5B0%5D%5Bproject%5D%5Bid%5D=";cbProjects.Selected.id;"&trackings%5B0%5D%5Bproject%5D%5BobjectName%5D=Project"));"&trackings%5B0%5D%5Bpart%5D%5Bid%5D=";cbParts.Selected.id;"&trackings%5B0%5D%5Bpart%5D%5BobjectName%5D=Part&trackings%5B0%5D%5Bemployee%5D%5Bid%5D=";cbEmployee.Selected.id;"&trackings%5B0%5D%5Bemployee%5D%5BobjectName%5D=SevUser&trackings%5B0%5D%5Btracking%5D=null&trackings%5B0%5D%5BinvoicePos%5D=null&trackings%5B0%5D%5Bdate%5D=";Text(DateDiff(Date(1970;1;1);dtDate.SelectedDate;Milliseconds)/1000);"&trackings%5B0%5D%5Bstatus%5D=null&trackings%5B0%5D%5Bbillable%5D=";Text(cbBillable.Value);"&trackings%5B0%5D%5Bprecision%5D=PT1M&trackings%5B0%5D%5Bquantity%5D=null&trackings%5B0%5D%5BtaxRate%5D=19&trackings%5B0%5D%5BhourlyGross%5D=";Substitute(Text(Value(txtNetHourPrice.Text )*Value(txtTax.Text)/100+Value(txtNetHourPrice.Text));",";".");"&trackings%5B0%5D%5BhourlyTax%5D=";Substitute(Text(Value(txtNetHourPrice.Text )*Value(txtTax.Text)/100);",";".");"&trackings%5B0%5D%5BhourlyNet%5D=";Text(Value(txtNetHourPrice.Text;"de-DE");"[$-de-DE]###.##");"&trackings%5B0%5D%5BsumGross%5D=null&trackings%5B0%5D%5BsumTax%5D=null&trackings%5B0%5D%5BsumNet%5D=null&trackings%5B0%5D%5BusedAt%5D=null&trackings%5B0%5D%5Bdescription%5D=";txtDescription.Text;"&trackings%5B0%5D%5BobjectName%5D=ContactTimeTracking&trackings%5B0%5D%5Btypes%5D=%5Bobject+Object%5D&trackings%5B0%5D%5Bid%5D=";If(isnew;"null";id);"&trackings%5B0%5D%5BmapAll%5D=true&trackings%5B0%5D%5Bduration%5D=null&durations=%5B%7B%22unit%22%3A%22date_interval%22%2C%22value%22%3A%22";Substitute(txtQuantityHour.Text;":";"%3A");"%22%7D%5D";If(IsBlank(cbProjects.Selected.id);"";"&projects=null");If(IsBlank(cbProjects.Selected.id);Concatenate("&projects%5B0%5D%5Bcreate%5D=null&projects%5B0%5D%5Bupdate%5D=null&projects%5B0%5D%5BsevClient%5D=null&projects%5B0%5D%5Bcontact%5D=null&projects%5B0%5D%5Bname%5D=";cbProjects.SearchText;"&projects%5B0%5D%5BobjectName%5D=Project&projects%5B0%5D%5Btypes%5D=%5Bobject Object%5D&projects%5B0%5D%5Bid%5D=null&projects%5B0%5D%5BmapAll%5D=true");"");"&parts%5B0%5D%5BobjectName%5D=Part&parts%5B0%5D%5BmapAll%5D=true"));;SevDesk.CreateATimeTracking({body:HttpMessage});;Navigate(ScreenSuccess;ScreenTransition.Cover);;If(IsBlank(cbProjects.Selected.id);ClearCollect(Projects;SevDesk.Projects({}).objects));;

Filter and expand collections

I also had the problem, if you like to search your entries, it is done in your list control. You define a collection and make a search request on a special column. In this case it is called “name”.

SortByColumns(If(IsBlank( txtSearchContact.Text);ContactQuery;Search(ContactQuery; txtSearchContact.Text;"name")); "date"; If(SortDescending1; SortOrder.Ascending; SortOrder.Descending))

The “name” column was not directly in our collection available. I had a more complex response structure:

[
  {contact:{name:"Contact 1"}, date:"2021-01-01", duration:"01:00",...}
  {contact:{name:"Contact 2"}, date:"2021-01-02", duration:"02:00",...}
]

So you can do a small workaround and expand the result into another flat collection.

ClearCollect(ContactQueryData;SevDesk.TimeTrackingAggregatedContactData({}).objects.objects);;ClearCollect(ContactQuery;AddColumns(ContactQueryData;"name";contact.name));;Clear(ContactQueryData);;

The searchable result looks like:

[
  {name:"Contact 1", date:"2021-01-01", duration:"01:00",...}
  {name:"Contact 2", date:"2021-01-02", duration:"02:00",...}
]

I hope this will get you some insides for building the application.