diff --git a/connecting-to-backends/syncfusion-reactgrid-with-signalr/ReadMe.md b/connecting-to-backends/syncfusion-reactgrid-with-signalr/ReadMe.md new file mode 100644 index 0000000..229d437 --- /dev/null +++ b/connecting-to-backends/syncfusion-reactgrid-with-signalr/ReadMe.md @@ -0,0 +1,106 @@ +# Syncfusion React Grid with SignalR + +The Syncfusion® React Grid supports real-time data binding using SignalR, a powerful library for bi-directional communication between servers and clients. This approach enables live data updates without page refreshes, making it ideal for applications that require instant information delivery such as stock tickers, live dashboards, and real-time notifications. + +**What is SignalR?** + +[SignalR](https://learn.microsoft.com/en-us/aspnet/signalr/) is an open-source .NET library that simplifies adding real-time web functionality to applications. It automatically handles the best transport method (WebSockets, Server-Sent Events, or Long Polling) and provides a high-level API for server-to-client and client-to-server communication. SignalR enables persistent two-way connections between clients and servers, allowing instant data synchronization without polling. + +**Key benefits of SignalR** + +- **Real-time communication**: Establish persistent connections for instant data updates across all connected clients. +- **Bidirectional**: Support both server-to-client (broadcasting) and client-to-server communication. +- **Automatic transport selection**: Intelligently choose the best transport protocol (WebSockets, SSE, Long Polling) based on browser and server capabilities. +- **Scalable broadcasting**: Efficiently broadcast updates to multiple clients simultaneously using SignalR groups. +- **Built-in reconnection**: Automatically handles client reconnection with exponential back off retry logic. +- **No page refresh required**: Update UI dynamically without reloading the page. +- **Cross-platform**: Works across browsers, mobile devices, and desktop applications. + +## Prerequisites + + +| **Software / Package** | **Recommended version** | **Purpose** | +|-----------------------------|------------------------------|-------------------------------------- | +| Node.js | 20.x LTS or later | Runtime | +| npm / yarn / pnpm | 11.x or later | Package manager | +| Vite | 7.3.1 | Use this to create the React application | +| TypeScript | 5.x or later | Server‑side and client‑side type safety | + +## Quick Start + +1. **Clone the repository** + + ```bash + git clone + ``` + +2. **Running the application** + +**Run the Server:** + +- Run the below commands to run the server. + + ```bash + cd SignalR.Server + dotnet run + ``` +- The server runs at **http://localhost:5083/** by default. + +**Run the client** + + - Execute the below commands to run the client application. + + ```bash + cd signalr.client + npm install + npm run dev + ``` +- Open **http://localhost:5173/** in the browser. + + +## Project Layout + +| **File/Folder** | **Purpose** | +|-------------|---------| +| `signalr.client/package.json` | Client package manifest and dev/start scripts | +| `signalr.client/tsconfig.json` / `tsconfig.app.json` | TypeScript configuration files for the client | +| `signalr.client/src/main.tsx` | React application entry point | +| `signalr.client/src/index.css` | Global styles for the client app | +| `signalr.client/src/components/StockGrid.tsx` | React component that renders the Syncfusion Grid and uses SignalR for live updates | +| `signalr.client/src/styles/StockGrid.css` | Styles for the `StockGrid` component (chips, colors, layout) | +| `SignalR.Server/Program.cs` | Server entry configuring services, middleware, and SignalR hubs (maps `/stockHub`) | +| `SignalR.Server/Controllers/StockController.cs` | API endpoints for initial grid datasource | +| `SignalR.Server/Hubs/StockHub.cs` | Lightweight SignalR hub; injects `StockDataService`, manages group membership (`StockTraders`) and sends initial `InitializeStocks` | +| `SignalR.Server/Models/Stock.cs` | Server-side `Stock` model with raw fields and `*Display` formatted fields | +| `SignalR.Server/Services/StockUpdateService.cs` | Background service that simulates price updates and broadcasts updates to the `StockTraders` group | +| `SignalR.Server/Services/StockDataService.cs` | Small service wrapper around `Stock.GetAllStocks()` used by `StockHub` | +| `SignalR.Server/appsettings.json` / `appsettings.Development.json` | Server configuration files | +| `SignalR.Server/SignalR.Server.csproj` | Server project file with dependencies and build settings | + + +## Common Tasks + +### Search / Filter / Sort +- Use the **Search** box (toolbar) to match across configured columns +- Use column filter icons for equals/contains/date filters +- Click column headers to sort ascending/descending + +## Steps to download GitHub samples using DownGit + +1. **Open the DownGit Website** + + Go to the official DownGit tool: https://downgit.github.io/#/home + +2. **Copy the GitHub URL** + + - Navigate to the sample folder you want to download and copy its URL. + - Example : https://github.com/SyncfusionExamples/ej2-react-grid-samples/tree/master/connecting-to-backends/syncfusion-reactgrid-with-django-server + +3. **Paste the URL into DownGit** + + In the DownGit input box, paste the copied GitHub URL. + +4. **Download the ZIP** + + - Click **Download**. + - DownGit will generate a ZIP file of the selected folder, which you can save and extract locally. diff --git a/connecting-to-backends/syncfusion-reactgrid-with-signalr/SignalR.Server/Controllers/StockController.cs b/connecting-to-backends/syncfusion-reactgrid-with-signalr/SignalR.Server/Controllers/StockController.cs new file mode 100644 index 0000000..05b07a2 --- /dev/null +++ b/connecting-to-backends/syncfusion-reactgrid-with-signalr/SignalR.Server/Controllers/StockController.cs @@ -0,0 +1,195 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using SignalR.Server.Models; +using Microsoft.AspNetCore.Mvc; +using Syncfusion.EJ2.Base; +using System.Collections; + +namespace SignalR.Server.Controllers +{ + [ApiController] + [Route("api/[controller]")] + public class StockController : ControllerBase + { + /// + /// Fetch stock data with support for Syncfusion DataManager operations + /// Supports: Search, Sorting, Filtering + /// + [HttpPost("UrlDatasource")] + public IActionResult UrlDatasource([FromBody] DataManagerRequest dm) + { + try + { + // Get all stocks from the static collection + IEnumerable DataSource = Stock.GetAllStocks().ToList(); + DataOperations operation = new DataOperations(); + + // Search operation + if (dm.Search != null && dm.Search.Count > 0) + { + DataSource = operation.PerformSearching(DataSource, dm.Search); + } + + // Sorting operation + if (dm.Sorted != null && dm.Sorted.Count > 0) + { + DataSource = operation.PerformSorting(DataSource, dm.Sorted); + } + + // Filtering operation + if (dm.Where != null && dm.Where.Count > 0) + { + DataSource = operation.PerformFiltering(DataSource, dm.Where, dm.Where[0].Operator); + } + + // Get total count before paging + int count = DataSource.Cast().Count(); + + // Paging operations + if (dm.Skip != 0) + { + DataSource = operation.PerformSkip(DataSource, dm.Skip); + } + + if (dm.Take != 0) + { + DataSource = operation.PerformTake(DataSource, dm.Take); + } + + // Return result with count if required + return dm.RequiresCounts + ? Ok(new { result = DataSource, count = count }) + : Ok(DataSource); + } + catch (Exception ex) + { + return BadRequest(new { error = ex.Message }); + } + } + + /// + /// Get all stocks + /// + [HttpGet("GetAll")] + public IActionResult GetAll() + { + try + { + var stocks = Stock.GetAllStocks(); + return Ok(stocks); + } + catch (Exception ex) + { + return BadRequest(new { error = ex.Message }); + } + } + + /// + /// Get a single stock by StockId + /// + [HttpGet("GetById/{id}")] + public IActionResult GetById(int id) + { + try + { + var stock = Stock.GetAllStocks().FirstOrDefault(s => s.StockId == id); + if (stock == null) + { + return NotFound(new { error = $"Stock with ID {id} not found" }); + } + return Ok(stock); + } + catch (Exception ex) + { + return BadRequest(new { error = ex.Message }); + } + } + + /// + /// Get stocks by symbol (e.g., "AAPL") + /// + [HttpGet("GetBySymbol/{symbol}")] + public IActionResult GetBySymbol(string symbol) + { + try + { + var stock = Stock.GetAllStocks() + .FirstOrDefault(s => s.Symbol.Equals(symbol, StringComparison.OrdinalIgnoreCase)); + + if (stock == null) + { + return NotFound(new { error = $"Stock with symbol {symbol} not found" }); + } + return Ok(stock); + } + catch (Exception ex) + { + return BadRequest(new { error = ex.Message }); + } + } + + + + /// + /// Get stock statistics (min, max, average price, etc.) + /// + [HttpGet("GetStatistics")] + public IActionResult GetStatistics() + { + try + { + var stocks = Stock.GetAllStocks(); + + if (stocks.Count == 0) + { + return NotFound(new { error = "No stocks available" }); + } + + var stats = new + { + totalStocks = stocks.Count, + minPrice = stocks.Min(s => s.CurrentPrice), + maxPrice = stocks.Max(s => s.CurrentPrice), + averagePrice = stocks.Average(s => s.CurrentPrice), + positiveChanges = stocks.Count(s => s.Change > 0), + negativeChanges = stocks.Count(s => s.Change < 0), + totalVolume = stocks.Sum(s => s.Volume), + averageChangePercent = stocks.Average(s => s.ChangePercent), + lastUpdated = DateTime.UtcNow + }; + + return Ok(stats); + } + catch (Exception ex) + { + return BadRequest(new { error = ex.Message }); + } + } + } + + /// + /// Where clause model for filtering operations + /// + public class Wheres + { + public List? predicates { get; set; } + public string? field { get; set; } + public bool ignoreCase { get; set; } + public bool isComplex { get; set; } + public string? value { get; set; } + public string? Operator { get; set; } + } + + /// + /// Predicates model for complex filtering + /// + public class Predicates + { + public string? value { get; set; } + public string? field { get; set; } + public bool isComplex { get; set; } + public bool ignoreCase { get; set; } + public string? Operator { get; set; } + } +} diff --git a/connecting-to-backends/syncfusion-reactgrid-with-signalr/SignalR.Server/Controllers/WeatherForecastController.cs b/connecting-to-backends/syncfusion-reactgrid-with-signalr/SignalR.Server/Controllers/WeatherForecastController.cs new file mode 100644 index 0000000..ab4a5b1 --- /dev/null +++ b/connecting-to-backends/syncfusion-reactgrid-with-signalr/SignalR.Server/Controllers/WeatherForecastController.cs @@ -0,0 +1,33 @@ +using Microsoft.AspNetCore.Mvc; + +namespace SignalR.Server.Controllers +{ + [ApiController] + [Route("[controller]")] + public class WeatherForecastController : ControllerBase + { + private static readonly string[] Summaries = new[] + { + "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" + }; + + private readonly ILogger _logger; + + public WeatherForecastController(ILogger logger) + { + _logger = logger; + } + + [HttpGet(Name = "GetWeatherForecast")] + public IEnumerable Get() + { + return Enumerable.Range(1, 5).Select(index => new WeatherForecast + { + Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)), + TemperatureC = Random.Shared.Next(-20, 55), + Summary = Summaries[Random.Shared.Next(Summaries.Length)] + }) + .ToArray(); + } + } +} diff --git a/connecting-to-backends/syncfusion-reactgrid-with-signalr/SignalR.Server/Hubs/StockHub.cs b/connecting-to-backends/syncfusion-reactgrid-with-signalr/SignalR.Server/Hubs/StockHub.cs new file mode 100644 index 0000000..13918fe --- /dev/null +++ b/connecting-to-backends/syncfusion-reactgrid-with-signalr/SignalR.Server/Hubs/StockHub.cs @@ -0,0 +1,37 @@ +using Microsoft.AspNetCore.SignalR; +using SignalR.Server.Models; +using SignalR.Server.Services; + +namespace SignalR.Server.Hubs +{ + public class StockHub : Hub + { + private readonly StockDataService _stockDataService; + + public StockHub(StockDataService stockDataService) + { + _stockDataService = stockDataService; + } + + public override async Task OnConnectedAsync() + { + await base.OnConnectedAsync(); + var stocks = _stockDataService.GetAllStocks(); + await Clients.Client(Context.ConnectionId).SendAsync("InitializeStocks", stocks); + } + + public async Task SubscribeToStocks() + { + await Groups.AddToGroupAsync(Context.ConnectionId, "StockTraders"); + var stocks = _stockDataService.GetAllStocks(); + await Clients.Caller.SendAsync("InitializeStocks", stocks); + } + + public async Task UnsubscribeFromStocks() + { + await Groups.RemoveFromGroupAsync(Context.ConnectionId, "StockTraders"); + } + } +} + + diff --git a/connecting-to-backends/syncfusion-reactgrid-with-signalr/SignalR.Server/Models/Stock.cs b/connecting-to-backends/syncfusion-reactgrid-with-signalr/SignalR.Server/Models/Stock.cs new file mode 100644 index 0000000..4face6e --- /dev/null +++ b/connecting-to-backends/syncfusion-reactgrid-with-signalr/SignalR.Server/Models/Stock.cs @@ -0,0 +1,555 @@ +using System; +using System.Collections.Generic; + +namespace SignalR.Server.Models +{ + public class Stock + { + /// + /// Gets or sets the unique identifier for the stock. + /// + public int StockId { get; set; } + + /// + /// Gets or sets the ticker symbol of the stock (e.g., AAPL, MSFT). + /// + public string Symbol { get; set; } = string.Empty; + + /// + /// Gets or sets the full company name. + /// + public string Company { get; set; } = string.Empty; + + /// + /// Gets or sets the current price of the stock. + /// + public decimal CurrentPrice { get; set; } + + /// + /// Gets or sets the previous price before the last update. + /// Used to calculate price changes. + /// + public decimal PreviousPrice { get; set; } + + /// + /// Gets or sets the price change in absolute value. + /// Calculated as CurrentPrice - PreviousPrice. + /// + public decimal Change { get; set; } + + /// + /// Gets or sets the percentage change of the stock price. + /// Calculated as (Change / PreviousPrice) * 100. + /// + public decimal ChangePercent { get; set; } + + /// + /// Gets or sets the trading volume (number of shares traded). + /// + public long Volume { get; set; } + + /// + /// Gets or sets the timestamp of the last price update. + /// + public DateTime LastUpdated { get; set; } + + /// + /// Formatted display value for current price (USD currency) + /// + public string CurrentPriceDisplay { get; set; } = string.Empty; + + /// + /// Formatted display value for price change + /// + public string ChangeDisplay { get; set; } = string.Empty; + + /// + /// Formatted display value for percentage change + /// + public string ChangePercentDisplay { get; set; } = string.Empty; + + /// + /// Formatted display value for trading volume + /// + public string VolumeDisplay { get; set; } = string.Empty; + + /// + /// Static collection of stocks + /// + public static readonly List Stocks = new List(); + + private static readonly Random _random = new Random(); + private static bool _initialized = false; + + /// + /// Static constructor to initialize stocks + /// + static Stock() + { + InitializeStocks(); + } + + private static void InitializeStocks() + { + var stockData = new[] + { + // Technology + ("AAPL", "Apple Inc.", 190.50m), + ("MSFT", "Microsoft Corporation", 380.25m), + ("GOOGL", "Alphabet Inc.", 140.75m), + ("AMZN", "Amazon.com Inc.", 180.50m), + ("NVDA", "NVIDIA Corporation", 870.20m), + ("META", "Meta Platforms Inc.", 520.15m), + ("TSLA", "Tesla Inc.", 242.80m), + ("CRM", "Salesforce Inc.", 285.40m), + ("ADBE", "Adobe Inc.", 520.60m), + ("INTC", "Intel Corporation", 32.15m), + ("AMD", "Advanced Micro Devices", 210.80m), + ("QCOM", "Qualcomm Inc.", 165.30m), + ("CSCO", "Cisco Systems Inc.", 48.20m), + ("AMAT", "Applied Materials Inc.", 220.75m), + ("LRCX", "Lam Research Corporation", 780.45m), + ("ASML", "ASML Holding N.V.", 720.20m), + ("AVGO", "Broadcom Inc.", 145.60m), + ("MU", "Micron Technology Inc.", 95.40m), + ("NXPI", "NXP Semiconductors", 210.15m), + ("MCHP", "Microchip Technology Inc.", 72.85m), + + // Financial Services + ("JPM", "JPMorgan Chase & Co.", 195.75m), + ("BAC", "Bank of America Corp.", 42.50m), + ("WFC", "Wells Fargo & Company", 70.20m), + ("GS", "The Goldman Sachs Group Inc.", 510.85m), + ("MS", "Morgan Stanley", 98.40m), + ("BLK", "BlackRock Inc.", 890.15m), + ("AXP", "American Express Company", 245.60m), + ("USB", "U.S. Bancorp", 45.75m), + ("PNC", "PNC Financial Services", 185.40m), + ("TD", "Toronto-Dominion Bank", 68.90m), + ("RY", "Royal Bank of Canada", 115.20m), + ("BNS", "Scotiabank", 72.50m), + ("BMO", "Bank of Montreal", 108.75m), + ("CM", "Canadian Imperial Bank", 58.40m), + ("SLF", "Sun Life Financial Inc.", 68.20m), + ("MFC", "Manulife Financial Corporation", 23.15m), + ("GWC", "Great-West Lifeco Inc.", 32.80m), + ("TRI", "Thomson Reuters Corporation", 165.45m), + ("RCI", "Rogers Communications Inc.", 44.75m), + ("BCE", "BCE Inc.", 40.20m), + + // Healthcare & Pharmaceuticals + ("JNJ", "Johnson & Johnson", 158.45m), + ("UNH", "UnitedHealth Group Incorporated", 495.80m), + ("PFE", "Pfizer Inc.", 28.60m), + ("ABBV", "AbbVie Inc.", 285.20m), + ("LLY", "Eli Lilly and Company", 745.30m), + ("MRK", "Merck & Co Inc.", 65.90m), + ("TMO", "Thermo Fisher Scientific Inc.", 525.75m), + ("AZN", "AstraZeneca PLC", 68.40m), + ("AMGN", "Amgen Inc.", 295.15m), + ("GILD", "Gilead Sciences Inc.", 78.85m), + ("REGN", "Regeneron Pharmaceuticals Inc.", 975.20m), + ("BIIB", "Biogen Inc.", 240.50m), + ("CVS", "CVS Health Corporation", 75.40m), + ("WBA", "Walgreens Boots Alliance Inc.", 28.75m), + ("HCA", "HCA Healthcare Inc.", 310.60m), + ("UHS", "Universal Health Services Inc.", 245.85m), + ("THC", "Tenet Healthcare Corporation", 95.20m), + ("VEEV", "Veeva Systems Inc.", 172.45m), + ("DXCM", "DexCom Inc.", 105.80m), + ("INTU", "Intuit Inc.", 685.25m), + + // Consumer Discretionary + ("AMZN", "Amazon.com Inc.", 180.50m), + ("TSLA", "Tesla Inc.", 242.80m), + ("MCD", "McDonald's Corporation", 298.75m), + ("NKE", "Nike Inc.", 82.40m), + ("HD", "The Home Depot Inc.", 418.20m), + ("LOW", "Lowe's Companies Inc.", 245.60m), + ("WMT", "Walmart Inc.", 92.50m), + ("TGT", "Target Corporation", 78.20m), + ("COST", "Costco Wholesale Corporation", 945.80m), + ("ROST", "Ross Stores Inc.", 102.35m), + ("DXY", "Dixie Brands", 12.40m), + ("GPS", "The Gap Inc.", 24.15m), + ("KSS", "Kohl's Corporation", 18.95m), + ("BBY", "Best Buy Co. Inc.", 95.60m), + ("GME", "GameStop Corp.", 28.75m), + ("DISH", "DISH Network Corporation", 18.40m), + ("F", "Ford Motor Company", 10.20m), + ("GM", "General Motors Company", 42.85m), + ("TM", "Toyota Motor Corporation", 185.40m), + ("HMC", "Honda Motor Company Inc.", 28.60m), + + // Industrials & Materials + ("BA", "The Boeing Company", 218.50m), + ("CAT", "Caterpillar Inc.", 385.20m), + ("GE", "General Electric Company", 172.40m), + ("MMM", "3M Company", 108.75m), + ("RTX", "Raytheon Technologies Corporation", 125.60m), + ("LMT", "Lockheed Martin Corporation", 485.80m), + ("NOC", "Northrop Grumman Corporation", 545.20m), + ("GD", "General Dynamics Corporation", 285.40m), + ("DOW", "Dow Inc.", 52.15m), + ("DD", "DuPont de Nemours Inc.", 78.85m), + ("FCX", "Freeport-McMoRan Inc.", 42.60m), + ("NUCOR", "Nucor Corporation", 182.75m), + ("CMC", "Commercial Metals Company", 48.20m), + ("X", "United States Steel Corporation", 38.45m), + ("AA", "Alcoa Corporation", 52.80m), + ("CLF", "Cleveland-Cliffs Inc.", 18.60m), + ("STLD", "Steel Dynamics Inc.", 72.40m), + ("LIN", "Linde PLC", 465.90m), + ("APD", "Air Products and Chemicals Inc.", 285.20m), + ("EMN", "Eastman Chemical Company", 78.50m), + + // Energy + ("XOM", "Exxon Mobil Corporation", 118.40m), + ("CVX", "Chevron Corporation", 165.80m), + ("COP", "ConocoPhillips", 125.20m), + ("SLB", "Schlumberger Limited", 55.40m), + ("EOG", "EOG Resources Inc.", 105.60m), + ("MPC", "Marathon Petroleum Corporation", 185.75m), + ("PSX", "Phillips 66", 125.40m), + ("VLO", "Valero Energy Corporation", 145.80m), + ("HES", "Hess Corporation", 165.20m), + ("PXD", "Pioneer Natural Resources", 245.60m), + ("OXY", "Occidental Petroleum Corporation", 62.80m), + ("APA", "APA Corporation", 28.40m), + ("DVN", "Devon Energy Corporation", 45.20m), + ("FANG", "Diamondback Energy Inc.", 165.85m), + ("MRO", "Marathon Oil Corporation", 22.60m), + ("NBL", "Noble Corporation PLC", 52.40m), + ("CIVI", "Civista Bancshares Inc.", 24.15m), + ("KMI", "Kinder Morgan Inc.", 28.75m), + ("ET", "Energy Transfer LP", 12.85m), + ("MMP", "Magellan Midstream Partners L.P.", 48.20m), + + // Utilities + ("NEE", "NextEra Energy Inc.", 68.40m), + ("DUK", "Duke Energy Corporation", 98.20m), + ("SO", "The Southern Company", 72.50m), + ("DTE", "DTE Energy Company", 125.80m), + ("EXC", "Exelon Corporation", 42.15m), + ("AEP", "American Electric Power Company Inc.", 88.60m), + ("XEL", "Xcel Energy Inc.", 65.40m), + ("PEG", "Public Service Enterprise Group Inc.", 68.75m), + ("ED", "Consolidated Edison Inc.", 85.20m), + ("AES", "The AES Corporation", 25.45m), + ("EIX", "Edison International", 78.40m), + ("AWK", "American Water Works Company Inc.", 155.80m), + ("CWT", "California Water Service Group", 48.20m), + ("AWH", "Aspire Water Holdings Inc.", 18.60m), + ("NRG", "NRG Energy Inc.", 38.75m), + ("CMS", "CMS Energy Corporation", 62.40m), + ("FE", "FirstEnergy Corp.", 38.85m), + ("LNT", "Alliant Energy Corporation", 58.20m), + ("WEC", "WEC Energy Group Inc.", 92.50m), + ("PPL", "PPL Corporation", 32.15m), + + // Real Estate + ("DLR", "Digital Realty Trust Inc.", 185.40m), + ("EQIX", "Equinix Inc.", 625.80m), + ("VICI", "VICI Properties Inc.", 38.20m), + ("PLD", "Prologis Inc.", 108.60m), + ("AMT", "American Tower Corporation", 265.20m), + ("CCI", "Crown Castle International Corp.", 125.40m), + ("SBAC", "SBA Communications Corporation", 398.75m), + ("SPG", "Simon Property Group Inc.", 185.50m), + ("PEI", "Pennsylvania REIT", 8.40m), + ("MAC", "Macerich Company", 18.60m), + ("BXP", "Boston Properties Inc.", 95.20m), + ("OFC", "Monmouth Real Estate Investment Corporation", 2.50m), + ("VNO", "Vornado Realty Trust", 38.45m), + ("RXP", "REX Real Estate Investment Trust", 18.75m), + ("ARE", "Alexandria Real Estate Equities Inc.", 145.80m), + ("WELL", "Welltower Inc.", 72.40m), + ("PTC", "PotlatchDeltic Corporation", 52.80m), + ("UMH", "UMH Properties Inc.", 22.60m), + ("SRC", "Sotherly Bank Inc.", 15.20m), + ("AKR", "Acadia Realty Trust", 18.95m), + + // Consumer Staples + ("PG", "The Procter & Gamble Company", 168.40m), + ("KO", "The Coca-Cola Company", 65.20m), + ("PEP", "PepsiCo Inc.", 195.80m), + ("MO", "Altria Group Inc.", 58.40m), + ("PM", "Philip Morris International Inc.", 105.60m), + ("GIS", "General Mills Inc.", 78.20m), + ("CAG", "Conagra Brands Inc.", 32.15m), + ("K", "Kellogg Company", 22.80m), + ("MNST", "Monster Beverage Corporation", 58.40m), + ("CELH", "Celsius Holdings Inc.", 35.60m), + ("BF/B", "Brown-Forman Corporation", 48.50m), + ("DEO", "Diageo PLC", 88.40m), + ("STZ", "Constellation Brands Inc.", 285.20m), + ("TAP", "Molson Coors Beverage Company", 48.60m), + ("SJM", "The J.M. Smucker Company", 142.45m), + ("TSN", "Tyson Foods Inc.", 38.80m), + ("JBS", "JBS S.A.", 28.40m), + ("HRL", "Hormel Foods Corporation", 52.75m), + ("SMPL", "Simply Goods Inc.", 18.60m), + ("AGRO", "Adecoagro S.A.", 8.40m), + + // Communications + ("CMCSA", "Comcast Corporation", 45.20m), + ("CHTR", "Charter Communications Inc.", 385.80m), + ("TWX", "Time Warner Inc.", 85.40m), + ("FOX", "Fox Corporation", 28.50m), + ("FOXA", "Fox Corporation Class A", 28.60m), + ("VIAC", "ViacomCBS Inc.", 18.40m), + ("DIS", "The Walt Disney Company", 95.20m), + ("NFLX", "Netflix Inc.", 285.40m), + ("ROKU", "Roku Inc.", 68.20m), + ("PENN", "Penn Entertainment Inc.", 22.85m), + ("LYV", "Live Nation Entertainment Inc.", 142.60m), + ("RCI", "Rogers Communications Inc.", 44.75m), + ("BCE", "BCE Inc.", 40.20m), + ("T", "AT&T Inc.", 22.50m), + ("VZ", "Verizon Communications Inc.", 42.80m), + ("TMUS", "T-Mobile US Inc.", 195.40m), + ("S", "Sprint Corporation", 5.20m), + ("DISH", "DISH Network Corporation", 18.40m), + ("SIRIUSX", "Sirius XM Holdings Inc.", 28.60m), + ("LBRDK", "Liberty Braves Group", 32.40m), + + // Additional Tech & Software + ("FTNT", "Fortinet Inc.", 68.20m), + ("PALO", "Palo Alto Networks Inc.", 285.40m), + ("NET", "Cloudflare Inc.", 95.80m), + ("CRWD", "CrowdStrike Holdings Inc.", 425.20m), + ("OKTA", "Okta Inc.", 118.40m), + ("ZS", "Zscaler Inc.", 142.60m), + ("WORK", "Slack Technologies Inc.", 48.20m), + ("ZOOM", "Zoom Video Communications Inc.", 165.80m), + ("TWLO", "Twilio Inc.", 52.40m), + ("RBLX", "Roblox Corporation", 42.15m), + ("SNAP", "Snap Inc.", 25.80m), + ("PINS", "Pinterest Inc.", 32.40m), + ("COIN", "Coinbase Global Inc.", 125.60m), + ("MSTR", "MicroStrategy Incorporated", 485.20m), + ("SQ", "Square Inc.", 68.40m), + ("PYPL", "PayPal Holdings Inc.", 78.20m), + ("V", "Visa Inc.", 295.40m), + ("MA", "Mastercard Incorporated", 518.80m), + ("DFS", "Discover Financial Services", 125.40m), + ("ACI", "Advance Auto Parts Inc.", 15.20m), + + // Additional Technology & Software + ("SPLK", "Splunk Inc.", 155.40m), + ("DDOG", "Datadog Inc.", 195.80m), + ("SNOW", "Snowflake Inc.", 185.20m), + ("DBX", "Dropbox Inc.", 42.60m), + ("CrowdStrike", "CrowdStrike Holdings Inc.", 425.20m), + ("NFLX", "Netflix Inc.", 285.40m), + ("ROKU", "Roku Inc.", 68.20m), + ("GTLB", "Gitlab Inc.", 78.40m), + ("MNDY", "Monday.com Ltd.", 195.60m), + ("SMCI", "Super Micro Computer Inc.", 68.40m), + + // Additional Healthcare + ("SGEN", "Seagen Inc.", 165.80m), + ("VEEV", "Veeva Systems Inc.", 172.45m), + ("EXAS", "Exact Sciences Corporation", 78.20m), + ("ILMN", "Illumina Inc.", 115.40m), + ("VRTX", "Vertex Pharmaceuticals Inc.", 485.20m), + ("ALKS", "Alkermes PLC", 42.60m), + ("SAGE", "Sage Therapeutics Inc.", 28.40m), + ("CARA", "Cara Therapeutics Inc.", 35.20m), + ("BNTX", "Biontech SE", 125.40m), + ("MRNA", "Moderna Inc.", 185.80m), + + // Additional Financial Services + ("SCHW", "Charles Schwab Corporation", 82.40m), + ("HOOD", "Robinhood Markets Inc.", 32.60m), + ("UPST", "Upstart Holdings Inc.", 48.20m), + ("SQ", "Square Inc.", 68.40m), + ("PYPL", "PayPal Holdings Inc.", 78.20m), + ("INFA", "Informatica Inc.", 38.60m), + ("SOFI", "SoFi Technologies Inc.", 22.80m), + ("COIN", "Coinbase Global Inc.", 125.60m), + ("MSTR", "MicroStrategy Incorporated", 485.20m), + ("MARA", "Marathon Digital Holdings Inc.", 18.40m), + + // Additional Consumer & Retail + ("ULTA", "Ulta Beauty Inc.", 428.60m), + ("GPC", "Genuine Parts Company", 185.20m), + ("FIVE", "Five Below Inc.", 52.40m), + ("AMTM", "Artisan Partners Asset Management", 65.80m), + ("LULU", "Lululemon Athletica Inc.", 385.40m), + ("DECK", "Deckers Outdoor Corporation", 795.20m), + ("CROX", "Crocs Inc.", 95.80m), + ("VIPS", "Vipshop Holdings Limited", 28.40m), + ("NU", "Nu Holdings Ltd.", 8.60m), + ("MGNX", "MagneX Holdings Inc.", 5.40m), + + // Additional Industrials & Manufacturing + ("RBLX", "Roblox Corporation", 42.15m), + ("PTON", "Peloton Interactive Inc.", 12.40m), + ("LCID", "Lucid Motors Inc.", 2.80m), + ("NIO", "NIO Inc.", 5.20m), + ("XP", "XP Inc.", 18.40m), + ("AVAV", "AeroVironment Inc.", 285.60m), + ("ATGE", "Allegiant Travel Company", 95.20m), + ("ALK", "Alaska Air Group Inc.", 42.60m), + ("DAL", "Delta Air Lines Inc.", 52.40m), + ("UAL", "United Airlines Holdings Inc.", 68.20m), + + // Additional Energy & Resources + ("RDS.A", "Royal Dutch Shell PLC Class A", 58.40m), + ("TTE", "TotalEnergies SE", 65.80m), + ("ENB", "Enbridge Inc.", 38.20m), + ("TC", "TC Energy Corporation", 52.40m), + ("PAA", "Plains All American Pipeline L.P.", 28.60m), + ("WMB", "Williams Companies Inc.", 42.40m), + ("NGL", "NGL Energy Partners L.P.", 22.80m), + ("ARCH", "Arch Coal Inc.", 148.60m), + ("BTU", "Peabody Energy Corporation", 28.40m), + ("AR", "Antero Resources Corporation", 32.20m), + + // Additional Real Estate & Property + ("SKT", "Tanger Inc.", 18.40m), + ("WPG", "Washington Prime Group Inc.", 2.80m), + ("KIM", "Kimco Realty Corporation", 25.20m), + ("REG", "Regency Centers Corporation", 68.40m), + ("RHP", "Retail Opportunity Investments Corp.", 38.60m), + ("SITM", "Sitemark Holdings Inc.", 15.40m), + ("STOR", "STORE Capital Corporation", 38.20m), + ("MAIN", "Mainstay Apartment Communities Inc.", 12.80m), + ("NHI", "National Health Investors Inc.", 52.40m), + ("LTC", "LTC Properties Inc.", 28.60m), + + // Additional Utilities & Energy Infrastructure + ("PSA", "Public Storage", 385.20m), + ("EQR", "Equity Residential", 68.40m), + ("AVB", "AvalonBay Communities Inc.", 242.60m), + ("CPT", "Camden Property Trust", 95.20m), + ("UMH", "UMH Properties Inc.", 22.60m), + ("AGR", "Agrify Holdings Inc.", 2.40m), + ("VATE", "Viata Energy Inc.", 8.20m), + ("PRIM", "Primotech Inc.", 5.60m), + ("NWLI", "National Western Life Group Inc.", 385.80m), + ("TXRH", "Texas Roadhouse Inc.", 78.20m), + + // Additional Diversified / Conglomerate + ("ITW", "Illinois Tool Works Inc.", 245.80m), + ("HOG", "Harley-Davidson Inc.", 35.20m), + ("LEG", "Leggett & Platt Incorporated", 28.40m), + ("WHR", "Whirlpool Corporation", 125.60m), + ("NWL", "Newell Brands Inc.", 18.40m), + ("SCCO", "Southern Copper Corporation", 95.20m), + ("TECK", "Teck Resources Limited", 28.60m), + ("VALE", "Vale S.A.", 12.40m), + ("RIO", "Rio Tinto Limited", 68.20m), + ("BHP", "BHP Group Limited", 52.80m), + }; + + if (_initialized) + return; + + int id = 1; + foreach (var (symbol, company, price) in stockData) + { + var stock = new Stock + { + StockId = id++, + Symbol = symbol, + Company = company, + CurrentPrice = price, + PreviousPrice = price, + Change = 0, + ChangePercent = 0, + Volume = _random.Next(1000000, 100000000), + LastUpdated = DateTime.Now + }; + + // Initialize display values + stock.UpdateDisplayValues(); + + Stocks.Add(stock); + } + + _initialized = true; + } + + /// + /// Get all stocks from the static collection + /// + public static List GetAllStocks() + { + if (!_initialized) + { + InitializeStocks(); + } + return Stocks; + } + + /// + /// Update all display values based on current raw values + /// Called after every price update + /// + public void UpdateDisplayValues() + { + CurrentPriceDisplay = FormatPrice(CurrentPrice); + ChangeDisplay = FormatCurrency(Change); + ChangePercentDisplay = FormatPercent(ChangePercent); + VolumeDisplay = FormatVolume(Volume); + } + + /// + /// Format decimal as USD currency with directional arrow + /// + private static string FormatCurrency(decimal amount) + { + if (amount < 0) + { + return $"▼ {amount.ToString("C2")}"; + } + else if (amount > 0) + { + return $"▲ {amount.ToString("C2")}"; + } + return amount.ToString("C2"); + } + + /// + /// Format current price as USD currency without directional arrow + /// + private static string FormatPrice(decimal amount) + { + return amount.ToString("C2"); + } + + /// + /// Format decimal as percentage with + or - sign and directional arrow + /// + private static string FormatPercent(decimal value) + { + if (value < 0) + { + return $" {value:F2}%"; + } + else if (value > 0) + { + return $" +{value:F2}%"; + } + return $"• {value:F2}%"; // Neutral indicator + } + + /// + /// Format long number as abbreviated volume (1.2M, 500K, etc.) + /// + private static string FormatVolume(long volume) + { + if (volume >= 1000000) + { + return $"{(volume / 1000000.0):F1}M"; + } + else if (volume >= 1000) + { + return $"{(volume / 1000.0):F1}K"; + } + return volume.ToString(); + } + } +} diff --git a/connecting-to-backends/syncfusion-reactgrid-with-signalr/SignalR.Server/Program.cs b/connecting-to-backends/syncfusion-reactgrid-with-signalr/SignalR.Server/Program.cs new file mode 100644 index 0000000..0d5fee9 --- /dev/null +++ b/connecting-to-backends/syncfusion-reactgrid-with-signalr/SignalR.Server/Program.cs @@ -0,0 +1,52 @@ +using SignalR.Server.Hubs; +using SignalR.Server.Services; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddSignalR(); // Add SignalR services +builder.Services.AddScoped(); // Add Stock Data Service +builder.Services.AddHostedService(); // Add Stock Update Service +// Add services to the container. +builder.Services.AddControllers() + .AddJsonOptions(options => + { + options.JsonSerializerOptions.PropertyNamingPolicy = null; // Use PascalCase + }); +// Add services to the container. + +builder.Services.AddCors(options => +{ + options.AddPolicy("CORSPolicy", + builder => builder + .AllowAnyMethod() + .AllowAnyHeader() + .AllowCredentials() + .WithOrigins("http://localhost:5173", "http://localhost:5174", "http://localhost:3000") // Explicitly allow React dev server + .SetIsOriginAllowed((hosts) => true)); +}); +builder.Services.AddControllersWithViews(); +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseDeveloperExceptionPage(); +} +else +{ + // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. + app.UseHsts(); + app.UseHttpsRedirection(); +} + +app.UseStaticFiles(); +app.UseRouting(); +app.UseCors("CORSPolicy"); // CORS must be after UseRouting but before MapHub +app.MapHub("/stockHub"); // Map the StockHub - MUST be after UseRouting +app.MapControllerRoute( + name: "default", + pattern: "{controller=Home}/{action=Index}/{id?}"); + +app.MapFallbackToFile("index.html"); + +app.Run(); diff --git a/connecting-to-backends/syncfusion-reactgrid-with-signalr/SignalR.Server/Properties/launchSettings.json b/connecting-to-backends/syncfusion-reactgrid-with-signalr/SignalR.Server/Properties/launchSettings.json new file mode 100644 index 0000000..5519ac4 --- /dev/null +++ b/connecting-to-backends/syncfusion-reactgrid-with-signalr/SignalR.Server/Properties/launchSettings.json @@ -0,0 +1,45 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:3573", + "sslPort": 44347 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5083", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "ASPNETCORE_HOSTINGSTARTUPASSEMBLIES": "Microsoft.AspNetCore.SpaProxy" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + //"launchUrl": "swagger", + "applicationUrl": "https://localhost:7011;http://localhost:5083", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "ASPNETCORE_HOSTINGSTARTUPASSEMBLIES": "Microsoft.AspNetCore.SpaProxy" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "ASPNETCORE_HOSTINGSTARTUPASSEMBLIES": "Microsoft.AspNetCore.SpaProxy" + } + } + } +} + diff --git a/connecting-to-backends/syncfusion-reactgrid-with-signalr/SignalR.Server/Services/StockDataService.cs b/connecting-to-backends/syncfusion-reactgrid-with-signalr/SignalR.Server/Services/StockDataService.cs new file mode 100644 index 0000000..93ffccb --- /dev/null +++ b/connecting-to-backends/syncfusion-reactgrid-with-signalr/SignalR.Server/Services/StockDataService.cs @@ -0,0 +1,12 @@ +using SignalR.Server.Models; + +namespace SignalR.Server.Services +{ + public class StockDataService + { + public List GetAllStocks() + { + return Stock.GetAllStocks(); + } + } +} diff --git a/connecting-to-backends/syncfusion-reactgrid-with-signalr/SignalR.Server/Services/StockUpdateService.cs b/connecting-to-backends/syncfusion-reactgrid-with-signalr/SignalR.Server/Services/StockUpdateService.cs new file mode 100644 index 0000000..944ad79 --- /dev/null +++ b/connecting-to-backends/syncfusion-reactgrid-with-signalr/SignalR.Server/Services/StockUpdateService.cs @@ -0,0 +1,85 @@ +using Microsoft.AspNetCore.SignalR; +using SignalR.Server.Hubs; +using SignalR.Server.Models; + +namespace SignalR.Server.Services +{ + /// + /// Background service to simulate and broadcast stock price updates + /// + public class StockUpdateService : BackgroundService + { + private readonly IHubContext _hubContext; + private readonly ILogger _logger; + private readonly Random _random = new Random(); + + public StockUpdateService(IHubContext hubContext, ILogger logger) + { + _hubContext = hubContext; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("Stock Update Service is starting."); + + // Initialize stocks + Stock.GetAllStocks(); + + try + { + while (!stoppingToken.IsCancellationRequested) + { + // Update stock prices + UpdateStockPrices(); + + // Broadcast updated stocks to all clients in the StockTraders group + await _hubContext.Clients.Group("StockTraders").SendAsync("ReceiveStockUpdate", Stock.Stocks); + + _logger.LogInformation($"Stock prices updated at {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss}"); + + // Wait for 2 seconds before next update + await Task.Delay(2000, stoppingToken); + } + } + catch (OperationCanceledException) + { + _logger.LogInformation("Stock Update Service is stopping."); + } + catch (Exception ex) + { + _logger.LogError($"Error in Stock Update Service: {ex.Message}"); + } + } + + /// + /// Update stock prices with random fluctuations + /// + private void UpdateStockPrices() + { + foreach (var stock in Stock.Stocks) + { + stock.PreviousPrice = stock.CurrentPrice; + + // Generate random price change between -2% and +2% + decimal changePercent = (decimal)(_random.NextDouble() * 4 - 2); // -2 to +2 + decimal priceChange = stock.CurrentPrice * (changePercent / 100); + stock.CurrentPrice = Math.Max(stock.CurrentPrice + priceChange, 0.01m); // Ensure price stays positive + + // Calculate change and change percent + stock.Change = stock.CurrentPrice - stock.PreviousPrice; + stock.ChangePercent = stock.PreviousPrice > 0 ? (stock.Change / stock.PreviousPrice) * 100 : 0; + + // Update volume randomly + stock.Volume = stock.Volume + (long)(_random.Next(-5000000, 5000000)); + stock.Volume = Math.Max(stock.Volume, 1000000); // Ensure volume is positive + + // Update timestamp + stock.LastUpdated = DateTime.UtcNow; + + // Update display values for UI rendering + stock.UpdateDisplayValues(); + } + } + } +} diff --git a/connecting-to-backends/syncfusion-reactgrid-with-signalr/SignalR.Server/SignalR.Server.csproj b/connecting-to-backends/syncfusion-reactgrid-with-signalr/SignalR.Server/SignalR.Server.csproj new file mode 100644 index 0000000..848489b --- /dev/null +++ b/connecting-to-backends/syncfusion-reactgrid-with-signalr/SignalR.Server/SignalR.Server.csproj @@ -0,0 +1,27 @@ + + + + net8.0 + enable + enable + ..\signalr.client + npm start + https://localhost:58982 + + + + + 8.*-* + + + + + + + + + + + + + diff --git a/connecting-to-backends/syncfusion-reactgrid-with-signalr/SignalR.Server/SignalR.Server.csproj.user b/connecting-to-backends/syncfusion-reactgrid-with-signalr/SignalR.Server/SignalR.Server.csproj.user new file mode 100644 index 0000000..9ff5820 --- /dev/null +++ b/connecting-to-backends/syncfusion-reactgrid-with-signalr/SignalR.Server/SignalR.Server.csproj.user @@ -0,0 +1,6 @@ + + + + https + + \ No newline at end of file diff --git a/connecting-to-backends/syncfusion-reactgrid-with-signalr/SignalR.Server/SignalR.Server.http b/connecting-to-backends/syncfusion-reactgrid-with-signalr/SignalR.Server/SignalR.Server.http new file mode 100644 index 0000000..1c8bf1d --- /dev/null +++ b/connecting-to-backends/syncfusion-reactgrid-with-signalr/SignalR.Server/SignalR.Server.http @@ -0,0 +1,6 @@ +@SignalR.Server_HostAddress = http://localhost:5083 + +GET {{SignalR.Server_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/connecting-to-backends/syncfusion-reactgrid-with-signalr/SignalR.Server/WeatherForecast.cs b/connecting-to-backends/syncfusion-reactgrid-with-signalr/SignalR.Server/WeatherForecast.cs new file mode 100644 index 0000000..9e122d9 --- /dev/null +++ b/connecting-to-backends/syncfusion-reactgrid-with-signalr/SignalR.Server/WeatherForecast.cs @@ -0,0 +1,13 @@ +namespace SignalR.Server +{ + public class WeatherForecast + { + public DateOnly Date { get; set; } + + public int TemperatureC { get; set; } + + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); + + public string? Summary { get; set; } + } +} diff --git a/connecting-to-backends/syncfusion-reactgrid-with-signalr/SignalR.Server/appsettings.Development.json b/connecting-to-backends/syncfusion-reactgrid-with-signalr/SignalR.Server/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/connecting-to-backends/syncfusion-reactgrid-with-signalr/SignalR.Server/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/connecting-to-backends/syncfusion-reactgrid-with-signalr/SignalR.Server/appsettings.json b/connecting-to-backends/syncfusion-reactgrid-with-signalr/SignalR.Server/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/connecting-to-backends/syncfusion-reactgrid-with-signalr/SignalR.Server/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/connecting-to-backends/syncfusion-reactgrid-with-signalr/SignalR.sln b/connecting-to-backends/syncfusion-reactgrid-with-signalr/SignalR.sln new file mode 100644 index 0000000..07f2040 --- /dev/null +++ b/connecting-to-backends/syncfusion-reactgrid-with-signalr/SignalR.sln @@ -0,0 +1,33 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.13.35919.96 d17.13 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{54A90642-561A-4BB1-A94E-469ADEE60C69}") = "signalr.client", "signalr.client\signalr.client.esproj", "{1B3BDA74-44F3-1C73-EFE8-8B2935647C2C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SignalR.Server", "SignalR.Server\SignalR.Server.csproj", "{C2EDC42D-F025-4867-B892-F4877F473A21}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {1B3BDA74-44F3-1C73-EFE8-8B2935647C2C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1B3BDA74-44F3-1C73-EFE8-8B2935647C2C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1B3BDA74-44F3-1C73-EFE8-8B2935647C2C}.Debug|Any CPU.Deploy.0 = Debug|Any CPU + {1B3BDA74-44F3-1C73-EFE8-8B2935647C2C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1B3BDA74-44F3-1C73-EFE8-8B2935647C2C}.Release|Any CPU.Build.0 = Release|Any CPU + {1B3BDA74-44F3-1C73-EFE8-8B2935647C2C}.Release|Any CPU.Deploy.0 = Release|Any CPU + {C2EDC42D-F025-4867-B892-F4877F473A21}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C2EDC42D-F025-4867-B892-F4877F473A21}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C2EDC42D-F025-4867-B892-F4877F473A21}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C2EDC42D-F025-4867-B892-F4877F473A21}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {868C1DB1-C5AA-4019-8703-971C218262EE} + EndGlobalSection +EndGlobal diff --git a/connecting-to-backends/syncfusion-reactgrid-with-signalr/signalr.client/eslint.config.js b/connecting-to-backends/syncfusion-reactgrid-with-signalr/signalr.client/eslint.config.js new file mode 100644 index 0000000..5e6b472 --- /dev/null +++ b/connecting-to-backends/syncfusion-reactgrid-with-signalr/signalr.client/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/connecting-to-backends/syncfusion-reactgrid-with-signalr/signalr.client/index.html b/connecting-to-backends/syncfusion-reactgrid-with-signalr/signalr.client/index.html new file mode 100644 index 0000000..c27c586 --- /dev/null +++ b/connecting-to-backends/syncfusion-reactgrid-with-signalr/signalr.client/index.html @@ -0,0 +1,14 @@ + + + + + + + client + + + +
+ + + diff --git a/connecting-to-backends/syncfusion-reactgrid-with-signalr/signalr.client/package.json b/connecting-to-backends/syncfusion-reactgrid-with-signalr/signalr.client/package.json new file mode 100644 index 0000000..c3749c5 --- /dev/null +++ b/connecting-to-backends/syncfusion-reactgrid-with-signalr/signalr.client/package.json @@ -0,0 +1,39 @@ +{ + "name": "client", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "start": "vite", + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@microsoft/signalr": "^10.0.0", + "@syncfusion/ej2-base": "*", + "@syncfusion/ej2-data": "*", + "@syncfusion/ej2-react-buttons": "*", + "@syncfusion/ej2-react-calendars": "*", + "@syncfusion/ej2-react-dropdowns": "*", + "@syncfusion/ej2-react-grids": "*", + "@syncfusion/ej2-react-inputs": "*", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@types/node": "^24.10.1", + "@types/react": "^19.2.8", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.46.4", + "vite": "^7.2.4" + } +} diff --git a/connecting-to-backends/syncfusion-reactgrid-with-signalr/signalr.client/public/vite.svg b/connecting-to-backends/syncfusion-reactgrid-with-signalr/signalr.client/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/connecting-to-backends/syncfusion-reactgrid-with-signalr/signalr.client/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/connecting-to-backends/syncfusion-reactgrid-with-signalr/signalr.client/signalr.client.esproj b/connecting-to-backends/syncfusion-reactgrid-with-signalr/signalr.client/signalr.client.esproj new file mode 100644 index 0000000..6631ad9 --- /dev/null +++ b/connecting-to-backends/syncfusion-reactgrid-with-signalr/signalr.client/signalr.client.esproj @@ -0,0 +1,10 @@ + + + npm start + Jasmine + + false + + $(MSBuildProjectDirectory)\dist\signalr.client\browser\ + + \ No newline at end of file diff --git a/connecting-to-backends/syncfusion-reactgrid-with-signalr/signalr.client/src/assets/react.svg b/connecting-to-backends/syncfusion-reactgrid-with-signalr/signalr.client/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/connecting-to-backends/syncfusion-reactgrid-with-signalr/signalr.client/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/connecting-to-backends/syncfusion-reactgrid-with-signalr/signalr.client/src/components/StockGrid.tsx b/connecting-to-backends/syncfusion-reactgrid-with-signalr/signalr.client/src/components/StockGrid.tsx new file mode 100644 index 0000000..2f27d9a --- /dev/null +++ b/connecting-to-backends/syncfusion-reactgrid-with-signalr/signalr.client/src/components/StockGrid.tsx @@ -0,0 +1,338 @@ +import React, { useRef, useEffect, useMemo } from "react"; +import * as SignalR from '@microsoft/SignalR'; +import { + GridComponent, + ColumnsDirective, + ColumnDirective, + Inject, + Sort, + Filter, + Search, + Toolbar, +} from "@syncfusion/ej2-react-grids"; +import "../styles/StockGrid.css"; +import { DataManager, UrlAdaptor } from "@syncfusion/ej2-data"; + +const StockGrid: React.FC = () => { + const gridRef = useRef(null); + const connectionRef = useRef(null); + + const stock = useMemo(() => { + return new DataManager({ + url: 'http://localhost:5083/api/Stock/UrlDatasource', + adaptor: new UrlAdaptor(), + crossDomain: true, + }); + }, []); + + // Initialize SignalR connection for real-time updates + useEffect(() => { + let isSubscribed = true; // Track if component is still mounted + let isSubscriptionPending = false; // Prevent duplicate subscription calls + + const conn = new SignalR.HubConnectionBuilder() + .withUrl("http://localhost:5083/stockHub", { + withCredentials: true, + skipNegotiation: false, + transport: SignalR.HttpTransportType.WebSockets | SignalR.HttpTransportType.LongPolling + }) + .withAutomaticReconnect([0, 2000, 5000, 10000, 30000]) + .configureLogging(SignalR.LogLevel.Information) + .build(); + + connectionRef.current = conn; + + // Handler for initial stock data on connection + conn.on("InitializeStocks", (stocks: any[]) => { + if (!stocks || stocks.length === 0) { + return; + } + + if (!gridRef.current) { + return; + } + + const grid = gridRef.current as any; + + // Update each stock's cells using formatted display values from server + stocks.forEach((stock) => { + try { + grid?.setCellValue(stock.stockId, 'CurrentPrice', stock.currentPrice); + grid?.setCellValue(stock.stockId, 'ChangeDisplay', stock.changeDisplay); + grid?.setCellValue(stock.stockId, 'ChangePercentDisplay', stock.changePercentDisplay); + grid?.setCellValue(stock.stockId, 'VolumeDisplay', stock.volumeDisplay); + grid?.setCellValue(stock.stockId, 'LastUpdated', stock.lastUpdated); + } catch (err) { + console.error(`Error updating stock ${stock?.symbol}:`, err); + } + }); + }); + + // Handler for real-time stock updates + conn.on("ReceiveStockUpdate", (updatedStocks: any[]) => { + if (!updatedStocks || updatedStocks.length === 0) { + return; + } + + if (!gridRef.current) { + return; + } + + const grid = gridRef.current as any; + + // Update each stock's cells with real-time data + updatedStocks.forEach((stock) => { + try { + grid?.setCellValue(stock.stockId, 'CurrentPrice', stock.currentPrice); + grid?.setCellValue(stock.stockId, 'ChangeDisplay', stock.changeDisplay); + grid?.setCellValue(stock.stockId, 'ChangePercentDisplay', stock.changePercentDisplay); + grid?.setCellValue(stock.stockId, 'VolumeDisplay', stock.volumeDisplay); + grid?.setCellValue(stock.stockId, 'LastUpdated', stock.lastUpdated); + } catch (err) { + console.error(`Error updating stock ${stock?.symbol}:`, err); + } + }); + }); + + // Helper function to subscribe - called only once per connection + const subscribeToStocks = async () => { + if (isSubscriptionPending || !isSubscribed) { + return; + } + + isSubscriptionPending = true; + try { + await conn.invoke("SubscribeToStocks"); + if (isSubscribed) { + console.log("✅ Subscribed to stock updates"); + } + } catch (err) { + if (isSubscribed) { + console.error("❌ Error subscribing to stocks:", err); + } + } finally { + isSubscriptionPending = false; + } + }; + + // Connection state logging + conn.onreconnecting((error) => { + console.log(`SignalR connection lost. Attempting to reconnect: ${error?.message}`); + }); + + conn.onclose((error) => { + console.error(`SignalR connection closed: ${error?.message || "Unknown error"}`); + }); + + // Start connection - do not manually call subscribe here + conn.start() + .then(() => { + if (!isSubscribed) { + // Component unmounted during connection - stop immediately + conn.stop(); + return; + } + console.log("✅ SignalR connection established successfully"); + return subscribeToStocks(); + }) + .catch((err: Error) => { + if (isSubscribed) { + console.error("❌ Error establishing SignalR connection:", err.message); + } + }); + + return () => { + isSubscribed = false; // Mark as unsubscribed + + if (connectionRef.current) { + const currentConn = connectionRef.current; + + // Only try to unsubscribe if connection is in Connected state + if (currentConn.state === SignalR.HubConnectionState.Connected) { + currentConn.invoke("UnsubscribeFromStocks") + .catch(() => { + // Silently catch - connection might already be closing + }); + } + + currentConn.off("InitializeStocks"); + currentConn.off("ReceiveStockUpdate"); + + currentConn.stop().catch(() => { + // Silently catch - already stopped or stopping + }); + + connectionRef.current = null; + } + }; + }, []); + + const filterSettings = { type: "Excel" as const }; + const toolbar: string[] = ["Search"]; + + // Column templates for custom styling + const symbolTemplate = (props: any) => ( +
+
{props?.Symbol}
+
+ ); + + const companyTemplate = (props: any) => ( +
+
{props?.Company}
+
+ ); + + // Helper function to determine if a value is positive or negative by parsing the display string + const isPositiveChange = (displayValue: string): boolean => { + if (!displayValue) return false; + // Check for positive indicators: starts with "+" or contains "▲" (up arrow) + return displayValue.includes('+') || displayValue.includes('▲'); + }; + + const isNegativeChange = (displayValue: string): boolean => { + if (!displayValue) return false; + // Check for negative indicators: starts with "-" or contains "▼" (down arrow) or "(" for parentheses notation + return displayValue.includes('-') || displayValue.includes('▼') || displayValue.includes('('); + }; + + // queryCellInfo handler - applies CSS classes for styling based on column type and display values + const queryCellInfo = (args: any) => { + try { + // Remove all possible styling classes first + args.cell?.classList.remove('e-poscell', 'e-negcell', 'e-volumecell', 'e-price'); + + const columnField = args.column?.field; + + // CURRENT PRICE: Blue text, no background + if (columnField === 'CurrentPrice') { + args.cell?.classList.add('e-price'); + } + + // CHANGE DISPLAY: Parse the display string to determine color + else if (columnField === 'ChangeDisplay') { + const changeDisplay = args.data?.ChangeDisplay ?? ''; + if (isNegativeChange(changeDisplay)) { + args.cell?.classList.add('e-negcell'); // RED for price drop + } else if (isPositiveChange(changeDisplay)) { + args.cell?.classList.add('e-poscell'); // GREEN for price increase + } + } + + // CHANGE PERCENT DISPLAY: Parse the display string to determine color + else if (columnField === 'ChangePercentDisplay') { + const changePercentDisplay = args.data?.ChangePercentDisplay ?? ''; + if (isNegativeChange(changePercentDisplay)) { + args.cell?.classList.add('e-negcell'); // RED for percentage drop + } else if (isPositiveChange(changePercentDisplay)) { + args.cell?.classList.add('e-poscell'); // GREEN for percentage increase + } + } + + // VOLUME: Default styling + else if (columnField === 'VolumeDisplay') { + args.cell?.classList.add('e-volumecell'); + } + + // LAST UPDATED: Format the timestamp + else if (columnField === 'LastUpdated') { + const raw = args.data?.LastUpdated; + if (raw) { + const dt = new Date(raw); + const day = String(dt.getDate()).padStart(2, '0'); + const mon = dt.toLocaleString('en-US', { month: 'short' }); + const year = dt.getFullYear(); + let hour = dt.getHours(); + hour = hour % 12 || 12; + const hh = String(hour).padStart(2, '0'); + const mm = String(dt.getMinutes()).padStart(2, '0'); + const ss = String(dt.getSeconds()).padStart(2, '0'); + const formatted = `${day} ${mon} ${year} ${hh}:${mm}:${ss}`; + if (args.cell) args.cell.textContent = formatted; + } + } + } catch (err) { + console.error("Error in queryCellInfo:", err); + } + }; + + return ( +
+
+

