From f76ba2ee0111b3105e87e8bb09d2e778bd24bcf6 Mon Sep 17 00:00:00 2001 From: Vincent Baaij Date: Thu, 2 Oct 2025 22:52:12 +0200 Subject: [PATCH 01/10] Implement #4036 --- .../DataGrid/FluentDataGridCell.razor.cs | 2 +- .../DataGrid/FluentDataGridRow.razor.cs | 5 +++++ .../DataGrid/FluentDataGridCellTests.razor | 15 +++++++++++++++ .../DataGrid/FluentDataGridRowTests.razor | 14 ++++++++++++++ .../Components/DataGrid/FluentDataGridTests.razor | 1 + 5 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/Core/Components/DataGrid/FluentDataGridCell.razor.cs b/src/Core/Components/DataGrid/FluentDataGridCell.razor.cs index d094b050c6..23a904b992 100644 --- a/src/Core/Components/DataGrid/FluentDataGridCell.razor.cs +++ b/src/Core/Components/DataGrid/FluentDataGridCell.razor.cs @@ -17,7 +17,7 @@ public partial class FluentDataGridCell : FluentComponentBase /// /// Gets a reference to the column that this cell belongs to. /// - private ColumnBase? Column => Grid._columns.ElementAtOrDefault(GridColumn - 1); + public ColumnBase? Column => Grid._columns.ElementAtOrDefault(GridColumn - 1); internal string CellId { get; set; } = string.Empty; diff --git a/src/Core/Components/DataGrid/FluentDataGridRow.razor.cs b/src/Core/Components/DataGrid/FluentDataGridRow.razor.cs index 8b515e45bb..1a08cb9966 100644 --- a/src/Core/Components/DataGrid/FluentDataGridRow.razor.cs +++ b/src/Core/Components/DataGrid/FluentDataGridRow.razor.cs @@ -86,6 +86,11 @@ public FluentDataGridRow(LibraryConfiguration configuration) : base(configuratio /// protected FluentDataGrid Grid => InternalGridContext.Grid; + /// + /// Gets the columns associated with this data grid row. + /// + public IReadOnlyList> Columns => Grid._columns; + /// /// Sets the RowIndex for this row. /// diff --git a/tests/Core/Components/DataGrid/FluentDataGridCellTests.razor b/tests/Core/Components/DataGrid/FluentDataGridCellTests.razor index a01c62587e..e5932f5148 100644 --- a/tests/Core/Components/DataGrid/FluentDataGridCellTests.razor +++ b/tests/Core/Components/DataGrid/FluentDataGridCellTests.razor @@ -42,6 +42,21 @@ Assert.Null(cell.Instance.ChildContent); } + [Fact] + public void FluentDataGridCell_Properties_CompileCorrectly() + { + // This test verifies that our new public properties compile correctly + // by checking that they exist and are public using reflection + + var cellType = typeof(FluentDataGridCell); + + // Verify that the Column property exists on DataGridCell + var columnProperty = cellType.GetProperty("Column"); + Assert.NotNull(columnProperty); + Assert.True(columnProperty.CanRead); + Assert.True(columnProperty.GetMethod?.IsPublic); + } + [Fact] public async Task FluentDataGridCell_HandleOnCellClickAsync_InvokesCallbacks() { diff --git a/tests/Core/Components/DataGrid/FluentDataGridRowTests.razor b/tests/Core/Components/DataGrid/FluentDataGridRowTests.razor index 2cb7b9b3cc..d544a681e1 100644 --- a/tests/Core/Components/DataGrid/FluentDataGridRowTests.razor +++ b/tests/Core/Components/DataGrid/FluentDataGridRowTests.razor @@ -41,6 +41,20 @@ Assert.NotNull(row.Instance.ChildContent); } + [Fact] + public void FluentDataGridRow_Properties_CompileCorrectly() + { + // This test verifies that our new public properties compile correctly + // by checking that they exist and are public using reflection + + var rowType = typeof(FluentDataGridRow); + + // Verify that the Columns property exists on DataGridRow + var columnsProperty = rowType.GetProperty("Columns"); + Assert.NotNull(columnsProperty); + Assert.True(columnsProperty.CanRead); + Assert.True(columnsProperty.GetMethod?.IsPublic); + } [Fact] public void FluentDataGridRow_TestNativeHandlers() diff --git a/tests/Core/Components/DataGrid/FluentDataGridTests.razor b/tests/Core/Components/DataGrid/FluentDataGridTests.razor index fbdc2b4f9f..07e70b2354 100644 --- a/tests/Core/Components/DataGrid/FluentDataGridTests.razor +++ b/tests/Core/Components/DataGrid/FluentDataGridTests.razor @@ -1910,6 +1910,7 @@ Assert.DoesNotContain("Customer Name", headerContent); // Display attribute should be ignored } + // Sample data with Display attributes From df083715cca5a945d10819c2ccf3db3471f31544 Mon Sep 17 00:00:00 2001 From: Vincent Baaij Date: Fri, 3 Oct 2025 17:13:35 +0200 Subject: [PATCH 02/10] Implement #4070 --- .../DataGrid/Columns/PropertyColumn.cs | 63 ++++++++++--- .../PropertyColumnFormatterTests.razor | 89 +++++++++++++++++++ 2 files changed, 140 insertions(+), 12 deletions(-) create mode 100644 tests/Core/Components/DataGrid/PropertyColumnFormatterTests.razor diff --git a/src/Core/Components/DataGrid/Columns/PropertyColumn.cs b/src/Core/Components/DataGrid/Columns/PropertyColumn.cs index 52c21d9d1f..a3bea39d2c 100644 --- a/src/Core/Components/DataGrid/Columns/PropertyColumn.cs +++ b/src/Core/Components/DataGrid/Columns/PropertyColumn.cs @@ -65,18 +65,7 @@ protected override void OnParametersSet() if (!string.IsNullOrEmpty(Format)) { - // TODO: Consider using reflection to avoid having to box every value just to call IFormattable.ToString - // For example, define a method "string Type(Func property) where U: IFormattable", and - // then construct the closed type here with U=TProp when we know TProp implements IFormattable - - // If the type is nullable, we're interested in formatting the underlying type - var nullableUnderlyingTypeOrNull = Nullable.GetUnderlyingType(typeof(TProp)); - if (!typeof(IFormattable).IsAssignableFrom(nullableUnderlyingTypeOrNull ?? typeof(TProp))) - { - throw new InvalidOperationException($"A '{nameof(Format)}' parameter was supplied, but the type '{typeof(TProp)}' does not implement '{typeof(IFormattable)}'."); - } - - _cellTextFunc = item => ((IFormattable?)compiledPropertyExpression!(item))?.ToString(Format, formatProvider: null); + _cellTextFunc = CreateFormatter(compiledPropertyExpression, Format); } else { @@ -117,7 +106,57 @@ protected override void OnParametersSet() } } } + #pragma warning restore IL2072 // Target parameter argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value of the source method does not have matching annotations. + private static Func CreateFormatter(Func getter, string format) + { + var closedType = typeof(PropertyColumn<,>).MakeGenericType(typeof(TGridItem), typeof(TProp)); + + //Nullable struct + if (Nullable.GetUnderlyingType(typeof(TProp)) is Type underlying && + typeof(IFormattable).IsAssignableFrom(underlying)) + { + var method = closedType + .GetMethod(nameof(CreateNullableValueTypeFormatter), BindingFlags.NonPublic | BindingFlags.Static)! + .MakeGenericMethod(underlying); + return (Func)method.Invoke(null, [getter, format])!; + } + + if (typeof(IFormattable).IsAssignableFrom(typeof(TProp))) + { + //Struct + if (typeof(TProp).IsValueType) + { + var method = closedType + .GetMethod(nameof(CreateValueTypeFormatter), BindingFlags.NonPublic | BindingFlags.Static)! + .MakeGenericMethod(typeof(TProp)); + return (Func)method.Invoke(null, [getter, format])!; + } + + //Double cast required because CreateReferenceTypeFormatter required the TProp to be a reference type which implements IFormattable. + return CreateReferenceTypeFormatter((Func)(object)getter, format); + } + + throw new InvalidOperationException($"A '{nameof(Format)}' parameter was supplied, but the type '{typeof(TProp)}' does not implement '{typeof(IFormattable)}'."); + } + + private static Func CreateReferenceTypeFormatter(Func getter, string format) + where T : class, IFormattable + { + return item => getter(item)?.ToString(format, null); + } + + private static Func CreateValueTypeFormatter(Func getter, string format) + where T : struct, IFormattable + { + return item => getter(item).ToString(format, null); + } + + private static Func CreateNullableValueTypeFormatter(Func getter, string format) + where T : struct, IFormattable + { + return item => getter(item)?.ToString(format, null); + } /// protected internal override void CellContent(RenderTreeBuilder builder, TGridItem item) diff --git a/tests/Core/Components/DataGrid/PropertyColumnFormatterTests.razor b/tests/Core/Components/DataGrid/PropertyColumnFormatterTests.razor new file mode 100644 index 0000000000..6a2848bafa --- /dev/null +++ b/tests/Core/Components/DataGrid/PropertyColumnFormatterTests.razor @@ -0,0 +1,89 @@ +@using Bunit +@using Xunit + +@inherits Bunit.TestContext + +@code +{ + public PropertyColumnFormatterTests() + { + JSInterop.Mode = JSRuntimeMode.Loose; + Services.AddFluentUIComponents(); + + var keycodeService = new KeyCodeService(); + Services.AddScoped(factory => keycodeService); + } + + private protected class CustomFormattable : IFormattable + { + private readonly string _value; + public CustomFormattable(string value) => _value = value; + public string ToString(string? format, IFormatProvider? provider) => + string.IsNullOrEmpty(format) ? _value : string.Format(provider, format, _value); + } + + private readonly IList _people = + [ + new Person(1, new("Jean Martin"), new DateOnly(1985, 3, 16), string.Empty), + new Person(2, new("Kenji Sato"), new DateOnly(2004, 1, 9), string.Empty), + new Person(3, new("Julie Smith"), new DateOnly(1958, 10, 10), string.Empty), + ]; + + private protected IQueryable People => _people.AsQueryable(); + + + [Fact] + public void PropertyGrid_Should_Format_ValueTypes() + { + var cut = Render( + @ + + + ); + + var firstCellText = cut.Find("td").TextContent; + Assert.Equal("0001", firstCellText); + } + + [Fact] + public void PropertyGrid_Should_Format_NullableValueTypes() + { + var cut = Render( + @ + + + ); + + var firstCellText = cut.Find("td").TextContent; + Assert.Equal(_people[0].BirthDate!.Value.ToString("m"), firstCellText); + } + + [Fact] + public void PropertyGrid_Should_Format_ReferenceTypes() + { + var cut = Render( + @ + + + ); + + var firstCellText = cut.Find("td").TextContent; + Assert.Equal(string.Format("{0} test", _people[0].Name), firstCellText); + } + + + [Fact] + public void PropertyGrid_Should_Throw_When_FormatUsedOnNonFormattableProperty() + { + Assert.Throws(() => Render( + @ + + + )); + } + + private protected record Person(int PersonId, CustomFormattable Name, DateOnly? BirthDate, string NickName) + { + public bool Selected { get; set; } + }; +} From 734bd5e7421122b275a77bacf60501dd51005c36 Mon Sep 17 00:00:00 2001 From: Vincent Baaij Date: Fri, 3 Oct 2025 17:41:13 +0200 Subject: [PATCH 03/10] Implement #4112 --- src/Core/Components/DataGrid/Columns/ColumnBase.razor.cs | 9 +++++++++ src/Core/Components/DataGrid/Columns/SelectColumn.cs | 1 + src/Core/Components/DataGrid/FluentDataGrid.razor.ts | 6 +++--- src/Core/Components/DataGrid/FluentDataGridCell.razor.cs | 1 + 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/Core/Components/DataGrid/Columns/ColumnBase.razor.cs b/src/Core/Components/DataGrid/Columns/ColumnBase.razor.cs index 888493e58c..e61d414567 100644 --- a/src/Core/Components/DataGrid/Columns/ColumnBase.razor.cs +++ b/src/Core/Components/DataGrid/Columns/ColumnBase.razor.cs @@ -187,6 +187,15 @@ public abstract partial class ColumnBase [Parameter] public string? Width { get; set; } + /// + /// Gets or sets the minimal width of the column. + /// Defaults to 100px for a regular column and 50px for a select column. + /// When resizing a column, the user will not be able to make it smaller than this value. + /// Needs to be a valid CSS width value like '100px', '10%' or '0.5fr'. + /// + [Parameter] + public string MinWidth { get; set; } = "100px"; + /// /// Sets the column index for the current instance. /// diff --git a/src/Core/Components/DataGrid/Columns/SelectColumn.cs b/src/Core/Components/DataGrid/Columns/SelectColumn.cs index 590c417bcf..58210bd0d4 100644 --- a/src/Core/Components/DataGrid/Columns/SelectColumn.cs +++ b/src/Core/Components/DataGrid/Columns/SelectColumn.cs @@ -37,6 +37,7 @@ public class SelectColumn : ColumnBase, IDisposable public SelectColumn() { Width = "50px"; + MinWidth = "50px"; ChildContent = GetDefaultChildContent(); _itemsChanged = new(EventCallback.Factory.Create(this, UpdateSelectedItemsAsync)); diff --git a/src/Core/Components/DataGrid/FluentDataGrid.razor.ts b/src/Core/Components/DataGrid/FluentDataGrid.razor.ts index ea12493530..ba867af17b 100644 --- a/src/Core/Components/DataGrid/FluentDataGrid.razor.ts +++ b/src/Core/Components/DataGrid/FluentDataGrid.razor.ts @@ -273,7 +273,7 @@ export namespace Microsoft.FluentUI.Blazor.DataGrid { const diffX = isRTL ? (pageX! - e.pageX) : (e.pageX - pageX!); const column: Column = columns.find(({ header }) => header === curCol)!; - column.size = parseInt(Math.max(minWidth, curColWidth! + diffX) as any, 10) + 'px'; + column.size = parseInt(Math.max(parseInt((column.header as HTMLElement).style.minWidth), curColWidth! + diffX) as any, 10) + 'px'; columns.forEach((col) => { if (col.size.startsWith('minmax')) { @@ -389,7 +389,7 @@ export namespace Microsoft.FluentUI.Blazor.DataGrid { const width = headerBeingResized!.getBoundingClientRect().width + change; if (change < 0) { - column.size = Math.max(minWidth, width) + 'px'; + column.size = Math.max(parseInt(column.header.style.minWidth), width) + 'px'; } else { column.size = width + 'px'; @@ -416,7 +416,7 @@ export namespace Microsoft.FluentUI.Blazor.DataGrid { grids[gridElement.id].columns.forEach((column: any) => { if (column.header === headerBeingResized) { - column.size = Math.max(minWidth, width) + 'px'; + column.size = Math.max(parseInt(column.header.style.minWidth), width) + 'px'; } else { if (column.size.startsWith('minmax')) { diff --git a/src/Core/Components/DataGrid/FluentDataGridCell.razor.cs b/src/Core/Components/DataGrid/FluentDataGridCell.razor.cs index 23a904b992..d15a2059ca 100644 --- a/src/Core/Components/DataGrid/FluentDataGridCell.razor.cs +++ b/src/Core/Components/DataGrid/FluentDataGridCell.razor.cs @@ -39,6 +39,7 @@ public FluentDataGridCell(LibraryConfiguration configuration) : base(configurati .AddStyle("grid-column", GridColumn.ToString(CultureInfo.InvariantCulture), () => !Grid.EffectiveLoadingValue && (Grid.Items is not null || Grid.ItemsProvider is not null) && 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 == DataGridRowType.Header) .AddStyle("padding-top", "10px", Column is SelectColumn && (Grid.RowSize == DataGridRowSize.Medium || Owner.RowType == DataGridRowType.Header)) .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) From f928a1c8e86fc7a8a512ab1eab7ad5fa93ac23d4 Mon Sep 17 00:00:00 2001 From: Vincent Baaij Date: Sun, 5 Oct 2025 23:43:16 +0200 Subject: [PATCH 04/10] Implement #4116 --- .../DataGrid/FluentDataGrid.razor.ts | 60 ++++++++++++------- .../DataGrid/FluentDataGridCell.razor.cs | 2 +- ...olumnOptionsUISettings.verified.razor.html | 2 +- ...ColumnResizeUISettings.verified.razor.html | 2 +- ...d_ColumnSortUISettings.verified.razor.html | 2 +- ...FluentDataGrid_Default.verified.razor.html | 2 +- ...tDataGrid_StickyHeader.verified.razor.html | 2 +- ...t_Customized_Rendering.verified.razor.html | 4 +- ..._MultiSelect_Rendering.verified.razor.html | 4 +- ...SingleSelect_Rendering.verified.razor.html | 4 +- ...StickySelect_Rendering.verified.razor.html | 4 +- 11 files changed, 52 insertions(+), 36 deletions(-) diff --git a/src/Core/Components/DataGrid/FluentDataGrid.razor.ts b/src/Core/Components/DataGrid/FluentDataGrid.razor.ts index ba867af17b..00501799bf 100644 --- a/src/Core/Components/DataGrid/FluentDataGrid.razor.ts +++ b/src/Core/Components/DataGrid/FluentDataGrid.razor.ts @@ -2,7 +2,7 @@ export namespace Microsoft.FluentUI.Blazor.DataGrid { interface Grid { id: string; - columns: any[]; // or a more specific type if you have one + columns: Column[]; // or a more specific type if you have one initialWidths: string; } @@ -12,8 +12,7 @@ export namespace Microsoft.FluentUI.Blazor.DataGrid { } // Use a dictionary for grids for id-based access - let grids: { [id: string]: Grid } = {}; - const minWidth = 100; + let grids: Grid[] = []; // { [id: string]: Grid } = {}; export function Initialize(gridElement: HTMLElement, autoFocus: boolean) { if (gridElement === undefined || gridElement === null) { @@ -150,7 +149,8 @@ export namespace Microsoft.FluentUI.Blazor.DataGrid { document.body.removeEventListener('click', bodyClickHandler); document.body.removeEventListener('mousedown', bodyClickHandler); gridElement.removeEventListener('keydown', keyDownHandler); - delete grids[gridElement.id]; + grids = grids.filter(grid => grid.id !== gridElement.id); + } }; } @@ -236,11 +236,13 @@ export namespace Microsoft.FluentUI.Blazor.DataGrid { } const id = gridElement.id; - grids[id] = { - id, - columns, - initialWidths, - }; + if (!grids.find((grid: Grid) => grid.id === id)) { + grids.push({ + id, + columns, + initialWidths, + }); + } function setListeners(div: HTMLElement, isRTL: boolean) { let pageX: number | undefined, curCol: HTMLElement | undefined, curColWidth: number | undefined; @@ -345,7 +347,7 @@ export namespace Microsoft.FluentUI.Blazor.DataGrid { export function ResetColumnWidths(gridElement: HTMLElement) { const isGrid = gridElement.classList.contains('grid'); - const grid = grids[gridElement.id]; + const grid = grids.find(grid => grid.id = gridElement.id); if (!grid) { return; } @@ -370,6 +372,7 @@ export namespace Microsoft.FluentUI.Blazor.DataGrid { } export function ResizeColumnDiscrete(gridElement: HTMLElement, column: string | undefined, change: number) { + const isGrid = gridElement.classList.contains('grid'); const columns: any[] = []; let headerBeingResized: HTMLElement | null | undefined; @@ -383,10 +386,10 @@ export namespace Microsoft.FluentUI.Blazor.DataGrid { else { headerBeingResized = gridElement.querySelector('.column-header[col-index="' + column + '"]') as HTMLElement | null; } - - grids[gridElement.id].columns.forEach((column: any) => { + grids.find(grid => grid.id = gridElement.id)!.columns.forEach((column: any) => { if (column.header === headerBeingResized) { - const width = headerBeingResized!.getBoundingClientRect().width + change; + const width = headerBeingResized!.offsetWidth + change; + //const width = headerBeingResized!.getBoundingClientRect().width + change; if (change < 0) { column.size = Math.max(parseInt(column.header.style.minWidth), width) + 'px'; @@ -394,19 +397,25 @@ export namespace Microsoft.FluentUI.Blazor.DataGrid { else { column.size = width + 'px'; } + column.header.style.width = column.size; } - else { + + if (isGrid) { + // for grid we need to recalculate all columns that are minmax if (column.size.startsWith('minmax')) { column.size = parseInt(column.header.clientWidth, 10) + 'px'; } + columns.push(column.size); } - columns.push(column.size); }); - gridElement.style.gridTemplateColumns = columns.join(' '); + if (isGrid) { + gridElement.style.gridTemplateColumns = columns.join(' '); + } } export function ResizeColumnExact(gridElement: HTMLElement, column: string, width: number) { + const isGrid = gridElement.classList.contains('grid'); const columns: any[] = []; let headerBeingResized = gridElement.querySelector('.column-header[col-index="' + column + '"]') as HTMLElement | null; @@ -414,19 +423,25 @@ export namespace Microsoft.FluentUI.Blazor.DataGrid { return; } - grids[gridElement.id].columns.forEach((column: any) => { + grids.find(grid => grid.id = gridElement.id)!.columns.forEach((column: any) => { if (column.header === headerBeingResized) { column.size = Math.max(parseInt(column.header.style.minWidth), width) + 'px'; + column.header.style.width = column.size; } - else { + + if (isGrid) { + // for grid we need to recalculate all columns that are minmax if (column.size.startsWith('minmax')) { column.size = parseInt(column.header.clientWidth, 10) + 'px'; } + column.header.style.width = column.size; + columns.push(column.size); } - columns.push(column.size); }); - gridElement.style.gridTemplateColumns = columns.join(' '); + if (isGrid) { + gridElement.style.gridTemplateColumns = columns.join(' '); + } gridElement.dispatchEvent(new CustomEvent('closecolumnresize', { bubbles: true })); gridElement.focus(); @@ -448,8 +463,9 @@ export namespace Microsoft.FluentUI.Blazor.DataGrid { gridElement.style.gridTemplateColumns = gridTemplateColumns; gridElement.classList.remove('auto-fit'); - if (grids[gridElement.id]) { - grids[gridElement.id].initialWidths = gridTemplateColumns; + const grid = grids.find(grid => grid.id = gridElement.id); + if (grid) { + grid.initialWidths = gridTemplateColumns; } } diff --git a/src/Core/Components/DataGrid/FluentDataGridCell.razor.cs b/src/Core/Components/DataGrid/FluentDataGridCell.razor.cs index d15a2059ca..ea8b08c93a 100644 --- a/src/Core/Components/DataGrid/FluentDataGridCell.razor.cs +++ b/src/Core/Components/DataGrid/FluentDataGridCell.razor.cs @@ -39,7 +39,7 @@ public FluentDataGridCell(LibraryConfiguration configuration) : base(configurati .AddStyle("grid-column", GridColumn.ToString(CultureInfo.InvariantCulture), () => !Grid.EffectiveLoadingValue && (Grid.Items is not null || Grid.ItemsProvider is not null) && 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 == DataGridRowType.Header) + .AddStyle("min-width", Column?.MinWidth, Owner.RowType is DataGridRowType.Header or DataGridRowType.StickyHeader) .AddStyle("padding-top", "10px", Column is SelectColumn && (Grid.RowSize == DataGridRowSize.Medium || Owner.RowType == DataGridRowType.Header)) .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) diff --git a/tests/Core/Components/DataGrid/FluentDataGridTests.FluentDataGrid_ColumnOptionsUISettings.verified.razor.html b/tests/Core/Components/DataGrid/FluentDataGridTests.FluentDataGrid_ColumnOptionsUISettings.verified.razor.html index e367fcfdae..bd729e4c31 100644 --- a/tests/Core/Components/DataGrid/FluentDataGridTests.FluentDataGrid_ColumnOptionsUISettings.verified.razor.html +++ b/tests/Core/Components/DataGrid/FluentDataGridTests.FluentDataGrid_ColumnOptionsUISettings.verified.razor.html @@ -2,7 +2,7 @@ -
+
Name
diff --git a/tests/Core/Components/DataGrid/FluentDataGridTests.FluentDataGrid_ColumnResizeUISettings.verified.razor.html b/tests/Core/Components/DataGrid/FluentDataGridTests.FluentDataGrid_ColumnResizeUISettings.verified.razor.html index e367fcfdae..bd729e4c31 100644 --- a/tests/Core/Components/DataGrid/FluentDataGridTests.FluentDataGrid_ColumnResizeUISettings.verified.razor.html +++ b/tests/Core/Components/DataGrid/FluentDataGridTests.FluentDataGrid_ColumnResizeUISettings.verified.razor.html @@ -2,7 +2,7 @@ -
+
Name
diff --git a/tests/Core/Components/DataGrid/FluentDataGridTests.FluentDataGrid_ColumnSortUISettings.verified.razor.html b/tests/Core/Components/DataGrid/FluentDataGridTests.FluentDataGrid_ColumnSortUISettings.verified.razor.html index e367fcfdae..bd729e4c31 100644 --- a/tests/Core/Components/DataGrid/FluentDataGridTests.FluentDataGrid_ColumnSortUISettings.verified.razor.html +++ b/tests/Core/Components/DataGrid/FluentDataGridTests.FluentDataGrid_ColumnSortUISettings.verified.razor.html @@ -2,7 +2,7 @@ -
+
Name
diff --git a/tests/Core/Components/DataGrid/FluentDataGridTests.FluentDataGrid_Default.verified.razor.html b/tests/Core/Components/DataGrid/FluentDataGridTests.FluentDataGrid_Default.verified.razor.html index e367fcfdae..bd729e4c31 100644 --- a/tests/Core/Components/DataGrid/FluentDataGridTests.FluentDataGrid_Default.verified.razor.html +++ b/tests/Core/Components/DataGrid/FluentDataGridTests.FluentDataGrid_Default.verified.razor.html @@ -2,7 +2,7 @@ -
+
Name
diff --git a/tests/Core/Components/DataGrid/FluentDataGridTests.FluentDataGrid_StickyHeader.verified.razor.html b/tests/Core/Components/DataGrid/FluentDataGridTests.FluentDataGrid_StickyHeader.verified.razor.html index cae38bc862..9784195a45 100644 --- a/tests/Core/Components/DataGrid/FluentDataGridTests.FluentDataGrid_StickyHeader.verified.razor.html +++ b/tests/Core/Components/DataGrid/FluentDataGridTests.FluentDataGrid_StickyHeader.verified.razor.html @@ -2,7 +2,7 @@ -
+
Name
diff --git a/tests/Core/Components/DataGrid/SelectColumnTests.SelectColumnTests_MultiSelect_Customized_Rendering.verified.razor.html b/tests/Core/Components/DataGrid/SelectColumnTests.SelectColumnTests_MultiSelect_Customized_Rendering.verified.razor.html index 8ee4faa02e..70fb97a685 100644 --- a/tests/Core/Components/DataGrid/SelectColumnTests.SelectColumnTests_MultiSelect_Customized_Rendering.verified.razor.html +++ b/tests/Core/Components/DataGrid/SelectColumnTests.SelectColumnTests_MultiSelect_Customized_Rendering.verified.razor.html @@ -2,10 +2,10 @@ - -
+
+
Name
diff --git a/tests/Core/Components/DataGrid/SelectColumnTests.SelectColumnTests_MultiSelect_Rendering.verified.razor.html b/tests/Core/Components/DataGrid/SelectColumnTests.SelectColumnTests_MultiSelect_Rendering.verified.razor.html index df78efa1a1..f28a1e8485 100644 --- a/tests/Core/Components/DataGrid/SelectColumnTests.SelectColumnTests_MultiSelect_Rendering.verified.razor.html +++ b/tests/Core/Components/DataGrid/SelectColumnTests.SelectColumnTests_MultiSelect_Rendering.verified.razor.html @@ -2,14 +2,14 @@ - -
+ +
Name
diff --git a/tests/Core/Components/DataGrid/SelectColumnTests.SelectColumnTests_SingleSelect_Rendering.verified.razor.html b/tests/Core/Components/DataGrid/SelectColumnTests.SelectColumnTests_SingleSelect_Rendering.verified.razor.html index a0454db2c8..79a6523419 100644 --- a/tests/Core/Components/DataGrid/SelectColumnTests.SelectColumnTests_SingleSelect_Rendering.verified.razor.html +++ b/tests/Core/Components/DataGrid/SelectColumnTests.SelectColumnTests_SingleSelect_Rendering.verified.razor.html @@ -2,8 +2,8 @@ - - +
+
Name
diff --git a/tests/Core/Components/DataGrid/SelectColumnTests.SelectColumnTests_SingleStickySelect_Rendering.verified.razor.html b/tests/Core/Components/DataGrid/SelectColumnTests.SelectColumnTests_SingleStickySelect_Rendering.verified.razor.html index e57a474479..7cdd0fbab2 100644 --- a/tests/Core/Components/DataGrid/SelectColumnTests.SelectColumnTests_SingleStickySelect_Rendering.verified.razor.html +++ b/tests/Core/Components/DataGrid/SelectColumnTests.SelectColumnTests_SingleStickySelect_Rendering.verified.razor.html @@ -2,8 +2,8 @@ - - + } - @if (EffectiveLoadingValue) + @if (_lastError != null) + { + @_renderErrorContent + } + else if (EffectiveLoadingValue) { @_renderLoadingContent } @@ -83,7 +87,11 @@ { 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()) + if (_lastError != null) + { + RenderErrorContent(__builder); + } + else if (_internalGridContext.Items.Any()) { foreach (var item in _internalGridContext.Items) { @@ -261,4 +269,36 @@ } + + 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(); + } + + + + @if (ErrorContent is null) + { + @("An error occurred while retrieving data.") + } + else + { + @ErrorContent(_lastError) + } + + + } } diff --git a/src/Core/Components/DataGrid/FluentDataGrid.razor.cs b/src/Core/Components/DataGrid/FluentDataGrid.razor.cs index d35c580b72..5ea1523179 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); } @@ -323,6 +327,26 @@ 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. + [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. /// @@ -821,7 +845,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); @@ -841,6 +865,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) @@ -857,6 +882,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; @@ -877,14 +907,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. { - // 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 + { + 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") From 0dc3ede4a96c89863aab44092e880fd78ae0af9d Mon Sep 17 00:00:00 2001 From: Vincent Baaij Date: Mon, 6 Oct 2025 22:46:17 +0200 Subject: [PATCH 08/10] - Remove NoTabbing parameter (not being used) - Exclude ErrorContent from code coverage - Add (partial) ErrorContentTest, add IsFixed test, update tests --- .../Components/DataGrid/FluentDataGrid.razor | 487 +++++++++--------- .../DataGrid/FluentDataGrid.razor.cs | 8 +- ...Grid_ColumnKeyGridSort.verified.razor.html | 48 -- ...ridSort_NoSortFunction.verified.razor.html | 48 -- ...erCellAsButtonWithMenu.verified.razor.html | 35 ++ ...FluentDataGrid_IsFixed.verified.razor.html | 21 + .../DataGrid/FluentDataGridTests.razor | 42 +- 7 files changed, 348 insertions(+), 341 deletions(-) delete mode 100644 tests/Core/Components/DataGrid/FluentDataGridTests.FluentDataGrid_ColumnKeyGridSort.verified.razor.html delete mode 100644 tests/Core/Components/DataGrid/FluentDataGridTests.FluentDataGrid_ColumnKeyGridSort_NoSortFunction.verified.razor.html create mode 100644 tests/Core/Components/DataGrid/FluentDataGridTests.FluentDataGrid_HeaderCellAsButtonWithMenu.verified.razor.html create mode 100644 tests/Core/Components/DataGrid/FluentDataGridTests.FluentDataGrid_IsFixed.verified.razor.html diff --git a/src/Core/Components/DataGrid/FluentDataGrid.razor b/src/Core/Components/DataGrid/FluentDataGrid.razor index 15853f7909..66197188f0 100644 --- a/src/Core/Components/DataGrid/FluentDataGrid.razor +++ b/src/Core/Components/DataGrid/FluentDataGrid.razor @@ -5,271 +5,278 @@ @inherits FluentComponentBase @typeparam TGridItem - @{ - StartCollectingColumns(); - } - @if (!_manualGrid) - { - @ChildContent - } - - @{ - FinishCollectingColumns(); - } - + @{ + StartCollectingColumns(); + } + @if (!_manualGrid) + { + @ChildContent + } + + @{ + FinishCollectingColumns(); + } + -
+
Name
From 5e76a6ca5285d6b110bf4f2d26bf7ebd182815da Mon Sep 17 00:00:00 2001 From: Vincent Baaij Date: Mon, 6 Oct 2025 13:52:31 +0200 Subject: [PATCH 05/10] Add extra test. Brings back code coverage to 100% for Row and Cell --- .../DataGrid/FluentDataGridRowTests.razor | 70 +++++++++++++------ 1 file changed, 48 insertions(+), 22 deletions(-) diff --git a/tests/Core/Components/DataGrid/FluentDataGridRowTests.razor b/tests/Core/Components/DataGrid/FluentDataGridRowTests.razor index d544a681e1..ec7bfbd334 100644 --- a/tests/Core/Components/DataGrid/FluentDataGridRowTests.razor +++ b/tests/Core/Components/DataGrid/FluentDataGridRowTests.razor @@ -41,6 +41,28 @@ Assert.NotNull(row.Instance.ChildContent); } + [Fact] + public void FluentDataGridRow_Columns() + { + // Arrange + var grid = Render>( + @ + + + + + ); + + // Act + var row = grid.FindComponent>(); + + // Assert + Assert.NotNull(grid); + Assert.NotNull(row); + + Assert.NotNull(row.Instance.Columns); + } + [Fact] public void FluentDataGridRow_Properties_CompileCorrectly() { @@ -238,40 +260,44 @@ - ); + + ); - // Act - var cell = cut.FindComponent>(); - await cell.Instance.HandleOnRowFocusAsync(); - // Assert - Assert.True(rowFocusInvoked); - } + // Act + var cell = cut.FindComponent>(); + await cell.Instance.HandleOnRowFocusAsync(); + // Assert + Assert.True(rowFocusInvoked); + } - [Fact] - public async Task FluentDataGridRow_HandleOnRowKeyDownAsync_HandlesKeyEnter() - { - // Arrange - var items = new List(People).AsQueryable(); - var rowKeyDownInvoked = false; + [Fact] + public async Task FluentDataGridRow_HandleOnRowKeyDownAsync_HandlesKeyEnter() + { + // Arrange + var items = new List(People).AsQueryable(); + var rowKeyDownInvoked = false; + bool click = false; var cut = Render>( - @ + @ - ); + + ); - // Act - var keyboardEvent = new KeyboardEventArgs { Code = "Enter" }; - var row = cut.FindComponent>(); - await row.Instance.HandleOnRowKeyDownAsync(row.Instance.RowId, keyboardEvent); + // Act + var keyboardEvent = new KeyboardEventArgs { Code = "Enter" }; + var row = cut.FindComponent>(); + await row.Instance.HandleOnRowKeyDownAsync(row.Instance.RowId, keyboardEvent); - // Assert - Assert.True(rowKeyDownInvoked); - } + // Assert + Assert.True(rowKeyDownInvoked); + Assert.True(click); + } [Fact] From be46fe01b842d5192aef566c7bb8a991b01bcad0 Mon Sep 17 00:00:00 2001 From: Vincent Baaij Date: Mon, 6 Oct 2025 14:18:02 +0200 Subject: [PATCH 06/10] Implement #4172 --- src/Core/Components/DataGrid/FluentDataGrid.razor.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/Core/Components/DataGrid/FluentDataGrid.razor.cs b/src/Core/Components/DataGrid/FluentDataGrid.razor.cs index bf527b56bd..d35c580b72 100644 --- a/src/Core/Components/DataGrid/FluentDataGrid.razor.cs +++ b/src/Core/Components/DataGrid/FluentDataGrid.razor.cs @@ -732,9 +732,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 From 89d24729e84b7bba537af441ef05f8bc7ea1510d Mon Sep 17 00:00:00 2001 From: Vincent Baaij Date: Mon, 6 Oct 2025 16:46:53 +0200 Subject: [PATCH 07/10] Implement #4177 --- .../Components/DataGrid/FluentDataGrid.razor | 44 ++++++++++- .../DataGrid/FluentDataGrid.razor.cs | 73 +++++++++++++++++-- 2 files changed, 108 insertions(+), 9 deletions(-) diff --git a/src/Core/Components/DataGrid/FluentDataGrid.razor b/src/Core/Components/DataGrid/FluentDataGrid.razor index 8ced7a3e77..15853f7909 100644 --- a/src/Core/Components/DataGrid/FluentDataGrid.razor +++ b/src/Core/Components/DataGrid/FluentDataGrid.razor @@ -40,7 +40,11 @@
- @if (GenerateHeader != DataGridGeneratedHeaderType.None) - { - DataGridRowType headerType = DataGridRowType.Header; - if (GenerateHeader == DataGridGeneratedHeaderType.Sticky) - { - headerType = DataGridRowType.StickyHeader; - } - - - @_renderColumnHeaders - - - } - +
+ @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 - } - -
- + { + @_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; + 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); - } - } + { + 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) + + } + + [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]; - - @((RenderFragment)(__builder => col.RenderPlaceholderContent(__builder, placeholderContext))) - - } - - } + + @((RenderFragment)(__builder => col.RenderPlaceholderContent(__builder, placeholderContext))) + + } + } - private void RenderColumnHeaders(RenderTreeBuilder __builder) - { - @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]; - if (_sortByColumn == col) - col.IsActiveSortColumn = true; - else - col.IsActiveSortColumn = false; + if (_sortByColumn == col) + col.IsActiveSortColumn = true; + else + col.IsActiveSortColumn = false; - - @col.HeaderContent - @if (HeaderCellAsButtonWithMenu) - { - @if (col == _displayOptionsForColumn) - { -
- @col.ColumnOptions -
- } - @if (ResizableColumns && col == _displayResizeForColumn) - { -
+ + @col.HeaderContent + @if (HeaderCellAsButtonWithMenu) + { + @if (col == _displayOptionsForColumn) + { +
+ @col.ColumnOptions +
+ } + @if (ResizableColumns && col == _displayResizeForColumn) + { +
- @if (ResizeType is not null) - { - - } + @if (ResizeType is not null) + { + + } -
- } - } - else - { - @if (col == _displayOptionsForColumn) - { -
- - @if (ResizeType is not null) - { - - @if (@col.ColumnOptions is not null) - { - - } - } - @col.ColumnOptions - -
- } - } +
+ } + } + else + { + @if (col == _displayOptionsForColumn) + { +
+ + @if (ResizeType is not null) + { + + @if (@col.ColumnOptions is not null) + { + + } + } + @col.ColumnOptions + +
+ } + } - @if (ResizableColumns) - { -
- } -
- } - } + @if (ResizableColumns) + { +
+ } + + } + } - private void RenderEmptyContent(RenderTreeBuilder __builder) - { - if (_manualGrid) - { - return; - } + private void RenderEmptyContent(RenderTreeBuilder __builder) + { + if (_manualGrid) + { + return; + } - string? style = null; - string? colspan = null; - if (DisplayMode == DataGridDisplayMode.Grid) - { - style = $"grid-column: 1 / {_columns.Count + 1}"; - } - else - { - colspan = _columns.Count.ToString(); - } + 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) - { + + + @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); - } + 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 - } -
-
- } + + + @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) @@ -290,15 +297,21 @@ - @if (ErrorContent is null) - { - @("An error occurred while retrieving data.") - } - else - { - @ErrorContent(_lastError) - } + RenderActualError(_lastError) } + + [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 5ea1523179..760410fa86 100644 --- a/src/Core/Components/DataGrid/FluentDataGrid.razor.cs +++ b/src/Core/Components/DataGrid/FluentDataGrid.razor.cs @@ -235,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 @@ -332,6 +325,7 @@ public FluentDataGrid(LibraryConfiguration configuration) : base(configuration) /// /// 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; } 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..947215c334 --- /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] From 0f01e2a5f0904fd08ef15786ccf9cea058a45900 Mon Sep 17 00:00:00 2001 From: Vincent Baaij Date: Mon, 6 Oct 2025 22:49:42 +0200 Subject: [PATCH 09/10] Implement #4178 Related Work Items: #41 --- src/Core/Components/DataGrid/FluentDataGridCell.razor.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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) From 8d4a54ee4d26df5499ea16cf28202db1f543ad8b Mon Sep 17 00:00:00 2001 From: Vincent Baaij Date: Mon, 6 Oct 2025 23:16:41 +0200 Subject: [PATCH 10/10] Add CustomIcon and IconsExtensions + tests --- src/Core/Components/Icons/CustomIcon.cs | 26 ++++ src/Core/Components/Icons/IconsExtensions.cs | 142 ++++++++++++++++++ ...FluentDataGrid_IsFixed.verified.razor.html | 4 +- .../Core/Components/Icons/CustomIconTests.cs | 43 ++++++ .../Components/Icons/IconsExtensionsTests.cs | 96 ++++++++++++ 5 files changed, 309 insertions(+), 2 deletions(-) create mode 100644 src/Core/Components/Icons/CustomIcon.cs create mode 100644 src/Core/Components/Icons/IconsExtensions.cs create mode 100644 tests/Core/Components/Icons/CustomIconTests.cs create mode 100644 tests/Core/Components/Icons/IconsExtensionsTests.cs 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_IsFixed.verified.razor.html b/tests/Core/Components/DataGrid/FluentDataGridTests.FluentDataGrid_IsFixed.verified.razor.html index 947215c334..956b8bb5ba 100644 --- a/tests/Core/Components/DataGrid/FluentDataGridTests.FluentDataGrid_IsFixed.verified.razor.html +++ b/tests/Core/Components/DataGrid/FluentDataGridTests.FluentDataGrid_IsFixed.verified.razor.html @@ -2,7 +2,7 @@ - - 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); + } +}
+
Name
@@ -13,7 +13,7 @@
+

empty content