Implement SignalR integration and refactor WeatherPage
Added SignalR for real-time progress updates during weather data fetch. Refactored WeatherPage to use a new reusable WeatherGrid component and SignalRHelper. Improved loading UI with a radial progress indicator.
This commit is contained in:
@@ -15,9 +15,10 @@ builder.Services.AddCors(options =>
|
|||||||
{
|
{
|
||||||
options.AddDefaultPolicy(policy =>
|
options.AddDefaultPolicy(policy =>
|
||||||
{
|
{
|
||||||
policy.AllowAnyOrigin()
|
policy.WithOrigins("http://localhost:5173")
|
||||||
.AllowAnyHeader()
|
.AllowAnyHeader()
|
||||||
.AllowAnyMethod();
|
.AllowAnyMethod()
|
||||||
|
.AllowCredentials();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -41,6 +42,14 @@ var summaries = new[]
|
|||||||
|
|
||||||
app.MapGet("/weatherforecast", async () =>
|
app.MapGet("/weatherforecast", async () =>
|
||||||
{
|
{
|
||||||
|
var hub = app.Services.GetRequiredService<IHubContext<WeatherUpdateHub>>();
|
||||||
|
for (var i = 0; i < 100; i++)
|
||||||
|
{
|
||||||
|
await hub.Clients.All.SendAsync("ProgressUpdate", "None", $$"""{"percentage": "{{i}}", "message": "{{DateTime.Now.Millisecond}}"}""");
|
||||||
|
await Task.Delay(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
var forecast = Enumerable.Range(1, 2500).Select(index =>
|
var forecast = Enumerable.Range(1, 2500).Select(index =>
|
||||||
new WeatherForecast
|
new WeatherForecast
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ namespace Api.SignalR
|
|||||||
logger.LogInformation("Background service sending message at: {Time}", DateTime.Now);
|
logger.LogInformation("Background service sending message at: {Time}", DateTime.Now);
|
||||||
|
|
||||||
// Send a message to all connected clients.
|
// Send a message to all connected clients.
|
||||||
await hubContext.Clients.User("user1234").SendAsync(
|
await hubContext.Clients.All.SendAsync(
|
||||||
"ReceiveMessage", // This is the client method name.
|
"ReceiveMessage", // This is the client method name.
|
||||||
"Background Service", // Example sender.
|
"Background Service", // Example sender.
|
||||||
$"Hello from the background task at {DateTime.Now:F}", // The message.
|
$"Hello from the background task at {DateTime.Now:F}", // The message.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import React from 'react';
|
|||||||
import {BrowserRouter as Router, Route, Routes} from 'react-router-dom';
|
import {BrowserRouter as Router, Route, Routes} from 'react-router-dom';
|
||||||
import HomePage from './pages/HomePage';
|
import HomePage from './pages/HomePage';
|
||||||
import AboutPage from './pages/AboutPage';
|
import AboutPage from './pages/AboutPage';
|
||||||
import WeatherPage from "./pages/WeatherPage.tsx";
|
import WeatherPage from "./pages/WeatherPage/WeatherPage.tsx";
|
||||||
|
|
||||||
|
|
||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
import with_main_layout from "../layouts/with_main_layout.ts.tsx";
|
|
||||||
import useLocalStorageState from 'use-local-storage-state'; // install via npm
|
|
||||||
import {Client, WeatherForecast} from "../ApiCient.ts";
|
|
||||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
|
||||||
import {faDownload, faTrash} from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import {ColumnDirective, ColumnsDirective, Filter, GridComponent, Inject, Page, Reorder, Resize, Sort} from '@syncfusion/ej2-react-grids';
|
|
||||||
|
|
||||||
function WeatherPage() {
|
|
||||||
// Use the hook to persist your weather data. It will default to null.
|
|
||||||
const [weatherData, setWeatherData] = useLocalStorageState<WeatherForecast[] | null>('WeatherPage-Forecast', {
|
|
||||||
defaultValue: null,
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
const fetchWeatherData = async () => {
|
|
||||||
const apiClient = new Client();
|
|
||||||
try {
|
|
||||||
const data = await apiClient.getWeatherForecast();
|
|
||||||
setWeatherData(data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching weather data:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="card shadow-md card-sm w-full bg-auto-50">
|
|
||||||
<div className="card-header text-2xl ml-5 mt-1">Weather Data</div>
|
|
||||||
<div className="card-body">
|
|
||||||
<div className="row-auto flex gap-x-2 ">
|
|
||||||
<button className="btn w-48" onClick={fetchWeatherData}>
|
|
||||||
<FontAwesomeIcon className="mr-1" icon={faDownload}/>
|
|
||||||
Fetch Weather Data
|
|
||||||
</button>
|
|
||||||
<button className="btn w-48" onClick={() => setWeatherData(null)}>
|
|
||||||
<FontAwesomeIcon className="mr-1" icon={faTrash}/>
|
|
||||||
Clear Weather
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="mt-5">
|
|
||||||
{!weatherData ? (
|
|
||||||
<p>No weather data, please request it!</p>
|
|
||||||
) : (
|
|
||||||
<GridComponent dataSource={weatherData || []} allowPaging={true} pageSettings={{pageCount: 5}} allowReordering={true} allowSorting={true}
|
|
||||||
allowFiltering={true} allowResizing={true}>
|
|
||||||
<ColumnsDirective>
|
|
||||||
<ColumnDirective field="date" format="dd.MM.yy" headerText="Date" width="150"/>
|
|
||||||
<ColumnDirective field="temperatureC" headerText="Temp. (C)" width="60"/>
|
|
||||||
<ColumnDirective field="temperatureF" headerText="Temp. (F)" width="60"/>
|
|
||||||
<ColumnDirective field="summary" headerText="Summary"/>
|
|
||||||
</ColumnsDirective>
|
|
||||||
<Inject services={[Page, Sort, Filter, Reorder, Resize]}/>
|
|
||||||
</GridComponent>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PageWithLayout = with_main_layout(WeatherPage);
|
|
||||||
export default PageWithLayout;
|
|
||||||
33
react-vite-client/src/pages/WeatherPage/WeatherGrid.tsx
Normal file
33
react-vite-client/src/pages/WeatherPage/WeatherGrid.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {ColumnDirective, ColumnsDirective, Filter, GridComponent, Inject, Page, Reorder, Resize, Sort} from "@syncfusion/ej2-react-grids";
|
||||||
|
import {WeatherForecast} from "../../ApiCient.ts";
|
||||||
|
|
||||||
|
interface WeatherGridProps {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents an optional array of weather forecast data.
|
||||||
|
* Each element in the array provides detailed weather forecast information.
|
||||||
|
*/
|
||||||
|
data?: WeatherForecast[];
|
||||||
|
|
||||||
|
testing?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NavMenu: React.FC<WeatherGridProps> = ({data: weatherData}) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<GridComponent dataSource={weatherData || []} allowPaging={true} pageSettings={{pageCount: 5}} allowReordering={true} allowSorting={true}
|
||||||
|
allowFiltering={true} allowResizing={true}>
|
||||||
|
<ColumnsDirective>
|
||||||
|
<ColumnDirective field="date" format="dd.MM.yy" headerText="Date" width="150"/>
|
||||||
|
<ColumnDirective field="temperatureC" headerText="Temp. (C)" width="60"/>
|
||||||
|
<ColumnDirective field="temperatureF" headerText="Temp. (F)" width="60"/>
|
||||||
|
<ColumnDirective field="summary" headerText="Summary"/>
|
||||||
|
</ColumnsDirective>
|
||||||
|
<Inject services={[Page, Sort, Filter, Reorder, Resize]}/>
|
||||||
|
</GridComponent>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NavMenu;
|
||||||
75
react-vite-client/src/pages/WeatherPage/WeatherPage.tsx
Normal file
75
react-vite-client/src/pages/WeatherPage/WeatherPage.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import with_main_layout from "../../layouts/with_main_layout.ts.tsx";
|
||||||
|
import useLocalStorageState from 'use-local-storage-state'; // install via npm
|
||||||
|
import {Client, WeatherForecast} from "../../ApiCient.ts";
|
||||||
|
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||||
|
import {faDownload, faTrash} from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import {useState} from "react";
|
||||||
|
import WeatherGrid from "./WeatherGrid.tsx";
|
||||||
|
import {useSignalR} from "../../signalr/SignalRHelper.ts";
|
||||||
|
|
||||||
|
function WeatherPage() {
|
||||||
|
// Use the hook to persist your weather data. It will default to null.
|
||||||
|
const [weatherData, setWeatherData] = useLocalStorageState<WeatherForecast[] | null>('WeatherPage-Forecast', {defaultValue: [],});
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [currentState, setCurrentState] = useState<string | null>(null);
|
||||||
|
const {subscribe} = useSignalR('http://localhost:5175', 'WeatherUpdateHub');
|
||||||
|
|
||||||
|
|
||||||
|
const fetchWeatherData = async () => {
|
||||||
|
const apiClient = new Client();
|
||||||
|
try {
|
||||||
|
const progressCleanup = subscribe<string>('ProgressUpdate', (_, message) => {
|
||||||
|
setCurrentState(message);
|
||||||
|
});
|
||||||
|
setLoading(true);
|
||||||
|
const data = await apiClient.getWeatherForecast();
|
||||||
|
setWeatherData(data);
|
||||||
|
setLoading(false);
|
||||||
|
progressCleanup();
|
||||||
|
setCurrentState(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching weather data:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card shadow-md card-sm w-full bg-auto-50">
|
||||||
|
<div className="card-header text-2xl ml-5 mt-1">Weather Data</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="row-auto flex gap-x-2 ">
|
||||||
|
<button className="btn w-48" onClick={fetchWeatherData}>
|
||||||
|
<FontAwesomeIcon className="mr-1" icon={faDownload}/>
|
||||||
|
Fetch Weather Data
|
||||||
|
</button>
|
||||||
|
<button className="btn w-48" onClick={() => setWeatherData(null)}>
|
||||||
|
<FontAwesomeIcon className="mr-1" icon={faTrash}/>
|
||||||
|
Clear Weather
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5">
|
||||||
|
{loading ?
|
||||||
|
<div>
|
||||||
|
{/*<p className="m-5">Progress: {currentState ? JSON.parse(currentState).percentage : 0}% - {currentState ? JSON.parse(currentState).message : ''}</p>*/}
|
||||||
|
{(() => {
|
||||||
|
const percentage = currentState ? JSON.parse(currentState).percentage : 0;
|
||||||
|
return (
|
||||||
|
<div className="radial-progress" style={{['--value' as unknown as string]: percentage}} aria-valuenow={percentage} role="progressbar">
|
||||||
|
{percentage}%
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
:
|
||||||
|
!weatherData || weatherData.length === 0 ? (
|
||||||
|
<p>No weather data, please request it!</p>
|
||||||
|
) : (
|
||||||
|
<WeatherGrid data={weatherData}/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PageWithLayout = with_main_layout(WeatherPage);
|
||||||
|
export default PageWithLayout;
|
||||||
63
react-vite-client/src/signalr/SignalRHelper.ts
Normal file
63
react-vite-client/src/signalr/SignalRHelper.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||||
|
import {HubConnection, HubConnectionBuilder, LogLevel} from '@microsoft/signalr';
|
||||||
|
|
||||||
|
export function useSignalR(baseUrl: string, hubName: string) {
|
||||||
|
const [connection, setConnection] = useState<HubConnection | null>(null);
|
||||||
|
const connectionRef = useRef<HubConnection | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const newConnection: HubConnection = new HubConnectionBuilder()
|
||||||
|
.withUrl(`${baseUrl}/${hubName}`)
|
||||||
|
.withAutomaticReconnect()
|
||||||
|
.configureLogging(LogLevel.Information)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
connectionRef.current = newConnection;
|
||||||
|
setConnection(newConnection);
|
||||||
|
|
||||||
|
newConnection
|
||||||
|
.start()
|
||||||
|
.then(() => console.log('SignalR connected.'))
|
||||||
|
.catch(error => console.error('SignalR connection error:', error));
|
||||||
|
|
||||||
|
// Cleanup on unmount: stop the connection.
|
||||||
|
return () => {
|
||||||
|
newConnection.stop();
|
||||||
|
};
|
||||||
|
}, [baseUrl, hubName]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribes to a SignalR event.
|
||||||
|
* @param eventName The event name.
|
||||||
|
* @param callback The callback invoked when the event is received.
|
||||||
|
* @returns A function to unsubscribe the event.
|
||||||
|
*/
|
||||||
|
const subscribe = useCallback(
|
||||||
|
<T>(eventName: string, callback: (...args: T[]) => void): (() => void) => {
|
||||||
|
if (connectionRef.current) {
|
||||||
|
connectionRef.current.on(eventName, callback);
|
||||||
|
return () => connectionRef.current?.off(eventName, callback);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a message via the SignalR connection.
|
||||||
|
* @param methodName The hub method name.
|
||||||
|
* @param args Arguments for the hub method.
|
||||||
|
*/
|
||||||
|
const sendMessage = useCallback(async (methodName: string, ...args: any[]): Promise<void> => {
|
||||||
|
if (connectionRef.current) {
|
||||||
|
try {
|
||||||
|
await connectionRef.current.send(methodName, ...args);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error sending message:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {connection, subscribe, sendMessage};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user