Files
jdescopingtool/openspec/changes/archive/2026-01-01-implement-blazor-ui/design.md
T
Joseph Doherty 26ff8d9b4f Initial commit: JDE Scoping Tool migration project
Set up repository with legacy .NET Framework 4.8 source (OLD/),
new .NET 10 Blazor solution (NEW/), OpenSpec specifications,
documentation, and project configuration.
2026-01-02 07:43:29 -05:00

694 lines
22 KiB
Markdown

# 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
```csharp
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
```razor
<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:
```csharp
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:
```csharp
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
```csharp
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
```csharp
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
```csharp
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
```csharp
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
```csharp
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
```csharp
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
```javascript
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
```csharp
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
```razor
@* 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
```razor
@* 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
```csharp
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
```razor
@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
```razor
<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<T> | AllowPaging, AllowSorting, IsLoading |
| Dropdown | RadzenDropDown<T> | 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:
```xml
<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>
```