Updating client application data from the server by using SignalR

Starting with version 2.3, the ability to change the global client state from the server using SignalR has been added to DWKit. SignalR is a library that allows you to send notifications to clients in the browser in real time, usually used to add interactivity to your applications. In this section, we will look at how to connect SignalR to a DWKit-based application. The full documentation for SignalR can be found here.

Prerequisites

Since the Microsoft.AspNetCore.SignalR package is already connected to the OptimaJet.DWKit.Core library, and the client JavaScript aspnet/signalr library is already connected to the client optimajet-app.js library you don’t need to add additional dependencies to your DWKit project. But you need to write some code to make SignalR work in your application.

  • add the transfer of the HubContext <ClientNotificationHub> instance to DWKitRuntime. ClientNotificationHub. This is a special DWKit class for sending messages to clients called a hub in SignalR. Since DWKITRuntime is the global object locator for all DWKit services, we add this instance to it. You can simply take and copy the lines below into the Configurator class, which can be found in the OptimaJet.DWKit.Application project.
public static void Configure(IHttpContextAccessor httpContextAccessor, IHubContext<ClientNotificationHub> notificationHubContext, IConfigurationRoot configuration, string connectionStringName = "default")
{
    DWKitRuntime.HubContext = notificationHubContext;
    Configure(httpContextAccessor, configuration, connectionStringName);
}
  • Then we need to connect SignalR to the ASP.NET Core application. To do this we need to change the Startup class, which is located in the OptimaJet.DWKit.StarterApplication project.

    • Add the following lines to the ConfigureServices method:
    services.AddSignalR(o =>
    {
        o.EnableDetailedErrors = true;
    });
    
    services.AddSingleton<IUserIdProvider, SignalRIdProvider>();

    SignalRIdProvider is also a DWKit class, which is located in the OptimaJet.DWKit.Core library. It is responsible for providing a user ID for each connection.

    • Add the following lines to the Configure method:
    app.UseSignalR(routes =>
    {
        routes.MapHub<ClientNotificationHub>("/hubs/notifications");
    });
    //DWKIT Init
    Configurator.Configure(
        (IHttpContextAccessor)app.ApplicationServices.GetService(typeof(IHttpContextAccessor)),
        (IHubContext<ClientNotificationHub>)app.ApplicationServices.GetService(typeof(IHubContext<ClientNotificationHub>)),
        Configuration);

    Note that there should be only one call to the Configurator.Configure method in theConfigure method, so you should remove the old call

  • Now you need to change the client part of the application so that the client application connects to the SignalR hub. To do this you need to change the app.jsx file, which is located in the OptimaJet.DWKit.StarterApplication project in the \wwwwroot\js\app folder.

    • First, we shall import two classes:
    import {SignalRConnector, StateBindedForm} from './../../scripts/optimajet-app.js'

    The SignalRConnector class contains a connection to the SignalR hub and processing of the messages received from the server. The StateBindedForm is a React component binded to the state of the application; its use will be explained further on.

    • Then we need to initiate a connection to the hub when we load the page for the first time. To do that, add the following code:
    SignalRConnector.Connect(Store);

    This code must be added immediately before creating and rendering the main component of the application.

    render(<App/>,document.getElementById('content'));
    • Now we need to initiate a reconnection to the hub when the application is updated, for example, when an impersonated user has changed, you need to reconnect to the hub with a new user id. Find the following method:
    onRefresh(){
        ...
    }

    and call the reconnection to the hub in the end of this method:

     onRefresh(){
        ...
         SignalRConnector.Connect(Store);
    }

After that, the client will connect to the hub either with the current user id, or the impersonated user id if you have impersonation configured. However, the hub does not make any useful actions. In the next section, we will look at how we can control the client application from the server by sending parts to be changed (delta) to the global client state.

Main operations

Changing the global application state from the server using SignalR Hub

All methods for sending messages with state changes to the client are represented as static methods of the DWKitRuntime object. You can send a status message to a user with a known identifier by calling the following method:

await DWKitRuntime.SendStateChangeToUserAsync(userId,path,delta);

here:

  • userId - id of the user - Guid or Guid.ToString(), the value of the Id column from the dwSecurityUser table
  • path - string specifying the position of the sent delta in the global state of the client application. Bear in mind, that state in the DWKit application is a single large JavaScript state object that includes many nested objects (for example, app and router). For example, if you specify path = "app.extra", then the changes will be applied to the object.
state["app"]["extra"]

If such an object is absent in state, then it will be created.

  • delta - any JSON-serializable object. It will be applied as a delta to the child object of the global state of the client application specified in the path parameter. You can pass objects of an anonymous type or Dictionary <string, object> in this parameter. Only those fields that are specified in the delta change in the already existing global state of the application. Say, the initial global state of the application looked like this:
var state = {
    ...
    app : {
        ...
        extra : {
            inbox : 10,
            total : 20
        }
    }
}

We make the following call on the server:

await DWKitRuntime.SendStateChangeToUserAsync(userId, "app.extra", new Dictionary<string, object>
{
    {"inbox", 40},
    {"outbox", 30}
});

