26ff8d9b4f
Set up repository with legacy .NET Framework 4.8 source (OLD/), new .NET 10 Blazor solution (NEW/), OpenSpec specifications, documentation, and project configuration.
22 KiB
22 KiB
Blazor UI Design
Overview
This document describes the architecture and implementation approach for the Blazor WebAssembly user interface, including the component structure, state management, SignalR integration, and service layer design.
Architecture
High-Level Component Diagram
+------------------------------------------------------------------+
| App.razor |
| - Router |
| - CascadingAuthenticationState |
+------------------------------------------------------------------+
|
v
+------------------------------------------------------------------+
| MainLayout.razor |
| - RadzenLayout (Header, Body, Footer) |
| - AuthorizeView for user display |
| - Navigation links |
+------------------------------------------------------------------+
|
+----------------------+----------------------+
| | |
v v v
+----------------+ +------------------+ +------------------+
| Login.razor | | Searches.razor | | RefreshStatus |
| (anonymous) | | SearchEdit.razor | | .razor |
| | | SearchQueue.razor| | |
+----------------+ +------------------+ +------------------+
|
+----------+-----------+
| |
v v
+------------------+ +------------------+
| Filter Panels | | Services |
| (components) | | (DI injected) |
+------------------+ +------------------+
Project Structure
NEW/src/JdeScoping.Client/
├── wwwroot/
│ ├── index.html
│ ├── css/
│ │ └── app.css
│ └── js/
│ └── interop.js # File download helper
├── Layout/
│ └── MainLayout.razor
├── Pages/
│ ├── Login.razor
│ ├── NotAuthorized.razor
│ ├── Searches.razor # Home/Search list
│ ├── SearchEdit.razor # Create/Edit search
│ ├── SearchQueue.razor
│ └── RefreshStatus.razor
├── Components/
│ ├── FilterPanels/
│ │ ├── TimeSpanFilterPanel.razor
│ │ ├── WorkOrderFilterPanel.razor
│ │ ├── ItemNumberFilterPanel.razor
│ │ ├── ProfitCenterFilterPanel.razor
│ │ ├── WorkCenterFilterPanel.razor
│ │ ├── ComponentLotFilterPanel.razor
│ │ ├── OperatorFilterPanel.razor
│ │ └── PartOperationFilterPanel.razor
│ └── Shared/
│ └── LoadingIndicator.razor
├── Services/
│ ├── IAuthService.cs
│ ├── AuthService.cs
│ ├── ISearchService.cs
│ ├── SearchService.cs
│ ├── ILookupService.cs
│ ├── LookupService.cs
│ ├── IFileService.cs
│ ├── FileService.cs
│ ├── IRefreshStatusService.cs
│ ├── RefreshStatusService.cs
│ ├── IHubConnectionService.cs
│ └── HubConnectionService.cs
├── Auth/
│ ├── AuthStateProvider.cs
│ └── TokenStorageService.cs
├── Models/
│ ├── ValidCombination.cs
│ ├── LoginModel.cs
│ ├── SearchViewModel.cs
│ ├── SearchCriteriaViewModel.cs
│ ├── ItemViewModel.cs
│ ├── ProfitCenterViewModel.cs
│ ├── WorkCenterViewModel.cs
│ ├── OperatorViewModel.cs
│ ├── WorkOrderViewModel.cs
│ ├── ComponentLotViewModel.cs
│ ├── PartOperationViewModel.cs
│ ├── DataUpdateViewModel.cs
│ ├── SearchUpdate.cs
│ └── StatusUpdate.cs
├── _Imports.razor
├── App.razor
└── Program.cs
Blazor WebAssembly Configuration
Program.cs Service Registration
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");
// Configure HttpClient for API calls
builder.Services.AddScoped(sp => new HttpClient
{
BaseAddress = new Uri(builder.HostEnvironment.BaseAddress)
});
// Radzen services
builder.Services.AddRadzenComponents();
builder.Services.AddScoped<DialogService>();
builder.Services.AddScoped<NotificationService>();
// Authentication
builder.Services.AddAuthorizationCore();
builder.Services.AddScoped<AuthenticationStateProvider, AuthStateProvider>();
builder.Services.AddScoped<AuthStateProvider>();
builder.Services.AddScoped<ITokenStorageService, TokenStorageService>();
// Application services
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<ISearchService, SearchService>();
builder.Services.AddScoped<ILookupService, LookupService>();
builder.Services.AddScoped<IFileService, FileService>();
builder.Services.AddScoped<IRefreshStatusService, RefreshStatusService>();
builder.Services.AddScoped<IHubConnectionService, HubConnectionService>();
// Logging
builder.Logging.SetMinimumLevel(LogLevel.Information);
await builder.Build().RunAsync();
App.razor Router Configuration
<CascadingAuthenticationState>
<Router AppAssembly="@typeof(App).Assembly">
<Found Context="routeData">
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
<NotAuthorized>
<RedirectToLogin />
</NotAuthorized>
</AuthorizeRouteView>
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found>
<NotFound>
<PageTitle>Not found</PageTitle>
<LayoutView Layout="@typeof(MainLayout)">
<p role="alert">Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>
</CascadingAuthenticationState>
State Management
AuthStateProvider
Custom authentication state provider for JWT token-based auth:
public class AuthStateProvider : AuthenticationStateProvider
{
private readonly ITokenStorageService _tokenStorage;
private readonly HttpClient _httpClient;
public AuthStateProvider(ITokenStorageService tokenStorage, HttpClient httpClient)
{
_tokenStorage = tokenStorage;
_httpClient = httpClient;
}
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
var token = await _tokenStorage.GetTokenAsync();
if (string.IsNullOrEmpty(token))
{
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
}
_httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", token);
var claims = ParseClaimsFromJwt(token);
var identity = new ClaimsIdentity(claims, "jwt");
return new AuthenticationState(new ClaimsPrincipal(identity));
}
public async Task NotifyAuthenticationStateChangedAsync()
{
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
}
public async Task LogoutAsync()
{
await _tokenStorage.RemoveTokenAsync();
_httpClient.DefaultRequestHeaders.Authorization = null;
NotifyAuthenticationStateChanged(Task.FromResult(
new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()))));
}
private IEnumerable<Claim> ParseClaimsFromJwt(string jwt)
{
var payload = jwt.Split('.')[1];
var jsonBytes = ParseBase64WithoutPadding(payload);
var keyValuePairs = JsonSerializer.Deserialize<Dictionary<string, object>>(jsonBytes);
return keyValuePairs!.Select(kvp => new Claim(kvp.Key, kvp.Value.ToString()!));
}
private byte[] ParseBase64WithoutPadding(string base64)
{
switch (base64.Length % 4)
{
case 2: base64 += "=="; break;
case 3: base64 += "="; break;
}
return Convert.FromBase64String(base64);
}
}
Token Storage Service
Uses browser localStorage via JS interop:
public interface ITokenStorageService
{
Task<string?> GetTokenAsync();
Task SetTokenAsync(string token);
Task RemoveTokenAsync();
}
public class TokenStorageService : ITokenStorageService
{
private readonly IJSRuntime _jsRuntime;
private const string TokenKey = "authToken";
public TokenStorageService(IJSRuntime jsRuntime)
{
_jsRuntime = jsRuntime;
}
public async Task<string?> GetTokenAsync()
{
return await _jsRuntime.InvokeAsync<string?>("localStorage.getItem", TokenKey);
}
public async Task SetTokenAsync(string token)
{
await _jsRuntime.InvokeVoidAsync("localStorage.setItem", TokenKey, token);
}
public async Task RemoveTokenAsync()
{
await _jsRuntime.InvokeVoidAsync("localStorage.removeItem", TokenKey);
}
}
SignalR Client Integration
IHubConnectionService
public interface IHubConnectionService
{
HubConnectionState State { get; }
event Action<SearchUpdate>? OnSearchUpdate;
event Action<StatusUpdate>? OnStatusUpdate;
Task StartAsync(CancellationToken ct = default);
Task StopAsync(CancellationToken ct = default);
Task<StatusUpdate?> GetCachedStatusAsync(CancellationToken ct = default);
}
HubConnectionService Implementation
public class HubConnectionService : IHubConnectionService, IAsyncDisposable
{
private readonly HubConnection _hubConnection;
private readonly ILogger<HubConnectionService> _logger;
public HubConnectionState State => _hubConnection.State;
public event Action<SearchUpdate>? OnSearchUpdate;
public event Action<StatusUpdate>? OnStatusUpdate;
public HubConnectionService(
NavigationManager navigation,
ILogger<HubConnectionService> logger)
{
_logger = logger;
_hubConnection = new HubConnectionBuilder()
.WithUrl(navigation.ToAbsoluteUri("/hubs/status"))
.WithAutomaticReconnect(new[]
{
TimeSpan.Zero,
TimeSpan.FromSeconds(2),
TimeSpan.FromSeconds(5),
TimeSpan.FromSeconds(10),
TimeSpan.FromSeconds(30)
})
.ConfigureLogging(logging =>
{
logging.SetMinimumLevel(LogLevel.Information);
})
.Build();
// Register message handlers
_hubConnection.On<SearchUpdate>("searchUpdate", update =>
{
_logger.LogDebug("Received searchUpdate: {SearchId} - {Status}",
update.ID, update.Status);
OnSearchUpdate?.Invoke(update);
});
_hubConnection.On<StatusUpdate>("statusUpdate", update =>
{
_logger.LogDebug("Received statusUpdate: {Message}", update.Message);
OnStatusUpdate?.Invoke(update);
});
// Handle reconnection events
_hubConnection.Reconnected += connectionId =>
{
_logger.LogInformation("SignalR reconnected: {ConnectionId}", connectionId);
return Task.CompletedTask;
};
_hubConnection.Reconnecting += error =>
{
_logger.LogWarning(error, "SignalR reconnecting...");
return Task.CompletedTask;
};
_hubConnection.Closed += error =>
{
_logger.LogWarning(error, "SignalR connection closed");
return Task.CompletedTask;
};
}
public async Task StartAsync(CancellationToken ct = default)
{
if (_hubConnection.State == HubConnectionState.Disconnected)
{
_logger.LogInformation("Starting SignalR connection...");
await _hubConnection.StartAsync(ct);
_logger.LogInformation("SignalR connected");
}
}
public async Task StopAsync(CancellationToken ct = default)
{
if (_hubConnection.State == HubConnectionState.Connected)
{
await _hubConnection.StopAsync(ct);
_logger.LogInformation("SignalR disconnected");
}
}
public async Task<StatusUpdate?> GetCachedStatusAsync(CancellationToken ct = default)
{
if (_hubConnection.State == HubConnectionState.Connected)
{
return await _hubConnection.InvokeAsync<StatusUpdate?>("GetCachedStatus", ct);
}
return null;
}
public async ValueTask DisposeAsync()
{
await _hubConnection.DisposeAsync();
}
}
SignalR Message Types
public record SearchUpdate(
int ID,
string Name,
string UserName,
SearchStatus Status,
DateTime? SubmitDT,
DateTime? StartDT,
DateTime? EndDT);
public record StatusUpdate(
string Message,
DateTime Timestamp);
API Client Services
ISearchService
public interface ISearchService
{
Task<List<SearchViewModel>> GetUserSearchesAsync(CancellationToken ct = default);
Task<SearchViewModel> GetSearchAsync(int id, CancellationToken ct = default);
Task<SearchViewModel> CopySearchAsync(int id, CancellationToken ct = default);
Task<int> SaveSearchAsync(SearchViewModel search, CancellationToken ct = default);
Task<List<SearchViewModel>> GetQueueAsync(CancellationToken ct = default);
Task DownloadResultsAsync(int searchId, CancellationToken ct = default);
}
ILookupService
public interface ILookupService
{
Task<IEnumerable<ItemViewModel>> FindItemsAsync(string query, CancellationToken ct = default);
Task<IEnumerable<ProfitCenterViewModel>> FindProfitCentersAsync(string query, CancellationToken ct = default);
Task<IEnumerable<WorkCenterViewModel>> FindWorkCentersAsync(string query, CancellationToken ct = default);
Task<IEnumerable<OperatorViewModel>> FindOperatorsAsync(string query, CancellationToken ct = default);
}
IFileService
public interface IFileService
{
Task DownloadTemplateAsync(string templateType, CancellationToken ct = default);
Task<FileUploadResult<T>> UploadAsync<T>(IBrowserFile file, string endpoint, CancellationToken ct = default);
}
public record FileUploadResult<T>(
bool WasSuccessful,
string? ErrorMessage,
List<T>? Data);
File Download via JS Interop
wwwroot/js/interop.js
window.downloadFileFromStream = async (fileName, streamRef) => {
const arrayBuffer = await streamRef.arrayBuffer();
const blob = new Blob([arrayBuffer]);
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = fileName ?? '';
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
};
window.downloadFileFromUrl = (url, fileName) => {
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = fileName ?? '';
anchor.click();
anchor.remove();
};
FileService Download Implementation
public async Task DownloadResultsAsync(int searchId, CancellationToken ct)
{
var response = await _httpClient.GetAsync($"/api/search/{searchId}/results", ct);
response.EnsureSuccessStatusCode();
var fileName = $"Search_{searchId}_Results.xlsx";
var content = await response.Content.ReadAsStreamAsync(ct);
using var streamRef = new DotNetStreamReference(content);
await _jsRuntime.InvokeVoidAsync("downloadFileFromStream", fileName, streamRef);
}
Filter Panel Component Pattern
Base Structure
All filter panels follow a consistent pattern with:
- Card container with title
- Optional toolbar (Download Template, Upload Data, Clear Data)
- Input controls (autocomplete, date picker, etc.)
- Data grid of selected items
- Count display
Two-Way Binding Pattern
@* ItemNumberFilterPanel.razor - simplified *@
<RadzenCard>
<RadzenStack Gap="1rem">
<RadzenText TextStyle="TextStyle.H6" Text="Filter by item number" />
@if (!IsReadOnly)
{
<RadzenRow Gap="1rem" AlignItems="AlignItems.End">
<RadzenColumn Size="8">
<RadzenAutoComplete @bind-Value="@selectedItemText"
Data="@itemSuggestions"
LoadData="@LoadItemSuggestions"
MinLength="3" />
</RadzenColumn>
<RadzenColumn Size="4">
<RadzenButton Text="Add to filter" Click="@HandleAddItem" />
</RadzenColumn>
</RadzenRow>
}
<RadzenDataGrid Data="@Items" TItem="ItemViewModel">
<Columns>
<RadzenDataGridColumn Property="ItemNumber" Title="Item Number" />
<RadzenDataGridColumn Property="Description" Title="Description" />
@if (!IsReadOnly)
{
<RadzenDataGridColumn Title="Actions" Width="100px">
<Template Context="item">
<RadzenButton Icon="delete" Click="@(() => HandleDeleteItem(item))" />
</Template>
</RadzenDataGridColumn>
}
</Columns>
</RadzenDataGrid>
<RadzenText Text="@($"# of item numbers: {Items.Count}")" />
</RadzenStack>
</RadzenCard>
@code {
[Parameter] public List<ItemViewModel> Items { get; set; } = new();
[Parameter] public EventCallback<List<ItemViewModel>> ItemsChanged { get; set; }
[Parameter] public bool IsReadOnly { get; set; }
private async Task HandleAddItem()
{
// Add item and notify parent
Items.Add(new ItemViewModel { ... });
await ItemsChanged.InvokeAsync(Items);
}
private async Task HandleDeleteItem(ItemViewModel item)
{
Items.Remove(item);
await ItemsChanged.InvokeAsync(Items);
}
}
Error Handling
Global Error Boundary
@* In MainLayout.razor *@
<ErrorBoundary @ref="errorBoundary">
<ChildContent>
@Body
</ChildContent>
<ErrorContent Context="exception">
<RadzenAlert AlertStyle="AlertStyle.Danger" ShowIcon="true">
An error occurred. Please try again or contact support.
@if (Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == "Development")
{
<pre>@exception.Message</pre>
}
</RadzenAlert>
</ErrorContent>
</ErrorBoundary>
@code {
private ErrorBoundary? errorBoundary;
protected override void OnParametersSet()
{
errorBoundary?.Recover();
}
}
Service-Level Error Handling
public class SearchService : ISearchService
{
private readonly HttpClient _httpClient;
private readonly NotificationService _notificationService;
private readonly ILogger<SearchService> _logger;
public async Task<List<SearchViewModel>> GetUserSearchesAsync(CancellationToken ct)
{
try
{
return await _httpClient.GetFromJsonAsync<List<SearchViewModel>>(
"/api/search", ct) ?? new();
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to fetch user searches");
_notificationService.Notify(NotificationSeverity.Error,
"Error", "Failed to load searches. Please try again.");
return new();
}
}
}
Loading States
Component-Level Loading
@if (isLoading)
{
<RadzenStack AlignItems="AlignItems.Center" JustifyContent="JustifyContent.Center"
Style="min-height: 200px;">
<RadzenProgressBarCircular Mode="ProgressBarMode.Indeterminate" Size="60" />
<RadzenText Text="Loading..." />
</RadzenStack>
}
else
{
@* Content *@
}
Grid-Level Loading
<RadzenDataGrid TItem="SearchViewModel" Data="@searches"
IsLoading="@isLoading" Style="min-height: 450px;">
Radzen Component Reference
| UI Element | Component | Key Properties |
|---|---|---|
| Layout container | RadzenLayout | RadzenHeader, RadzenBody, RadzenFooter |
| Navigation | RadzenLink, NavigationManager | Path, NavigateTo |
| Data grid | RadzenDataGrid | AllowPaging, AllowSorting, IsLoading |
| Dropdown | RadzenDropDown | Data, TextProperty, ValueProperty |
| Autocomplete | RadzenAutoComplete | LoadData, MinLength |
| Date picker | RadzenDatePicker | DateFormat, Min, Max |
| Text input | RadzenTextBox | Placeholder, Disabled |
| Password | RadzenPassword | Placeholder |
| Button | RadzenButton | Text, Icon, ButtonStyle, IsBusy |
| Card | RadzenCard | - |
| Alert | RadzenAlert | AlertStyle, ShowIcon |
| Badge | RadzenBadge | BadgeStyle, Text |
| Progress | RadzenProgressBarCircular | Mode="Indeterminate" |
| Dialog | DialogService | Confirm, Alert |
| Notification | NotificationService | Notify |
| File upload | RadzenUpload | Url, Accept, Complete |
| Checkbox | RadzenCheckBox | @bind-Value |
| Validation | EditForm + DataAnnotationsValidator | OnValidSubmit |
NuGet Dependencies
The JdeScoping.Client project already includes required packages:
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.*" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.*" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.*" />
<PackageReference Include="Radzen.Blazor" Version="8.*" />
</ItemGroup>