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

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>