refactor: address code review findings across all projects
Apply comprehensive fixes from code reviews including: - Extract shared utilities (SqlFormatHelper, CellValueConverter, DbDestinationBase) - Add interface abstractions (IAuthenticationService, IDatabaseMigrator, IMisQueryBuilder) - Implement SecureStore for encrypted secrets storage - Fix error handling with proper HTTP status codes and logging - Optimize double enumeration in DevEtlRegistry - Add DataSync.Dev README for developer onboarding - Extract filter panel base classes to reduce duplication - Update code review docs to mark all issues as fixed
This commit is contained in:
@@ -1,11 +1,22 @@
|
||||
@*
|
||||
SearchEdit.razor - Main search creation and editing page.
|
||||
|
||||
Handles creating new searches, editing existing drafts, and viewing completed searches.
|
||||
Integrates with SignalR for real-time status updates during search execution.
|
||||
*@
|
||||
@page "/search"
|
||||
@page "/search/{Id:int}"
|
||||
@attribute [Authorize]
|
||||
@using JdeScoping.Core.ApiContracts
|
||||
@using JdeScoping.Client.Extensions
|
||||
@using JdeScoping.Client.Auth
|
||||
@using JdeScoping.Client.Components.Search
|
||||
@using Microsoft.JSInterop
|
||||
@inject ISearchApiClient SearchApi
|
||||
@inject IHubConnectionService HubConnection
|
||||
@inject AuthStateProvider AuthStateProvider
|
||||
@inject IAuthStateProvider AuthStateProvider
|
||||
@inject ISearchValidationService ValidationService
|
||||
@inject ISearchSubmissionService SubmissionService
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject DialogService DialogService
|
||||
@inject NotificationService NotificationService
|
||||
@@ -34,133 +45,79 @@ else if (!string.IsNullOrEmpty(_errorMessage))
|
||||
}
|
||||
else
|
||||
{
|
||||
<EditForm Model="@_search" OnValidSubmit="@HandleValidSubmit">
|
||||
<DataAnnotationsValidator />
|
||||
<SignalRStatusHandler SearchId="@_search.Id" OnStatusChanged="HandleSearchUpdate" />
|
||||
|
||||
@if (_search.IsReadOnly)
|
||||
{
|
||||
<RadzenAlert AlertStyle="AlertStyle.Warning" ShowIcon="true" Variant="Variant.Flat" class="rz-mb-4">
|
||||
<strong>Note:</strong> Search is read-only because it has already been submitted. To change or re-run the search again click the Copy button.
|
||||
<RadzenButton Text="Copy" ButtonStyle="ButtonStyle.Secondary" Size="ButtonSize.Small" Click="@CopySearchAsync" class="rz-ml-2" />
|
||||
</RadzenAlert>
|
||||
}
|
||||
<FilterVisibilityManager @ref="_visibilityManager" Criteria="@_search.Criteria" OnSearchTypeChanged="OnSearchTypeChanged">
|
||||
<EditForm Model="@_search" OnValidSubmit="@HandleValidSubmit">
|
||||
<DataAnnotationsValidator />
|
||||
|
||||
<!-- Validation Summary -->
|
||||
<ValidationSummary class="rz-mb-4" />
|
||||
@if (_search.IsReadOnly)
|
||||
{
|
||||
<RadzenAlert AlertStyle="AlertStyle.Warning" ShowIcon="true" Variant="Variant.Flat" class="rz-mb-4">
|
||||
<strong>Note:</strong> Search is read-only because it has already been submitted. To change or re-run the search again click the Copy button.
|
||||
<RadzenButton Text="Copy" ButtonStyle="ButtonStyle.Secondary" Size="ButtonSize.Small" Click="@CopySearchAsync" class="rz-ml-2" />
|
||||
</RadzenAlert>
|
||||
}
|
||||
|
||||
<!-- Search Details Panel -->
|
||||
<RadzenCard class="rz-mb-4">
|
||||
<RadzenText TextStyle="TextStyle.H6" class="rz-mb-3">Search Details</RadzenText>
|
||||
<ValidationSummary class="rz-mb-4" />
|
||||
|
||||
<RadzenRow Gap="1rem">
|
||||
<RadzenColumn Size="12">
|
||||
<RadzenFormField Text="Search Type" Style="width: 100%;">
|
||||
<RadzenDropDown @bind-Value="_selectedSearchType" Data="@_validCombinations" TextProperty="Name" ValueProperty="Id"
|
||||
Placeholder="Select type" Disabled="@_search.IsReadOnly" Change="@OnSearchTypeChanged" Style="width: 100%;" />
|
||||
</RadzenFormField>
|
||||
</RadzenColumn>
|
||||
</RadzenRow>
|
||||
<SearchDetailsSection
|
||||
Search="@_search"
|
||||
@bind-SelectedSearchType="@_visibilityManager.SelectedSearchType"
|
||||
ValidCombinations="@_visibilityManager.ValidCombinations"
|
||||
OnDownloadResults="@DownloadResultsAsync" />
|
||||
|
||||
<RadzenRow Gap="1rem" class="rz-mt-3">
|
||||
<RadzenColumn Size="12">
|
||||
<RadzenFormField Text="Name" Style="width: 100%;">
|
||||
<RadzenTextBox @bind-Value="_search.Name" Disabled="@_search.IsReadOnly" Style="width: 100%;" />
|
||||
</RadzenFormField>
|
||||
<ValidationMessage For="@(() => _search.Name)" class="validation-message text-danger" />
|
||||
</RadzenColumn>
|
||||
</RadzenRow>
|
||||
@if (_visibilityManager.ShowTimespan)
|
||||
{
|
||||
<TimeSpanFilterPanel @bind-MinimumDT="_search.Criteria.MinimumDt" @bind-MaximumDT="_search.Criteria.MaximumDt" IsReadOnly="@_search.IsReadOnly" />
|
||||
}
|
||||
|
||||
<RadzenRow Gap="1rem" class="rz-mt-3">
|
||||
<RadzenColumn Size="4">
|
||||
<RadzenFormField Text="Submitted At" Style="width: 100%;">
|
||||
<RadzenTextBox Value="@(_search.SubmitDt?.ToString("MM/dd/yyyy hh:mm:ss tt") ?? "")" ReadOnly="true" Style="width: 100%;" />
|
||||
</RadzenFormField>
|
||||
</RadzenColumn>
|
||||
<RadzenColumn Size="4">
|
||||
<RadzenFormField Text="Started At" Style="width: 100%;">
|
||||
<RadzenTextBox Value="@(_search.StartDt?.ToString("MM/dd/yyyy hh:mm:ss tt") ?? "")" ReadOnly="true" Style="width: 100%;" />
|
||||
</RadzenFormField>
|
||||
</RadzenColumn>
|
||||
<RadzenColumn Size="4">
|
||||
<RadzenFormField Text="Completed At" Style="width: 100%;">
|
||||
<RadzenTextBox Value="@(_search.EndDt?.ToString("MM/dd/yyyy hh:mm:ss tt") ?? "")" ReadOnly="true" Style="width: 100%;" />
|
||||
</RadzenFormField>
|
||||
</RadzenColumn>
|
||||
</RadzenRow>
|
||||
@if (_visibilityManager.ShowWorkOrder)
|
||||
{
|
||||
<WorkOrderFilterPanel @bind-WorkOrders="_search.Criteria.WorkOrders" IsReadOnly="@_search.IsReadOnly" />
|
||||
}
|
||||
|
||||
<RadzenRow Gap="1rem" class="rz-mt-3">
|
||||
<RadzenColumn Size="4">
|
||||
<RadzenFormField Text="User" Style="width: 100%;">
|
||||
<RadzenTextBox Value="@_search.UserName" ReadOnly="true" Style="width: 100%;" />
|
||||
</RadzenFormField>
|
||||
</RadzenColumn>
|
||||
<RadzenColumn Size="4">
|
||||
<RadzenFormField Text="Status" Style="width: 100%;">
|
||||
<RadzenTextBox Value="@_search.Status" ReadOnly="true" Style="@($"width: 100%; background-color: {_search.StatusColor};")" />
|
||||
</RadzenFormField>
|
||||
</RadzenColumn>
|
||||
<RadzenColumn Size="4">
|
||||
@if (_search.HasResults)
|
||||
{
|
||||
<RadzenFormField Text=" " Style="width: 100%;">
|
||||
<RadzenButton Text="Download Results" Icon="download" ButtonStyle="ButtonStyle.Success" Click="@DownloadResultsAsync" Style="width: 100%;" />
|
||||
</RadzenFormField>
|
||||
}
|
||||
</RadzenColumn>
|
||||
</RadzenRow>
|
||||
</RadzenCard>
|
||||
@if (_visibilityManager.ShowItemNumber)
|
||||
{
|
||||
<ItemNumberFilterPanel @bind-Items="_search.Criteria.Items" IsReadOnly="@_search.IsReadOnly" />
|
||||
}
|
||||
|
||||
<!-- Filter Panels -->
|
||||
@if (_showTimespan)
|
||||
{
|
||||
<TimeSpanFilterPanel @bind-MinimumDT="_search.Criteria.MinimumDt" @bind-MaximumDT="_search.Criteria.MaximumDt" IsReadOnly="@_search.IsReadOnly" />
|
||||
}
|
||||
@if (_visibilityManager.ShowProfitCenter)
|
||||
{
|
||||
<ProfitCenterFilterPanel @bind-ProfitCenters="_search.Criteria.ProfitCenters" IsReadOnly="@_search.IsReadOnly" />
|
||||
}
|
||||
|
||||
@if (_showWorkOrder)
|
||||
{
|
||||
<WorkOrderFilterPanel @bind-WorkOrders="_search.Criteria.WorkOrders" IsReadOnly="@_search.IsReadOnly" />
|
||||
}
|
||||
@if (_visibilityManager.ShowWorkCenter)
|
||||
{
|
||||
<WorkCenterFilterPanel @bind-WorkCenters="_search.Criteria.WorkCenters" IsReadOnly="@_search.IsReadOnly" />
|
||||
}
|
||||
|
||||
@if (_showItemNumber)
|
||||
{
|
||||
<ItemNumberFilterPanel @bind-Items="_search.Criteria.Items" IsReadOnly="@_search.IsReadOnly" />
|
||||
}
|
||||
@if (_visibilityManager.ShowComponentLot)
|
||||
{
|
||||
<ComponentLotFilterPanel @bind-ComponentLots="_search.Criteria.ComponentLots" IsReadOnly="@_search.IsReadOnly" />
|
||||
}
|
||||
|
||||
@if (_showProfitCenter)
|
||||
{
|
||||
<ProfitCenterFilterPanel @bind-ProfitCenters="_search.Criteria.ProfitCenters" IsReadOnly="@_search.IsReadOnly" />
|
||||
}
|
||||
@if (_visibilityManager.ShowOperator)
|
||||
{
|
||||
<OperatorFilterPanel @bind-Operators="_search.Criteria.Operators" IsReadOnly="@_search.IsReadOnly" />
|
||||
}
|
||||
|
||||
@if (_showWorkCenter)
|
||||
{
|
||||
<WorkCenterFilterPanel @bind-WorkCenters="_search.Criteria.WorkCenters" IsReadOnly="@_search.IsReadOnly" />
|
||||
}
|
||||
@if (_visibilityManager.ShowItemOperationMis)
|
||||
{
|
||||
<PartOperationFilterPanel @bind-PartOperations="_search.Criteria.PartOperations" IsReadOnly="@_search.IsReadOnly" />
|
||||
}
|
||||
|
||||
@if (_showComponentLot)
|
||||
{
|
||||
<ComponentLotFilterPanel @bind-ComponentLots="_search.Criteria.ComponentLots" IsReadOnly="@_search.IsReadOnly" />
|
||||
}
|
||||
|
||||
@if (_showOperator)
|
||||
{
|
||||
<OperatorFilterPanel @bind-Operators="_search.Criteria.Operators" IsReadOnly="@_search.IsReadOnly" />
|
||||
}
|
||||
|
||||
@if (_showItemOperationMis)
|
||||
{
|
||||
<PartOperationFilterPanel @bind-PartOperations="_search.Criteria.PartOperations" IsReadOnly="@_search.IsReadOnly" />
|
||||
}
|
||||
|
||||
@if (_showExtractMis)
|
||||
{
|
||||
<RadzenCard class="rz-mb-4">
|
||||
<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" Gap="0.5rem">
|
||||
<RadzenCheckBox @bind-Value="_search.Criteria.ExtractMisData" Disabled="true" />
|
||||
<RadzenText TextStyle="TextStyle.Body1">Extract MIS data</RadzenText>
|
||||
</RadzenStack>
|
||||
</RadzenCard>
|
||||
}
|
||||
</EditForm>
|
||||
@if (_visibilityManager.ShowExtractMis)
|
||||
{
|
||||
<RadzenCard class="rz-mb-4">
|
||||
<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" Gap="0.5rem">
|
||||
<RadzenCheckBox @bind-Value="_search.Criteria.ExtractMisData" Disabled="true" />
|
||||
<RadzenText TextStyle="TextStyle.Body1">Extract MIS data</RadzenText>
|
||||
</RadzenStack>
|
||||
</RadzenCard>
|
||||
}
|
||||
</EditForm>
|
||||
</FilterVisibilityManager>
|
||||
}
|
||||
|
||||
@code {
|
||||
@@ -171,28 +128,15 @@ else
|
||||
public int? CopySearchId { get; set; }
|
||||
|
||||
private ClientSearchViewModel _search = new() { Criteria = new() };
|
||||
private IReadOnlyList<ValidCombination> _validCombinations = ValidCombination.GetAll();
|
||||
private int? _selectedSearchType;
|
||||
private FilterVisibilityManager _visibilityManager = null!;
|
||||
|
||||
private bool _isLoading = true;
|
||||
private bool _isSubmitting;
|
||||
private string? _errorMessage;
|
||||
|
||||
// Filter visibility flags
|
||||
private bool _showTimespan;
|
||||
private bool _showWorkOrder;
|
||||
private bool _showItemNumber;
|
||||
private bool _showProfitCenter;
|
||||
private bool _showWorkCenter;
|
||||
private bool _showComponentLot;
|
||||
private bool _showOperator;
|
||||
private bool _showItemOperationMis;
|
||||
private bool _showExtractMis;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadSearchAsync();
|
||||
await SetupSignalRAsync();
|
||||
}
|
||||
|
||||
private async Task LoadSearchAsync()
|
||||
@@ -232,7 +176,6 @@ else
|
||||
}
|
||||
else
|
||||
{
|
||||
// New search
|
||||
_search = new ClientSearchViewModel
|
||||
{
|
||||
Status = "New",
|
||||
@@ -240,12 +183,6 @@ else
|
||||
Criteria = new SearchCriteriaViewModel()
|
||||
};
|
||||
}
|
||||
|
||||
// Detect search type from criteria (only if no error)
|
||||
if (string.IsNullOrEmpty(_errorMessage))
|
||||
{
|
||||
DetectSearchType();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -259,92 +196,26 @@ else
|
||||
return string.Join(" ", messages);
|
||||
}
|
||||
|
||||
private void DetectSearchType()
|
||||
private void OnSearchTypeChanged(ValidCombination? combo)
|
||||
{
|
||||
var criteria = _search.Criteria;
|
||||
|
||||
bool hasTimespan = criteria.MinimumDt.HasValue || criteria.MaximumDt.HasValue;
|
||||
bool hasWorkOrder = criteria.WorkOrders.Count > 0;
|
||||
bool hasItemNumber = criteria.Items.Count > 0;
|
||||
bool hasProfitCenter = criteria.ProfitCenters.Count > 0;
|
||||
bool hasWorkCenter = criteria.WorkCenters.Count > 0;
|
||||
bool hasComponentLot = criteria.ComponentLots.Count > 0;
|
||||
bool hasOperator = criteria.Operators.Count > 0;
|
||||
bool hasPartOperation = criteria.PartOperations.Count > 0;
|
||||
bool hasExtractMis = criteria.ExtractMisData;
|
||||
|
||||
foreach (var combo in _validCombinations)
|
||||
{
|
||||
if (combo.Matches(hasTimespan, hasWorkOrder, hasItemNumber, hasProfitCenter, hasWorkCenter, hasComponentLot, hasOperator, hasPartOperation, hasExtractMis))
|
||||
{
|
||||
_selectedSearchType = combo.Id;
|
||||
UpdateFilterVisibility(combo);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSearchTypeChanged()
|
||||
{
|
||||
var combo = _validCombinations.FirstOrDefault(c => c.Id == _selectedSearchType);
|
||||
if (combo != null)
|
||||
{
|
||||
UpdateFilterVisibility(combo);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateFilterVisibility(ValidCombination combo)
|
||||
{
|
||||
_showTimespan = combo.Timespan;
|
||||
_showWorkOrder = combo.WorkOrder;
|
||||
_showItemNumber = combo.ItemNumber;
|
||||
_showProfitCenter = combo.ProfitCenter;
|
||||
_showWorkCenter = combo.WorkCenter;
|
||||
_showComponentLot = combo.ComponentLot;
|
||||
_showOperator = combo.Operator;
|
||||
_showItemOperationMis = combo.ItemOperationMis;
|
||||
_showExtractMis = combo.ExtractMis;
|
||||
|
||||
// Set ExtractMisData flag based on combo
|
||||
_search.Criteria.ExtractMisData = combo.ExtractMis;
|
||||
}
|
||||
|
||||
private async Task SetupSignalRAsync()
|
||||
{
|
||||
HubConnection.OnSearchUpdate += HandleSearchUpdate;
|
||||
await HubConnection.StartAsync();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void HandleSearchUpdate(SearchUpdateViewModel update)
|
||||
{
|
||||
if (update.Id == _search.Id)
|
||||
{
|
||||
InvokeAsync(() =>
|
||||
{
|
||||
_search.Status = update.Status;
|
||||
_search.SubmitDt = update.SubmitDt;
|
||||
_search.StartDt = update.StartDt;
|
||||
_search.EndDt = update.EndDt;
|
||||
StateHasChanged();
|
||||
});
|
||||
}
|
||||
_search.Status = update.Status;
|
||||
_search.SubmitDt = update.SubmitDt;
|
||||
_search.StartDt = update.StartDt;
|
||||
_search.EndDt = update.EndDt;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task HandleValidSubmit()
|
||||
{
|
||||
// DataAnnotationsValidator has already validated the model
|
||||
// Now perform additional custom validation
|
||||
if (_selectedSearchType == null)
|
||||
{
|
||||
NotificationService.Notify(NotificationSeverity.Error, "Validation Error", "Search type is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate filter data based on search type
|
||||
var validationError = ValidateFilters();
|
||||
var validationError = ValidationService.Validate(_search, _visibilityManager.SelectedSearchType, _visibilityManager);
|
||||
if (!string.IsNullOrEmpty(validationError))
|
||||
{
|
||||
NotificationService.Notify(NotificationSeverity.Error, "Filter Validation Error", validationError);
|
||||
NotificationService.Notify(NotificationSeverity.Error, "Validation Error", validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -353,24 +224,10 @@ else
|
||||
|
||||
private async Task SubmitSearchAsync()
|
||||
{
|
||||
// Manual submit button handler - validate and submit
|
||||
if (string.IsNullOrWhiteSpace(_search.Name))
|
||||
{
|
||||
NotificationService.Notify(NotificationSeverity.Error, "Validation Error", "Name is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (_selectedSearchType == null)
|
||||
{
|
||||
NotificationService.Notify(NotificationSeverity.Error, "Validation Error", "Search type is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate filter data based on search type
|
||||
var validationError = ValidateFilters();
|
||||
var validationError = ValidationService.Validate(_search, _visibilityManager.SelectedSearchType, _visibilityManager);
|
||||
if (!string.IsNullOrEmpty(validationError))
|
||||
{
|
||||
NotificationService.Notify(NotificationSeverity.Error, "Filter Validation Error", validationError);
|
||||
NotificationService.Notify(NotificationSeverity.Error, "Validation Error", validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -393,15 +250,15 @@ else
|
||||
_isSubmitting = true;
|
||||
try
|
||||
{
|
||||
var result = await SearchApi.CreateSearchAsync(_search.ToCore());
|
||||
result.Switch(
|
||||
id => { NavigationManager.NavigateTo($"/search/{id}"); },
|
||||
notFound => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Search not found."); },
|
||||
validation => { NotificationService.Notify(NotificationSeverity.Error, "Validation Error", FormatValidationErrors(validation.FieldErrors)); },
|
||||
unauthorized => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Session expired."); },
|
||||
forbidden => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Access denied."); },
|
||||
error => { NotificationService.Notify(NotificationSeverity.Error, "Error", error.Message); }
|
||||
);
|
||||
var result = await SubmissionService.SubmitAsync(_search);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
NavigationManager.NavigateTo($"/search/{result.SearchId}");
|
||||
}
|
||||
else
|
||||
{
|
||||
NotificationService.Notify(NotificationSeverity.Error, "Error", result.ErrorMessage);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -409,32 +266,6 @@ else
|
||||
}
|
||||
}
|
||||
|
||||
private string? ValidateFilters()
|
||||
{
|
||||
if (_showWorkOrder && _search.Criteria.WorkOrders.Count == 0)
|
||||
return "At least one work order must be specified for the work order filter.";
|
||||
|
||||
if (_showItemNumber && _search.Criteria.Items.Count == 0)
|
||||
return "At least one item number must be specified for the item number filter.";
|
||||
|
||||
if (_showProfitCenter && _search.Criteria.ProfitCenters.Count == 0)
|
||||
return "At least one profit center must be specified for the profit center filter.";
|
||||
|
||||
if (_showWorkCenter && _search.Criteria.WorkCenters.Count == 0)
|
||||
return "At least one work center must be specified for the work center filter.";
|
||||
|
||||
if (_showComponentLot && _search.Criteria.ComponentLots.Count == 0)
|
||||
return "At least one component lot must be specified for the component lot filter.";
|
||||
|
||||
if (_showOperator && _search.Criteria.Operators.Count == 0)
|
||||
return "At least one operator must be specified for the operator filter.";
|
||||
|
||||
if (_showItemOperationMis && _search.Criteria.PartOperations.Count == 0)
|
||||
return "At least one item/operation/MIS entry must be specified for the MIS data filter.";
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private void CopySearchAsync()
|
||||
{
|
||||
NavigationManager.NavigateTo($"/search?copySearchId={_search.Id}");
|
||||
@@ -448,7 +279,17 @@ else
|
||||
{
|
||||
if (bytes.Length > 0)
|
||||
{
|
||||
_ = JSRuntime.InvokeVoidAsync("downloadFile", $"search_results_{_search.Id}.xlsx", bytes);
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("downloadFile", $"search_results_{_search.Id}.xlsx", bytes);
|
||||
}
|
||||
catch (JSException ex)
|
||||
{
|
||||
Console.WriteLine($"JS interop failed during file download: {ex.Message}");
|
||||
}
|
||||
});
|
||||
NotificationService.Notify(NotificationSeverity.Success, "Download", "Results downloaded successfully.");
|
||||
}
|
||||
else
|
||||
@@ -466,6 +307,6 @@ else
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
HubConnection.OnSearchUpdate -= HandleSearchUpdate;
|
||||
// SignalRStatusHandler handles its own disposal
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user