diff --git a/src/Core/Components/DataGrid/FluentDataGrid.razor b/src/Core/Components/DataGrid/FluentDataGrid.razor index 8ced7a3e77..66197188f0 100644 --- a/src/Core/Components/DataGrid/FluentDataGrid.razor +++ b/src/Core/Components/DataGrid/FluentDataGrid.razor @@ -5,260 +5,313 @@ @inherits FluentComponentBase @typeparam TGridItem - @{ - StartCollectingColumns(); - } - @if (!_manualGrid) - { - @ChildContent - } - - @{ - FinishCollectingColumns(); - } - + @{ + StartCollectingColumns(); + } + @if (!_manualGrid) + { + @ChildContent + } + + @{ + FinishCollectingColumns(); + } + - - @if (GenerateHeader != DataGridGeneratedHeaderType.None) - { - DataGridRowType headerType = DataGridRowType.Header; - if (GenerateHeader == DataGridGeneratedHeaderType.Sticky) - { - headerType = DataGridRowType.StickyHeader; - } - - - @_renderColumnHeaders - - - } - - @if (EffectiveLoadingValue) - { - @_renderLoadingContent - } - else - { - @if (Virtualize) - { - if (_internalGridContext.TotalItemCount == 0) - { - @_renderEmptyContent - } - else - { - - } - } - else - { - @_renderNonVirtualizedRows - } - } - @if (_manualGrid) - { - @ChildContent - } - -
-
+ + @if (GenerateHeader != DataGridGeneratedHeaderType.None) + { + DataGridRowType headerType = DataGridRowType.Header; + if (GenerateHeader == DataGridGeneratedHeaderType.Sticky) + { + headerType = DataGridRowType.StickyHeader; + } + + + @_renderColumnHeaders + + + } + + @if (_lastError != null) + { + @_renderErrorContent + } + else if (EffectiveLoadingValue) + { + @_renderLoadingContent + } + else + { + @if (Virtualize) + { + if (_internalGridContext.TotalItemCount == 0) + { + @_renderEmptyContent + } + else + { + + } + } + else + { + @_renderNonVirtualizedRows + } + } + @if (_manualGrid) + { + @ChildContent + } + +
+
@code { - private void RenderNonVirtualizedRows(RenderTreeBuilder __builder) - { - var initialRowIndex = (GenerateHeader != DataGridGeneratedHeaderType.None) ? 2 : 1; // aria-rowindex is 1-based, plus 1 if there is a header - var rowIndex = initialRowIndex; - if (_internalGridContext.Items.Any()) - { - foreach (var item in _internalGridContext.Items) - { - RenderRow(__builder, rowIndex++, item); - } - } - else - { - RenderEmptyContent(__builder); - } - } + private void RenderNonVirtualizedRows(RenderTreeBuilder __builder) + { + var initialRowIndex = (GenerateHeader != DataGridGeneratedHeaderType.None) ? 2 : 1; // aria-rowindex is 1-based, plus 1 if there is a header + var rowIndex = initialRowIndex; + if (_lastError != null) + { + RenderErrorContent(__builder); + } + else if (_internalGridContext.Items.Any()) + { + foreach (var item in _internalGridContext.Items) + { + RenderRow(__builder, rowIndex++, item); + } + } + else + { + RenderEmptyContent(__builder); + } + } - private void RenderRow(RenderTreeBuilder __builder, int rowIndex, TGridItem item) - { - var rowClass = RowClass?.Invoke(item) ?? null; - var rowStyle = RowStyle?.Invoke(item) ?? null; + private void RenderRow(RenderTreeBuilder __builder, int rowIndex, TGridItem item) + { + var rowClass = RowClass?.Invoke(item) ?? null; + var rowStyle = RowStyle?.Invoke(item) ?? null; - - @for (var colIndex = 0; colIndex < _columns.Count; colIndex++) - { - var col = _columns[colIndex]; + + @for (var colIndex = 0; colIndex < _columns.Count; colIndex++) + { + var col = _columns[colIndex]; - string? tooltip = col.Tooltip ? @col.RawCellContent(item) : null; + string? tooltip = col.Tooltip ? @col.RawCellContent(item) : null; - - @((RenderFragment)(__builder => col.CellContent(__builder, item))) - - } - - } + + @((RenderFragment)(__builder => col.CellContent(__builder, item))) + + } + + } - [ExcludeFromCodeCoverage(Justification = "This method is used virtualized mode and cannot be tested with bUnit currently.")] - private void RenderPlaceholderRow(RenderTreeBuilder __builder, PlaceholderContext placeholderContext) - { - string? _rowsDataSize = $"height: {ItemSize}px"; + [ExcludeFromCodeCoverage(Justification = "This method is used virtualized mode and cannot be tested with bUnit currently.")] + private void RenderPlaceholderRow(RenderTreeBuilder __builder, PlaceholderContext placeholderContext) + { + string? _rowsDataSize = $"height: {ItemSize}px"; - - @for (var i = 0; i < _columns.Count; i++) - { - var col = _columns[i]; + + RenderPlaceholderCells(placeholderContext) + + } - - @((RenderFragment)(__builder => col.RenderPlaceholderContent(__builder, placeholderContext))) - - } - - } + [ExcludeFromCodeCoverage(Justification = "Circumvent EFCC shortcomings when using razor.")] + private void RenderPlaceholderCells(RenderTreeBuilder __builder, PlaceholderContext placeholderContext) + { + for (var i = 0; i < _columns.Count; i++) + { + var col = _columns[i]; - private void RenderColumnHeaders(RenderTreeBuilder __builder) - { - @for (var i = 0; i < _columns.Count; i++) - { - var col = _columns[i]; + + @((RenderFragment)(__builder => col.RenderPlaceholderContent(__builder, placeholderContext))) + + } + } - if (_sortByColumn == col) - col.IsActiveSortColumn = true; - else - col.IsActiveSortColumn = false; + private void RenderColumnHeaders(RenderTreeBuilder __builder) + { + @for (var i = 0; i < _columns.Count; i++) + { + var col = _columns[i]; - - @col.HeaderContent - @if (HeaderCellAsButtonWithMenu) - { - @if (col == _displayOptionsForColumn) - { -
- @col.ColumnOptions -
- } - @if (ResizableColumns && col == _displayResizeForColumn) - { -
+ if (_sortByColumn == col) + col.IsActiveSortColumn = true; + else + col.IsActiveSortColumn = false; - @if (ResizeType is not null) - { - - } + + @col.HeaderContent + @if (HeaderCellAsButtonWithMenu) + { + @if (col == _displayOptionsForColumn) + { +
+ @col.ColumnOptions +
+ } + @if (ResizableColumns && col == _displayResizeForColumn) + { +
-
- } - } - else - { - @if (col == _displayOptionsForColumn) - { -
- - @if (ResizeType is not null) - { - - @if (@col.ColumnOptions is not null) - { - - } - } - @col.ColumnOptions - -
- } - } + @if (ResizeType is not null) + { + + } +
+ } + } + else + { + @if (col == _displayOptionsForColumn) + { +
+ + @if (ResizeType is not null) + { + + @if (@col.ColumnOptions is not null) + { + + } + } + @col.ColumnOptions + +
+ } + } - @if (ResizableColumns) - { -
- } -
- } - } - private void RenderEmptyContent(RenderTreeBuilder __builder) - { - if (_manualGrid) - { - return; - } + @if (ResizableColumns) + { +
+ } + + } + } - string? style = null; - string? colspan = null; - if (DisplayMode == DataGridDisplayMode.Grid) - { - style = $"grid-column: 1 / {_columns.Count + 1}"; - } - else - { - colspan = _columns.Count.ToString(); - } + private void RenderEmptyContent(RenderTreeBuilder __builder) + { + if (_manualGrid) + { + return; + } - - - @if (EmptyContent is null) - { + string? style = null; + string? colspan = null; + if (DisplayMode == DataGridDisplayMode.Grid) + { + style = $"grid-column: 1 / {_columns.Count + 1}"; + } + else + { + colspan = _columns.Count.ToString(); + } + + + + @if (EmptyContent is null) + { @Localizer[Localization.LanguageResource.DataGrid_EmptyContent] - } - else - { - @EmptyContent - } - - + } + else + { + @EmptyContent + } + + + + } + + private void RenderLoadingContent(RenderTreeBuilder __builder) + { + string? style = null; + string? colspan = null; + if (DisplayMode == DataGridDisplayMode.Grid) + { + style = $"grid-column: 1 / {_columns.Count + 1}"; + } + else + { + colspan = _columns.Count.ToString(CultureInfo.InvariantCulture); + } + + + + @if (LoadingContent is null) + { + +
Loading...
+
+ } + else + { + @LoadingContent + } +
+
+ } + + [ExcludeFromCodeCoverage(Justification = "This method requires a failing db connection and is to complex to be tested with bUnit.")] + private void RenderErrorContent(RenderTreeBuilder __builder) + { + if (_lastError == null) + { + return; + } - } + string? style = null; + string? colspan = null; + if (DisplayMode == DataGridDisplayMode.Grid) + { + style = $"grid-column: 1 / {_columns.Count + 1}"; + } + else + { + colspan = _columns.Count.ToString(); + } - private void RenderLoadingContent(RenderTreeBuilder __builder) - { - string? style = null; - string? colspan = null; - if (DisplayMode == DataGridDisplayMode.Grid) - { - style = $"grid-column: 1 / {_columns.Count + 1}"; - } - else - { - colspan = _columns.Count.ToString(CultureInfo.InvariantCulture); - } + + + RenderActualError(_lastError) + + + } - - - @if (LoadingContent is null) - { - -
Loading...
-
- } - else - { - @LoadingContent - } -
-
- } + [ExcludeFromCodeCoverage(Justification = "Circumvent EFCC shortcomings when using razor.")] + private void RenderActualError(RenderTreeBuilder __builder, Exception ex) + { + if (ErrorContent is null) + { + @("An error occurred while retrieving data.") + } + else + { + @ErrorContent(ex) + } + } } diff --git a/src/Core/Components/DataGrid/FluentDataGrid.razor.cs b/src/Core/Components/DataGrid/FluentDataGrid.razor.cs index bf527b56bd..760410fa86 100644 --- a/src/Core/Components/DataGrid/FluentDataGrid.razor.cs +++ b/src/Core/Components/DataGrid/FluentDataGrid.razor.cs @@ -26,6 +26,7 @@ public partial class FluentDataGrid : 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; @@ -44,11 +45,13 @@ public partial class FluentDataGrid : 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? _lastAssignedItems; private GridItemsProvider? _lastAssignedItemsProvider; private CancellationTokenSource? _pendingDataLoadCancellationTokenSource; + private Exception? _lastError; private GridItemsProviderRequest? _lastRequest; private bool _forceRefreshData; private readonly EventCallbackSubscriber _currentPageItemsChanged; @@ -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? 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? columnsFirstCollectedSubscriber = new( EventCallback.Factory.Create(this, RefreshDataCoreAsync)); columnsFirstCollectedSubscriber.SubscribeOrMove(_internalGridContext.ColumnsFirstCollected); } @@ -231,13 +235,6 @@ public FluentDataGrid(LibraryConfiguration configuration) : base(configuration) [Parameter] public PaginationState? Pagination { get; set; } - /// - /// Gets or sets a value indicating whether the component will not add itself to the tab queue. - /// Default is false. - /// - [Parameter] - public bool NoTabbing { get; set; } - /// /// Gets or sets a value indicating whether the grid should automatically generate a header row and its type. /// See @@ -323,6 +320,27 @@ public FluentDataGrid(LibraryConfiguration configuration) : base(configuration) [Parameter] public RenderFragment? LoadingContent { get; set; } + /// + /// Gets or sets the callback that is invoked when the asynchronous loading state of items changes and is used. + /// + /// The callback receives a value when items start loading + /// and a value when the loading process completes. + [ExcludeFromCodeCoverage(Justification = "This method requires a db connection and is to complex to be tested with bUnit.")] + [Parameter] + public EventCallback OnItemsLoading { get; set; } + + /// + /// Gets or sets a delegate that determines whether a given exception should be handled. + /// + [Parameter] + public Func? HandleLoadingError { get; set; } + + /// + /// Gets or sets the content to render when an error occurs. + /// + [Parameter] + public RenderFragment? ErrorContent { get; set; } + /// /// Sets to automatically fit the columns to the available width as best it can. /// @@ -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 @@ -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); @@ -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> ResolveItemsRequestAsync(GridItemsProviderRequest request) { + CheckAndResetLastError(); try { if (ItemsProvider is not null) @@ -860,6 +876,11 @@ private async ValueTask> 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? result; @@ -880,14 +901,43 @@ private async ValueTask> 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(), 0); } + private void CheckAndResetLoading() + { + if (Loading == true) + { + Loading = false; + StateHasChanged(); + } + } + + private void CheckAndResetLastError() + { + if (_lastError != null) + { + _lastError = null; + StateHasChanged(); + } + } + private string AriaSortValue(ColumnBase column) => _sortByColumn == column ? (_sortByAscending ? "ascending" : "descending") diff --git a/src/Core/Components/DataGrid/FluentDataGridCell.razor.cs b/src/Core/Components/DataGrid/FluentDataGridCell.razor.cs index ea8b08c93a..f28abfabda 100644 --- a/src/Core/Components/DataGrid/FluentDataGridCell.razor.cs +++ b/src/Core/Components/DataGrid/FluentDataGridCell.razor.cs @@ -36,7 +36,7 @@ public FluentDataGridCell(LibraryConfiguration configuration) : base(configurati /// 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) .AddStyle("align-content", "center", Column is SelectColumn) .AddStyle("min-width", Column?.MinWidth, Owner.RowType is DataGridRowType.Header or DataGridRowType.StickyHeader) @@ -44,7 +44,7 @@ public FluentDataGridCell(LibraryConfiguration configuration) : base(configurati .AddStyle("padding-top", "6px", Column is SelectColumn && 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) diff --git a/src/Core/Components/Icons/CustomIcon.cs b/src/Core/Components/Icons/CustomIcon.cs new file mode 100644 index 0000000000..00189b76c1 --- /dev/null +++ b/src/Core/Components/Icons/CustomIcon.cs @@ -0,0 +1,26 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Custom icon loaded from +/// +public class CustomIcon : Icon +{ + /// + /// Initializes a new instance of the class. + /// + public CustomIcon() + : base(string.Empty, IconVariant.Regular, IconSize.Size24, string.Empty) + { } + + /// + /// Initializes a new instance of the class. + /// + /// + public CustomIcon(Icon icon) + : base(icon.Name, icon.Variant, icon.Size, icon.Content) + { } +} diff --git a/src/Core/Components/Icons/IconsExtensions.cs b/src/Core/Components/Icons/IconsExtensions.cs new file mode 100644 index 0000000000..72af0120b2 --- /dev/null +++ b/src/Core/Components/Icons/IconsExtensions.cs @@ -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; + +/// +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. + + /// + /// Returns a new instance of the icon. + /// + /// The to instantiate. + /// true to throw an exception if the type is not found (default); false to return null. + /// + /// 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). + /// + /// + /// Raised when the is not found in predefined icons. + [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!; + } + + /// + /// Tries to return a new instance of the icon. + /// + /// The to instantiate. + /// When this method returns, contains the 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. + /// + /// 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). + /// + /// True if the icon was found and created; otherwise, false. + [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; + } + + /// + /// Returns a new instance of the icon. + /// + /// + /// This method requires dynamic access to code. This code may be removed by the trimmer. + /// + /// + /// Raised when the is not found in predefined icons. + [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 GetAllIcons() + { + var allIcons = new List(); + + foreach (var variant in Enum.GetValues(typeof(IconVariant)).Cast()) + { + 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; + } + + /// + public static IEnumerable AllIcons + { + [RequiresUnreferencedCode("This method requires dynamic access to code. This code may be removed by the trimmer.")] + get + { + return GetAllIcons(); + } + } + + /// + 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; + } + } +} diff --git a/tests/Core/Components/DataGrid/FluentDataGridTests.FluentDataGrid_ColumnKeyGridSort.verified.razor.html b/tests/Core/Components/DataGrid/FluentDataGridTests.FluentDataGrid_ColumnKeyGridSort.verified.razor.html deleted file mode 100644 index d67660990f..0000000000 --- a/tests/Core/Components/DataGrid/FluentDataGridTests.FluentDataGrid_ColumnKeyGridSort.verified.razor.html +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- - -
First Name
- -
-
-
-
-
-
- - -
Last Name
-
-
-
-
-
DollyParton
JamesBond
NicoleKidman
TomCruise
\ No newline at end of file diff --git a/tests/Core/Components/DataGrid/FluentDataGridTests.FluentDataGrid_ColumnKeyGridSort_NoSortFunction.verified.razor.html b/tests/Core/Components/DataGrid/FluentDataGridTests.FluentDataGrid_ColumnKeyGridSort_NoSortFunction.verified.razor.html deleted file mode 100644 index db6e62aad7..0000000000 --- a/tests/Core/Components/DataGrid/FluentDataGridTests.FluentDataGrid_ColumnKeyGridSort_NoSortFunction.verified.razor.html +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- - -
First Name
- -
-
-
-
-
-
- - -
Last Name
-
-
-
-
-
TomCruise
DollyParton
NicoleKidman
JamesBond
\ No newline at end of file diff --git a/tests/Core/Components/DataGrid/FluentDataGridTests.FluentDataGrid_HeaderCellAsButtonWithMenu.verified.razor.html b/tests/Core/Components/DataGrid/FluentDataGridTests.FluentDataGrid_HeaderCellAsButtonWithMenu.verified.razor.html new file mode 100644 index 0000000000..58eec53350 --- /dev/null +++ b/tests/Core/Components/DataGrid/FluentDataGridTests.FluentDataGrid_HeaderCellAsButtonWithMenu.verified.razor.html @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + +
+ +
Name
+ +
+ + + + + Sort (ascending) + + +
Bill Gates
Denis Voituron
Vincent Baaij
\ No newline at end of file diff --git a/tests/Core/Components/DataGrid/FluentDataGridTests.FluentDataGrid_IsFixed.verified.razor.html b/tests/Core/Components/DataGrid/FluentDataGridTests.FluentDataGrid_IsFixed.verified.razor.html new file mode 100644 index 0000000000..956b8bb5ba --- /dev/null +++ b/tests/Core/Components/DataGrid/FluentDataGridTests.FluentDataGrid_IsFixed.verified.razor.html @@ -0,0 +1,21 @@ + + + + + + + + + + + + +
+
+
+
Name
+
+
+
+

