The WHERE clause was comparing Code to itself instead of the aliased table reference, which would always be true.
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:
- Completeness - Type accurately represents all possible server responses
- Defense in depth - Fallback if handler fails or is bypassed
- Testing - Can test unauthorized scenarios without HTTP layer
- 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 handlerClient/Extensions/ViewModelMappingExtensions.cs- Core <-> Client mapping
Modify (12 files)
Pages:
Pages/Searches.razor- UsesISearchServicePages/SearchEdit.razor- UsesISearchService,IFileServicePages/SearchQueue.razor- UsesISearchServicePages/Login.razor- UsesIAuthServiceLayout/MainLayout.razor- UsesIAuthService
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.csClient/Services/SearchService.csClient/Services/ILookupService.csClient/Services/LookupService.csClient/Services/IAuthService.csClient/Services/AuthService.csClient/Services/IFileService.csClient/Services/FileService.cs
Keep (not migrating)
IRefreshStatusService/RefreshStatusService- No corresponding API client yetIHubConnectionService/HubConnectionService- SignalR, not HTTPICryptoService/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
- Create foundation files (AuthRedirectHandler, ViewModelMappingExtensions)
- Update Program.cs to register handler
- 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)
- Migrate filter panels:
- ItemNumberFilterPanel (lookup + file)
- WorkCenterFilterPanel (lookup only)
- ProfitCenterFilterPanel (lookup only)
- OperatorFilterPanel (lookup only)
- WorkOrderFilterPanel (file only)
- ComponentLotFilterPanel (file only)
- PartOperationFilterPanel (file only)
- Delete old service files after all components migrated
- Final verification pass
Acceptance Criteria
- All 401 responses redirect to
/loginwith return URL - Components display appropriate error messages for each error type
- No old
I*Serviceinterfaces 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)