diff --git a/README.md b/README.md index 95a0e357..25178a96 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,7 @@ builder.Services.AddBlazorBlueprintComponents(); ```razor @using BlazorBlueprint.Components +@using BlazorBlueprint.Primitives.Services ``` **3. Add CSS** to your `App.razor` ``: diff --git a/V3-MIGRATION-GUIDE.md b/V3-MIGRATION-GUIDE.md index d7e59231..aac0a595 100644 --- a/V3-MIGRATION-GUIDE.md +++ b/V3-MIGRATION-GUIDE.md @@ -1368,11 +1368,11 @@ A new component that renders child `Avatar` components with overlapping negative ```razor - + U1 - + U2 diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataView/items-provider.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataView/items-provider.txt new file mode 100644 index 00000000..b9c0f0de --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataView/items-provider.txt @@ -0,0 +1,75 @@ +@using BlazorBlueprint.Primitives.DataView +@using BlazorBlueprint.Primitives.Table + + + + + + @GetInitials(person.Name) + +
+

@person.Name

+ @person.Email + @person.Department · @person.Role +
+ + @person.Status + +
+
+ + + + + + +
+ +@code { + private List asyncPeople = MockDataService.GeneratePersons(200); + + private async ValueTask> LoadPeopleAsync(DataViewRequest request) + { + // Simulate latency from a real server + await Task.Delay(300, request.CancellationToken); + + IEnumerable query = asyncPeople; + + // Apply search + if (!string.IsNullOrWhiteSpace(request.SearchText)) + { + var search = request.SearchText; + query = query.Where(p => + p.Name.Contains(search, StringComparison.OrdinalIgnoreCase) || + p.Email.Contains(search, StringComparison.OrdinalIgnoreCase) || + p.Department.Contains(search, StringComparison.OrdinalIgnoreCase) || + p.Role.Contains(search, StringComparison.OrdinalIgnoreCase)); + } + + // Apply sorting + if (!string.IsNullOrEmpty(request.SortField) && request.SortDirection != SortDirection.None) + { + var asc = request.SortDirection == SortDirection.Ascending; + query = request.SortField switch + { + "name" => asc ? query.OrderBy(p => p.Name) : query.OrderByDescending(p => p.Name), + "department" => asc ? query.OrderBy(p => p.Department) : query.OrderByDescending(p => p.Department), + "role" => asc ? query.OrderBy(p => p.Role) : query.OrderByDescending(p => p.Role), + _ => query + }; + } + + // Apply paging after filtering/sorting so TotalItemCount reflects the filtered set + var materialized = query.ToList(); + var items = materialized + .Skip(request.StartIndex) + .Take(request.Count ?? materialized.Count) + .ToList(); + + return new DataViewResult + { + Items = items, + TotalItemCount = materialized.Count + }; + } +} diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataViewDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataViewDemo.razor index 98658a14..04647452 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataViewDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataViewDemo.razor @@ -1,5 +1,7 @@ @page "/components/dataview" @using BlazorBlueprint.Demo.Services +@using BlazorBlueprint.Primitives.DataView +@using BlazorBlueprint.Primitives.Table @inject MockDataService MockDataService Data View Component - Blazor Blueprint @@ -431,6 +433,45 @@ + +
+
+

Async Data Loading

+

+ Use ItemsProvider for server-side data fetching. + The view passes the current pagination, sort, and search state and the provider returns the + matching page plus a total count. The built-in loading overlay is shown automatically while + the request is in flight, and rapid search keystrokes cancel the previous request. Provide + either Data or + ItemsProvider, not both. +

+
+ + + + + @GetInitials(person.Name) + +
+

@person.Name

+ @person.Email + @person.Department · @person.Role +
+ + @person.Status + +
+
+ + + + + + +
+ +
+
@@ -525,8 +566,13 @@
- - The data source for the view. + + In-memory data source for the view. Mutually exclusive with ItemsProvider. + + + Async delegate for server-side data fetching. Receives the current pagination, + sort, search, and a cancellation token and returns the matching page plus a total + count. Mutually exclusive with Data. Template used to render each item in list layout. When set without GridTemplate the @@ -653,12 +699,14 @@ private List people = new(); private List emptyPeople = new(); private List products = new(); + private List asyncPeople = new(); private bool isLoading; protected override void OnInitialized() { people = MockDataService.GeneratePersons(500); products = MockDataService.GenerateProducts(60); + asyncPeople = MockDataService.GeneratePersons(200); } private static string GetInitials(string name) @@ -668,4 +716,43 @@ ? $"{parts[0][0]}{parts[^1][0]}" : name.Length > 0 ? name[0].ToString() : "?"; } + + private async ValueTask> LoadPeopleAsync(DataViewRequest request) + { + IEnumerable query = asyncPeople; + + if (!string.IsNullOrWhiteSpace(request.SearchText)) + { + var search = request.SearchText; + query = query.Where(p => + p.Name.Contains(search, StringComparison.OrdinalIgnoreCase) || + p.Email.Contains(search, StringComparison.OrdinalIgnoreCase) || + p.Department.Contains(search, StringComparison.OrdinalIgnoreCase) || + p.Role.Contains(search, StringComparison.OrdinalIgnoreCase)); + } + + if (!string.IsNullOrEmpty(request.SortField) && request.SortDirection != SortDirection.None) + { + var asc = request.SortDirection == SortDirection.Ascending; + query = request.SortField switch + { + "name" => asc ? query.OrderBy(p => p.Name) : query.OrderByDescending(p => p.Name), + "department" => asc ? query.OrderBy(p => p.Department) : query.OrderByDescending(p => p.Department), + "role" => asc ? query.OrderBy(p => p.Role) : query.OrderByDescending(p => p.Role), + _ => query + }; + } + + var materialized = query.ToList(); + var items = materialized + .Skip(request.StartIndex) + .Take(request.Count ?? materialized.Count) + .ToList(); + + return new DataViewResult + { + Items = items, + TotalItemCount = materialized.Count + }; + } } diff --git a/src/BlazorBlueprint.Components/Components/DataView/BbDataView.razor b/src/BlazorBlueprint.Components/Components/DataView/BbDataView.razor index e65a445f..85e8791f 100644 --- a/src/BlazorBlueprint.Components/Components/DataView/BbDataView.razor +++ b/src/BlazorBlueprint.Components/Components/DataView/BbDataView.razor @@ -101,7 +101,7 @@ } @* ── Content ─────────────────────────────────────────────────── *@ - @if (IsLoading) + @if (IsLoading || _isProviderLoading) { @if (LoadingTemplate != null) { @@ -191,7 +191,7 @@ } @* ── Pagination – hidden in infinite scroll mode ─────────────── *@ - @if (ShowPagination && !EnableInfiniteScroll && !IsLoading && _filteredSortedData.Count > 0) + @if (ShowPagination && !EnableInfiniteScroll && !IsLoading && !_isProviderLoading && _filteredSortedData.Count > 0) { ? _lastItemsProvider; + private List _accumulatedProviderItems = new(); + // ShouldRender tracking fields private bool _parametersChanged; private IEnumerable? _lastData; @@ -95,9 +104,18 @@ internal sealed class FieldData /// /// Gets or sets the data source for the view. + /// Provide either or , not both. /// - [Parameter, EditorRequired] - public IEnumerable Data { get; set; } = Array.Empty(); + [Parameter] + public IEnumerable? Data { get; set; } + + /// + /// Gets or sets an async callback for server-side data loading. + /// Invoked whenever pagination, sort, or search state changes. + /// Provide either or , not both. + /// + [Parameter] + public DataViewItemsProvider? ItemsProvider { get; set; } /// /// Gets or sets the template used to render each item in list layout mode. @@ -332,7 +350,9 @@ private DataViewLayout _effectiveLayout /// True when there are more batched items to reveal in infinite scroll mode. /// private bool CanLoadMore => EnableInfiniteScroll - && _currentInfinitePage * _paginationState.PageSize < _filteredSortedData.Count; + && (ItemsProvider != null + ? _accumulatedProviderItems.Count < _paginationState.TotalItems + : _currentInfinitePage * _paginationState.PageSize < _filteredSortedData.Count); /// /// The list template in effect: the named parameter takes precedence over any @@ -368,8 +388,10 @@ protected override async Task OnParametersSetAsync() // Sync the backing field when the Layout parameter changes externally. currentLayout = Layout; - // Skip reprocessing when the data source has not changed. - if (ReferenceEquals(_lastData, Data) && _lastData != null) + // Skip reprocessing when neither data source has changed. + if (ReferenceEquals(_lastData, Data) + && ReferenceEquals(_lastItemsProvider, ItemsProvider) + && (_lastData != null || _lastItemsProvider != null)) { return; } @@ -411,6 +433,7 @@ internal void RegisterField(BbDataViewColumn field) }); _fieldsVersion++; + StateHasChanged(); } /// @@ -439,32 +462,108 @@ internal void SetGridTemplate(RenderFragment? template) private async Task ProcessDataAsync() { - var data = Data ?? Array.Empty(); + if (ItemsProvider != null) + { + if (EnableInfiniteScroll) + { + _accumulatedProviderItems.Clear(); + } - if (PreprocessData != null) + await LoadFromProviderAsync(); + } + else { - data = await PreprocessData(data); + var data = Data ?? Array.Empty(); + + if (PreprocessData != null) + { + data = await PreprocessData(data); + } + + var filtered = ApplyFiltering(data); + var sorted = ApplySorting(filtered); + + _filteredSortedData = sorted.ToList(); + _paginationState.TotalItems = _filteredSortedData.Count; + + if (EnableInfiniteScroll) + { + // Reveal items from pages 1..N; N is incremented by LoadMore / scroll. + _visibleData = _filteredSortedData + .Take(_currentInfinitePage * _paginationState.PageSize) + .ToList(); + } + else + { + _visibleData = _filteredSortedData + .Skip(_paginationState.StartIndex) + .Take(_paginationState.PageSize) + .ToList(); + } } + } + + private async Task LoadFromProviderAsync() + { + var oldCts = _loadCts; + oldCts?.Cancel(); + oldCts?.Dispose(); + _loadCts = new CancellationTokenSource(); + var token = _loadCts.Token; + + _isProviderLoading = true; + _providerLoadingVersion++; + StateHasChanged(); - var filtered = ApplyFiltering(data); - var sorted = ApplySorting(filtered); + try + { + var startIndex = EnableInfiniteScroll + ? _accumulatedProviderItems.Count + : _paginationState.StartIndex; + + var request = new DataViewRequest + { + StartIndex = startIndex, + Count = _paginationState.PageSize, + SortField = _sortingState.SortedColumn, + SortDirection = _sortingState.Direction, + SearchText = string.IsNullOrWhiteSpace(_searchValue) ? null : _searchValue, + CancellationToken = token + }; - _filteredSortedData = sorted.ToList(); - _paginationState.TotalItems = _filteredSortedData.Count; + var result = await ItemsProvider!(request); + + if (token.IsCancellationRequested) + { + return; + } - if (EnableInfiniteScroll) + if (EnableInfiniteScroll) + { + _accumulatedProviderItems.AddRange(result.Items); + _filteredSortedData = _accumulatedProviderItems; + _visibleData = _accumulatedProviderItems; + } + else + { + _filteredSortedData = result.Items.ToList(); + _visibleData = _filteredSortedData; + } + + _paginationState.TotalItems = result.TotalItemCount; + } + catch (OperationCanceledException) { - // Reveal items from pages 1..N; N is incremented by LoadMore / scroll. - _visibleData = _filteredSortedData - .Take(_currentInfinitePage * _paginationState.PageSize) - .ToList(); + // Superseded by a newer request — the new request manages loading state. + return; } - else + finally { - _visibleData = _filteredSortedData - .Skip(_paginationState.StartIndex) - .Take(_paginationState.PageSize) - .ToList(); + if (!token.IsCancellationRequested) + { + _isProviderLoading = false; + _providerLoadingVersion++; + } } } @@ -642,9 +741,19 @@ private async Task LoadMore() } _isLoadingMore = true; - _currentInfinitePage++; _infiniteScrollVersion++; - await ProcessDataAsync(); + + if (ItemsProvider != null) + { + // startIndex is derived from _accumulatedProviderItems.Count inside LoadFromProviderAsync + await LoadFromProviderAsync(); + } + else + { + _currentInfinitePage++; + await ProcessDataAsync(); + } + _isLoadingMore = false; StateHasChanged(); } @@ -679,6 +788,7 @@ protected override bool ShouldRender() { _parametersChanged = false; _lastData = Data; + _lastItemsProvider = ItemsProvider; _lastLayout = currentLayout; _lastIsLoading = IsLoading; _lastFieldsVersion = _fieldsVersion; @@ -687,6 +797,7 @@ protected override bool ShouldRender() _lastSlotVersion = _slotVersion; _lastInfiniteScrollVersion = _infiniteScrollVersion; _lastSortingVersion = _sortingVersion; + _lastProviderLoadingVersion = _providerLoadingVersion; return true; } @@ -699,10 +810,12 @@ protected override bool ShouldRender() var slotChanged = _lastSlotVersion != _slotVersion; var infiniteScrollChanged = _lastInfiniteScrollVersion != _infiniteScrollVersion; var sortingChanged = _lastSortingVersion != _sortingVersion; + var providerLoadingChanged = _lastProviderLoadingVersion != _providerLoadingVersion; - if (dataChanged || layoutChanged || loadingChanged || fieldsChanged || searchChanged || paginationChanged || slotChanged || infiniteScrollChanged || sortingChanged) + if (dataChanged || layoutChanged || loadingChanged || fieldsChanged || searchChanged || paginationChanged || slotChanged || infiniteScrollChanged || sortingChanged || providerLoadingChanged) { _lastData = Data; + _lastItemsProvider = ItemsProvider; _lastLayout = currentLayout; _lastIsLoading = IsLoading; _lastFieldsVersion = _fieldsVersion; @@ -711,6 +824,7 @@ protected override bool ShouldRender() _lastSlotVersion = _slotVersion; _lastInfiniteScrollVersion = _infiniteScrollVersion; _lastSortingVersion = _sortingVersion; + _lastProviderLoadingVersion = _providerLoadingVersion; return true; } @@ -721,6 +835,10 @@ protected override bool ShouldRender() public async ValueTask DisposeAsync() { + _loadCts?.Cancel(); + _loadCts?.Dispose(); + _loadCts = null; + if (_jsModule != null) { try diff --git a/src/BlazorBlueprint.Components/README.md b/src/BlazorBlueprint.Components/README.md index 9277d7d4..e56c32d6 100644 --- a/src/BlazorBlueprint.Components/README.md +++ b/src/BlazorBlueprint.Components/README.md @@ -280,7 +280,7 @@ Convenience wrappers that combine a form control with `BbField` for label, descr ```razor - + JD ``` diff --git a/src/BlazorBlueprint.Primitives/Primitives/DataView/DataViewItemsProvider.cs b/src/BlazorBlueprint.Primitives/Primitives/DataView/DataViewItemsProvider.cs new file mode 100644 index 00000000..b13d858d --- /dev/null +++ b/src/BlazorBlueprint.Primitives/Primitives/DataView/DataViewItemsProvider.cs @@ -0,0 +1,67 @@ +using BlazorBlueprint.Primitives.Table; + +namespace BlazorBlueprint.Primitives.DataView; + +/// +/// Delegate for asynchronous server-side data fetching in a DataView. +/// Invoked whenever pagination, sort, or search state changes. +/// +/// The type of data items. +/// The request containing sort, pagination, search, and cancellation information. +/// A result containing the items for the current page and the total count. +public delegate ValueTask> DataViewItemsProvider( + DataViewRequest request) where TItem : class; + +/// +/// Describes the data request from BbDataView to the items provider. +/// +public class DataViewRequest +{ + /// + /// Gets the zero-based index of the first item to return. + /// + public int StartIndex { get; init; } + + /// + /// Gets the maximum number of items to return. Null means return all. + /// + public int? Count { get; init; } + + /// + /// Gets the ID of the column to sort by, or null if no sort is active. + /// + public string? SortField { get; init; } + + /// + /// Gets the sort direction. when no sort is active. + /// + public SortDirection SortDirection { get; init; } + + /// + /// Gets the global search text, or null if no search is active. + /// + public string? SearchText { get; init; } + + /// + /// Gets the cancellation token for the request. + /// + public CancellationToken CancellationToken { get; init; } +} + +/// +/// The result returned by a . +/// +/// The type of data items. +public class DataViewResult +{ + /// + /// Gets the items for the current page/request. + /// + public required ICollection Items { get; init; } + + /// + /// Gets the total number of items across all pages. + /// Used by the pagination component to calculate total pages. + /// + public int TotalItemCount { get; init; } +} diff --git a/src/BlazorBlueprint.Primitives/wwwroot/js/primitives/sortable.js b/src/BlazorBlueprint.Primitives/wwwroot/js/primitives/sortable.js index d7305a97..87e19ef0 100644 --- a/src/BlazorBlueprint.Primitives/wwwroot/js/primitives/sortable.js +++ b/src/BlazorBlueprint.Primitives/wwwroot/js/primitives/sortable.js @@ -24,7 +24,7 @@ async function loadSortable() { if (!sortableLoadPromise) { sortableLoadPromise = (async () => { // Resolve relative to this module's own URL - const libPath = new URL('../../lib/sortable/Sortable.min.js', import.meta.url).href; + const libPath = new URL('../../lib/sortable/sortable.min.js', import.meta.url).href; const mod = await import(libPath); return mod; })(); diff --git a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt index 945f299e..d7bb066c 100644 --- a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt +++ b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt @@ -944,7 +944,7 @@ ### BbDataView`1 (BlazorBlueprint.Components) - Class : String - - Data : IEnumerable [EditorRequired] + - Data : IEnumerable - EmptyTemplate : RenderFragment - EnableInfiniteScroll : Boolean - Fields : RenderFragment @@ -953,6 +953,7 @@ - GridTemplate : RenderFragment - InitialPageSize : Int32 - IsLoading : Boolean + - ItemsProvider : DataViewItemsProvider - Layout : DataViewLayout - ListClass : String - ListTemplate : RenderFragment