# 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"); builder.RootComponents.Add("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(); builder.Services.AddScoped(); // Authentication builder.Services.AddAuthorizationCore(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); // Application services builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); // Logging builder.Logging.SetMinimumLevel(LogLevel.Information); await builder.Build().RunAsync(); ``` ### App.razor Router Configuration ```razor Not found

Sorry, there's nothing at this address.

``` ## 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 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 ParseClaimsFromJwt(string jwt) { var payload = jwt.Split('.')[1]; var jsonBytes = ParseBase64WithoutPadding(payload); var keyValuePairs = JsonSerializer.Deserialize>(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 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 GetTokenAsync() { return await _jsRuntime.InvokeAsync("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? OnSearchUpdate; event Action? OnStatusUpdate; Task StartAsync(CancellationToken ct = default); Task StopAsync(CancellationToken ct = default); Task GetCachedStatusAsync(CancellationToken ct = default); } ``` ### HubConnectionService Implementation ```csharp public class HubConnectionService : IHubConnectionService, IAsyncDisposable { private readonly HubConnection _hubConnection; private readonly ILogger _logger; public HubConnectionState State => _hubConnection.State; public event Action? OnSearchUpdate; public event Action? OnStatusUpdate; public HubConnectionService( NavigationManager navigation, ILogger 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", update => { _logger.LogDebug("Received searchUpdate: {SearchId} - {Status}", update.ID, update.Status); OnSearchUpdate?.Invoke(update); }); _hubConnection.On("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 GetCachedStatusAsync(CancellationToken ct = default) { if (_hubConnection.State == HubConnectionState.Connected) { return await _hubConnection.InvokeAsync("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> GetUserSearchesAsync(CancellationToken ct = default); Task GetSearchAsync(int id, CancellationToken ct = default); Task CopySearchAsync(int id, CancellationToken ct = default); Task SaveSearchAsync(SearchViewModel search, CancellationToken ct = default); Task> GetQueueAsync(CancellationToken ct = default); Task DownloadResultsAsync(int searchId, CancellationToken ct = default); } ``` ### ILookupService ```csharp public interface ILookupService { Task> FindItemsAsync(string query, CancellationToken ct = default); Task> FindProfitCentersAsync(string query, CancellationToken ct = default); Task> FindWorkCentersAsync(string query, CancellationToken ct = default); Task> FindOperatorsAsync(string query, CancellationToken ct = default); } ``` ### IFileService ```csharp public interface IFileService { Task DownloadTemplateAsync(string templateType, CancellationToken ct = default); Task> UploadAsync(IBrowserFile file, string endpoint, CancellationToken ct = default); } public record FileUploadResult( bool WasSuccessful, string? ErrorMessage, List? 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 *@ @if (!IsReadOnly) { } @if (!IsReadOnly) { } @code { [Parameter] public List Items { get; set; } = new(); [Parameter] public EventCallback> 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 *@ @Body An error occurred. Please try again or contact support. @if (Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == "Development") {
@exception.Message
}
@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 _logger; public async Task> GetUserSearchesAsync(CancellationToken ct) { try { return await _httpClient.GetFromJsonAsync>( "/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) { } else { @* Content *@ } ``` ### Grid-Level Loading ```razor ``` ## 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: ```xml ```