Skip to content

Commit 696f10d

Browse files
[DataGrid] Asynchronous IQueryable based loading and error handling UI feedback (#4177)
* Added DataGrid asynchronous IQueryable based loading and error handling UI feedback * Render DataGrid errors in virtualized mode * Fix multiple blank lines warning * Fixed loading state behavior * Fixed ErrorContent rendering * Fixed Loading status handling after _asyncQueryExecutor execution --------- Co-authored-by: Denis Voituron <dvoituron@outlook.com>
1 parent b500ff2 commit 696f10d

3 files changed

Lines changed: 111 additions & 4 deletions

File tree

examples/Demo/Shared/Microsoft.FluentUI.AspNetCore.Components.xml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2104,6 +2104,23 @@
21042104
A default fragment is used if loading content is not specified.
21052105
</summary>
21062106
</member>
2107+
<member name="P:Microsoft.FluentUI.AspNetCore.Components.FluentDataGrid`1.OnItemsLoading">
2108+
<summary>
2109+
Gets or sets the callback that is invoked when the asynchronous loading state of items changes and <see cref="T:Microsoft.FluentUI.AspNetCore.Components.DataGrid.Infrastructure.IAsyncQueryExecutor"/> is used.
2110+
</summary>
2111+
<remarks>The callback receives a <see langword="true"/> value when items start loading
2112+
and a <see langword="false"/> value when the loading process completes.</remarks>
2113+
</member>
2114+
<member name="P:Microsoft.FluentUI.AspNetCore.Components.FluentDataGrid`1.HandleLoadingError">
2115+
<summary>
2116+
Gets or sets a delegate that determines whether a given exception should be handled.
2117+
</summary>
2118+
</member>
2119+
<member name="P:Microsoft.FluentUI.AspNetCore.Components.FluentDataGrid`1.ErrorContent">
2120+
<summary>
2121+
Gets or sets the content to render when an error occurs.
2122+
</summary>
2123+
</member>
21072124
<member name="P:Microsoft.FluentUI.AspNetCore.Components.FluentDataGrid`1.AutoFit">
21082125
<summary>
21092126
Sets <see cref="P:Microsoft.FluentUI.AspNetCore.Components.FluentDataGrid`1.GridTemplateColumns"/> to automatically fit the columns to the available width as best it can.

src/Core/Components/DataGrid/FluentDataGrid.razor

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,11 @@
4040
</thead>
4141
}
4242
<tbody>
43-
@if (EffectiveLoadingValue)
43+
@if (_lastError != null)
44+
{
45+
@_renderErrorContent
46+
}
47+
else if (EffectiveLoadingValue)
4448
{
4549
@_renderLoadingContent
4650
}
@@ -83,7 +87,11 @@
8387
{
8488
var initialRowIndex = (GenerateHeader != GenerateHeaderOption.None) ? 2 : 1; // aria-rowindex is 1-based, plus 1 if there is a header
8589
var rowIndex = initialRowIndex;
86-
if (_internalGridContext.Items.Any())
90+
if (_lastError != null)
91+
{
92+
RenderErrorContent(__builder);
93+
}
94+
else if (_internalGridContext.Items.Any())
8795
{
8896
foreach (var item in _internalGridContext.Items)
8997
{
@@ -260,4 +268,36 @@
260268
</FluentDataGridCell>
261269
</FluentDataGridRow>
262270
}
271+
272+
private void RenderErrorContent(RenderTreeBuilder __builder)
273+
{
274+
if (_lastError == null)
275+
{
276+
return;
277+
}
278+
279+
string? style = null;
280+
string? colspan = null;
281+
if (DisplayMode == DataGridDisplayMode.Grid)
282+
{
283+
style = $"grid-column: 1 / {_columns.Count + 1}";
284+
}
285+
else
286+
{
287+
colspan = _columns.Count.ToString();
288+
}
289+
290+
<FluentDataGridRow Class="@ERROR_CONTENT_ROW_CLASS" TGridItem="TGridItem">
291+
<FluentDataGridCell Class="empty-content-cell" Style="@style" colspan="@colspan">
292+
@if (ErrorContent is null)
293+
{
294+
@("An error occurred while retrieving data.")
295+
}
296+
else
297+
{
298+
@ErrorContent(_lastError)
299+
}
300+
</FluentDataGridCell>
301+
</FluentDataGridRow>
302+
}
263303
}

src/Core/Components/DataGrid/FluentDataGrid.razor.cs

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ public partial class FluentDataGrid<TGridItem> : FluentComponentBase, IHandleEve
2424
private const string JAVASCRIPT_FILE = "./_content/Microsoft.FluentUI.AspNetCore.Components/Components/DataGrid/FluentDataGrid.razor.js";
2525
public const string EMPTY_CONTENT_ROW_CLASS = "empty-content-row";
2626
public const string LOADING_CONTENT_ROW_CLASS = "loading-content-row";
27+
public const string ERROR_CONTENT_ROW_CLASS = "error-content-row";
2728
public List<FluentMenu> _menuReferences = [];
2829

2930
/// <summary />
@@ -280,6 +281,26 @@ public partial class FluentDataGrid<TGridItem> : FluentComponentBase, IHandleEve
280281
[Parameter]
281282
public RenderFragment? LoadingContent { get; set; }
282283

284+
/// <summary>
285+
/// Gets or sets the callback that is invoked when the asynchronous loading state of items changes and <see cref="IAsyncQueryExecutor"/> is used.
286+
/// </summary>
287+
/// <remarks>The callback receives a <see langword="true"/> value when items start loading
288+
/// and a <see langword="false"/> value when the loading process completes.</remarks>
289+
[Parameter]
290+
public EventCallback<bool> OnItemsLoading { get; set; }
291+
292+
/// <summary>
293+
/// Gets or sets a delegate that determines whether a given exception should be handled.
294+
/// </summary>
295+
[Parameter]
296+
public Func<Exception, bool>? HandleLoadingError { get; set; }
297+
298+
/// <summary>
299+
/// Gets or sets the content to render when an error occurs.
300+
/// </summary>
301+
[Parameter]
302+
public RenderFragment<Exception>? ErrorContent { get; set; }
303+
283304
/// <summary>
284305
/// Sets <see cref="GridTemplateColumns"/> to automatically fit the columns to the available width as best it can.
285306
/// </summary>
@@ -378,9 +399,9 @@ public partial class FluentDataGrid<TGridItem> : FluentComponentBase, IHandleEve
378399
// Caches of method->delegate conversions
379400
private readonly RenderFragment _renderColumnHeaders;
380401
private readonly RenderFragment _renderNonVirtualizedRows;
381-
382402
private readonly RenderFragment _renderEmptyContent;
383403
private readonly RenderFragment _renderLoadingContent;
404+
private readonly RenderFragment _renderErrorContent;
384405

385406
private string? _internalGridTemplateColumns;
386407

@@ -394,6 +415,7 @@ public partial class FluentDataGrid<TGridItem> : FluentComponentBase, IHandleEve
394415
private GridItemsProvider<TGridItem>? _lastAssignedItemsProvider;
395416
private CancellationTokenSource? _pendingDataLoadCancellationTokenSource;
396417

418+
private Exception? _lastError;
397419
private GridItemsProviderRequest<TGridItem>? _lastRequest;
398420
private bool _forceRefreshData;
399421

@@ -416,6 +438,7 @@ public FluentDataGrid()
416438
_renderNonVirtualizedRows = RenderNonVirtualizedRows;
417439
_renderEmptyContent = RenderEmptyContent;
418440
_renderLoadingContent = RenderLoadingContent;
441+
_renderErrorContent = RenderErrorContent;
419442

420443
// As a special case, we don't issue the first data load request until we've collected the initial set of columns
421444
// This is so we can apply default sort order (or any future per-column options) before loading data
@@ -842,7 +865,7 @@ private async Task RefreshDataCoreAsync()
842865
{
843866
Pagination?.SetTotalItemCountAsync(_internalGridContext.TotalItemCount);
844867
}
845-
if (_internalGridContext.TotalItemCount > 0 && Loading is null)
868+
if ((_internalGridContext.TotalItemCount > 0 && Loading is null) || _lastError != null)
846869
{
847870
Loading = false;
848871
StateHasChanged();
@@ -861,6 +884,12 @@ private async Task RefreshDataCoreAsync()
861884
// Normalizes all the different ways of configuring a data source so they have common GridItemsProvider-shaped API
862885
private async ValueTask<GridItemsProviderResult<TGridItem>> ResolveItemsRequestAsync(GridItemsProviderRequest<TGridItem> request)
863886
{
887+
if (_lastError != null)
888+
{
889+
_lastError = null;
890+
StateHasChanged();
891+
}
892+
864893
try
865894
{
866895
if (ItemsProvider is not null)
@@ -875,6 +904,10 @@ private async ValueTask<GridItemsProviderResult<TGridItem>> ResolveItemsRequestA
875904
}
876905
else if (Items is not null)
877906
{
907+
if (_asyncQueryExecutor is not null)
908+
{
909+
await OnItemsLoading.InvokeAsync(true);
910+
}
878911
var totalItemCount = _asyncQueryExecutor is null ? Items.Count() : await _asyncQueryExecutor.CountAsync(Items, request.CancellationToken);
879912
_internalGridContext.TotalItemCount = totalItemCount;
880913
IQueryable<TGridItem>? result;
@@ -898,6 +931,23 @@ private async ValueTask<GridItemsProviderResult<TGridItem>> ResolveItemsRequestA
898931
{
899932
// No-op; we canceled the operation, so it's fine to suppress this exception.
900933
}
934+
catch (Exception ex) when (HandleLoadingError?.Invoke(ex) == true)
935+
{
936+
_lastError = ex.GetBaseException();
937+
}
938+
finally
939+
{
940+
if (Items is not null && _asyncQueryExecutor is not null)
941+
{
942+
if (Loading == true)
943+
{
944+
Loading = false;
945+
StateHasChanged();
946+
}
947+
await OnItemsLoading.InvokeAsync(false);
948+
}
949+
}
950+
901951
return GridItemsProviderResult.From(Array.Empty<TGridItem>(), 0);
902952
}
903953

0 commit comments

Comments
 (0)