diff --git a/.github/workflows/build-core-lib.yml b/.github/workflows/build-core-lib.yml index 2d5de67676..af768943ae 100644 --- a/.github/workflows/build-core-lib.yml +++ b/.github/workflows/build-core-lib.yml @@ -64,6 +64,11 @@ jobs: dotnet-version: 10.0.x dotnet-quality: preview + - name: Setup .NET 11.0 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 11.0.100-preview.7.26381.103 + # Build - name: Restore @@ -92,6 +97,10 @@ jobs: done working-directory: ${{ github.workspace }} + - name: Tests .NET 11.0 + run: dotnet test ./tests/Core/Components.Tests.csproj --report-trx --results-directory ./TestResults --verbosity normal --configuration Release /p:ExampleNetVersion=net11.0 + working-directory: ${{ github.workspace }} + - name: Publish Unit Tests if: success() || failure() continue-on-error: true @@ -229,6 +238,11 @@ jobs: dotnet-version: 10.0.x dotnet-quality: preview + - name: Setup .NET 11.0 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 11.0.100-preview.7.26381.103 + - name: Initialize CodeQL uses: github/codeql-action/init@v3 with: diff --git a/Directory.Build.props b/Directory.Build.props index 9b75e6d4cd..6759ff2d0f 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -22,9 +22,12 @@ - net8.0;net9.0;net10.0 + net8.0;net9.0;net10.0;net11.0 $(NetVersion) + + $(NoWarn);BL0016 + 5.0.0 5.0.0 diff --git a/Directory.Packages.props b/Directory.Packages.props index 86c7f18b95..b069bcfe39 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -122,10 +122,8 @@ - - diff --git a/_ListOutdatedDependencies.ps1 b/_ListOutdatedDependencies.ps1 index 10245dc8b3..a99f3b5a01 100644 --- a/_ListOutdatedDependencies.ps1 +++ b/_ListOutdatedDependencies.ps1 @@ -5,7 +5,7 @@ .DESCRIPTION Temporarily switches the property in Directory.Build.props to each of - net8.0, net9.0 and net10.0, runs `dotnet outdated` for the solution, and + net8.0, net9.0, net10.0 and net11.0, runs `dotnet outdated` for the solution, and aggregates the results into a single de-duplicated summary list. Also lists outdated npm packages (via npm-check-updates) for the Core.Scripts and Charts.Scripts projects, without changing any package.json or lock file. @@ -24,7 +24,7 @@ $ErrorActionPreference = 'Stop' $repoRoot = $PSScriptRoot $propsPath = Join-Path $repoRoot 'Directory.Build.props' $slnPath = Join-Path $repoRoot 'Microsoft.FluentUI-v5.slnx' -$netVersions = @('net8.0', 'net9.0', 'net10.0') +$netVersions = @('net8.0', 'net9.0', 'net10.0', 'net11.0') $netVersionPattern = 'net\d+\.\d+' $exampleVersionPattern = 'net\d+\.\d+' # Demo/Samples/Tests projects require net9.0+ (see ExampleNetVersion comment in Directory.Build.props). diff --git a/eng/pipelines/build-all-lib.yml b/eng/pipelines/build-all-lib.yml index 09892817e4..68e941e18c 100644 --- a/eng/pipelines/build-all-lib.yml +++ b/eng/pipelines/build-all-lib.yml @@ -142,6 +142,12 @@ extends: version: 10.0.x includePreviewVersions: true + - task: UseDotNet@2 + displayName: 'Install .NET 11.0' + inputs: + version: 11.0.100-preview.7.26381.103 + includePreviewVersions: true + # Set version number (exclude some folders) - task: PowerShell@2 displayName: 'Versioning $(Build.BuildNumber)' diff --git a/eng/pipelines/build-core-lib.yml b/eng/pipelines/build-core-lib.yml index 24f2545a23..4ab757377f 100644 --- a/eng/pipelines/build-core-lib.yml +++ b/eng/pipelines/build-core-lib.yml @@ -165,6 +165,12 @@ extends: version: 10.0.x includePreviewVersions: true + - task: UseDotNet@2 + displayName: 'Install .NET 11.0' + inputs: + version: 11.0.100-preview.7.26381.103 + includePreviewVersions: true + # Set version number (exclude some folders) - task: PowerShell@2 displayName: 'Versioning $(Build.BuildNumber)' diff --git a/examples/Demo/FluentUI.Demo.Client/DebugPages/EditFormPage.razor b/examples/Demo/FluentUI.Demo.Client/DebugPages/EditFormPage.razor index 149a3937d7..f9714dd150 100644 --- a/examples/Demo/FluentUI.Demo.Client/DebugPages/EditFormPage.razor +++ b/examples/Demo/FluentUI.Demo.Client/DebugPages/EditFormPage.razor @@ -132,7 +132,11 @@ void ValidateForm() { +#if NET11_0_OR_GREATER + _ = form.EditContext?.ValidateAsync(); +#else form.EditContext?.Validate(); +#endif } void SetFormToValid() diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/DataGrid/Pages/DataGridVirtualizePage.md b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/DataGrid/Pages/DataGridVirtualizePage.md index 0b298035f6..b42f1002ef 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/DataGrid/Pages/DataGridVirtualizePage.md +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/DataGrid/Pages/DataGridVirtualizePage.md @@ -17,3 +17,55 @@ Enabling virtualization is just a matter of passing `Virtualize="true"`. For it properly and reliably, every row rendered must have the same known height. **This is handled by the `FluentDataGrid` code** {{ DataGridVirtualize }} + +## Positioning and anchoring in .NET 11 + +When targeting .NET 11, a virtualized grid can open at a specific zero-based row index, scroll to a row +programmatically, and keep the viewport anchored as provider data changes. Out-of-range indexes are clamped by +the underlying `Virtualize` component. + +An `ItemComparer` should compare stable item identity rather than object references when an items provider +returns new instances across requests. + +```razor +@using Microsoft.AspNetCore.Components.Web.Virtualization + +Back to top + +
+ + + +
+ +@code { + private FluentDataGrid? _grid; + + private Task ScrollToTopAsync() + => _grid!.ScrollToItemAsync(0); + + private sealed class ProductIdComparer : IEqualityComparer + { + public static ProductIdComparer Instance { get; } = new(); + + public bool Equals(Product? first, Product? second) + => first?.Id == second?.Id; + + public int GetHashCode(Product product) + => product.Id.GetHashCode(); + } +} +``` + +`InitialItemIndex` is applied only on the first interactive render. Use `ScrollToItemAsync` after the grid has +rendered for later navigation. Calling it before rendering or while `Virtualize` is disabled throws an +`InvalidOperationException`. Cancellation and repeated-call behavior are delegated to the underlying virtualizer; +when calls overlap, the last call wins. diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Drag/Examples/DragDropNested.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Drag/Examples/DragDropNested.razor index 710356ce10..33c02f8e46 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Drag/Examples/DragDropNested.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Drag/Examples/DragDropNested.razor @@ -175,8 +175,6 @@ targetRow.Columns.Add(source); } } - - StateHasChanged(); } private void OnDropElement(FluentDragEventArgs e) @@ -213,8 +211,6 @@ targetColumn.Elements.Add(source); } } - - StateHasChanged(); } public class Form diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/General/Theme/Designer/ThemeDesigner.razor.cs b/examples/Demo/FluentUI.Demo.Client/Documentation/General/Theme/Designer/ThemeDesigner.razor.cs index e12d627172..90317aa5e9 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/General/Theme/Designer/ThemeDesigner.razor.cs +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/General/Theme/Designer/ThemeDesigner.razor.cs @@ -94,7 +94,5 @@ private async Task ResetThemeAsync() await ThemeService.SetThemeToElementAsync(_themePreviewElement, settings); await ThemeService.SetThemeAsync(settings); await ThemeService.ClearStoredThemeSettingsAsync(); - - StateHasChanged(); } } diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/GetStarted/Mcp/Examples/AISkillsDownload.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/GetStarted/Mcp/Examples/AISkillsDownload.razor index fab409da8d..2e5f671a94 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/GetStarted/Mcp/Examples/AISkillsDownload.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/GetStarted/Mcp/Examples/AISkillsDownload.razor @@ -147,7 +147,6 @@ private async Task DownloadAllAsync() { _downloadingAll = true; - StateHasChanged(); try { @@ -161,7 +160,6 @@ finally { _downloadingAll = false; - StateHasChanged(); } } diff --git a/examples/Demo/FluentUI.Demo/Program.cs b/examples/Demo/FluentUI.Demo/Program.cs index 1802348295..da00593f53 100644 --- a/examples/Demo/FluentUI.Demo/Program.cs +++ b/examples/Demo/FluentUI.Demo/Program.cs @@ -39,7 +39,9 @@ // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { +#if !NET11_0_OR_GREATER app.UseWebAssemblyDebugging(); +#endif } else { diff --git a/examples/Tools/FluentUI.Demo.DocViewer/Components/MarkdownViewer.razor.cs b/examples/Tools/FluentUI.Demo.DocViewer/Components/MarkdownViewer.razor.cs index 50e2ccf4d1..35aaf04704 100644 --- a/examples/Tools/FluentUI.Demo.DocViewer/Components/MarkdownViewer.razor.cs +++ b/examples/Tools/FluentUI.Demo.DocViewer/Components/MarkdownViewer.razor.cs @@ -81,7 +81,6 @@ protected override async Task OnInitializedAsync() { HttpClient.BaseAddress ??= new Uri(NavigationManager.BaseUri); await McpDocumentationService.LoadAsync(HttpClient, "/mcp-documentation.json").ConfigureAwait(true); - StateHasChanged(); } } diff --git a/src/Core/Components/DataGrid/FluentDataGrid.razor b/src/Core/Components/DataGrid/FluentDataGrid.razor index b11ca8b6c3..72d898e48a 100644 --- a/src/Core/Components/DataGrid/FluentDataGrid.razor +++ b/src/Core/Components/DataGrid/FluentDataGrid.razor @@ -66,14 +66,7 @@ } else { - + @((RenderFragment)RenderVirtualize) } } else @@ -91,6 +84,29 @@ @code { + // RenderTreeBuilder is required while Virtualize parameters are target-framework conditional. + // Replace this with Razor markup when older targets no longer require these #if directives. + private void RenderVirtualize(RenderTreeBuilder builder) + { + builder.OpenComponent>(0); + builder.AddAttribute(1, nameof(Virtualize<(int, TGridItem)>.ItemSize), ItemSize); + builder.AddAttribute(2, nameof(Virtualize<(int, TGridItem)>.OverscanCount), OverscanCount); +#if NET9_0_OR_GREATER + builder.AddAttribute(3, nameof(Virtualize<(int, TGridItem)>.MaxItemCount), MaxItemCount); +#endif +#if NET11_0_OR_GREATER + builder.AddAttribute(4, nameof(Virtualize<(int, TGridItem)>.InitialItemIndex), InitialItemIndex); + builder.AddAttribute(5, nameof(Virtualize<(int, TGridItem)>.AnchorMode), AnchorMode); + builder.AddAttribute(6, nameof(Virtualize<(int, TGridItem)>.ItemComparer), _virtualizeItemComparer); +#endif + builder.AddAttribute(7, nameof(Virtualize<(int, TGridItem)>.ItemsProvider), (ItemsProviderDelegate<(int, TGridItem)>)ProvideVirtualizedItemsAsync); + builder.AddAttribute(8, nameof(Virtualize<(int, TGridItem)>.ItemContent), (RenderFragment<(int RowIndex, TGridItem Data)>)(item => contentBuilder => RenderRow(contentBuilder, item.RowIndex, item.Data))); + builder.AddAttribute(9, nameof(Virtualize<(int, TGridItem)>.Placeholder), (RenderFragment)(placeholderContext => contentBuilder => RenderPlaceholderRow(contentBuilder, placeholderContext))); + builder.AddAttribute(10, nameof(Virtualize<(int, TGridItem)>.SpacerElement), "tr"); + builder.AddComponentReferenceCapture(11, value => _virtualizeComponent = (Virtualize<(int, TGridItem)>)value); + builder.CloseComponent(); + } + private void RenderNonVirtualizedRows(RenderTreeBuilder __builder) { var initialRowIndex = (GenerateHeader != DataGridGeneratedHeaderType.None) ? 2 : 1; // aria-rowindex is 1-based, plus 1 if there is a header diff --git a/src/Core/Components/DataGrid/FluentDataGrid.razor.cs b/src/Core/Components/DataGrid/FluentDataGrid.razor.cs index d76bba9ef5..b9bfab7e57 100644 --- a/src/Core/Components/DataGrid/FluentDataGrid.razor.cs +++ b/src/Core/Components/DataGrid/FluentDataGrid.razor.cs @@ -34,10 +34,35 @@ private enum ColumnHeaderUiKind Resize, } +#if NET11_0_OR_GREATER + // Adapts ItemComparer to the virtualizer's (row index, item) shape while comparing item identity only. + // Reading from the owner on each call ensures parameter updates take effect. + private sealed class VirtualizeItemComparer(FluentDataGrid owner) : IEqualityComparer<(int, TGridItem)> + { + private readonly FluentDataGrid _owner = owner; + + private IEqualityComparer ItemComparer => _owner._itemComparer ?? EqualityComparer.Default; + + public bool Equals((int, TGridItem) first, (int, TGridItem) second) + { + return ItemComparer.Equals(first.Item2, second.Item2); + } + + public int GetHashCode((int, TGridItem) item) + { + return item.Item2 is null ? 0 : ItemComparer.GetHashCode(item.Item2); + } + } +#endif + private const string JAVASCRIPT_FILE = FluentJSModule.JAVASCRIPT_ROOT + "DataGrid/FluentDataGrid.razor.js"; private ElementReference? _gridReference; private Virtualize<(int, TGridItem)>? _virtualizeComponent; +#if NET11_0_OR_GREATER + private IEqualityComparer? _itemComparer; + private readonly IEqualityComparer<(int, TGridItem)> _virtualizeItemComparer; +#endif private IAsyncQueryExecutor? _asyncQueryExecutor; private AsyncServiceScope? _scope; private bool _asyncQueryExecuted; @@ -94,6 +119,9 @@ public FluentDataGrid(LibraryConfiguration configuration) : base(configuration) _renderEmptyContent = RenderEmptyContent; _renderLoadingContent = RenderLoadingContent; _renderErrorContent = RenderErrorContent; + #if NET11_0_OR_GREATER + _virtualizeItemComparer = new VirtualizeItemComparer(this); + #endif // 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 @@ -169,6 +197,15 @@ public FluentDataGrid(LibraryConfiguration configuration) : base(configuration) [Parameter] public int OverscanCount { get; set; } = 3; +#if NET9_0_OR_GREATER + /// + /// This is applicable only when using . It defines the maximum number of items + /// that can be rendered, even when the viewport would otherwise require more items. + /// + [Parameter] + public int MaxItemCount { get; set; } = 100; +#endif + /// /// This is applicable only when using . It defines an expected height in pixels for /// each row, allowing the virtualization mechanism to fetch the correct number of items to match the display @@ -177,6 +214,39 @@ public FluentDataGrid(LibraryConfiguration configuration) : base(configuration) [Parameter] public float ItemSize { get; set; } = 32; +#if NET11_0_OR_GREATER + /// + /// This is applicable only when using . Gets or sets the zero-based index of the + /// row to scroll to on first interactive render. Applied once when the grid first knows its item count and + /// ignored on subsequent re-renders; to scroll programmatically at any later point, call + /// . Out-of-range values are clamped. The default value, + /// 0, means no initial scroll. + /// + [Parameter] + public int InitialItemIndex { get; set; } + + /// + /// This is applicable only when using . Gets or sets the anchor mode that controls + /// how the viewport behaves at the edges of the list + /// when new items arrive during virtualization. The default is . + /// + [Parameter] + public VirtualizeAnchorMode AnchorMode { get; set; } = VirtualizeAnchorMode.Start; + + /// + /// This is applicable only when using . Gets or sets a comparer used during + /// virtualization to detect whether items were prepended or appended between data loads. + /// Provide a comparer that compares items by a stable unique identifier. + /// Defaults to . + /// + [Parameter] + public IEqualityComparer? ItemComparer + { + get => _itemComparer; + set => _itemComparer = value; + } +#endif + /// /// If true, renders draggable handles around the column headers and adds a button to invoke a resize UI. /// This allows the user to resize columns manually. Size changes are not persisted. @@ -1336,6 +1406,30 @@ public async Task RefreshDataAsync(bool force = false) await RefreshDataCoreAsync(); } +#if NET11_0_OR_GREATER + /// + /// Scrolls the virtualized grid so the row at is aligned to the top of the scrollable area. + /// + /// + /// Each call cancels any previously-running operation. + /// Must be called on the renderer's synchronization context; background-thread callers should wrap with + /// . + /// + /// The zero-based index of the row to scroll to. + /// A token that lets the caller request cancellation. + /// A that completes when the target is aligned or superseded by another call. + public Task ScrollToItemAsync(int itemIndex, CancellationToken cancellationToken = default) + { + if (!Virtualize || _virtualizeComponent is null) + { + throw new InvalidOperationException( + $"{nameof(ScrollToItemAsync)} can only be used when {nameof(Virtualize)} is enabled and the grid has been rendered."); + } + + return _virtualizeComponent.ScrollToItemAsync(itemIndex, cancellationToken); + } +#endif + // Same as RefreshDataAsync, except without forcing a re-render. We use this from OnParametersSetAsync // because in that case there's going to be a re-render anyway. [SuppressMessage("Design", "MA0051:Method is too long", Justification = "Not going to do artificial optimization because of some random arbitrary determined line count number")] diff --git a/src/Core/Components/PullToRefresh/FluentPullToRefresh.razor.cs b/src/Core/Components/PullToRefresh/FluentPullToRefresh.razor.cs index d370b45dc1..89efa65e17 100644 --- a/src/Core/Components/PullToRefresh/FluentPullToRefresh.razor.cs +++ b/src/Core/Components/PullToRefresh/FluentPullToRefresh.razor.cs @@ -300,6 +300,7 @@ private async Task OnTouchEndAsync(TouchEventArgs e) if (!hasMoreData) { SetPullStatus(PullStatus.NoData); + StateHasChanged(); await Task.Delay(StatusUpdateMessageTimeout); } else diff --git a/src/Core/Components/Wizard/FluentWizard.razor.cs b/src/Core/Components/Wizard/FluentWizard.razor.cs index a7c99d9208..d25977a890 100644 --- a/src/Core/Components/Wizard/FluentWizard.razor.cs +++ b/src/Core/Components/Wizard/FluentWizard.razor.cs @@ -208,8 +208,6 @@ protected virtual async Task OnNextHandlerAsync(MouseEventArgs e) { await ValueChanged.InvokeAsync(Value); } - - StateHasChanged(); } } @@ -235,8 +233,6 @@ protected virtual async Task OnPreviousHandlerAsync(MouseEventArgs e) { await ValueChanged.InvokeAsync(Value); } - - StateHasChanged(); } } diff --git a/src/Core/Components/Wizard/FluentWizardStep.razor.cs b/src/Core/Components/Wizard/FluentWizardStep.razor.cs index 367a9b207a..487e190c01 100644 --- a/src/Core/Components/Wizard/FluentWizardStep.razor.cs +++ b/src/Core/Components/Wizard/FluentWizardStep.razor.cs @@ -196,6 +196,8 @@ public void RegisterEditFormAndContext(EditForm editForm, EditContext editContex /// /// Validates all registered EditContexts. /// +#pragma warning disable CS0618 + // TODO: Make this method asynchronous and use EditContext.ValidateAsync when the public API can be changed. public bool ValidateEditContexts() { var isValid = true; @@ -219,6 +221,7 @@ public bool ValidateEditContexts() return isValid; } +#pragma warning restore CS0618 /// public override async ValueTask DisposeAsync() diff --git a/src/Core/Microsoft.FluentUI.AspNetCore.Components.csproj b/src/Core/Microsoft.FluentUI.AspNetCore.Components.csproj index b4a583a111..8ff739757d 100644 --- a/src/Core/Microsoft.FluentUI.AspNetCore.Components.csproj +++ b/src/Core/Microsoft.FluentUI.AspNetCore.Components.csproj @@ -88,9 +88,7 @@ - - - + all diff --git a/src/Tools/McpServer/Services/DocumentationService.cs b/src/Tools/McpServer/Services/DocumentationService.cs index efd2860b77..5a32f3399d 100644 --- a/src/Tools/McpServer/Services/DocumentationService.cs +++ b/src/Tools/McpServer/Services/DocumentationService.cs @@ -220,7 +220,7 @@ private static string ExtractSummary(string content) // Remove headers and get first meaningful paragraph var lines = content.Split('\n') .Select(l => l.Trim()) - .Where(l => !string.IsNullOrEmpty(l) && !l.StartsWith('#')) + .Where(l => !string.IsNullOrEmpty(l) && !l.StartsWith("#", StringComparison.Ordinal)) .Take(3); var summary = string.Join(' ', lines); diff --git a/src/Tools/McpServer/Services/MigrationService.cs b/src/Tools/McpServer/Services/MigrationService.cs index d12f09dcc1..ab27cda30c 100644 --- a/src/Tools/McpServer/Services/MigrationService.cs +++ b/src/Tools/McpServer/Services/MigrationService.cs @@ -220,7 +220,7 @@ private static string ExtractSummary(string content) { var lines = content.Split('\n') .Select(l => l.Trim()) - .Where(l => !string.IsNullOrEmpty(l) && !l.StartsWith('#')) + .Where(l => !string.IsNullOrEmpty(l) && !l.StartsWith("#", StringComparison.Ordinal)) .Take(3); var summary = string.Join(' ', lines); diff --git a/tests/Core/Components.Tests.csproj b/tests/Core/Components.Tests.csproj index ca1112552c..ac0dcb9136 100644 --- a/tests/Core/Components.Tests.csproj +++ b/tests/Core/Components.Tests.csproj @@ -10,7 +10,7 @@ false Microsoft.FluentUI.AspNetCore.Components.Tests Microsoft.FluentUI.AspNetCore.Components.Tests - xUnit1069 + xUnit1069;BL0016 diff --git a/tests/Core/Components/DataGrid/FluentDataGridTests.razor b/tests/Core/Components/DataGrid/FluentDataGridTests.razor index 2e73f2b3f9..f7f57e4fe5 100644 --- a/tests/Core/Components/DataGrid/FluentDataGridTests.razor +++ b/tests/Core/Components/DataGrid/FluentDataGridTests.razor @@ -369,6 +369,152 @@ builder.AddContent(0, text); }; +#if NET9_0_OR_GREATER + [Fact] + public void FluentDataGrid_Virtualize_SetsMaxItemCount() + { + // Arrange && Act + var cut = Render>( + @ + + ); + + // Assert + var virtualize = cut.FindComponent>(); + Assert.Equal(42, virtualize.Instance.MaxItemCount); + } +#endif + +#if NET11_0_OR_GREATER + [Fact] + public void FluentDataGrid_Virtualize_SetsNet11VirtualizationParameters() + { + // Arrange + var customers = GetCustomers().ToArray(); + var itemComparer = EqualityComparer.Create( + (first, second) => first?.Id == second?.Id, + customer => customer.Id); + GridItemsProvider itemsProvider = request => + ValueTask.FromResult(GridItemsProviderResult.From( + customers + .Skip(request.StartIndex) + .Take(request.Count ?? customers.Length) + .Select(customer => customer with { }) + .ToArray(), + customers.Length)); + + // Act + var cut = Render>( + @ + + ); + + // Assert + var virtualize = cut.FindComponent>().Instance; + var firstCustomer = customers[0]; + var equivalentCustomer = firstCustomer with { }; + var differentCustomer = customers[1]; + + Assert.Equal(2, virtualize.InitialItemIndex); + Assert.Equal(VirtualizeAnchorMode.End, virtualize.AnchorMode); + Assert.True(virtualize.ItemComparer.Equals((1, firstCustomer), (99, equivalentCustomer))); + Assert.False(virtualize.ItemComparer.Equals((1, firstCustomer), (1, differentCustomer))); + } + + [Fact] + public void FluentDataGrid_ScrollToItemAsync_VirtualizationDisabled_ThrowsInvalidOperationException() + { + // Arrange + var cut = Render>( + @ + + ); + + // Act + var exception = Assert.Throws(() => + { + _ = cut.Instance.ScrollToItemAsync(1, Xunit.TestContext.Current.CancellationToken); + }); + + // Assert + Assert.Contains(nameof(FluentDataGrid.Virtualize), exception.Message); + } + + [Fact] + public void FluentDataGrid_ScrollToItemAsync_VirtualizerNotRendered_ThrowsInvalidOperationException() + { + // Arrange + GridItemsProvider itemsProvider = request => + ValueTask.FromResult(GridItemsProviderResult.From(Array.Empty(), 0)); + + var cut = Render>( + @ + + ); + + // Act + var exception = Assert.Throws(() => + { + _ = cut.Instance.ScrollToItemAsync(1, Xunit.TestContext.Current.CancellationToken); + }); + + // Assert + Assert.Contains("grid has been rendered", exception.Message); + } + + [Fact] + public async Task FluentDataGrid_ScrollToItemAsync_CanceledToken_ThrowsOperationCanceledException() + { + // Arrange + FluentDataGrid? grid = null; + var cut = Render>( + @ + + ); + using var cancellationSource = CancellationTokenSource.CreateLinkedTokenSource(Xunit.TestContext.Current.CancellationToken); + await cancellationSource.CancelAsync(); + + // Act && Assert + await Assert.ThrowsAnyAsync(async () => + await cut.InvokeAsync(() => grid!.ScrollToItemAsync(10, cancellationSource.Token))); + } + + [Fact] + public async Task FluentDataGrid_ScrollToItemAsync_RepeatedCalls_BothCallsCompleteSuccessfully() + { + // Arrange + FluentDataGrid? grid = null; + var cut = Render>( + @ + + ); + Task? firstCall = null; + Task? secondCall = null; + + // Act + await cut.InvokeAsync(() => + { + firstCall = grid!.ScrollToItemAsync(500, Xunit.TestContext.Current.CancellationToken); + secondCall = grid.ScrollToItemAsync(750, Xunit.TestContext.Current.CancellationToken); + }); + + await firstCall!.WaitAsync(TimeSpan.FromSeconds(5), Xunit.TestContext.Current.CancellationToken); + await secondCall!.WaitAsync(TimeSpan.FromSeconds(5), Xunit.TestContext.Current.CancellationToken); + + // Assert + Assert.True(firstCall.IsCompletedSuccessfully); + Assert.True(secondCall.IsCompletedSuccessfully); + } +#endif + [Fact] public async Task FluentDataGrid_Virtualize() { diff --git a/tests/Core/Components/PullToRefresh/FluentPullToRefreshTests.razor b/tests/Core/Components/PullToRefresh/FluentPullToRefreshTests.razor index 003674ddc1..7362143407 100644 --- a/tests/Core/Components/PullToRefresh/FluentPullToRefreshTests.razor +++ b/tests/Core/Components/PullToRefresh/FluentPullToRefreshTests.razor @@ -338,13 +338,16 @@ public async Task FluentPullToRefresh_OnTouchEnd_WithMoreData_CompletesThenResets() { var refreshCalls = 0; + var refreshStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var refreshCompletion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var cut = Render(parameters => parameters .Add(p => p.EmulateTouch, false) .Add(p => p.StatusUpdateMessageTimeout, 10) - .Add(p => p.OnRefreshAsync, () => + .Add(p => p.OnRefreshAsync, async () => { refreshCalls++; - return Task.FromResult(true); + refreshStarted.SetResult(true); + return await refreshCompletion.Task; }) .Add(p => p.ChildContent, builder => builder.AddContent(0, "Content"))); @@ -352,7 +355,10 @@ var container = cut.Find("div.fluent-pull-container"); var touchEndTask = container.TriggerEventAsync("ontouchend", CreateTouchEventArgs(0)); - await cut.WaitForAssertionAsync(() => Assert.Equal(PullStatus.Completed, GetPullStatus(cut))); + await refreshStarted.Task; + var completedStatusTask = cut.WaitForAssertionAsync(() => Assert.Equal(PullStatus.Completed, GetPullStatus(cut))); + refreshCompletion.SetResult(true); + await completedStatusTask; await touchEndTask; @@ -364,17 +370,26 @@ [Fact] public async Task FluentPullToRefresh_OnTouchEnd_NoMoreData_ShowsNoDataBeforeReset() { + var refreshStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var refreshCompletion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var cut = Render(parameters => parameters .Add(p => p.EmulateTouch, false) .Add(p => p.StatusUpdateMessageTimeout, 10) - .Add(p => p.OnRefreshAsync, () => Task.FromResult(false)) + .Add(p => p.OnRefreshAsync, async () => + { + refreshStarted.SetResult(true); + return await refreshCompletion.Task; + }) .Add(p => p.ChildContent, builder => builder.AddContent(0, "Content"))); await SetDistanceAsync(cut, 80); var container = cut.Find("div.fluent-pull-container"); var touchEndTask = container.TriggerEventAsync("ontouchend", CreateTouchEventArgs(0)); - await cut.WaitForAssertionAsync(() => Assert.Equal(PullStatus.NoData, GetPullStatus(cut))); + await refreshStarted.Task; + var noDataStatusTask = cut.WaitForAssertionAsync(() => Assert.Equal(PullStatus.NoData, GetPullStatus(cut))); + refreshCompletion.SetResult(false); + await noDataStatusTask; await touchEndTask; diff --git a/tests/Core/Components/Wizard/FluentWizardAdvancedTests.razor b/tests/Core/Components/Wizard/FluentWizardAdvancedTests.razor index ea194acfa1..da9edc6cd2 100644 --- a/tests/Core/Components/Wizard/FluentWizardAdvancedTests.razor +++ b/tests/Core/Components/Wizard/FluentWizardAdvancedTests.razor @@ -66,7 +66,9 @@ // Act 2: make model valid, then finish again model.Name = "John"; + #pragma warning disable CS0618 await cut.InvokeAsync(() => editContext.Validate()); + #pragma warning restore CS0618 await cut.InvokeAsync(() => wizard.FinishAsync(validateEditContexts: true)); // Assert 2 @@ -219,7 +221,9 @@ // Act: valid model model.Name = "John"; + #pragma warning disable CS0618 await cut.InvokeAsync(() => editContext.Validate()); + #pragma warning restore CS0618 await cut.InvokeAsync(() => wizard.FinishAsync(validateEditContexts: true)); // Assert valid path