Files
jdescopingtool/PLANS/2026-01-06-blazor-migration-design.md
T
Joseph Doherty d4135e8ad3 fix(data-access): correct self-referential SQL in WorkCenter filter
The WHERE clause was comparing Code to itself instead of the aliased
table reference, which would always be true.
2026-01-06 14:12:07 -05:00

7.9 KiB

Blazor Component Migration Design

Purpose

Migrate all Blazor components from using the old I*Service interfaces to the new I*ApiClient interfaces, implementing proper error handling with the ApiResult<T> discriminated union pattern.

Architecture

View Model Mapping

The client has its own view models (JdeScoping.Client.Models.*) that differ from Core view models (JdeScoping.Core.ViewModels.*):

Client Core Differences
SearchViewModel SearchViewModel Client uses string Status, Core uses SearchStatus enum
SearchCriteriaViewModel SearchCriteria Same structure, different namespaces
ItemViewModel ItemViewModel Same
OperatorViewModel JdeUserViewModel Different names

Strategy: Create mapping extension methods to convert Core -> Client view models.

Error Handling Strategy

Hybrid approach:

  • Global 401 handling: AuthRedirectHandler (DelegatingHandler) intercepts all 401 responses and redirects to /login
  • Component-level handling: Components use result.Switch() for all other cases (success, not found, validation errors, general errors)

Why Keep Unauthorized in ApiResult

Even with global 401 handling, we keep Unauthorized in the type for:

  1. Completeness - Type accurately represents all possible server responses
  2. Defense in depth - Fallback if handler fails or is bypassed
  3. Testing - Can test unauthorized scenarios without HTTP layer
  4. Future flexibility - May need component-specific handling later

Pattern Usage

Components use result.Switch() directly - no shared helper method. This provides:

  • Explicit handling at each call site
  • Flexibility for different UI patterns per component
  • Clear, readable code without indirection

Components

AuthRedirectHandler

public class AuthRedirectHandler : DelegatingHandler
{
    private readonly NavigationManager _navigationManager;

    public AuthRedirectHandler(NavigationManager navigationManager)
    {
        _navigationManager = navigationManager;
    }

    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        var response = await base.SendAsync(request, cancellationToken);

        if (response.StatusCode == HttpStatusCode.Unauthorized)
        {
            var returnUrl = Uri.EscapeDataString(_navigationManager.Uri);
            _navigationManager.NavigateTo($"/login?returnUrl={returnUrl}", forceLoad: true);
        }

        return response;
    }
}

View Model Mapping Extensions

public static class ViewModelMappingExtensions
{
    public static ClientSearchViewModel ToClient(this Core.ViewModels.SearchViewModel vm) => new()
    {
        Id = vm.Id,
        Name = vm.Name,
        UserName = vm.UserName,
        Status = vm.Status.ToString(),
        SubmitDt = vm.SubmitDt,
        StartDt = vm.StartDt,
        EndDt = vm.EndDt,
        Criteria = vm.Criteria?.ToClientCriteria() ?? new()
    };

    public static Core.ViewModels.SearchViewModel ToCore(this ClientSearchViewModel vm) => new()
    {
        Id = vm.Id,
        Name = vm.Name,
        UserName = vm.UserName,
        Status = Enum.Parse<SearchStatus>(vm.Status),
        SubmitDt = vm.SubmitDt,
        StartDt = vm.StartDt,
        EndDt = vm.EndDt,
        Criteria = vm.Criteria.ToCoreCriteria()
    };
}

Component Pattern

Before:

@inject ISearchService SearchService

var searches = await SearchService.GetUserSearchesAsync();

After:

@inject ISearchApiClient SearchApi

var result = await SearchApi.GetUserSearchesAsync();
result.Switch(
    searches => { _searches = searches.Select(s => s.ToClient()).ToList(); },
    notFound => { _errorMessage = "Not found"; },
    validation => { _errorMessage = validation.Message; },
    unauthorized => { /* handled globally, but fallback */ },
    forbidden => { _errorMessage = "Access denied"; },
    error => { _errorMessage = error.Message; }
);