or an analogous call:

await DWKitRuntime.SendStateChangeToUserAsync(userId, "app.extra", new {inbox = 40, outbox = 30 });

After this the global state on the client connected with a specified userid will change in the following way:

var state = {
    ...
    app : {
        ...
        extra : {
            inbox : 40,
            total : 20,
            outbox : 30
        }
    }
}

If the message needs to be sent to several users rather than to one user, then users should be classified into groups. To do this, you need to specify a classifier function in the DWKitRuntime object.

DWKitRuntime.SignalRGroupClassifier = SignalRGroupClassifier;

The classifier function is asynchronous and should return Task<List<string>> - a list of the names of the groups to which a particular user is assigned. It might look like this:

public static async Task<List<string>> SignalRGroupClassifier(string userId)
{
    if (DWKitRuntime.Security.CheckPermission(new Guid(userId), "Documents", "ViewAll"))
    {
        return new List<string> {"AllUsers","CanViewAllDocuments"};
    }

    return new List<string>{"AllUsers"};
}

That is, all users are placed in the "AllUsers" group to broadcast to all users, whereas users who have permission to view all documents are additionally placed in the "CanViewAllDocuments" group. To broadcast to a user group, use the following method:

await DWKitRuntime.SendStateChangeToGroupAsync(groupName,path,delta);

where groupName is the name of the group being broadcasted to. The other parameters are similar to calling the SendStateChangeToUserAsync method.

It is also important to know that if the broadcast is made to an unconnected user or to a non-existent group, then such a broadcast will simply not be made without throwing any errors.

Setting the initial state when connecting a client

It often happens that you need to set some initial state on the client. For example, the client must know how many documents he has in the Inbox and Outbox folders, and then receive only the changes to one of these folders. To do this, pass the initialization function to the DWKitRuntime object as follows:

DWKitRuntime.AddClientNotifier(typeof(ClientNotificationHub), Func<string, Task> notifier);

The asynchronous notifier function will be called when each new client connects toClientNotificationHub and userId will be passed to it as an argument. In this function, you must send the initial state to the client using the await DWKitRuntime.SendStateChangeToUserAsync (userId, path, delta) call.

StateBindedForm - form binded to a state

The StateBindedForm is a React component that wraps the DWKitForm component and passes various parts of the global state of the application to its data property (data to display on the form). For example, StateBindedForm can be used as follows:

<Provider store={Store}>
    <StateBindedForm {...sectorprops} formName="formName" stateDataPath="app.extra" data={{someProperty: someValue}} modelurl="/ui/form/formName" />
</Provider>

This component will display a form named formName and will pass the composite object as data. This object will consist of a portion of the global state of the application, which can be obtained as state["app"]["extra"] and the data object. Thus, if our global application state is as follows:

var state = {
    ...
    app : {
        ...
        extra : {
            inbox : 10,
            total : 20
        }
    }
}

then the following data will be passed to the form:

var data = {
    inbox : 10,
    total : 20
    someProperty: someValue
}

Thus, the form can display this data. Besides, you can specify several paths to data in the stateDataPath property.

<Provider store={Store}>
    <StateBindedForm {...sectorprops} formName="formName" stateDataPath={["app.extra","app.data"]} data={{someProperty: someValue}} modelurl="/ui/form/formName" />
</Provider>

Say, the global application state is as follows:

var state = {
    ...
    app : {
        ...
        extra : {
            inbox : 10,
            total : 20
        },
        ...
        data {
            field1 : value1,
            field2 : value2
        }

    }
}

then the following data will be passed to the form:

var data = {
    inbox : 10,
    total : 20,
    field1 : value1,
    field2 : value2,
    someProperty: someValue
}

Thus, you can use the StateBindedForm component to pass a set of data compiled from different parts of the global state of the application to the form.

The next section explains how it works based on an example.

Using SignalR in a sample Vacation Request application

In the Vacation Request application, SignalR is used to interactively update the Inbox and Outbox folder counters. In this section, we take a closer look at how this is done. It is assumed that you have read the previous two sections, so we will only consider issues relating directly to the business logic and will not consider connecting SignalR.

Sending the initial state when connecting the client

When a user connects to the SignalR hub, we need to send him the initial state of the Inbox and Outbox counters. It is necessary to open the Configurator class, which is located in the OptimaJet.DWKit.Application project. In the Configure method, the initial notification function is connected as follows:

//Initial inbox/outbox notifiers
DWKitRuntime.AddClientNotifier(typeof(ClientNotificationHub), ClientNotifiers.NotifyClientsAboutInboxStatus);

Mind that all the notification logic is collected in the ClientNotifiers static class. The NotifyClientsAboutInboxStatus function is very simple.

public static async Task NotifyClientsAboutInboxStatus (string userId)
{
    long inboxCount = ... //getting the number of documents in the inbox folder for the user with the userId identifier
    long outboxCount = ... //getting the number of documents in the outbox folder for the user with the userId identifier

    await SendInboxOutboxCountNotification(userId, inboxCount, outboxCount);
}

