Skip to content

Commit

Permalink
use SignalR for AlertDropdown
Browse files Browse the repository at this point in the history
  • Loading branch information
olmobrutall committed Dec 14, 2021
1 parent 15116a8 commit c3e89ed
Show file tree
Hide file tree
Showing 10 changed files with 411 additions and 27 deletions.
2 changes: 1 addition & 1 deletion Signum.React.Extensions/Alerts/AlertController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
using Signum.React.Filters;
using Signum.Entities.Alerts;

namespace Signum.React.Authorization;
namespace Signum.React.Alerts;

[ValidateModelFilter]
public class AlertController : ControllerBase
Expand Down
35 changes: 16 additions & 19 deletions Signum.React.Extensions/Alerts/AlertDropdown.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
import * as React from 'react'
import { isRtl } from '@framework/AppContext'
import * as Operations from '@framework/Operations'
import { getTypeInfo, symbolNiceName } from '@framework/Reflection'
import * as Finder from '@framework/Finder'
import { is, JavascriptMessage, toLite } from '@framework/Signum.Entities'
import { Toast, NavItem, Button, ButtonGroup } from 'react-bootstrap'
import { Toast, Button, ButtonGroup } from 'react-bootstrap'
import { DateTime } from 'luxon'
import { useAPI, useAPIWithReload, useDocumentEvent, useForceUpdate, useInterval, usePrevious, useThrottle, useUpdatedRef } from '@framework/Hooks';
import { LinkContainer } from '@framework/Components'
import { useAPIWithReload, useForceUpdate, useUpdatedRef } from '@framework/Hooks';
import * as AuthClient from '../Authorization/AuthClient'
import * as Navigator from '@framework/Navigator'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
Expand All @@ -17,27 +14,33 @@ import "./AlertDropdown.css"
import { Link } from 'react-router-dom';
import { classes, Dic } from '@framework/Globals'
import MessageModal from '@framework/Modals/MessageModal'
import { EntityLink } from '@framework/Search'
import { useSignalRCallback, useSignalRConnection } from './useSignalR'