Files to Change

Create

  • Client/Http/AuthRedirectHandler.cs - Global 401 redirect handler
  • Client/Extensions/ViewModelMappingExtensions.cs - Core <-> Client mapping

Modify (12 files)

Pages:

  1. Pages/Searches.razor - Uses ISearchService
  2. Pages/SearchEdit.razor - Uses ISearchService, IFileService
  3. Pages/SearchQueue.razor - Uses ISearchService
  4. Pages/Login.razor - Uses IAuthService
  5. Layout/MainLayout.razor - Uses IAuthService

Filter Panels: 6. Components/FilterPanels/ItemNumberFilterPanel.razor - Uses ILookupService, IFileService 7. Components/FilterPanels/WorkCenterFilterPanel.razor - Uses ILookupService 8. Components/FilterPanels/ProfitCenterFilterPanel.razor - Uses ILookupService 9. Components/FilterPanels/OperatorFilterPanel.razor - Uses ILookupService 10. Components/FilterPanels/WorkOrderFilterPanel.razor - Uses IFileService 11. Components/FilterPanels/ComponentLotFilterPanel.razor - Uses IFileService 12. Components/FilterPanels/PartOperationFilterPanel.razor - Uses IFileService

Update

  • Client/Program.cs - Register AuthRedirectHandler, configure HttpClient, remove old services

Delete (8 old service files)

  • Client/Services/ISearchService.cs
  • Client/Services/SearchService.cs
  • Client/Services/ILookupService.cs
  • Client/Services/LookupService.cs
  • Client/Services/IAuthService.cs
  • Client/Services/AuthService.cs
  • Client/Services/IFileService.cs
  • Client/Services/FileService.cs

Keep (not migrating)

  • IRefreshStatusService / RefreshStatusService - No corresponding API client yet
  • IHubConnectionService / HubConnectionService - SignalR, not HTTP
  • ICryptoService / CryptoService - Client-side encryption

DI Registration

// Add handler
builder.Services.AddTransient<AuthRedirectHandler>();

// Configure HttpClient with handler pipeline
builder.Services.AddScoped(sp =>
{
    var navigationManager = sp.GetRequiredService<NavigationManager>();
    var handler = new AuthRedirectHandler(navigationManager)
    {
        InnerHandler = new HttpClientHandler()
    };
    return new HttpClient(handler)
    {
        BaseAddress = new Uri(sp.GetRequiredService<IWebAssemblyHostEnvironment>().BaseAddress)
    };
});

// Remove old service registrations
// - builder.Services.AddScoped<ISearchService, SearchService>();
// - builder.Services.AddScoped<ILookupService, LookupService>();
// - builder.Services.AddScoped<IAuthService, AuthService>();
// - builder.Services.AddScoped<IFileService, FileService>();

Migration Order

  1. Create foundation files (AuthRedirectHandler, ViewModelMappingExtensions)
  2. Update Program.cs to register handler
  3. Migrate pages one at a time, testing each:
    • Searches.razor (simplest, read-only)
    • SearchQueue.razor (read-only)
    • SearchEdit.razor (most complex, read + write)
    • Login.razor (auth)
    • MainLayout.razor (logout)
  4. Migrate filter panels:
    • ItemNumberFilterPanel (lookup + file)
    • WorkCenterFilterPanel (lookup only)
    • ProfitCenterFilterPanel (lookup only)
    • OperatorFilterPanel (lookup only)
    • WorkOrderFilterPanel (file only)
    • ComponentLotFilterPanel (file only)
    • PartOperationFilterPanel (file only)
  5. Delete old service files after all components migrated
  6. Final verification pass

Acceptance Criteria

  • All 401 responses redirect to /login with return URL
  • Components display appropriate error messages for each error type
  • No old I*Service interfaces remain in use
  • Old service files deleted
  • All components compile and function correctly
  • Authentication flow works end-to-end
  • View model mapping works correctly (Core <-> Client)