In this function we will receive the number of documents in the Inbox and Outbox folders of the user with the specified userId, and then send the notification to the client that connects under the sameuserId.

The SendInboxOutboxCountNotification method simply calls the DWKitRuntime.SendStateChangeToUserAsync method.

private static async Task SendInboxOutboxCountNotification(string userId, long inboxCount, long outboxCount)
{
    await DWKitRuntime.SendStateChangeToUserAsync(userId, "app.extra", new Dictionary<string, object>
                                                                            {
                                                                                {"inbox", inboxCount},
                                                                                {"outbox", outboxCount}
                                                                            }
    );
}

Sending the changes of the amount of documents upon changes in the state of the WFE process

When the document goes over WFE statuses, WorkflowRuntime calls the event handler for ProcessActivityChanged. Here we need to look at the WorkflowInit class, which is located in the OptimaJet.DWKit.Application project.

runtime.ProcessActivityChanged +=  (sender, args) => {  ActivityChanged(args, runtime).Wait(); };

In this handler, the WorkflowInbox folder is overwritten and, accordingly, we can understand which users stopped seeing the processed document in the inbox and which ones, on the contrary, started. We need to send notifications to these users. At the end of the method that handles the ProcessActivityChanged event, a method that sends notifications to users is called.

private static async Task ActivityChanged(ProcessActivityChangedEventArgs args, WorkflowRuntime runtime)
{
    ...
    userIdsForNotification = userIdsForNotification.Distinct().ToList();
    Func<Task> task = async () => { await ClientNotifiers.NotifyClientsAboutInboxStatus(userIdsForNotification); };
    task.FireAndForgetWithDefaultExceptionLogger();
}

Here we do not want the sending of notifications to slow down the document processing, so we use the FireAndForgetWithDefaultExceptionLogger method. The NotifyClientsAboutInboxStatus method from the ClientNotifiers class sends a notification to users from the userIdsForNotification list and works in the same way as the method that sends notifications to one user.

Sending the changes of the amount of documents upon document deletion

If someone deletes a document from the system, it is obvious that we will need to update the number of documents in the Inbox and Outbox folders of those users who could see this document there. To implement this logic, you must subscribe to the deletion of documents from the database. It is necessary to open the Configurator class, which is located in the OptimaJet.DWKit.Application project. In the Configure method, the subscription to the deletion is done with the following code:

DynamicEntityOperationNotifier.SubscribeToDeleteByTableName("Document", "WorkflowDelete", (e, c) =>
{
    Func<Task> task = async () => { await ClientNotifiers.DeleteWokflowAndNotifyClients(e, c); };
    task.FireAndForgetWithDefaultExceptionLogger();
});

This handler will be called when the document is physically deleted from the database. It is still not that important whether it will be executed or not (if someone doesn’t update the Inbox counter, the world will not collapse) and we don’t want the notification to slow down the work with documents, so we use FireAndForgetWithDefaultExceptionLogger.

The DeleteWokflowAndNotifyClients method does the following work:

public static async Task DeleteWokflowAndNotifyClients(EntityModel model, List<ChangeOperation> changes)
{
    var usersToNotify = ... // Getting a list of users who were affected by the deletion of documents
    // Process deletion
    foreach (var processId in processIds)
    {
        if (await WorkflowInit.Runtime.IsProcessExistsAsync(processId))
            await WorkflowInit.Runtime.DeleteInstanceAsync(processId);
    }
    // Clearing the Inbox table
    await inboxModel.DeleteAsync(inboxes.Select(i => i.GetId()));
    // Notifying clients of changes
    await NotifyClientsAboutInboxStatus(usersToNotify);
}

Displaying Inbox and Outbox folder counters on the client

As for now, we were making efforts to transfer the Inbox and Outbox counters to the client. But we also need to display them on the main page. Counters are displayed in the form - the menu with the name "top". This form is loaded in the app.jsx file (the OptimaJet.DWKit.StarterApplication project, \wwwwroot\js\app folder), with the following code:

<Provider store={Store}>
    <StateBindedForm {...sectorprops} formName="top" stateDataPath="app.extra" data={{currentEmployee: currentEmployee}} modelurl="/ui/form/top" />
</Provider>

We have already described the work of the StateBindedForm component earlier. Here we see that it loads a part of the global application state located at the address app.extra into the form. The updated inbox and outbox folder counters are transmitted from the server to it. Thus, the data for the "top" form will be represented as an object:

var data = {
    inbox : 10,
    outbox : 20,
    currentEmployee : currentEmployeeId
}

At the end, open the "top" form in [the admin area] (http://demo.dwkit.com/admin?apanel=forms&aid=top). If you open the properties of the menu component, you can see that the code that displays the menu items looks like this:

Inbox <div class="ui gray label">{inbox}</div>

DWKit substitutes the properties of the data object that came to the form in the expression in curly braces. In this case, the following HTML code will be generated:

Inbox <div class="ui gray label">10</div>

The React + Redux bundle itself tracks changes in the global state of the client application, so in order to redraw the counter, it is enough to change this state. A state change initiated from the server through SignalR is applied to the global state of the client application, and the counter is redrawn without additional actions.