463 lines
16 KiB
C#
463 lines
16 KiB
C#
using AyCode.Core.Helpers;
|
|
using AyCode.Core.Interfaces;
|
|
using AyCode.Core.Loggers;
|
|
using AyCode.Services.Server.SignalRs;
|
|
using AyCode.Services.SignalRs;
|
|
using DevExpress.Blazor;
|
|
using DevExpress.Data.Filtering;
|
|
using DevExpress.Data.Linq;
|
|
using DevExpress.Data.Linq.Helpers;
|
|
using System.Collections;
|
|
|
|
namespace AyCode.Blazor.Components.Components.Grids;
|
|
|
|
#region Models
|
|
|
|
/// <summary>
|
|
/// Sorting information for a single field
|
|
/// </summary>
|
|
public class SignalRGridSortInfo
|
|
{
|
|
public string FieldName { get; set; } = "";
|
|
public bool Descending { get; set; }
|
|
}
|
|
|
|
#endregion
|
|
|
|
/// <summary>
|
|
/// GridCustomDataSource implementation that wraps AcSignalRDataSource.
|
|
/// Provides instant local filtering for previously seen filter criteria,
|
|
/// while refreshing data in background using SignalR callback pattern.
|
|
///
|
|
/// Key features:
|
|
/// - Uses AcSignalRDataSource for caching and background populate
|
|
/// - Tracks seen filter criteria - if already seen, returns local data instantly
|
|
/// - Background refresh with callback/populate pattern (no UI blocking)
|
|
/// - Full GridCustomDataSource support (filter, sort, page, group, summary)
|
|
/// </summary>
|
|
/// <typeparam name="TDataItem">Entity type implementing IId</typeparam>
|
|
/// <typeparam name="TId">ID type (int, Guid, long, etc.)</typeparam>
|
|
public class MgGridSignalRDataSource<TDataItem, TId> : GridCustomDataSource
|
|
where TDataItem : class, IId<TId>
|
|
where TId : struct
|
|
{
|
|
private readonly AcSignalRDataSource<TDataItem, TId, AcObservableCollection<TDataItem>> _innerDataSource;
|
|
private readonly AcLoggerBase? _logger;
|
|
|
|
// DevExpress CriteriaOperator to Expression converter
|
|
private readonly CriteriaToExpressionConverter _criteriaConverter = new();
|
|
|
|
// Track filter criteria that have been seen before
|
|
private readonly HashSet<string> _knownFilterCriteria = new(StringComparer.Ordinal);
|
|
|
|
// Lock for thread-safe operations
|
|
private readonly object _syncLock = new();
|
|
|
|
// Event fired when background refresh completes
|
|
public event Action? OnBackgroundRefreshCompleted;
|
|
|
|
/// <summary>
|
|
/// Creates a new MgGridSignalRDataSource wrapping an existing AcSignalRDataSource
|
|
/// </summary>
|
|
/// <param name="innerDataSource">The underlying AcSignalRDataSource that handles caching and SignalR communication</param>
|
|
/// <param name="logger">Optional logger for debugging</param>
|
|
public MgGridSignalRDataSource(
|
|
AcSignalRDataSource<TDataItem, TId, AcObservableCollection<TDataItem>> innerDataSource,
|
|
AcLoggerBase? logger = null)
|
|
{
|
|
_innerDataSource = innerDataSource ?? throw new ArgumentNullException(nameof(innerDataSource));
|
|
_logger = logger;
|
|
|
|
// Subscribe to data source events
|
|
_innerDataSource.OnDataSourceLoaded += OnInnerDataSourceLoaded;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Specifies the data item type for the grid
|
|
/// </summary>
|
|
protected override Type DataItemType => typeof(TDataItem);
|
|
|
|
/// <summary>
|
|
/// Gets the inner AcSignalRDataSource for direct access if needed
|
|
/// </summary>
|
|
public AcSignalRDataSource<TDataItem, TId, AcObservableCollection<TDataItem>> InnerDataSource => _innerDataSource;
|
|
|
|
#region GridCustomDataSource Implementation
|
|
|
|
/// <summary>
|
|
/// Gets the total count of items matching the current filter.
|
|
/// If filter was seen before, returns local count instantly and refreshes in background.
|
|
/// </summary>
|
|
public override async Task<int> GetItemCountAsync(
|
|
GridCustomDataSourceCountOptions options,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var filterKey = GetFilterKey(options.FilterCriteria);
|
|
|
|
_logger?.Debug($"[MgGridSignalRDataSource] GetItemCountAsync - Filter: {filterKey}");
|
|
|
|
// If we have local data and this filter was seen before, return local count
|
|
if (_innerDataSource.Count > 0 && IsKnownFilter(filterKey))
|
|
{
|
|
var localCount = ApplyLocalFilter(_innerDataSource.ToList(), options.FilterCriteria).Count;
|
|
|
|
_logger?.Debug($"[MgGridSignalRDataSource] Returning local count: {localCount}, refreshing in background");
|
|
|
|
// Refresh in background (fire-and-forget)
|
|
_ = RefreshInBackgroundAsync(filterKey);
|
|
|
|
return localCount;
|
|
}
|
|
|
|
// First time seeing this filter - must wait for server
|
|
_logger?.Debug("[MgGridSignalRDataSource] New filter, waiting for server data");
|
|
|
|
await LoadFromServerAsync();
|
|
MarkFilterAsKnown(filterKey);
|
|
|
|
return ApplyLocalFilter(_innerDataSource.ToList(), options.FilterCriteria).Count;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets items for the current page with filtering and sorting applied.
|
|
/// If filter was seen before, returns local data instantly and refreshes in background.
|
|
/// </summary>
|
|
public override async Task<IList> GetItemsAsync(
|
|
GridCustomDataSourceItemsOptions options,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var filterKey = GetFilterKey(options.FilterCriteria);
|
|
|
|
_logger?.Debug($"[MgGridSignalRDataSource] GetItemsAsync - Skip: {options.StartIndex}, Take: {options.Count}, Filter: {filterKey}");
|
|
|
|
// If we have local data and this filter was seen before, return local data
|
|
if (_innerDataSource.Count > 0 && IsKnownFilter(filterKey))
|
|
{
|
|
var localResult = GetLocalItems(options);
|
|
|
|
_logger?.Debug($"[MgGridSignalRDataSource] Returning {localResult.Count} local items, refreshing in background");
|
|
|
|
// Refresh in background (fire-and-forget)
|
|
RefreshInBackgroundAsync(filterKey).Forget();
|
|
|
|
return localResult;
|
|
}
|
|
|
|
// First time seeing this filter - must wait for server
|
|
_logger?.Debug("[MgGridSignalRDataSource] New filter, waiting for server data");
|
|
|
|
await LoadFromServerAsync();
|
|
MarkFilterAsKnown(filterKey);
|
|
|
|
return GetLocalItems(options);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets unique values for a column (used in filter dropdowns).
|
|
/// Always returns from local data.
|
|
/// </summary>
|
|
public override Task<object[]> GetUniqueValuesAsync(
|
|
GridCustomDataSourceUniqueValuesOptions options,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
_logger?.Debug($"[MgGridSignalRDataSource] GetUniqueValuesAsync - Field: {options.FieldName}");
|
|
|
|
if (_innerDataSource.Count == 0)
|
|
return Task.FromResult(Array.Empty<object>());
|
|
|
|
try
|
|
{
|
|
var propertyInfo = typeof(TDataItem).GetProperty(options.FieldName);
|
|
if (propertyInfo == null)
|
|
return Task.FromResult(Array.Empty<object>());
|
|
|
|
var uniqueValues = _innerDataSource
|
|
.Select(item => propertyInfo.GetValue(item))
|
|
.Where(v => v != null)
|
|
.Distinct()
|
|
.Cast<object>()
|
|
.ToArray();
|
|
|
|
_logger?.Debug($"[MgGridSignalRDataSource] Found {uniqueValues.Length} unique values for {options.FieldName}");
|
|
|
|
return Task.FromResult(uniqueValues);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger?.Error($"[MgGridSignalRDataSource] GetUniqueValuesAsync failed: {ex.Message}", ex);
|
|
return Task.FromResult(Array.Empty<object>());
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets group information for grouped data.
|
|
/// Currently delegates to base implementation.
|
|
/// </summary>
|
|
public override async Task<IList<GridCustomDataSourceGroupInfo>> GetGroupInfoAsync(
|
|
GridCustomDataSourceGroupingOptions options,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
_logger?.Debug("[MgGridSignalRDataSource] GetGroupInfoAsync");
|
|
|
|
// TODO: Implement local grouping when needed
|
|
return await base.GetGroupInfoAsync(options, cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets total summary values.
|
|
/// Calculates from local data.
|
|
/// </summary>
|
|
public override Task<IList> GetTotalSummaryAsync(
|
|
GridCustomDataSourceTotalSummaryOptions options,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
_logger?.Debug($"[MgGridSignalRDataSource] GetTotalSummaryAsync - Summaries: {options.SummaryInfo?.Count ?? 0}");
|
|
|
|
if (options.SummaryInfo == null || options.SummaryInfo.Count == 0 || _innerDataSource.Count == 0)
|
|
return Task.FromResult<IList>(new List<object?>());
|
|
|
|
var filteredData = ApplyLocalFilter(_innerDataSource.ToList(), options.FilterCriteria);
|
|
var summaryValues = new List<object?>();
|
|
|
|
foreach (var summaryInfo in options.SummaryInfo)
|
|
{
|
|
var value = CalculateSummary(filteredData, summaryInfo);
|
|
summaryValues.Add(value);
|
|
}
|
|
|
|
_logger?.Debug($"[MgGridSignalRDataSource] Calculated {summaryValues.Count} summary values");
|
|
|
|
return Task.FromResult<IList>(summaryValues);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Local Data Operations
|
|
|
|
/// <summary>
|
|
/// Gets items from local cache with filter, sort, and paging applied
|
|
/// </summary>
|
|
private List<TDataItem> GetLocalItems(GridCustomDataSourceItemsOptions options)
|
|
{
|
|
var data = _innerDataSource.ToList();
|
|
|
|
// Apply filter
|
|
var filtered = ApplyLocalFilter(data, options.FilterCriteria);
|
|
|
|
// Apply sorting
|
|
var sorted = ApplyLocalSort(filtered, options.SortInfo);
|
|
|
|
// Apply paging
|
|
var paged = sorted
|
|
.Skip(options.StartIndex)
|
|
.Take(options.Count)
|
|
.ToList();
|
|
|
|
return paged;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Applies CriteriaOperator filter to local data using DevExpress CriteriaToExpressionConverter
|
|
/// </summary>
|
|
private List<TDataItem> ApplyLocalFilter(List<TDataItem> data, CriteriaOperator? criteria)
|
|
{
|
|
if (criteria is null || data.Count == 0)
|
|
return data;
|
|
|
|
try
|
|
{
|
|
// Use DevExpress built-in CriteriaToExpressionConverter
|
|
var filteredData = data.AsQueryable().AppendWhere(_criteriaConverter, criteria);
|
|
return filteredData.Cast<TDataItem>().ToList();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger?.Error($"[MgGridSignalRDataSource] Local filter failed: {ex.Message}", ex);
|
|
return data;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Applies sorting to local data
|
|
/// </summary>
|
|
private List<TDataItem> ApplyLocalSort(List<TDataItem> data, IReadOnlyList<GridCustomDataSourceSortInfo>? sortInfo)
|
|
{
|
|
if (sortInfo == null || sortInfo.Count == 0 || data.Count == 0)
|
|
return data;
|
|
|
|
try
|
|
{
|
|
IOrderedEnumerable<TDataItem>? ordered = null;
|
|
|
|
for (var i = 0; i < sortInfo.Count; i++)
|
|
{
|
|
var sort = sortInfo[i];
|
|
var propertyInfo = typeof(TDataItem).GetProperty(sort.FieldName);
|
|
|
|
if (propertyInfo == null)
|
|
continue;
|
|
|
|
Func<TDataItem, object?> keySelector = item => propertyInfo.GetValue(item);
|
|
|
|
if (i == 0)
|
|
{
|
|
ordered = sort.DescendingSortOrder
|
|
? data.OrderByDescending(keySelector)
|
|
: data.OrderBy(keySelector);
|
|
}
|
|
else if (ordered != null)
|
|
{
|
|
ordered = sort.DescendingSortOrder
|
|
? ordered.ThenByDescending(keySelector)
|
|
: ordered.ThenBy(keySelector);
|
|
}
|
|
}
|
|
|
|
return ordered?.ToList() ?? data;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger?.Error($"[MgGridSignalRDataSource] Local sort failed: {ex.Message}", ex);
|
|
return data;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Calculates a summary value for the given data
|
|
/// </summary>
|
|
private object? CalculateSummary(List<TDataItem> data, GridCustomDataSourceSummaryInfo summaryInfo)
|
|
{
|
|
if (data.Count == 0)
|
|
return null;
|
|
|
|
try
|
|
{
|
|
var propertyInfo = typeof(TDataItem).GetProperty(summaryInfo.FieldName);
|
|
|
|
return summaryInfo.SummaryType switch
|
|
{
|
|
GridSummaryItemType.Count => data.Count,
|
|
GridSummaryItemType.Sum when propertyInfo != null =>
|
|
data.Sum(item => Convert.ToDecimal(propertyInfo.GetValue(item) ?? 0)),
|
|
GridSummaryItemType.Min when propertyInfo != null =>
|
|
data.Min(item => propertyInfo.GetValue(item)),
|
|
GridSummaryItemType.Max when propertyInfo != null =>
|
|
data.Max(item => propertyInfo.GetValue(item)),
|
|
GridSummaryItemType.Avg when propertyInfo != null =>
|
|
data.Average(item => Convert.ToDecimal(propertyInfo.GetValue(item) ?? 0)),
|
|
_ => null
|
|
};
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger?.Error($"[MgGridSignalRDataSource] Summary calculation failed: {ex.Message}", ex);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Filter Criteria Tracking
|
|
|
|
/// <summary>
|
|
/// Gets a unique key for the filter criteria
|
|
/// </summary>
|
|
private string GetFilterKey(CriteriaOperator? criteria)
|
|
{
|
|
if (criteria is null)
|
|
return string.Empty;
|
|
|
|
try
|
|
{
|
|
return CriteriaOperator.ToString(criteria);
|
|
}
|
|
catch
|
|
{
|
|
return string.Empty;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks if this filter has been seen before
|
|
/// </summary>
|
|
private bool IsKnownFilter(string filterKey)
|
|
{
|
|
lock (_syncLock)
|
|
{
|
|
return _knownFilterCriteria.Contains(filterKey);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Marks a filter as known (seen before)
|
|
/// </summary>
|
|
private void MarkFilterAsKnown(string filterKey)
|
|
{
|
|
lock (_syncLock)
|
|
{
|
|
_knownFilterCriteria.Add(filterKey);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Clears the known filter cache
|
|
/// </summary>
|
|
public void ClearKnownFilters()
|
|
{
|
|
lock (_syncLock)
|
|
{
|
|
_knownFilterCriteria.Clear();
|
|
}
|
|
_logger?.Debug("[MgGridSignalRDataSource] Known filters cleared");
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Server Communication
|
|
|
|
/// <summary>
|
|
/// Loads data from server synchronously (blocking)
|
|
/// </summary>
|
|
private Task LoadFromServerAsync()
|
|
{
|
|
_logger?.Debug("[MgGridSignalRDataSource] Loading from server (sync)");
|
|
return _innerDataSource.LoadDataSource();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Refreshes data in background using callback pattern (non-blocking)
|
|
/// </summary>
|
|
private Task RefreshInBackgroundAsync(string filterKey)
|
|
{
|
|
_logger?.Debug($"[MgGridSignalRDataSource] Starting background refresh for filter: {filterKey}");
|
|
|
|
// Use async callback version - this won't block
|
|
return _innerDataSource.LoadDataSourceAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Called when inner data source finishes loading
|
|
/// </summary>
|
|
private Task OnInnerDataSourceLoaded()
|
|
{
|
|
_logger?.Debug("[MgGridSignalRDataSource] Inner data source loaded, triggering refresh event");
|
|
OnBackgroundRefreshCompleted?.Invoke();
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Cleanup
|
|
|
|
/// <summary>
|
|
/// Invalidates all caches and clears known filters
|
|
/// </summary>
|
|
public void InvalidateCache()
|
|
{
|
|
ClearKnownFilters();
|
|
_logger?.Debug("[MgGridSignalRDataSource] Cache invalidated");
|
|
}
|
|
|
|
#endregion
|
|
}
|