Skip to content
515 changes: 284 additions & 231 deletions src/Core/Components/DataGrid/FluentDataGrid.razor

Large diffs are not rendered by default.

84 changes: 67 additions & 17 deletions src/Core/Components/DataGrid/FluentDataGrid.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ public partial class FluentDataGrid<TGridItem> : FluentComponentBase, IHandleEve

internal const string EMPTY_CONTENT_ROW_CLASS = "empty-content-row";
internal const string LOADING_CONTENT_ROW_CLASS = "loading-content-row";
internal const string ERROR_CONTENT_ROW_CLASS = "error-content-row";

private ElementReference? _gridReference;
private Virtualize<(int, TGridItem)>? _virtualizeComponent;
Expand All @@ -44,11 +45,13 @@ public partial class FluentDataGrid<TGridItem> : FluentComponentBase, IHandleEve
private readonly RenderFragment _renderNonVirtualizedRows;
private readonly RenderFragment _renderEmptyContent;
private readonly RenderFragment _renderLoadingContent;
private readonly RenderFragment _renderErrorContent;
private string? _internalGridTemplateColumns;
private PaginationState? _lastRefreshedPaginationState;
private IQueryable<TGridItem>? _lastAssignedItems;
private GridItemsProvider<TGridItem>? _lastAssignedItemsProvider;
private CancellationTokenSource? _pendingDataLoadCancellationTokenSource;
private Exception? _lastError;
private GridItemsProviderRequest<TGridItem>? _lastRequest;
private bool _forceRefreshData;
private readonly EventCallbackSubscriber<PaginationState> _currentPageItemsChanged;
Expand All @@ -64,11 +67,12 @@ public FluentDataGrid(LibraryConfiguration configuration) : base(configuration)
_renderNonVirtualizedRows = RenderNonVirtualizedRows;
_renderEmptyContent = RenderEmptyContent;
_renderLoadingContent = RenderLoadingContent;
_renderErrorContent = RenderErrorContent;

// As a special case, we don't issue the first data load request until we've collected the initial set of columns
// This is so we can apply default sort order (or any future per-column options) before loading data
// We use EventCallbackSubscriber to safely hook this async operation into the synchronous rendering flow
EventCallbackSubscriber<object?>? columnsFirstCollectedSubscriber = new(
// As a special case, we don't issue the first data load request until we've collected the initial set of columns
// This is so we can apply default sort order (or any future per-column options) before loading data
// We use EventCallbackSubscriber to safely hook this async operation into the synchronous rendering flow
EventCallbackSubscriber<object?>? columnsFirstCollectedSubscriber = new(
EventCallback.Factory.Create<object?>(this, RefreshDataCoreAsync));
columnsFirstCollectedSubscriber.SubscribeOrMove(_internalGridContext.ColumnsFirstCollected);
}
Expand Down Expand Up @@ -231,13 +235,6 @@ public FluentDataGrid(LibraryConfiguration configuration) : base(configuration)
[Parameter]
public PaginationState? Pagination { get; set; }

/// <summary>
/// Gets or sets a value indicating whether the component will not add itself to the tab queue.
/// Default is false.
/// </summary>
[Parameter]
public bool NoTabbing { get; set; }

/// <summary>
/// Gets or sets a value indicating whether the grid should automatically generate a header row and its type.
/// See <see cref="DataGridGeneratedHeaderType"/>
Expand Down Expand Up @@ -323,6 +320,27 @@ public FluentDataGrid(LibraryConfiguration configuration) : base(configuration)
[Parameter]
public RenderFragment? LoadingContent { get; set; }

/// <summary>
/// Gets or sets the callback that is invoked when the asynchronous loading state of items changes and <see cref="IAsyncQueryExecutor"/> is used.
/// </summary>
/// <remarks>The callback receives a <see langword="true"/> value when items start loading
/// and a <see langword="false"/> value when the loading process completes.</remarks>
[ExcludeFromCodeCoverage(Justification = "This method requires a db connection and is to complex to be tested with bUnit.")]
[Parameter]
public EventCallback<bool> OnItemsLoading { get; set; }

/// <summary>
/// Gets or sets a delegate that determines whether a given exception should be handled.
/// </summary>
[Parameter]
public Func<Exception, bool>? HandleLoadingError { get; set; }

/// <summary>
/// Gets or sets the content to render when an error occurs.
/// </summary>
[Parameter]
public RenderFragment<Exception>? ErrorContent { get; set; }