export default function AlertDropdown(props: { checkForChangesEvery?: number, keepRingingFor?: number }) {
export default function AlertDropdown(props: { keepRingingFor?: number }) {

if (!Navigator.isViewable(AlertEntity))
return null;

return <AlertDropdownImp checkForChangesEvery={props.checkForChangesEvery ?? 30 * 1000} keepRingingFor={props.keepRingingFor ?? 10 * 1000} />;
return <AlertDropdownImp keepRingingFor={props.keepRingingFor ?? 10 * 1000} />;
}

function AlertDropdownImp(props: { checkForChangesEvery: number, keepRingingFor: number }) {
function AlertDropdownImp(props: { keepRingingFor: number }) {

const conn = useSignalRConnection("~/api/alertshub", {
accessTokenFactory: () => AuthClient.getAuthToken()!,
});

useSignalRCallback(conn, "AlertsChanged", () => {
reloadCount();
}, []);

const forceUpdate = useForceUpdate();
const [isOpen, setIsOpen] = React.useState<boolean>(false);
const [ringing, setRinging] = React.useState<boolean>(false);
const ringingRef = useUpdatedRef(ringing);

const [showAlerts, setShowAlert] = React.useState<number>(5);

var ticks = useInterval(props.checkForChangesEvery, 0, n => n + 1);


const isOpenRef = useUpdatedRef(isOpen);

var [countResult, reloadCount] = useAPIWithReload<AlertsClient.NumAlerts>((signal, oldResult) => AlertsClient.API.myAlertsCount().then(res => {
Expand All @@ -63,7 +66,7 @@ function AlertDropdownImp(props: { checkForChangesEvery: number, keepRingingFor:
}

return res;
}), [ticks], { avoidReset: true });
}), [], { avoidReset: true });

React.useEffect(() => {
if (ringing) {
Expand All @@ -75,10 +78,6 @@ function AlertDropdownImp(props: { checkForChangesEvery: number, keepRingingFor:
}
}, [ringing]);

useDocumentEvent("refresh-alerts", (e: Event) => {
reloadCount();
}, []);

const [alerts, setAlerts] = React.useState<AlertEntity[] | undefined>(undefined);
const [groupBy, setGroupBy] = React.useState<AlertDropDownGroup>("ByTypeAndUser");

Expand Down Expand Up @@ -108,8 +107,6 @@ function AlertDropdownImp(props: { checkForChangesEvery: number, keepRingingFor:
countResult.numAlerts -= toRemove.length;
forceUpdate();



Operations.API.executeMultiple(toRemove.map(a => toLite(a)), AlertOperation.Attend)
.then(res => {

Expand Down
12 changes: 9 additions & 3 deletions Signum.React.Extensions/Alerts/AlertsClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,14 @@ export function start(options: { routes: JSX.Element[], showAlerts?: (typeName:

var cellFormatter = new Finder.CellFormatter((cell, ctx) => {

if (cell == null)
return undefined;

var alert: Partial<AlertEntity> = {
target: ctx.row.columns[ctx.columns.indexOf(AlertEntity.token(a => a.target).toString())],
textArguments: ctx.row.columns[ctx.columns.indexOf(AlertEntity.token(a => a.entity.textArguments).toString())]
};
return formatText(cell, alert);
return formatText(cell, alert);
});

Finder.registerPropertyFormatter(PropertyRoute.tryParse(AlertEntity, "Text"), cellFormatter);
Expand Down Expand Up @@ -120,10 +123,13 @@ export function getTitle(titleField: string | null, type: AlertTypeSymbol | null
if (titleField)
return titleField;

if (type!.key)
if (type == null)
return " - ";

if (type.key)
return symbolNiceName(type! as Entity & ISymbol);

return type!.name;
return type.name;
}
export function formatText(text: string, alert: Partial<AlertEntity>, onNavigated?: () => void): React.ReactElement {
var nodes: (string | React.ReactElement)[] = [];
Expand Down
98 changes: 98 additions & 0 deletions Signum.React.Extensions/Alerts/AlertsHub.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.SignalR;
using Signum.Entities.Basics;
using Signum.React.Alerts;
using Signum.React.Authorization;
using System.Threading.Tasks;

namespace Signum.React.Facades;

public interface IAlertsClient
{
Task AlertsChanged();
}

public class AlertsHub : Hub<IAlertsClient>
{
public override Task OnConnectedAsync()
{
var user = GetUser(Context.GetHttpContext()!);

AlertsServer.Connections.Add(user.ToLite(), Context.ConnectionId);

return base.OnConnectedAsync();
}

public override Task OnDisconnectedAsync(Exception? exception)
{
AlertsServer.Connections.Remove(Context.ConnectionId);
return base.OnDisconnectedAsync(exception);
}

IUserEntity GetUser(HttpContext httpContext)
{
var tokenString = httpContext.Request.Query["access_token"];
if (tokenString.Count > 1)
throw new InvalidOperationException($"{tokenString.Count} values in 'access_token' query string found");

if (tokenString.Count == 0)
{
tokenString = httpContext.Request.Headers["Authorization"];

if (tokenString.Count != 1)
throw new InvalidOperationException($"{tokenString.Count} values in 'Authorization' header found");
}

var token = AuthTokenServer.DeserializeToken(tokenString.SingleEx());

return token.User;
}
}

public class ConnectionMapping<T> where T : class
{
private readonly Dictionary<T, HashSet<string>> userToConnection = new Dictionary<T, HashSet<string>>();
private readonly Dictionary<string, T> connectionToUser = new Dictionary<string, T>();

public int Count => userToConnection.Count;

public void Add(T key, string connectionId)
{
lock (this)
{
HashSet<string>? connections;
if (!userToConnection.TryGetValue(key, out connections))
{
connections = new HashSet<string>();
userToConnection.Add(key, connections);
}

connections.Add(connectionId);

connectionToUser.Add(connectionId, key);
}
}

public IEnumerable<string> GetConnections(T key) => userToConnection.TryGetC(key) ?? Enumerable.Empty<string>();

public void Remove(string connectionId)
{
lock (this)
{
var user = connectionToUser.TryGetC(connectionId);
if (user != null)
{
HashSet<string>? connections = userToConnection.TryGetC(user);
if (connections != null)
{
connections.Remove(connectionId);
if (connections.Count == 0)
userToConnection.Remove(user);
}

connectionToUser.Remove(connectionId);
}
}
}
}

81 changes: 81 additions & 0 deletions Signum.React.Extensions/Alerts/AlertsServer.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,93 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.SignalR;
using Signum.Entities.Alerts;
using Signum.Entities.Authorization;
using Signum.Entities.Basics;
using Signum.React.Facades;

namespace Signum.React.Alerts;

public static class AlertsServer
{
internal static ConnectionMapping<Lite<IUserEntity>> Connections = null!;

public static IHubContext<AlertsHub, IAlertsClient> AlertsHub { get; private set; }

public static void Start(IApplicationBuilder app)
{
SignumControllerFactory.RegisterArea(MethodInfo.GetCurrentMethod());
}

public static void MapAlertsHub(IEndpointRouteBuilder endpoints)
{
endpoints.MapHub<AlertsHub>("/api/alertshub");
Connections = new ConnectionMapping<Lite<IUserEntity>>();
AlertsHub = (IHubContext<AlertsHub, IAlertsClient>)endpoints.ServiceProvider.GetService(typeof(IHubContext<AlertsHub, IAlertsClient>))!;

var alertEvents = Schema.Current.EntityEvents<AlertEntity>();

alertEvents.Saved += AlertEvents_Saved;
alertEvents.PreUnsafeDelete += AlertEvents_PreUnsafeDelete;
alertEvents.PreUnsafeUpdate += AlertEvents_PreUnsafeUpdate;
alertEvents.PreUnsafeInsert += AlertEvents_PreUnsafeInsert;
}

private static IDisposable? AlertEvents_PreUnsafeUpdate(IUpdateable update, IQueryable<AlertEntity> entityQuery)
{
NotifyOnCommitQuery(entityQuery);
return null;
}

private static LambdaExpression AlertEvents_PreUnsafeInsert(IQueryable query, LambdaExpression constructor, IQueryable<AlertEntity> entityQuery)
{
NotifyOnCommitQuery(entityQuery);
return constructor;
}

private static IDisposable? AlertEvents_PreUnsafeDelete(IQueryable<AlertEntity> entityQuery)
{
NotifyOnCommitQuery(entityQuery);
return null;
}

private static void AlertEvents_Saved(AlertEntity ident, SavedEventArgs args)
{
if (ident.Recipient != null)
NotifyOnCommit(ident.Recipient);
}

private static void NotifyOnCommitQuery(IQueryable<AlertEntity> alerts)
{
var recipients = alerts.Where(a => a.Recipient != null && a.State == AlertState.Saved).Select(a => a.Recipient!).Distinct().ToArray();
if (recipients.Any())
NotifyOnCommit(recipients);
}
private static void NotifyOnCommit(params Lite<IUserEntity>[] recipients)
{
var hs = (HashSet<Lite<IUserEntity>>)Transaction.UserData.GetOrCreate("AlertRecipients", new HashSet<Lite<IUserEntity>>());
hs.AddRange(recipients);

Transaction.PostRealCommit -= Transaction_PostRealCommit;
Transaction.PostRealCommit += Transaction_PostRealCommit;
}

private static void Transaction_PostRealCommit(Dictionary<string, object> dic)
{
var hashSet = (HashSet<Lite<IUserEntity>>)dic["AlertRecipients"];
foreach (var user in hashSet)
{
foreach (var connectionId in Connections.GetConnections(user))
{
try
{
AlertsServer.AlertsHub.Clients.Client(connectionId).AlertsChanged();
}
catch(Exception ex)
{
ex.LogException();
}
}
}
}
}
Loading

3 comments on commit c3e89ed

@olmobrutall
Copy link
Collaborator Author

@olmobrutall olmobrutall commented on c3e89ed Dec 16, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add SignalR to AlertDropdown

This commit (plus a few fixes 1, 2) add SignalR to AlertDropdown in order to show real-time notifications to the user created by your business logic.

This means that "@microsoft/signalr" is now a dependency in Signum.React.Extensions and is needed if you want to use this control. If you want to check what is necessary to activate it check this commit.

If you need notifications in your application, or you have any other use case that could benefit from a real-time refresh, continue reading.

How it works

There is an AlertHub, an strongly-typed SignalR Hub<IAlertsClient>, like a controller that keeps a connection open.

In this case the notifications have to be sent to all the SignalR connectionIDs that are logged with a particular user, so when connecting the connectionId is associated with the user using an AuthToken when creating the connection. We keep this UserEntity <-> ConnectionId relationship in memory (from here)

Then there is a AlertServer that listens to the EntityEvents of AlertEntity and registers all the users that will be needed to notify.

When the transaction is commited, sends the necessary notifications to all the connectionIDs using IHubContext<AlertsHub, IAlertsClient>

Finally there is a CacheLogic.BroadcastReceivers.... but this will be explained tomorrow ;)

@doganc
Copy link

@doganc doganc commented on c3e89ed Dec 16, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perfect 👍

@MehdyKarimpour
Copy link
Contributor

@MehdyKarimpour MehdyKarimpour commented on c3e89ed Dec 17, 2021 via email

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.