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

228 lines
7.9 KiB
Markdown

# 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<T>
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
```csharp
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
```csharp
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:
```csharp
@inject ISearchService SearchService
var searches = await SearchService.GetUserSearchesAsync();
```
After:
```csharp
@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
```csharp
// 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)