/// <summary>
/// Sets <see cref="GridTemplateColumns"/> to automatically fit the columns to the available width as best it can.
/// </summary>
Expand Down Expand Up @@ -732,9 +750,6 @@ private async Task RefreshDataCoreAsync()
// (2) We won't know what slice of data to query for
await _virtualizeComponent.RefreshDataAsync();
_pendingDataLoadCancellationTokenSource = null;

StateHasChanged();
return;
}

// If we're not using Virtualize, we build and execute a request against the items provider directly
Expand Down Expand Up @@ -824,7 +839,7 @@ private async Task RefreshDataCoreAsync()
Pagination?.SetTotalItemCountAsync(_internalGridContext.TotalItemCount);
}

if (_internalGridContext.TotalItemCount > 0 && Loading is null)
if ((_internalGridContext.TotalItemCount > 0 && Loading is null) || _lastError != null)
{
Loading = false;
_ = InvokeAsync(StateHasChanged);
Expand All @@ -844,6 +859,7 @@ private async Task RefreshDataCoreAsync()
// Normalizes all the different ways of configuring a data source so they have common GridItemsProvider-shaped API
private async ValueTask<GridItemsProviderResult<TGridItem>> ResolveItemsRequestAsync(GridItemsProviderRequest<TGridItem> request)
{
CheckAndResetLastError();
try
{
if (ItemsProvider is not null)
Expand All @@ -860,6 +876,11 @@ private async ValueTask<GridItemsProviderResult<TGridItem>> ResolveItemsRequestA

if (Items is not null)
{
if (_asyncQueryExecutor is not null)
{
await OnItemsLoading.InvokeAsync(true);
}

var totalItemCount = _asyncQueryExecutor is null ? Items.Count() : await _asyncQueryExecutor.CountAsync(Items, request.CancellationToken);
_internalGridContext.TotalItemCount = totalItemCount;
IQueryable<TGridItem>? result;
Expand All @@ -880,14 +901,43 @@ private async ValueTask<GridItemsProviderResult<TGridItem>> ResolveItemsRequestA
return GridItemsProviderResult.From(resultArray, totalItemCount);
}
}
catch (OperationCanceledException oce) when (oce.CancellationToken == request.CancellationToken)
catch (OperationCanceledException oce) when (oce.CancellationToken == request.CancellationToken) // No-op; we canceled the operation, so it's fine to suppress this exception.
{
}
catch (Exception ex) when (HandleLoadingError?.Invoke(ex) == true)
{
_lastError = ex.GetBaseException();
}
finally
{
// No-op; we canceled the operation, so it's fine to suppress this exception.
if (Items is not null && _asyncQueryExecutor is not null)
{
CheckAndResetLoading();
await OnItemsLoading.InvokeAsync(false);
}
}

return GridItemsProviderResult.From(Array.Empty<TGridItem>(), 0);
}

private void CheckAndResetLoading()
{
if (Loading == true)
{
Loading = false;
StateHasChanged();
}
}

private void CheckAndResetLastError()
{
if (_lastError != null)
{
_lastError = null;
StateHasChanged();
}
}

private string AriaSortValue(ColumnBase<TGridItem> column)
=> _sortByColumn == column
? (_sortByAscending ? "ascending" : "descending")
Expand Down
4 changes: 2 additions & 2 deletions src/Core/Components/DataGrid/FluentDataGridCell.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,15 @@ public FluentDataGridCell(LibraryConfiguration configuration) : base(configurati

/// <summary />
protected string? StyleValue => DefaultStyleBuilder
.AddStyle("grid-column", GridColumn.ToString(CultureInfo.InvariantCulture), () => !Grid.EffectiveLoadingValue && (Grid.Items is not null || Grid.ItemsProvider is not null) && Grid.DisplayMode == DataGridDisplayMode.Grid)
.AddStyle("grid-column", GridColumn.ToString(CultureInfo.InvariantCulture), () => !Grid.EffectiveLoadingValue && (Grid.Items is not null || Grid.ItemsProvider is not null) && InternalGridContext.TotalItemCount > 0 && Grid.DisplayMode == DataGridDisplayMode.Grid)
.AddStyle("text-align", "center", Column is SelectColumn<TGridItem>)
.AddStyle("align-content", "center", Column is SelectColumn<TGridItem>)
.AddStyle("min-width", Column?.MinWidth, Owner.RowType is DataGridRowType.Header or DataGridRowType.StickyHeader)
.AddStyle("padding-top", "10px", Column is SelectColumn<TGridItem> && (Grid.RowSize == DataGridRowSize.Medium || Owner.RowType == DataGridRowType.Header))
.AddStyle("padding-top", "6px", Column is SelectColumn<TGridItem> && Grid.RowSize == DataGridRowSize.Small && Owner.RowType == DataGridRowType.Default)
.AddStyle("width", Column?.Width, !string.IsNullOrEmpty(Column?.Width) && Grid.DisplayMode == DataGridDisplayMode.Table)
.AddStyle("height", $"{Grid.ItemSize.ToString(CultureInfo.InvariantCulture):0}px", () => !Grid.EffectiveLoadingValue && Grid.Virtualize)
.AddStyle("height", $"{((int)Grid.RowSize).ToString(CultureInfo.InvariantCulture)}px", () => !Grid.EffectiveLoadingValue && !Grid.Virtualize && !Grid.MultiLine && (Grid.Items is not null || Grid.ItemsProvider is not null))
.AddStyle("height", $"{((int)Grid.RowSize).ToString(CultureInfo.InvariantCulture)}px", () => !Grid.EffectiveLoadingValue && !Grid.Virtualize && !Grid.MultiLine && (Grid.Items is not null || Grid.ItemsProvider is not null) && InternalGridContext.TotalItemCount > 0)
.AddStyle("height", "100%", Grid.MultiLine)
.AddStyle("min-height", "44px", Owner.RowType != DataGridRowType.Default)
.AddStyle("z-index", ZIndex.DataGridHeaderPopup.ToString(CultureInfo.InvariantCulture), CellType == DataGridCellType.ColumnHeader && Grid._columns.Count > 0 && Grid.UseMenuService)
Expand Down
26 changes: 26 additions & 0 deletions src/Core/Components/Icons/CustomIcon.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// ------------------------------------------------------------------------
// This file is licensed to you under the MIT License.
// ------------------------------------------------------------------------

namespace Microsoft.FluentUI.AspNetCore.Components;

/// <summary>
/// Custom icon loaded from <see cref="IconsExtensions.GetInstance(IconInfo, bool?)"/>
/// </summary>
public class CustomIcon : Icon
{
/// <summary>
/// Initializes a new instance of the <see cref="CustomIcon"/> class.
/// </summary>
public CustomIcon()
: base(string.Empty, IconVariant.Regular, IconSize.Size24, string.Empty)
{ }

/// <summary>
/// Initializes a new instance of the <see cref="CustomIcon"/> class.
/// </summary>
/// <param name="icon"></param>
public CustomIcon(Icon icon)
: base(icon.Name, icon.Variant, icon.Size, icon.Content)
{ }
}
142 changes: 142 additions & 0 deletions src/Core/Components/Icons/IconsExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// ------------------------------------------------------------------------
// This file is licensed to you under the MIT License.
// ------------------------------------------------------------------------

using System.Diagnostics.CodeAnalysis;
using System.Reflection;

namespace Microsoft.FluentUI.AspNetCore.Components;

/// <summary />
public static partial class IconsExtensions
{
private const string Namespace = "Microsoft.FluentUI.AspNetCore.Components";
private const string LibraryName = "Microsoft.FluentUI.AspNetCore.Components.Icons.{0}"; // {0} must be replaced with the "Variant": Regular, Filled, etc.

/// <summary>
/// Returns a new instance of the icon.
/// </summary>
/// <param name="icon">The <see cref="IconInfo"/> to instantiate.</param>
/// <param name="throwOnError">true to throw an exception if the type is not found (default); false to return null.</param>
/// <remarks>
/// This method requires dynamic access to code. This code may be removed by the trimmer.
/// If the assembly is not yet loaded, it will be loaded by the method `Assembly.Load`.
/// To avoid any issues, the assembly must be loaded before calling this method (e.g. adding an icon in your code).
/// </remarks>
/// <returns></returns>
/// <exception cref="ArgumentException">Raised when the <see cref="IconInfo.Name"/> is not found in predefined icons.</exception>
[ExcludeFromCodeCoverage(Justification = "We can't test the Icon.* DLLs here")]
[RequiresUnreferencedCode("This method requires dynamic access to code. This code may be removed by the trimmer.")]
public static CustomIcon GetInstance(this IconInfo icon, bool? throwOnError = true)
{
var assemblyName = string.Format(System.Globalization.CultureInfo.InvariantCulture, LibraryName, icon.Variant);
var assembly = GetAssembly(assemblyName);

if (assembly != null)
{
var allIcons = assembly.GetTypes().Where(i => i.BaseType == typeof(Icon));

// Ex. Microsoft.FluentUI.AspNetCore.Components.Icons.Filled.Size10+PresenceAvailable
var iconFullName = $"{Namespace}.Icons.{icon.Variant}.Size{(int)icon.Size}+{icon.Name}";
var iconType = allIcons.FirstOrDefault(i => string.Equals(i.FullName, iconFullName, StringComparison.InvariantCultureIgnoreCase));

if (iconType != null)
{
var newIcon = Activator.CreateInstance(iconType);
if (newIcon != null)
{
return new CustomIcon((Icon)newIcon);
}
}
}

if (throwOnError == true || throwOnError == null)
{
throw new ArgumentException(
string.Format(
System.Globalization.CultureInfo.InvariantCulture,
"Icon 'Icons.{0}.Size{1}.{2}' not found.",
icon.Variant.ToString(),
((int)icon.Size).ToString(System.Globalization.CultureInfo.InvariantCulture),
icon.Name),
nameof(icon));
}

return default!;
}

/// <summary>
/// Tries to return a new instance of the icon.
/// </summary>
/// <param name="icon">The <see cref="IconInfo"/> to instantiate.</param>
/// <param name="result">When this method returns, contains the <see cref="CustomIcon"/> value if the conversion succeeded, or null if the conversion failed. This parameter is passed uninitialized; any value originally supplied in result will be overwritten.</param>
/// <remarks>
/// This method requires dynamic access to code. This code may be removed by the trimmer.
/// If the assembly is not yet loaded, it will be loaded by the method `Assembly.Load`.
/// To avoid any issues, the assembly must be loaded before calling this method (e.g. adding an icon in your code).
/// </remarks>
/// <returns>True if the icon was found and created; otherwise, false.</returns>
[RequiresUnreferencedCode("This method requires dynamic access to code. This code may be removed by the trimmer.")]
public static bool TryGetInstance(this IconInfo icon, out CustomIcon? result)
{
result = GetInstance(icon, throwOnError: false);
return result != null;
}

/// <summary>
/// Returns a new instance of the icon.
/// </summary>
/// <remarks>
/// This method requires dynamic access to code. This code may be removed by the trimmer.
/// </remarks>
/// <returns></returns>
/// <exception cref="ArgumentException">Raised when the <see cref="IconInfo.Name"/> is not found in predefined icons.</exception>
[ExcludeFromCodeCoverage(Justification = "We can't test the Icon.* DLLs here.")]
[RequiresUnreferencedCode("This method requires dynamic access to code. This code may be removed by the trimmer.")]
public static IEnumerable<IconInfo> GetAllIcons()
{
var allIcons = new List<IconInfo>();

foreach (var variant in Enum.GetValues(typeof(IconVariant)).Cast<IconVariant>())
{
var assemblyName = string.Format(System.Globalization.CultureInfo.InvariantCulture, LibraryName, variant);
var assembly = GetAssembly(assemblyName);

if (assembly != null)
{
var allTypes = assembly.GetTypes().Where(i => i.BaseType == typeof(Icon) && !string.Equals(i.Name, nameof(CustomIcon), StringComparison.OrdinalIgnoreCase));

allIcons.AddRange(allTypes.Select(type => Activator.CreateInstance(type) as IconInfo ?? new IconInfo()));
}
}

return allIcons;
}

/// <summary />
public static IEnumerable<IconInfo> AllIcons
{
[RequiresUnreferencedCode("This method requires dynamic access to code. This code may be removed by the trimmer.")]
get
{
return GetAllIcons();
}
}

/// <summary />
private static Assembly? GetAssembly(string assemblyName)
{
try
{
return AppDomain.CurrentDomain
.GetAssemblies()
.FirstOrDefault(i => string.Equals(i.ManifestModule.Name, assemblyName + ".dll", StringComparison.OrdinalIgnoreCase))
?? Assembly.Load(assemblyName);

}
catch (Exception)
{
return null;
}
}
}
Loading
Loading