Live Stock Market

+

Real-time stock prices updated every 2 seconds via SignalR

+
+ + + + + + + + + + + + + +
+ ); +}; + +export default StockGrid; diff --git a/connecting-to-backends/syncfusion-reactgrid-with-signalr/signalr.client/src/index.css b/connecting-to-backends/syncfusion-reactgrid-with-signalr/signalr.client/src/index.css new file mode 100644 index 0000000..f5428b7 --- /dev/null +++ b/connecting-to-backends/syncfusion-reactgrid-with-signalr/signalr.client/src/index.css @@ -0,0 +1,10 @@ +@import '../node_modules/@syncfusion/ej2-base/styles/tailwind.css'; +@import '../node_modules/@syncfusion/ej2-buttons/styles/tailwind.css'; +@import '../node_modules/@syncfusion/ej2-calendars/styles/tailwind.css'; +@import '../node_modules/@syncfusion/ej2-dropdowns/styles/tailwind.css'; +@import '../node_modules/@syncfusion/ej2-inputs/styles/tailwind.css'; +@import '../node_modules/@syncfusion/ej2-navigations/styles/tailwind.css'; +@import '../node_modules/@syncfusion/ej2-popups/styles/tailwind.css'; +@import '../node_modules/@syncfusion/ej2-splitbuttons/styles/tailwind.css'; +@import '../node_modules/@syncfusion/ej2-notifications/styles/tailwind.css'; +@import "../node_modules/@syncfusion/ej2-react-grids/styles/tailwind.css"; \ No newline at end of file diff --git a/connecting-to-backends/syncfusion-reactgrid-with-signalr/signalr.client/src/main.tsx b/connecting-to-backends/syncfusion-reactgrid-with-signalr/signalr.client/src/main.tsx new file mode 100644 index 0000000..3445c85 --- /dev/null +++ b/connecting-to-backends/syncfusion-reactgrid-with-signalr/signalr.client/src/main.tsx @@ -0,0 +1,7 @@ +import { createRoot } from 'react-dom/client'; +import './index.css'; +import StockGrid from './components/StockGrid.tsx'; + +createRoot(document.getElementById('root')!).render( + +) diff --git a/connecting-to-backends/syncfusion-reactgrid-with-signalr/signalr.client/src/styles/StockGrid.css b/connecting-to-backends/syncfusion-reactgrid-with-signalr/signalr.client/src/styles/StockGrid.css new file mode 100644 index 0000000..1569af7 --- /dev/null +++ b/connecting-to-backends/syncfusion-reactgrid-with-signalr/signalr.client/src/styles/StockGrid.css @@ -0,0 +1,223 @@ +/* Stock Grid Styles */ + +.app-container { + padding: 20px; + margin-top:30px; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.stock-header { + margin-bottom: 20px; +} + +.stock-header h1 { + margin: 0; + font-size: 28px; + color: #1a1a1a; + font-weight: 600; +} + +.stock-header .subtitle { + margin: 8px 0 0 0; + font-size: 14px; + color: #666; + font-weight: 400; +} + +/* Stock Grid Customization */ + +/* Symbol Cell */ +.symbol-pill { + display: inline-block; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + padding: 6px 12px; + border-radius: 20px; + font-weight: 600; + font-size: 13px; + letter-spacing: 0.5px; +} + +/* symbol templates */ + +.symbol-text { + display: inline-block; + background-color: #eef6ff; /* pale blue background */ + color: #2563eb; /* blue text */ + border: 1px solid #c7e0ff; + padding: 4px 10px; + border-radius: 999px; + font-weight: 700; + font-size: 12px; + box-shadow: 0 1px 0 rgba(37,99,235,0.05); +} + +.symbol-text:hover { + background-color: #e6f0ff; + transform: translateY(-1px); +} + +/* Company Name */ +.company-name { + font-size: 13px; + color: #444; + font-weight: 500; +} + +.company-cell { + display: flex; + align-items: center; +} + +/* Grid cell styles using Syncfusion classes */ + +/* Positive/Green cells */ +.e-grid .e-rowcell.e-poscell { + color: #10b981; + font-weight: 700; + background-color: #ecfdf5; + padding: 8px 12px; + border-radius: 4px; + font-size: 14px; +} + +.e-grid tbody .e-row .e-rowcell.e-poscell { + color: #10b981; + font-weight: 700; + background-color: #ecfdf5; + padding: 8px 12px; + border-radius: 4px; + font-size: 14px; +} + +/* Negative/Red cells */ +.e-grid .e-rowcell.e-negcell { + color: #ef4444; + font-weight: 700; + background-color: #fef2f2; + padding: 8px 12px; + border-radius: 4px; + font-size: 14px; +} + +.e-grid tbody .e-row .e-rowcell.e-negcell { + color: #ef4444; + font-weight: 700; + background-color: #fef2f2; + padding: 8px 12px; + border-radius: 4px; + font-size: 14px; +} + +/* Price column: blue text, no background */ +.e-grid .e-rowcell.e-price { + color: #2563eb; + font-weight: 700; + background-color: transparent; + padding: 8px 12px; + font-size: 14px; +} + +.e-grid tbody .e-row .e-rowcell.e-price { + color: #2563eb; + font-weight: 700; + background-color: transparent; + padding: 8px 12px; + font-size: 14px; +} + +.e-grid .e-rowcell.e-volumecell { + + font-weight: 600; + font-size: 14px; + padding: 8px 12px; + border-radius: 6px; + text-align: right; +} + +.e-grid tbody .e-row .e-rowcell.e-volumecell { + color: #11543e; + font-weight: 600; + font-size: 14px; + padding: 8px 12px; + border-radius: 6px; + text-align: right; +} + +/* Timestamp Cell */ +.timestamp { + color: #999; + font-size: 12px; +} + + +/* Up/Down Symbols */ +.symbol { + font-weight: 700; + margin-right: 6px; + font-size: 14px; + display: inline-block; +} + +.value { + font-weight: 700; + font-size: 14px; +} + +/* Change cell with styling */ +.change-cell { + display: flex; + align-items: center; + gap: 4px; +} + +.change-cell .symbol { + margin: 0; +} + +/* Change percent cell */ +.change-percent-cell { + display: flex; + align-items: center; + gap: 4px; +} + +.change-percent-cell .symbol { + margin: 0; +} + + + +/* Responsive adjustments */ +@media (max-width: 768px) { + .app-container { + padding: 10px; + } + + .stock-header h1 { + font-size: 20px; + } + + .stock-header .subtitle { + font-size: 12px; + } + + .symbol-pill { + font-size: 11px; + padding: 4px 8px; + } + + .company-name { + font-size: 11px; + } + + .price { + font-size: 12px; + } + + .change-percent { + font-size: 12px; + padding: 2px 6px; + } +} diff --git a/connecting-to-backends/syncfusion-reactgrid-with-signalr/signalr.client/tsconfig.app.json b/connecting-to-backends/syncfusion-reactgrid-with-signalr/signalr.client/tsconfig.app.json new file mode 100644 index 0000000..a9b5a59 --- /dev/null +++ b/connecting-to-backends/syncfusion-reactgrid-with-signalr/signalr.client/tsconfig.app.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/connecting-to-backends/syncfusion-reactgrid-with-signalr/signalr.client/tsconfig.json b/connecting-to-backends/syncfusion-reactgrid-with-signalr/signalr.client/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/connecting-to-backends/syncfusion-reactgrid-with-signalr/signalr.client/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/connecting-to-backends/syncfusion-reactgrid-with-signalr/signalr.client/tsconfig.node.json b/connecting-to-backends/syncfusion-reactgrid-with-signalr/signalr.client/tsconfig.node.json new file mode 100644 index 0000000..8a67f62 --- /dev/null +++ b/connecting-to-backends/syncfusion-reactgrid-with-signalr/signalr.client/tsconfig.node.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/connecting-to-backends/syncfusion-reactgrid-with-signalr/signalr.client/vite.config.ts b/connecting-to-backends/syncfusion-reactgrid-with-signalr/signalr.client/vite.config.ts new file mode 100644 index 0000000..8b0f57b --- /dev/null +++ b/connecting-to-backends/syncfusion-reactgrid-with-signalr/signalr.client/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], +})