empty content

+
\ No newline at end of file diff --git a/tests/Core/Components/DataGrid/FluentDataGridTests.razor b/tests/Core/Components/DataGrid/FluentDataGridTests.razor index 07e70b2354..00207f7378 100644 --- a/tests/Core/Components/DataGrid/FluentDataGridTests.razor +++ b/tests/Core/Components/DataGrid/FluentDataGridTests.razor @@ -131,10 +131,32 @@ + + ); + + // Assert + Assert.NotNull(cut.Find("#empty-content")); + } + + [Fact] + public void FluentDataGrid_ErrorContent_TableMode() + { + // This is sort of untestable because you'd need to force a connection errror or something like that + // Arrange && Act + IQueryable? Items = null; + + var cut = Render>( + @ +

empty content

+

error content

+ + +
); // Assert Assert.NotNull(cut.Find("#empty-content")); + Assert.Throws(() => cut.Find("#error-content")); } [Fact] @@ -202,6 +224,23 @@ }); } + [Fact] + public void FluentDataGrid_IsFixed() + { + // Arrange && Act + var cut = Render>( + @ + + + +

empty content

+
); + + // Assert + cut.Verify(); + } + + [Fact] public void FluentDataGrid_GridTemplateColumns_And_Width() { @@ -1432,7 +1471,6 @@ FluentDataGrid? grid = default!; ColumnKeyGridSort? _firstNameSort = null; - var _lastNameSort = new ColumnKeyGridSort( "lastname", (queryable, sortAscending) => @@ -1491,6 +1529,8 @@ // Assert Assert.NotEmpty(rows); // Asserting that there are rows present Assert.Equal(4, rows.Count); //3 DataRows + 1 header row + + cut.Verify(); } [Fact] diff --git a/tests/Core/Components/Icons/CustomIconTests.cs b/tests/Core/Components/Icons/CustomIconTests.cs new file mode 100644 index 0000000000..60b1724df8 --- /dev/null +++ b/tests/Core/Components/Icons/CustomIconTests.cs @@ -0,0 +1,43 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using System; +using System.Collections.Generic; +using System.Text; +using Xunit; +using Microsoft.FluentUI.AspNetCore.Components; + +namespace Microsoft.FluentUI.AspNetCore.Components.Tests.Components.Icons; + +public class CustomIconTests : Bunit.TestContext +{ + [Fact] + public void DefaultConstructor_SetsExpectedDefaults() + { + // Arrange & Act + var icon = new CustomIcon(); + + // Assert + Assert.Equal(string.Empty, icon.Name); + Assert.Equal(IconVariant.Regular, icon.Variant); + Assert.Equal(IconSize.Size24, icon.Size); + Assert.Equal(string.Empty, icon.Content); + } + + [Fact] + public void Constructor_WithIcon_CopiesValues() + { + // Arrange + var source = new Icon("my-icon", IconVariant.Filled, IconSize.Size16, ""); + + // Act + var icon = new CustomIcon(source); + + // Assert + Assert.Equal("my-icon", icon.Name); + Assert.Equal(IconVariant.Filled, icon.Variant); + Assert.Equal(IconSize.Size16, icon.Size); + Assert.Equal("", icon.Content); + } +} diff --git a/tests/Core/Components/Icons/IconsExtensionsTests.cs b/tests/Core/Components/Icons/IconsExtensionsTests.cs new file mode 100644 index 0000000000..35c331f9ce --- /dev/null +++ b/tests/Core/Components/Icons/IconsExtensionsTests.cs @@ -0,0 +1,96 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using System; +using System.Collections.Generic; +using Bunit; +using Microsoft.FluentUI.AspNetCore.Components; +using Xunit; + +namespace Microsoft.FluentUI.AspNetCore.Components.Tests.Components.Icons; + +public class IconsExtensionsTests : Bunit.TestContext +{ + private static IconInfo CreateMissingIcon() + { + // Construct an IconInfo that is very unlikely to exist in the icon assemblies. + // Rely on public enums for Variant and Size so the extension methods run their logic. + return new IconInfo + { + Name = "Nonexistent_Icon_For_UnitTest", + Variant = IconVariant.Regular, + Size = IconSize.Size16 + }; + } + + [Fact] + public void GetInstance_ThrowsArgumentException_ForMissingIcon_ByDefault() + { + var icon = CreateMissingIcon(); + + var ex = Assert.Throws(() => icon.GetInstance()); + Assert.Contains("Icons.", ex.Message, StringComparison.InvariantCultureIgnoreCase); + } + + [Fact] + public void GetInstance_ReturnsNull_WhenThrowOnErrorFalse() + { + var icon = CreateMissingIcon(); + + var result = icon.GetInstance(throwOnError: false); + + Assert.Null(result); + } + + [Fact] + public void TryGetInstance_ReturnsFalse_AndResultNull_ForMissingIcon() + { + var icon = CreateMissingIcon(); + + var success = icon.TryGetInstance(out var result); + + Assert.False(success); + Assert.Null(result); + } + + [Fact] + public void GetAllIcons_DoesNotThrow_AndReturnsEnumerable() + { + IEnumerable all = null!; + Exception? thrown = null; + + try + { + all = IconsExtensions.GetAllIcons(); + } + catch (Exception ex) + { + thrown = ex; + } + + Assert.Null(thrown); + Assert.NotNull(all); + // Ensure it's an enumerable (deferred) and can be iterated without throwing. + foreach (var item in all) + { + Assert.NotNull(item); + } + } + + [Fact] + public void AllIcons_Property_ReturnsEnumerable_AndMatchesGetAllIcons() + { + var fromMethod = IconsExtensions.GetAllIcons(); + var fromProperty = IconsExtensions.AllIcons; + + Assert.NotNull(fromProperty); + Assert.NotNull(fromMethod); + + // Compare counts without forcing multiple enumerations in pathological cases. + var listMethod = new List(fromMethod); + var listProp = new List(fromProperty); + + Assert.Equal(listMethod.Count, listProp.Count); + } +}