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 toDWKitRuntime
.ClientNotificationHub
, this is a special DWKit class for sending messages to clients called a hub in SignalR. SinceDWKITRuntime
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 theConfigurator
class, which can be found in theOptimaJet.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 theOptimaJet.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 theOptimaJet.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);noteNote that there should be only one call to the
Configurator.Configure
method in theConfigure
method, so you should remove the old call. - Add the following lines to the
-
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 theOptimaJet.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. TheStateBindedForm
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
orGuid.ToString()
, the value of theId
column from thedwSecurityUser
table. -
path
- string indicating 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 JavaScriptstate
object that includes many nested objects (for example,app
androuter
). For example, if you specifypath
= "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 thepath
parameter. You can pass objects of an anonymous type orDictionary<string, object>
in this parameter. Only those fields that are specified in thedelta
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 to ClientNotificationHub
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 same userId
.
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 Workflow Engine process
When the document goes over Workflow Engine 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.DeleteWorkflowAndNotifyClients(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 DeleteWorkflowAndNotifyClients
method does the following work:
public static async Task DeleteWorkflowAndNotifyClients(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 "sidebar". 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 className="dwkit-sidebar-container" {...sectorprops} formName="sidebar"
stateDataPath="app.extra" data={{currentEmployee: currentEmployee}}
modelurl="/ui/form/sidebar"/>
</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 "sidebar" form will be represented as an object:
var data = {
inbox: 10,
outbox: 20,
currentEmployee: currentEmployeeId
}
At the end, open the "sidebar" form in the admin panel. 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.