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 =>
|
||||
{
|
||||
policy.AllowAnyOrigin()
|
||||
policy.WithOrigins("http://localhost:5173")
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod();
|
||||
.AllowAnyMethod()
|
||||
.AllowCredentials();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,6 +42,14 @@ var summaries = new[]
|
||||
|
||||
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 =>
|
||||
new WeatherForecast
|
||||
(
|
||||
|
||||
@@ -18,7 +18,7 @@ namespace Api.SignalR
|
||||
logger.LogInformation("Background service sending message at: {Time}", DateTime.Now);
|
||||
|
||||
// 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.
|
||||
"Background Service", // Example sender.
|
||||
$"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 HomePage from './pages/HomePage';
|
||||
import AboutPage from './pages/AboutPage';
|
||||
import WeatherPage from "./pages/WeatherPage.tsx";
|
||||
import WeatherPage from "./pages/WeatherPage/WeatherPage.tsx";
|
||||
|
||||
|
||||
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