From fdaa0673357da50a2ac7345987f798aa7ff2758a Mon Sep 17 00:00:00 2001 From: msynk Date: Sun, 21 Jun 2026 18:59:00 +0330 Subject: [PATCH 01/35] add new BitDataGrid component #12502 --- .../Components/DataGrid/BitDataGrid.razor | 486 ++++-- .../Components/DataGrid/BitDataGrid.razor.cs | 1400 ++++++++++++---- .../Components/DataGrid/BitDataGrid.scss | 386 +++-- .../Components/DataGrid/BitDataGrid.ts | 112 +- .../Components/DataGrid/BitDataGridCell.razor | 72 + .../DataGrid/BitDataGridCellEditor.razor | 61 + .../Components/DataGrid/BitDataGridColumn.cs | 164 ++ .../BitDataGridJsRuntimeExtensions.cs | 14 - .../Components/DataGrid/BitDataGridRow.razor | 147 ++ .../Columns/BitDataGridColumnBase.razor.cs | 9 - .../Columns/IBitDataGridSortBuilderColumn.cs | 18 - .../BitDataGridDataProcessor.cs | 239 +++ .../Infrastructure/BitDataGridGroup.cs | 32 + .../BitDataGridPropertyAccessor.cs | 111 ++ .../BitDataGridValueComparer.cs | 25 + .../ItemsProvider/BitDataGridItemsProvider.cs | 9 - .../Models/BitDataGridAggregateResult.cs | 10 + .../Models/BitDataGridAggregateType.cs | 12 + .../Models/BitDataGridCellEventArgs.cs | 26 + .../DataGrid/Models/BitDataGridColumnAlign.cs | 9 + .../Models/BitDataGridColumnDataType.cs | 12 + .../DataGrid/Models/BitDataGridDirection.cs | 8 + .../Models/BitDataGridFilterDescriptor.cs | 9 + .../Models/BitDataGridFilterOperator.cs | 18 + .../Models/BitDataGridGroupDescriptor.cs | 8 + .../Models/BitDataGridPagerPosition.cs | 9 + .../DataGrid/Models/BitDataGridReadRequest.cs | 21 + .../DataGrid/Models/BitDataGridReadResult.cs | 18 + .../Models/BitDataGridRowReorderEventArgs.cs | 14 + .../Models/BitDataGridSelectionMode.cs | 9 + .../Models/BitDataGridSortDescriptor.cs | 10 + .../Models/BitDataGridSortDirection.cs | 9 + .../Components/QuickGrid/BitQuickGrid.razor | 138 ++ .../QuickGrid/BitQuickGrid.razor.cs | 536 ++++++ .../Components/QuickGrid/BitQuickGrid.scss | 139 ++ .../Components/QuickGrid/BitQuickGrid.ts | 101 ++ .../BitQuickGridJsRuntimeExtensions.cs | 14 + .../BitQuickGridRowTemplateArgs.cs} | 6 +- .../BitQuickGridSortDirection.cs} | 8 +- .../Columns/BitQuickGridAlign.cs} | 6 +- .../Columns/BitQuickGridColumnBase.razor} | 42 +- .../Columns/BitQuickGridColumnBase.razor.cs | 9 + .../Columns/BitQuickGridPropertyColumn.cs} | 12 +- .../Columns/BitQuickGridSort.cs} | 50 +- .../Columns/BitQuickGridTemplateColumn.cs} | 10 +- .../Columns/IBitQuickGridSortBuilderColumn.cs | 18 + .../AsyncQueryExecutorSupplier.cs | 6 +- .../BitQuickGridColumnsCollectedNotifier.cs} | 14 +- .../Infrastructure/BitQuickGridDefer.cs} | 6 +- .../EventCallbackSubscribable.cs | 2 +- .../Infrastructure/EventCallbackSubscriber.cs | 2 +- .../Infrastructure/EventHandlers.cs | 4 +- .../Infrastructure/IAsyncQueryExecutor.cs | 2 +- .../Infrastructure/InternalGridContext.cs | 6 +- .../BitQuickGridItemsProvider.cs | 9 + .../BitQuickGridItemsProviderRequest.cs} | 28 +- .../BitQuickGridItemsProviderResult.cs} | 22 +- .../BitQuickGridPaginationState.cs} | 16 +- .../Pagination/BitQuickGridPaginator.razor} | 0 .../BitQuickGridPaginator.razor.cs} | 26 +- .../Pagination/BitQuickGridPaginator.scss} | 0 .../Styles/extra-components.scss | 3 +- .../Dtos/AppJsonContext.cs | 2 +- .../Dtos/DataGridDemo/Openfda.cs | 5 - .../FoodRecall.cs | 2 +- .../FoodRecallQueryResult.cs | 2 +- .../{DataGridDemo => QuickGridDemo}/Meta.cs | 2 +- .../Dtos/QuickGridDemo/Openfda.cs | 5 + .../Results.cs | 2 +- .../Extras/DataGrid/BitDataGridDemo.razor | 650 ++++++-- .../Extras/DataGrid/BitDataGridDemo.razor.cs | 850 +++------- .../DataGrid/BitDataGridDemo.razor.params.cs | 267 +++ .../DataGrid/BitDataGridDemo.razor.samples.cs | 1464 +++-------------- .../DataGrid/BitDataGridDemo.razor.scss | 134 +- .../Extras/DataGrid/FileSystemData.cs | 74 + .../Components/Extras/DataGrid/Product.cs | 27 + .../Components/Extras/DataGrid/SampleData.cs | 35 + .../Extras/DataGrid/SupplierModel.cs | 10 + .../Extras/QuickGrid/BitQuickGridDemo.razor | 198 +++ .../QuickGrid/BitQuickGridDemo.razor.cs | 681 ++++++++ .../BitQuickGridDemo.razor.samples.cs | 1239 ++++++++++++++ .../QuickGrid/BitQuickGridDemo.razor.scss | 131 ++ .../{DataGrid => QuickGrid}/CountryModel.cs | 2 +- .../{DataGrid => QuickGrid}/MedalsModel.cs | 2 +- .../Shared/MainLayout.razor.NavItems.cs | 1 + .../compilerconfig.json | 6 + 86 files changed, 7522 insertions(+), 2991 deletions(-) create mode 100644 src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridCell.razor create mode 100644 src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridCellEditor.razor create mode 100644 src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridColumn.cs delete mode 100644 src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridJsRuntimeExtensions.cs create mode 100644 src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridRow.razor delete mode 100644 src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Columns/BitDataGridColumnBase.razor.cs delete mode 100644 src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Columns/IBitDataGridSortBuilderColumn.cs create mode 100644 src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridDataProcessor.cs create mode 100644 src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridGroup.cs create mode 100644 src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridPropertyAccessor.cs create mode 100644 src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridValueComparer.cs delete mode 100644 src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/ItemsProvider/BitDataGridItemsProvider.cs create mode 100644 src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridAggregateResult.cs create mode 100644 src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridAggregateType.cs create mode 100644 src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridCellEventArgs.cs create mode 100644 src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridColumnAlign.cs create mode 100644 src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridColumnDataType.cs create mode 100644 src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridDirection.cs create mode 100644 src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridFilterDescriptor.cs create mode 100644 src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridFilterOperator.cs create mode 100644 src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridGroupDescriptor.cs create mode 100644 src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridPagerPosition.cs create mode 100644 src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridReadRequest.cs create mode 100644 src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridReadResult.cs create mode 100644 src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridRowReorderEventArgs.cs create mode 100644 src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridSelectionMode.cs create mode 100644 src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridSortDescriptor.cs create mode 100644 src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridSortDirection.cs create mode 100644 src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor create mode 100644 src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor.cs create mode 100644 src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.scss create mode 100644 src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.ts create mode 100644 src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGridJsRuntimeExtensions.cs rename src/BlazorUI/Bit.BlazorUI.Extras/Components/{DataGrid/BitDataGridRowTemplateArgs.cs => QuickGrid/BitQuickGridRowTemplateArgs.cs} (82%) rename src/BlazorUI/Bit.BlazorUI.Extras/Components/{DataGrid/BitDataGridSortDirection.cs => QuickGrid/BitQuickGridSortDirection.cs} (58%) rename src/BlazorUI/Bit.BlazorUI.Extras/Components/{DataGrid/Columns/BitDataGridAlign.cs => QuickGrid/Columns/BitQuickGridAlign.cs} (73%) rename src/BlazorUI/Bit.BlazorUI.Extras/Components/{DataGrid/Columns/BitDataGridColumnBase.razor => QuickGrid/Columns/BitQuickGridColumnBase.razor} (73%) create mode 100644 src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridColumnBase.razor.cs rename src/BlazorUI/Bit.BlazorUI.Extras/Components/{DataGrid/Columns/BitDataGridPropertyColumn.cs => QuickGrid/Columns/BitQuickGridPropertyColumn.cs} (81%) rename src/BlazorUI/Bit.BlazorUI.Extras/Components/{DataGrid/Columns/BitDataGridSort.cs => QuickGrid/Columns/BitQuickGridSort.cs} (68%) rename src/BlazorUI/Bit.BlazorUI.Extras/Components/{DataGrid/Columns/BitDataGridTemplateColumn.cs => QuickGrid/Columns/BitQuickGridTemplateColumn.cs} (65%) create mode 100644 src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/IBitQuickGridSortBuilderColumn.cs rename src/BlazorUI/Bit.BlazorUI.Extras/Components/{DataGrid => QuickGrid}/Infrastructure/AsyncQueryExecutorSupplier.cs (91%) rename src/BlazorUI/Bit.BlazorUI.Extras/Components/{DataGrid/Infrastructure/BitDataGridColumnsCollectedNotifier.cs => QuickGrid/Infrastructure/BitQuickGridColumnsCollectedNotifier.cs} (76%) rename src/BlazorUI/Bit.BlazorUI.Extras/Components/{DataGrid/Infrastructure/BitDataGridDefer.cs => QuickGrid/Infrastructure/BitQuickGridDefer.cs} (74%) rename src/BlazorUI/Bit.BlazorUI.Extras/Components/{DataGrid => QuickGrid}/Infrastructure/EventCallbackSubscribable.cs (97%) rename src/BlazorUI/Bit.BlazorUI.Extras/Components/{DataGrid => QuickGrid}/Infrastructure/EventCallbackSubscriber.cs (98%) rename src/BlazorUI/Bit.BlazorUI.Extras/Components/{DataGrid => QuickGrid}/Infrastructure/EventHandlers.cs (64%) rename src/BlazorUI/Bit.BlazorUI.Extras/Components/{DataGrid => QuickGrid}/Infrastructure/IAsyncQueryExecutor.cs (98%) rename src/BlazorUI/Bit.BlazorUI.Extras/Components/{DataGrid => QuickGrid}/Infrastructure/InternalGridContext.cs (71%) create mode 100644 src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/ItemsProvider/BitQuickGridItemsProvider.cs rename src/BlazorUI/Bit.BlazorUI.Extras/Components/{DataGrid/ItemsProvider/BitDataGridItemsProviderRequest.cs => QuickGrid/ItemsProvider/BitQuickGridItemsProviderRequest.cs} (68%) rename src/BlazorUI/Bit.BlazorUI.Extras/Components/{DataGrid/ItemsProvider/BitDataGridItemsProviderResult.cs => QuickGrid/ItemsProvider/BitQuickGridItemsProviderResult.cs} (60%) rename src/BlazorUI/Bit.BlazorUI.Extras/Components/{DataGrid/Pagination/BitDataGridPaginationState.cs => QuickGrid/Pagination/BitQuickGridPaginationState.cs} (81%) rename src/BlazorUI/Bit.BlazorUI.Extras/Components/{DataGrid/Pagination/BitDataGridPaginator.razor => QuickGrid/Pagination/BitQuickGridPaginator.razor} (100%) rename src/BlazorUI/Bit.BlazorUI.Extras/Components/{DataGrid/Pagination/BitDataGridPaginator.razor.cs => QuickGrid/Pagination/BitQuickGridPaginator.razor.cs} (69%) rename src/BlazorUI/Bit.BlazorUI.Extras/Components/{DataGrid/Pagination/BitDataGridPaginator.scss => QuickGrid/Pagination/BitQuickGridPaginator.scss} (100%) delete mode 100644 src/BlazorUI/Demo/Bit.BlazorUI.Demo.Shared/Dtos/DataGridDemo/Openfda.cs rename src/BlazorUI/Demo/Bit.BlazorUI.Demo.Shared/Dtos/{DataGridDemo => QuickGridDemo}/FoodRecall.cs (97%) rename src/BlazorUI/Demo/Bit.BlazorUI.Demo.Shared/Dtos/{DataGridDemo => QuickGridDemo}/FoodRecallQueryResult.cs (77%) rename src/BlazorUI/Demo/Bit.BlazorUI.Demo.Shared/Dtos/{DataGridDemo => QuickGridDemo}/Meta.cs (87%) create mode 100644 src/BlazorUI/Demo/Bit.BlazorUI.Demo.Shared/Dtos/QuickGridDemo/Openfda.cs rename src/BlazorUI/Demo/Bit.BlazorUI.Demo.Shared/Dtos/{DataGridDemo => QuickGridDemo}/Results.cs (79%) create mode 100644 src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.params.cs create mode 100644 src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/FileSystemData.cs create mode 100644 src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/Product.cs create mode 100644 src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/SampleData.cs create mode 100644 src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/SupplierModel.cs create mode 100644 src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor create mode 100644 src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.cs create mode 100644 src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.samples.cs create mode 100644 src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.scss rename src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/{DataGrid => QuickGrid}/CountryModel.cs (70%) rename src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/{DataGrid => QuickGrid}/MedalsModel.cs (70%) diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor index a3e59460a4..a390ef0173 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor @@ -1,138 +1,418 @@ +@typeparam TItem @namespace Bit.BlazorUI -@typeparam TGridItem - - @{ - StartCollectingColumns(); +@* Collect column definitions (these render no visible markup) *@ + + @ChildContent + + +
+ + @* ---------------------------------------------------------- Toolbar *@ + @if (ShowToolbar || ToolbarTemplate is not null || ShowColumnChooser || ShowCsvExport || (Editable && NewItemFactory is not null)) + { +
+
+ @ToolbarTemplate + @if (Editable && NewItemFactory is not null) + { + + } +
+
+ @if (_filters.Count > 0) + { + + } + @if (ShowCsvExport) + { + Export CSV + } + @if (ShowColumnChooser) + { + + } +
+
} - @(Columns ?? ChildContent) - - @{ - FinishCollectingColumns(); - } - - - - - - @_renderColumnHeaders - - - - @if (Virtualize) + + @if (_showColumnChooserPanel) + { +
+ @foreach (var col in AllColumns) + { + + } +
+ } + + @if (Pageable && (PagerPosition is BitDataGridPagerPosition.Top or BitDataGridPagerPosition.TopAndBottom)) + { + @RenderPager + } + + @* ------------------------------------------------------- Grid viewport *@ +
+
+ + @if (ShowHeader) + { +
+ @if (HasColumnGroups) + { +
+ @if (HasReorderColumn) + { +
+ } + @if (HasDetailColumn) + { +
+ } + @if (HasSelectColumn) + { +
+ } + @foreach (var span in ColumnGroupSpans()) + { +
+ @span.Name +
+ } + @if (HasCommandColumn) + { +
+ } +
+ } +
+ @if (HasReorderColumn) + { +
+ } + @if (HasDetailColumn) + { +
+ } + @if (HasSelectColumn) + { +
+ +
+ } + + @foreach (var column in VisibleColumns) + { + var sort = GetSort(column); + var hcellClass = HeaderCellClass(column); + var draggable = ColumnReorderable(column) ? "true" : "false"; +
+ + + @if (column.HeaderTemplate is not null) + { + @column.HeaderTemplate + } + else + { + @column.DisplayTitle + } + @if (sort is not null) + { + @(sort.Direction == BitDataGridSortDirection.Ascending ? "▲" : "▼") + @if (MultiSort && _sorts.Count > 1) + { + @sort.Priority + } + } + + + @if (ColumnGroupable(column)) + { + var groupClass = IsGrouped(column) ? "bit-dtg-icon-btn bit-dtg-group-btn bit-dtg-active" : "bit-dtg-icon-btn bit-dtg-group-btn"; + + } + + @if (ColumnResizable(column)) + { + + } +
+ } + + @if (HasCommandColumn) + { +
Actions
+ } +
+ + @if (Filterable || VisibleColumns.Any(ColumnFilterable)) + { +
+ @if (HasReorderColumn) + { +
+ } + @if (HasDetailColumn) + { +
+ } + @if (HasSelectColumn) + { +
+ } + @foreach (var column in VisibleColumns) + { +
+ @if (ColumnFilterable(column)) + { + + } +
+ } + @if (HasCommandColumn) + { +
+ } +
+ } +
+ } + +
+ @if (PendingNewItem is not null) { - + } - else + @if (Loading) + { +
Loading…
+ } + else if (TotalCount == 0 && PendingNewItem is null && !IsInfiniteMode) { - if (IsLoading && LoadingTemplate is not null) +
+ @if (EmptyTemplate is not null) + { + @EmptyTemplate + } + else + { + No records to display. + } +
+ } + else if (_viewGroups is not null) + { + @RenderGroupList(_viewGroups) + } + else if (IsInfiniteMode) + { + @if (InfiniteItems.Count == 0 && !InfiniteLoading) { -
- - +
+ @if (EmptyTemplate is not null) + { + @EmptyTemplate + } + else + { + No records to display. + } +
} else { - @_renderNonVirtualizedRows + @foreach (var item in InfiniteItems) + { + + } + @if (InfiniteLoading) + { +
+
+ +
+
+ } + else if (!InfiniteHasMore) + { +
+ — End of results — +
+ } } } -
-
- @LoadingTemplate -
-
- + else if (UseVirtualization) + { + + + + } + else + { + @foreach (var item in _pageItems) + { + + } + } +
+ + @if (ShowFooter && _footerAggregates.Count > 0) + { + + } + + + + @if (Pageable && (PagerPosition is BitDataGridPagerPosition.Bottom or BitDataGridPagerPosition.TopAndBottom)) + { + @RenderPager + } + + +@* --------------------------------------------- Resize tracking overlay (zero-JS) *@ +@if (IsResizing) +{ +
+} @code { - private void RenderNonVirtualizedRows(RenderTreeBuilder __builder) + private bool UseVirtualization => Virtualize && _viewGroups is null && !IsServerMode; + private ICollection VirtualRows => _view as ICollection ?? _view.ToList(); + + private RenderFragment RenderGroupList(IReadOnlyList> groups) => __builder => { - var initialRowIndex = 2; // aria-rowindex is 1-based, plus the first row is the header - var rowIndex = initialRowIndex; - foreach (var item in _currentNonVirtualizedViewItems) + foreach (var group in groups) { - RenderRow(__builder, rowIndex++, item); + @RenderGroup(group) } + }; - // When pagination is enabled, by default ensure we render the exact number of expected rows per page, - // even if there aren't enough data items. This avoids the layout jumping on the last page. - // Consider making this optional. - if (Pagination is not null) + private RenderFragment RenderGroup(BitDataGridGroup group) => __builder => + { + var groupCol = _columnsById.GetValueOrDefault(group.ColumnId); + var collapsed = IsGroupCollapsed(group); +
+
+ + @(groupCol?.DisplayTitle): @group.KeyText + (@group.Count) + @foreach (var agg in group.Aggregates) + { + @(_columnsById.GetValueOrDefault(agg.ColumnId)?.DisplayTitle): @AggregateLabel(agg) + } +
+
+ @if (!collapsed) { - while (rowIndex++ < initialRowIndex + Pagination.ItemsPerPage) + if (group.HasSubGroups) { - + @RenderGroupList(group.SubGroups) + } + else + { + foreach (var item in group.Items) + { + + } } } - } + }; - private void RenderRow(RenderTreeBuilder __builder, int rowIndex, TGridItem item) + private string ViewportStyle { - if (RowTemplate is null) - { - RenderOriginalRow(__builder, rowIndex, item); - } - else + get { - var args = new BitDataGridRowTemplateArgs - { - RowIndex = rowIndex, - RowItem = item, - OriginalRow = (builder) => RenderOriginalRow(builder, rowIndex, item) - }; - __builder.AddContent(0, RowTemplate(args)); + var s = ""; + if (!string.IsNullOrEmpty(Height)) s += $"height:{Height};"; + return s; } } - private void RenderOriginalRow(RenderTreeBuilder __builder, int rowIndex, TGridItem item) + private async Task OnHeaderClick(BitDataGridColumn column, MouseEventArgs e) { - - @foreach (var col in _columns) - { - - @{ - col.CellContent(__builder, item); - } - - } - + if (!ColumnSortable(column)) return; + await ToggleSortAsync(column, MultiSort && (e.CtrlKey || e.MetaKey)); } - private void RenderPlaceholderRow(RenderTreeBuilder __builder, PlaceholderContext placeholderContext) - { - - @foreach (var col in _columns) - { - - @{ - col.RenderPlaceholderContent(__builder, placeholderContext); - } - + private RenderFragment RenderPager => @
+
+ @{ + var from = TotalCount == 0 ? 0 : (CurrentPage - 1) * _effectivePageSize + 1; + var to = Math.Min(CurrentPage * _effectivePageSize, TotalCount); } - - } - - private void RenderColumnHeaders(RenderTreeBuilder __builder) - { - foreach (var col in _columns) - { - -
@col.HeaderContent
- - @if (col == _displayOptionsForColumn) + @from–@to of @TotalCount + +
+
+ + + Page @CurrentPage of @TotalPages + + +
+
; } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs index 9336224b43..3b6979d17b 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs @@ -1,536 +1,1214 @@ -// a fork from the Blazor QuickGrid at https://github.com/dotnet/aspnetcore/tree/main/src/Components/QuickGrid +using System.Globalization; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Components.Web.Virtualization; +using Microsoft.JSInterop; namespace Bit.BlazorUI; /// -/// BitDataGrid is a robust way to display an information-rich collection of items, and allow people to sort, and filter the content. +/// A feature-rich, generic data grid for Blazor: sorting, filtering, paging, +/// virtualization, selection, inline editing, column resize/reorder, frozen +/// columns, grouping, aggregates and theming. /// -/// The type of data represented by each row in the grid. -[CascadingTypeParameter(nameof(TGridItem))] -public partial class BitDataGrid : IAsyncDisposable +/// The row item type. +public partial class BitDataGrid : ComponentBase, IAsyncDisposable { - private bool _disposed; - private int _ariaBodyRowCount; - private ElementReference _tableReference; - private Virtualize<(int, TGridItem)>? _virtualizeComponent; - private ICollection _currentNonVirtualizedViewItems = Array.Empty(); + [Inject] private IJSRuntime JS { get; set; } = default!; - // IQueryable only exposes synchronous query APIs. IAsyncQueryExecutor is an adapter that lets us invoke any - // async query APIs that might be available. We have built-in support for using EF Core's async query APIs. - private IAsyncQueryExecutor? _asyncQueryExecutor; + // ---------------------------------------------------------------- Data + [Parameter] public IEnumerable? Items { get; set; } - // We cascade the InternalGridContext to descendants, which in turn call it to add themselves to _columns - // This happens on every render so that the column list can be updated dynamically - private InternalGridContext _internalGridContext; - private List> _columns; - private bool _collectingColumns; // Columns might re-render themselves arbitrarily. We only want to capture them at a defined time. + /// Server-side data callback. When set, the grid delegates sort/filter/page to the caller. + [Parameter] public Func>>? OnRead { get; set; } - // Tracking state for options and sorting - private BitDataGridColumnBase? _displayOptionsForColumn; - private BitDataGridColumnBase? _sortByColumn; - private bool _sortByAscending; - private bool _checkColumnOptionsPosition; + /// + /// Infinite-scrolling data callback. When set, the grid loads rows in batches and appends the + /// next batch automatically as the user scrolls to the end of the viewport — with no paging UI + /// and no knowledge of the total row count. Each call receives a + /// whose Skip is the number of rows already loaded and whose Take is + /// . The grid stops requesting more once a batch returns fewer rows + /// than requested (signalling the end of the data). Mirrors react-data-grid's Infinite Scrolling. + /// Requires a fixed . The returned TotalCount is ignored in this mode. + /// + [Parameter] public Func>>? OnLoadMore { get; set; } - // The associated ES6 module, which uses document-level event listeners - //private IJSObjectReference? _jsModule; - private IJSObjectReference? _jsEventDisposable; + /// Number of rows fetched per batch in infinite-scrolling mode. Default: 50. + [Parameter] public int LoadMoreBatchSize { get; set; } = 50; - // Caches of method->delegate conversions - private readonly RenderFragment _renderColumnHeaders; - private readonly RenderFragment _renderNonVirtualizedRows; + /// Column definitions and other declarative children. + [Parameter] public RenderFragment? ChildContent { get; set; } - // We try to minimize the number of times we query the items provider, since queries may be expensive - // We only re-query when the developer calls RefreshDataAsync, or if we know something's changed, such - // as sort order, the pagination state, or the data source itself. These fields help us detect when - // things have changed, and to discard earlier load attempts that were superseded. - private int? _lastRefreshedPaginationStateHash; - private object? _lastAssignedItemsOrProvider; - private CancellationTokenSource? _pendingDataLoadCancellationTokenSource; + [Parameter] public bool Loading { get; set; } - // If the PaginationState mutates, it raises this event. We use it to trigger a re-render. - private readonly EventCallbackSubscriber _currentPageItemsChanged; + /// Optional key selector used for selection/edit identity. Defaults to reference equality. + [Parameter] public Func? KeyField { get; set; } + /// + /// Optional child selector that turns the grid into a hierarchical tree grid. + /// Return the direct children of an item, or null/empty for a leaf. When set, + /// the bound are treated as the root nodes and rows render with + /// expand/collapse toggles and indentation. Mirrors react-data-grid's Tree View. + /// + [Parameter] public Func?>? ChildrenSelector { get; set; } + /// When tree mode is active, controls whether nodes start expanded. Default: collapsed. + [Parameter] public bool TreeInitiallyExpanded { get; set; } - [Inject] private IJSRuntime _js { get; set; } = default!; - [Inject] private IServiceProvider _services { get; set; } = default!; + // ------------------------------------------------------------ Appearance + [Parameter] public string? Class { get; set; } + [Parameter] public string? Style { get; set; } + /// Height of the scroll viewport, e.g. "480px". Required for virtualization. + [Parameter] public string? Height { get; set; } + [Parameter] public bool Striped { get; set; } = true; + [Parameter] public bool Hoverable { get; set; } = true; + [Parameter] public bool Bordered { get; set; } = true; + [Parameter] public bool ShowHeader { get; set; } = true; + [Parameter] public bool ShowFooter { get; set; } + [Parameter] public BitDataGridDirection Direction { get; set; } = BitDataGridDirection.Ltr; + + // -------------------------------------------------------- Feature toggles + [Parameter] public bool Sortable { get; set; } = true; + [Parameter] public bool MultiSort { get; set; } = true; + [Parameter] public bool Filterable { get; set; } + [Parameter] public bool Resizable { get; set; } + [Parameter] public bool Reorderable { get; set; } + [Parameter] public bool Groupable { get; set; } + [Parameter] public bool ShowToolbar { get; set; } + [Parameter] public bool ShowColumnChooser { get; set; } + [Parameter] public bool ShowCsvExport { get; set; } + /// + /// Enables keyboard cell navigation. Cells become focusable via a roving tabindex and + /// respond to arrow keys, Home/End, PageUp/PageDown + /// (and Ctrl variants). Enter/F2 begins editing an editable + /// cell and Esc cancels. Mirrors react-data-grid's Cell Navigation. No JavaScript + /// is used — focus is driven by Blazor's built-in FocusAsync. + /// + [Parameter] public bool CellNavigation { get; set; } + /// + /// Enables drag-and-drop row reordering using native HTML drag-and-drop (no JS interop). + /// Provide to persist the new order. + /// + [Parameter] public bool RowReorderable { get; set; } /// - /// Constructs an instance of . + /// Raised when a row is dropped onto another row during reordering. The grid reorders the + /// bound list in place when it is a mutable ; + /// use this callback to persist or override the change. /// - public BitDataGrid() - { - _columns = new(); - _internalGridContext = new(this); - _currentPageItemsChanged = new(EventCallback.Factory.Create(this, RefreshDataCoreAsync)); - _renderColumnHeaders = RenderColumnHeaders; - _renderNonVirtualizedRows = RenderNonVirtualizedRows; + [Parameter] public EventCallback> OnRowReorder { get; set; } - // 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 - var columnsFirstCollectedSubscriber = new EventCallbackSubscriber( - EventCallback.Factory.Create(this, RefreshDataCoreAsync)); - columnsFirstCollectedSubscriber.SubscribeOrMove(_internalGridContext.ColumnsFirstCollected); - } + // ------------------------------------------------------------- Selection + [Parameter] public BitDataGridSelectionMode SelectionMode { get; set; } = BitDataGridSelectionMode.None; + [Parameter] public IReadOnlyList? SelectedItems { get; set; } + [Parameter] public EventCallback> SelectedItemsChanged { get; set; } + [Parameter] public EventCallback OnRowClick { get; set; } - private bool IsLoading => _pendingDataLoadCancellationTokenSource is not null; + /// Raised when a data cell is clicked. + [Parameter] public EventCallback> OnCellClick { get; set; } + /// Raised when a data cell is double-clicked. + [Parameter] public EventCallback> OnCellDoubleClick { get; set; } + /// Raised when a data cell is right-clicked. Useful for custom context menus. + [Parameter] public EventCallback> OnCellContextMenu { get; set; } /// - /// Defines the child components of this instance. For example, you may define columns by adding - /// components derived from the base class. + /// Optional predicate that returns true when a given row may not be selected. + /// Mirrors react-data-grid's isRowSelectionDisabled; such rows are skipped by + /// select-all and render a disabled checkbox. /// - [Parameter] public RenderFragment? ChildContent { get; set; } + [Parameter] public Func? IsRowSelectionDisabled { get; set; } - /// - /// An optional CSS class name. If given, this will be included in the class attribute of the rendered table. - /// - [Parameter] public string? Class { get; set; } + // --------------------------------------------------------------- Paging + [Parameter] public bool Pageable { get; set; } + [Parameter] public int PageSize { get; set; } = 20; + [Parameter] public int[] PageSizeOptions { get; set; } = { 10, 20, 50, 100 }; + [Parameter] public BitDataGridPagerPosition PagerPosition { get; set; } = BitDataGridPagerPosition.Bottom; - /// - /// Alias of the ChildContent parameter. - /// - [Parameter] public RenderFragment? Columns { get; set; } + // --------------------------------------------------------- Virtualization + [Parameter] public bool Virtualize { get; set; } + [Parameter] public float RowHeight { get; set; } = 36f; /// - /// Optionally defines a value for @key on each rendered row. Typically this should be used to specify a - /// unique identifier, such as a primary key value, for each data item. - /// - /// This allows the grid to preserve the association between row elements and data items based on their - /// unique identifiers, even when the TGridItem instances are replaced by new copies (for - /// example, after a new query against the underlying data store). - /// - /// If not set, the @key will be the TGridItem instance itself. + /// Optional per-row height selector (in pixels). Mirrors react-data-grid's functional + /// rowHeight. Ignored while is enabled, which requires a + /// uniform . /// - [Parameter] public Func ItemKey { get; set; } = x => x!; + [Parameter] public Func? RowHeightSelector { get; set; } + + // -------------------------------------------------------------- Editing + [Parameter] public bool Editable { get; set; } + [Parameter] public Func? NewItemFactory { get; set; } + [Parameter] public EventCallback OnRowSave { get; set; } + [Parameter] public EventCallback OnRowCancel { get; set; } + [Parameter] public EventCallback OnRowDelete { get; set; } + [Parameter] public EventCallback OnRowCreate { get; set; } + + // ------------------------------------------------------------ Templates + [Parameter] public RenderFragment? EmptyTemplate { get; set; } + [Parameter] public RenderFragment? ToolbarTemplate { get; set; } + [Parameter] public RenderFragment? DetailTemplate { get; set; } + + // ---------------------------------------------------------------- State + private readonly List> _columns = new(); + private readonly Dictionary> _columnsById = new(); + private readonly List _sorts = new(); + private readonly List _filters = new(); + private readonly List _groups = new(); + private readonly HashSet _selected = new(); + private readonly HashSet _expandedDetails = new(); + private readonly HashSet _collapsedGroups = new(); + + // tree mode + private readonly HashSet _expandedTree = new(); + private readonly Dictionary _treeMeta = new(); + private List? _treeRows; + private bool _treeInitialized; + + // cell navigation + private TItem? _focusedRow; + private int _focusedCol; + private bool _focusPending; + + private IReadOnlyList _view = Array.Empty(); // filtered + sorted (full) + private IReadOnlyList _pageItems = Array.Empty(); // current page slice + private List>? _viewGroups; + private List _footerAggregates = new(); + private int _totalCount; + + private int _currentPage = 1; + private int _effectivePageSize; + private bool _showColumnChooserPanel; + + // Tracks external data inputs so we only (re)load when they actually change, + // rather than on every parent re-render (which would loop in server mode). + private bool _dataInitialized; + private IEnumerable? _lastItems; + private int _lastPageSize; + private BitDataGridSelectionMode? _lastSelectionMode; + + // editing + private TItem? _editItem; + private TItem? _pendingNew; + private bool _isNewItem; + private Dictionary? _editSnapshot; + + // resizing + private BitDataGridColumn? _resizingColumn; + private double _resizeStartX; + private double _resizeStartWidth; + + // reordering + private BitDataGridColumn? _dragColumn; + private TItem? _dragRow; + + // infinite scrolling + private readonly List _infiniteItems = new(); + private bool _infiniteHasMore = true; + private bool _infiniteLoading; + private ElementReference _infiniteViewport; + private DotNetObjectReference>? _infiniteSelfRef; + private IJSObjectReference? _infiniteHandle; + private bool _infiniteObserverAttached; + + internal IReadOnlyList> AllColumns => _columns; + internal IReadOnlyList> VisibleColumns => _columns.Where(c => c.Visible).ToList(); + internal IReadOnlyList Sorts => _sorts; + internal bool IsServerMode => OnRead is not null; + internal bool IsInfiniteMode => OnLoadMore is not null; + internal bool IsTreeMode => ChildrenSelector is not null; + internal bool IsEditing(TItem item) => _editItem is not null && KeyEquals(_editItem, item); + internal bool IsRowSelected(TItem item) => _selected.Contains(item); + internal int TotalCount => IsServerMode ? _totalCount : _view.Count; + internal int TotalPages => _effectivePageSize <= 0 ? 1 : Math.Max(1, (int)Math.Ceiling(TotalCount / (double)_effectivePageSize)); + internal int CurrentPage => _currentPage; + internal IReadOnlyList FooterAggregates => _footerAggregates; + internal TItem? PendingNewItem => _pendingNew; + + // ------------------------------------------------- Column registration + internal void AddColumn(BitDataGridColumn column) + { + if (_columns.Contains(column)) return; + _columns.Add(column); + _columnsById[column.Id] = column; + InvokeAsync(StateHasChanged); + } - /// - /// A queryable source of data for the grid. - /// - /// This could be in-memory data converted to queryable using the - /// extension method, - /// or an EntityFramework DataSet or an derived from it. - /// - /// You should supply either or , but not both. - /// - [Parameter] public IQueryable? Items { get; set; } + internal void RemoveColumn(BitDataGridColumn column) + { + if (_columns.Remove(column)) + { + _columnsById.Remove(column.Id); + InvokeAsync(StateHasChanged); + } + } - /// - /// 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 - /// size and to ensure accurate scrolling. - /// - [Parameter] public float ItemSize { get; set; } = 50; + // ------------------------------------------------------- Lifecycle + protected override async Task OnParametersSetAsync() + { + _effectivePageSize = Pageable ? Math.Max(1, PageSize) : int.MaxValue; - /// - /// A callback that supplies data for the rid. - /// - /// You should supply either or , but not both. - /// - [Parameter] public BitDataGridItemsProvider? ItemsProvider { get; set; } + // Reset the current selection whenever the selection mode changes + // (e.g. switching between Single and Multiple), since the previous + // selection no longer makes sense under the new semantics. + if (_lastSelectionMode is not null && _lastSelectionMode != SelectionMode) + { + if (_selected.Count > 0) + { + _selected.Clear(); + await NotifySelectionAsync(); + } + } + else if (SelectedItems is not null) + { + _selected.Clear(); + foreach (var i in SelectedItems) _selected.Add(i); + } + _lastSelectionMode = SelectionMode; - /// - /// The custom template to render while loading the new items. - /// - [Parameter] public RenderFragment? LoadingTemplate { get; set; } + // Only (re)load data when an external input that affects it actually changes. + // Refreshing on every parameter set would cause an infinite loop in server mode: + // OnRead -> caller StateHasChanged -> parent re-render -> OnParametersSetAsync -> OnRead... + var inputsChanged = !ReferenceEquals(Items, _lastItems) || PageSize != _lastPageSize; + if (!_dataInitialized || inputsChanged) + { + _lastItems = Items; + _lastPageSize = PageSize; + _dataInitialized = true; + await RefreshAsync(); + } + } - /// - /// Optionally links this instance with a model, - /// causing the grid to fetch and render only the current page of data. - /// - /// This is normally used in conjunction with a component or some other UI logic - /// that displays and updates the supplied instance. - /// - [Parameter] public BitDataGridPaginationState? Pagination { get; set; } + /// Recomputes the data view (filter → sort → group → page). + public async Task RefreshAsync() + { + if (IsInfiniteMode) + { + // True infinite scroll: reset the accumulated rows and load the first batch. + // Further batches are appended as the user scrolls toward the end. + await ResetInfiniteAsync(); + return; + } - /// - /// If true, renders draggable handles around the column headers, allowing the user to resize the columns - /// manually. Size changes are not persisted. - /// - [Parameter] public bool ResizableColumns { get; set; } + if (IsServerMode) + { + await LoadServerDataAsync(); + } + else + { + ProcessClientData(); + } + StateHasChanged(); + } - /// - /// The CSS class of all rows of the data grid. - /// - [Parameter] public string? RowClass { get; set; } + private void ProcessClientData() + { + var source = Items ?? Enumerable.Empty(); - /// - /// The function to generate the CSS class of each row of the data grid. - /// - [Parameter] public Func? RowClassSelector { get; set; } + if (IsTreeMode) + { + ProcessTreeData(source); + return; + } - /// - /// The CSS style of all row of the data grid. - /// - [Parameter] public string? RowStyle { get; set; } + var filtered = BitDataGridDataProcessor.Filter(source, _filters, _columnsById); + _view = BitDataGridDataProcessor.Sort(filtered, _sorts, _columnsById); + _footerAggregates = BitDataGridDataProcessor.Aggregate(_view, _columns); - /// - /// The function to generate the CSS style of each row of the data grid. - /// - [Parameter] public Func? RowStyleSelector { get; set; } + ClampPage(); - /// - /// Optional template to customize row rendering. Receives with - /// set to the default row content; call it to render the original cells or replace with custom content. - /// - [Parameter] public RenderFragment>? RowTemplate { get; set; } + if (_groups.Count > 0) + { + _viewGroups = BitDataGridDataProcessor.Group(_view, _groups, _columnsById); + _pageItems = _view; // grouping ignores paging in this implementation + } + else + { + _viewGroups = null; + _pageItems = Pageable + ? _view.Skip((_currentPage - 1) * _effectivePageSize).Take(_effectivePageSize).ToList() + : _view; + } + } /// - /// A theme name, with default value "default". This affects which styling rules match the table. + /// Flattens the hierarchical source into the list of currently-visible rows, honouring + /// per-sibling sorting and expand/collapse state. Paging and grouping do not apply in tree mode. /// - [Parameter] public string? Theme { get; set; } = "default"; + private void ProcessTreeData(IEnumerable roots) + { + if (!_treeInitialized && TreeInitiallyExpanded) + { + ExpandTreeRecursive(roots); + _treeInitialized = true; + } - /// - /// If true, the grid will be rendered with virtualization. This is normally used in conjunction with - /// scrolling and causes the grid to fetch and render only the data around the current scroll viewport. - /// This can greatly improve the performance when scrolling through large data sets. - /// - /// If you use , you should supply a value for and must - /// ensure that every row renders with the same constant height. - /// - /// Generally it's preferable not to use if the amount of data being rendered - /// is small or if you are using pagination. - /// - [Parameter] public bool Virtualize { get; set; } + var flat = new List(); + _treeMeta.Clear(); + Walk(roots, 0); + _treeRows = flat; + _view = flat; + _pageItems = flat; + _viewGroups = null; + _footerAggregates = BitDataGridDataProcessor.Aggregate(flat, _columns); + void Walk(IEnumerable siblings, int level) + { + var sorted = _sorts.Count > 0 + ? BitDataGridDataProcessor.Sort(siblings.ToList(), _sorts, _columnsById) + : siblings.ToList(); + foreach (var item in sorted) + { + var children = ChildrenSelector!(item); + var hasChildren = children is not null && children.Any(); + _treeMeta[GetKey(item)] = (level, hasChildren); + flat.Add(item); + if (hasChildren && IsTreeExpanded(item)) + Walk(children!, level + 1); + } + } + } - /// - /// Sets the grid's current sort column to the specified . - /// - /// The column that defines the new sort order. - /// The direction of sorting. If the value is , then it will toggle the direction on each call. - /// A representing the completion of the operation. - public Task SortByColumnAsync(BitDataGridColumnBase column, BitDataGridSortDirection direction = BitDataGridSortDirection.Auto) + private void ExpandTreeRecursive(IEnumerable siblings) { - _sortByAscending = direction switch + foreach (var item in siblings) { - BitDataGridSortDirection.Ascending => true, - BitDataGridSortDirection.Descending => false, - BitDataGridSortDirection.Auto => _sortByColumn == column ? !_sortByAscending : true, - _ => throw new NotSupportedException($"Unknown sort direction {direction}"), - }; + var children = ChildrenSelector!(item); + if (children is not null && children.Any()) + { + _expandedTree.Add(GetKey(item)); + ExpandTreeRecursive(children); + } + } + } - _sortByColumn = column; + // ------------------------------------------------------------- Tree view + internal int TreeLevel(TItem item) => _treeMeta.TryGetValue(GetKey(item), out var m) ? m.Level : 0; + internal bool TreeHasChildren(TItem item) => _treeMeta.TryGetValue(GetKey(item), out var m) && m.HasChildren; + internal bool IsTreeExpanded(TItem item) => _expandedTree.Contains(GetKey(item)); - StateHasChanged(); // We want to see the updated sort order in the header, even before the data query is completed - return RefreshDataAsync(); + internal async Task ToggleTreeNodeAsync(TItem item) + { + var key = GetKey(item); + if (!_expandedTree.Add(key)) _expandedTree.Remove(key); + await RefreshAsync(); } - /// - /// Displays the UI for the specified column, closing any other column - /// options UI that was previously displayed. - /// - /// The column whose options are to be displayed, if any are available. - public void ShowColumnOptions(BitDataGridColumnBase column) + /// Expands every node in the tree. No-op outside tree mode. + public async Task ExpandAllAsync() { - _displayOptionsForColumn = column; - _checkColumnOptionsPosition = true; // Triggers a call to JS to position the options element, apply autofocus, and any other setup - StateHasChanged(); + if (!IsTreeMode) return; + _expandedTree.Clear(); + ExpandTreeRecursive(Items ?? Enumerable.Empty()); + await RefreshAsync(); + } + + /// Collapses every node in the tree. No-op outside tree mode. + public async Task CollapseAllAsync() + { + if (!IsTreeMode) return; + _expandedTree.Clear(); + await RefreshAsync(); + } + + // --------------------------------------------------- Infinite scrolling + internal IReadOnlyList InfiniteItems => _infiniteItems; + internal bool InfiniteLoading => _infiniteLoading; + internal bool InfiniteHasMore => _infiniteHasMore; + + /// Clears the accumulated rows and (re)loads the first batch. Used on init and whenever + /// sorting/filtering changes in infinite-scrolling mode. + private async Task ResetInfiniteAsync() + { + _infiniteItems.Clear(); + _infiniteHasMore = true; + _infiniteLoading = false; + _view = _infiniteItems; + _pageItems = _infiniteItems; + + if (_infiniteHandle is not null) + { + try { await _infiniteHandle.InvokeVoidAsync("scrollToTop"); } + catch (JSException) { } + catch (JSDisconnectedException) { } + } + + await LoadNextBatchAsync(); } /// - /// Instructs the grid to re-fetch and render the current data from the supplied data source - /// (either or ). + /// Appends the next batch of rows. No total count is assumed: the end of the data is detected + /// when a batch returns fewer rows than requested (or none at all). Re-entrancy is guarded so + /// rapid scroll events coalesce into a single in-flight request. /// - /// A that represents the completion of the operation. - public async Task RefreshDataAsync() + private async Task LoadNextBatchAsync() { - await RefreshDataCoreAsync(); + if (OnLoadMore is null || _infiniteLoading || !_infiniteHasMore) return; + + _infiniteLoading = true; StateHasChanged(); - } + var batch = Math.Max(1, LoadMoreBatchSize); + var read = new BitDataGridReadRequest + { + Skip = _infiniteItems.Count, + Take = batch, + Sorts = _sorts.Where(s => s.Direction != BitDataGridSortDirection.None).OrderBy(s => s.Priority).ToList(), + Filters = _filters.ToList() + }; + + var result = await OnLoadMore(read); + var loaded = result.Items; + _infiniteItems.AddRange(loaded); + if (loaded.Count < batch) _infiniteHasMore = false; + + _view = _infiniteItems; + _pageItems = _infiniteItems; + _footerAggregates = BitDataGridDataProcessor.Aggregate(_infiniteItems, _columns); + + _infiniteLoading = false; + StateHasChanged(); + } + /// Invoked from JavaScript when the viewport is scrolled near its end. + [JSInvokable] + public async Task OnInfiniteScrollNearEndAsync() + { + if (!IsInfiniteMode) return; + await LoadNextBatchAsync(); + // If the freshly loaded rows still don't fill the viewport, keep loading until they do + // (or the data runs out). The JS re-check fires this method again only while near the end. + if (_infiniteHasMore && _infiniteHandle is not null) + { + try { await _infiniteHandle.InvokeVoidAsync("check"); } + catch (JSException) { } + catch (JSDisconnectedException) { } + } + } - // Invoked by descendant columns at a special time during rendering - internal void AddColumn(BitDataGridColumnBase column, BitDataGridSortDirection? isDefaultSortDirection) + protected override async Task OnAfterRenderAsync(bool firstRender) { - if (_collectingColumns) + if (IsInfiniteMode && !_infiniteObserverAttached) { - _columns.Add(column); + _infiniteObserverAttached = true; + _infiniteSelfRef ??= DotNetObjectReference.Create(this); + _infiniteHandle = await JS.InvokeAsync( + "BitBlazorUI.DataGrid.initInfiniteScroll", _infiniteViewport, _infiniteSelfRef, 200); + } + } - if (_sortByColumn is null && isDefaultSortDirection.HasValue) + public async ValueTask DisposeAsync() + { + try + { + if (_infiniteHandle is not null) { - _sortByColumn = column; - _sortByAscending = isDefaultSortDirection.Value != BitDataGridSortDirection.Descending; + await _infiniteHandle.InvokeVoidAsync("dispose"); + await _infiniteHandle.DisposeAsync(); } } + catch (JSDisconnectedException) { } + catch (JSException) { } + _infiniteSelfRef?.Dispose(); + GC.SuppressFinalize(this); + } + + private async Task LoadServerDataAsync() + { + var request = new BitDataGridReadRequest + { + Skip = Pageable ? (_currentPage - 1) * _effectivePageSize : 0, + Take = Pageable ? _effectivePageSize : null, + Sorts = _sorts.Where(s => s.Direction != BitDataGridSortDirection.None).OrderBy(s => s.Priority).ToList(), + Filters = _filters.ToList() + }; + var result = await OnRead!(request); + _pageItems = result.Items; + _view = result.Items; + _totalCount = result.TotalCount; + _footerAggregates = BitDataGridDataProcessor.Aggregate(_pageItems, _columns); + _viewGroups = null; + ClampPage(); } + private void ClampPage() + { + var pages = TotalPages; + if (_currentPage > pages) _currentPage = pages; + if (_currentPage < 1) _currentPage = 1; + } + // ------------------------------------------------------------- Sorting + internal bool ColumnSortable(BitDataGridColumn column) + => column.HasField && (column.Sortable ?? Sortable); - /// - protected override Task OnParametersSetAsync() + internal BitDataGridSortDescriptor? GetSort(BitDataGridColumn column) + => _sorts.FirstOrDefault(s => s.ColumnId == column.Id); + + internal async Task ToggleSortAsync(BitDataGridColumn column, bool additive) { - // The associated pagination state may have been added/removed/replaced - _currentPageItemsChanged.SubscribeOrMove(Pagination?.CurrentPageItemsChanged); + if (!ColumnSortable(column)) return; + var existing = GetSort(column); - if (Items is not null && ItemsProvider is not null) + if (!additive && (!MultiSort || true)) { - throw new InvalidOperationException($"BitDataGrid requires one of {nameof(Items)} or {nameof(ItemsProvider)}, but both were specified."); + // Single sort: clear others unless additive + if (!additive) + { + var keep = existing; + _sorts.Clear(); + if (keep is not null) _sorts.Add(keep); + } } - // Perform a re-query only if the data source or something else has changed - var _newItemsOrItemsProvider = Items ?? (object?)ItemsProvider; - var dataSourceHasChanged = _newItemsOrItemsProvider != _lastAssignedItemsOrProvider; - if (dataSourceHasChanged) + if (existing is null) { - _lastAssignedItemsOrProvider = _newItemsOrItemsProvider; - _asyncQueryExecutor = AsyncQueryExecutorSupplier.GetAsyncQueryExecutor(_services, Items); + var initial = column.SortDescendingFirst + ? BitDataGridSortDirection.Descending + : BitDataGridSortDirection.Ascending; + _sorts.Add(new BitDataGridSortDescriptor { ColumnId = column.Id, Direction = initial, Priority = _sorts.Count + 1 }); } + else if (existing.Direction == (column.SortDescendingFirst ? BitDataGridSortDirection.Descending : BitDataGridSortDirection.Ascending)) + { + existing.Direction = column.SortDescendingFirst + ? BitDataGridSortDirection.Ascending + : BitDataGridSortDirection.Descending; + } + else + { + _sorts.Remove(existing); + } + Reprioritize(); + await RefreshAsync(); + } - var mustRefreshData = dataSourceHasChanged - || (Pagination?.GetHashCode() != _lastRefreshedPaginationStateHash); - - // We don't want to trigger the first data load until we've collected the initial set of columns, - // because they might perform some action like setting the default sort order, so it would be wasteful - // to have to re-query immediately - return (_columns.Count > 0 && mustRefreshData) ? RefreshDataCoreAsync() : Task.CompletedTask; + private void Reprioritize() + { + for (int i = 0; i < _sorts.Count; i++) _sorts[i].Priority = i + 1; } - protected override async Task OnAfterRenderAsync(bool firstRender) + // ----------------------------------------------------------- Filtering + internal bool ColumnFilterable(BitDataGridColumn column) + => column.HasField && (column.Filterable ?? Filterable); + + internal BitDataGridFilterDescriptor? GetFilter(BitDataGridColumn column) + => _filters.FirstOrDefault(f => f.ColumnId == column.Id); + + internal async Task SetFilterAsync(BitDataGridColumn column, BitDataGridFilterOperator op, object? value) { - if (firstRender) + var existing = GetFilter(column); + var isEmpty = value is null || (value is string s && s.Length == 0); + if (isEmpty && op is not (BitDataGridFilterOperator.IsEmpty or BitDataGridFilterOperator.IsNotEmpty)) { - _jsEventDisposable = await _js.BitDataGridInit(_tableReference); + if (existing is not null) _filters.Remove(existing); } - - if (_checkColumnOptionsPosition && _displayOptionsForColumn is not null) + else if (existing is null) { - _checkColumnOptionsPosition = false; - _ = _js.BitDataGridCheckColumnOptionsPosition(_tableReference); + _filters.Add(new BitDataGridFilterDescriptor { ColumnId = column.Id, Operator = op, Value = value }); } + else + { + existing.Operator = op; + existing.Value = value; + } + _currentPage = 1; + await RefreshAsync(); + } + + public async Task ClearFiltersAsync() + { + _filters.Clear(); + await RefreshAsync(); } + // ----------------------------------------------------------- Grouping + internal bool ColumnGroupable(BitDataGridColumn column) + => column.HasField && (column.Groupable ?? Groupable); + + internal bool IsGrouped(BitDataGridColumn column) => _groups.Any(g => g.ColumnId == column.Id); + internal async Task ToggleGroupAsync(BitDataGridColumn column) + { + var existing = _groups.FirstOrDefault(g => g.ColumnId == column.Id); + if (existing is null) + { + // Append as the next (nested) grouping level. + _groups.Add(new BitDataGridGroupDescriptor { ColumnId = column.Id }); + } + else + { + _groups.Remove(existing); + } + await RefreshAsync(); + } - private void StartCollectingColumns() + /// Removes all active groupings. + public async Task ClearGroupsAsync() { - _columns.Clear(); - _collectingColumns = true; + if (_groups.Count == 0) return; + _groups.Clear(); + await RefreshAsync(); } - private void FinishCollectingColumns() + internal int GroupLevel(BitDataGridColumn column) { - _collectingColumns = false; + var idx = _groups.FindIndex(g => g.ColumnId == column.Id); + return idx < 0 ? -1 : idx + 1; } - // 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. - private async Task RefreshDataCoreAsync() + internal bool IsGroupCollapsed(BitDataGridGroup group) => _collapsedGroups.Contains(group.Path); + internal void ToggleGroup(BitDataGridGroup group) { - // Move into a "loading" state, cancelling any earlier-but-still-pending load - _pendingDataLoadCancellationTokenSource?.Cancel(); - var thisLoadCts = _pendingDataLoadCancellationTokenSource = new CancellationTokenSource(); + if (!_collapsedGroups.Add(group.Path)) _collapsedGroups.Remove(group.Path); + StateHasChanged(); + } + + // ---------------------------------------------------------- Selection + internal bool SelectionEnabled => SelectionMode != BitDataGridSelectionMode.None; - if (_virtualizeComponent is not null) + /// True when the given row is allowed to be selected. + internal bool CanSelectRow(TItem item) => IsRowSelectionDisabled is null || !IsRowSelectionDisabled(item); + + internal async Task ToggleRowSelectionAsync(TItem item, bool? value = null) + { + if (SelectionMode == BitDataGridSelectionMode.None) return; + if (!CanSelectRow(item)) return; + var selected = value ?? !_selected.Contains(item); + if (SelectionMode == BitDataGridSelectionMode.Single) { - // If we're using Virtualize, we have to go through its RefreshDataAsync API otherwise: - // (1) It won't know to update its own internal state if the provider output has changed - // (2) We won't know what slice of data to query for - await _virtualizeComponent.RefreshDataAsync(); - _pendingDataLoadCancellationTokenSource = null; + _selected.Clear(); + if (selected) _selected.Add(item); } else { - // If we're not using Virtualize, we build and execute a request against the items provider directly - _lastRefreshedPaginationStateHash = Pagination?.GetHashCode(); - var startIndex = Pagination is null ? 0 : (Pagination.CurrentPageIndex * Pagination.ItemsPerPage); - var request = new BitDataGridItemsProviderRequest( - startIndex, Pagination?.ItemsPerPage, _sortByColumn, _sortByAscending, thisLoadCts.Token); - var result = await ResolveItemsRequestAsync(request); - if (!thisLoadCts.IsCancellationRequested) - { - _currentNonVirtualizedViewItems = result.Items; - _ariaBodyRowCount = _currentNonVirtualizedViewItems.Count; - Pagination?.SetTotalItemCountAsync(result.TotalItemCount); - _pendingDataLoadCancellationTokenSource = null; - } + if (selected) _selected.Add(item); else _selected.Remove(item); } + await NotifySelectionAsync(); } - // Gets called both by RefreshDataCoreAsync and directly by the Virtualize child component during scrolling - private async ValueTask> ProvideVirtualizedItems(ItemsProviderRequest request) - { - _lastRefreshedPaginationStateHash = Pagination?.GetHashCode(); + internal bool AllPageSelected => _pageItems.Where(CanSelectRow).Any() && _pageItems.Where(CanSelectRow).All(_selected.Contains); + internal bool SomePageSelected => _pageItems.Where(CanSelectRow).Any(_selected.Contains) && !AllPageSelected; - // Debounce the requests. This eliminates a lot of redundant queries at the cost of slight lag after interactions. - // TODO: Consider making this configurable, or smarter (e.g., doesn't delay on first call in a batch, then the amount - // of delay increases if you rapidly issue repeated requests, such as when scrolling a long way) - await Task.Delay(100); - if (request.CancellationToken.IsCancellationRequested) + internal async Task ToggleSelectAllAsync(bool value) + { + foreach (var item in _pageItems) { - return default; + if (!CanSelectRow(item)) continue; + if (value) _selected.Add(item); else _selected.Remove(item); } + await NotifySelectionAsync(); + } - // Combine the query parameters from Virtualize with the ones from PaginationState - var startIndex = request.StartIndex; - var count = request.Count; - if (Pagination is not null) - { - startIndex += Pagination.CurrentPageIndex * Pagination.ItemsPerPage; - count = Math.Min(request.Count, Pagination.ItemsPerPage - request.StartIndex); - } + private async Task NotifySelectionAsync() + { + if (SelectedItemsChanged.HasDelegate) + await SelectedItemsChanged.InvokeAsync(_selected.ToList()); + StateHasChanged(); + } - var providerRequest = new BitDataGridItemsProviderRequest( - startIndex, count, _sortByColumn, _sortByAscending, request.CancellationToken); - var providerResult = await ResolveItemsRequestAsync(providerRequest); + internal async Task HandleRowClickAsync(TItem item) + { + if (OnRowClick.HasDelegate) await OnRowClick.InvokeAsync(item); + if (SelectionMode == BitDataGridSelectionMode.Single && _editItem is null) + await ToggleRowSelectionAsync(item, true); + } - if (!request.CancellationToken.IsCancellationRequested) - { - // ARIA's rowcount is part of the UI, so it should reflect what the human user regards as the number of rows in the table, - // not the number of physical elements. For virtualization this means what's in the entire scrollable range, not just - // the current viewport. In the case where you're also paginating then it means what's conceptually on the current page. - // TODO: This currently assumes we always want to expand the last page to have ItemsPerPage rows, but the experience might - // be better if we let the last page only be as big as its number of actual rows. - _ariaBodyRowCount = Pagination is null ? providerResult.TotalItemCount : Pagination.ItemsPerPage; + // ------------------------------------------------------ Detail rows + internal bool IsDetailExpanded(TItem item) => _expandedDetails.Contains(GetKey(item)); + internal void ToggleDetail(TItem item) + { + var key = GetKey(item); + if (!_expandedDetails.Add(key)) _expandedDetails.Remove(key); + StateHasChanged(); + } - Pagination?.SetTotalItemCountAsync(providerResult.TotalItemCount); + // ---------------------------------------------------------- Editing + internal bool ColumnEditable(BitDataGridColumn column) + => column.HasField && column.Accessor?.CanWrite == true && (column.Editable ?? Editable); - // We're supplying the row index along with each row's data because we need it for aria-rowindex, and we have to account for - // the virtualized start index. It might be more performant just to have some _latestQueryRowStartIndex field, but we'd have - // to make sure it doesn't get out of sync with the rows being rendered. - return new ItemsProviderResult<(int, TGridItem)>( - items: providerResult.Items.Select((x, i) => ValueTuple.Create(i + request.StartIndex + 2, x)), - totalItemCount: _ariaBodyRowCount); - } + internal void BeginEdit(TItem item) + { + _editItem = item; + _isNewItem = false; + SnapshotEdit(item); + StateHasChanged(); + } + + internal async Task AddNewRowAsync() + { + if (NewItemFactory is null) return; + var item = NewItemFactory(); + _pendingNew = item; + _editItem = item; + _isNewItem = true; + _editSnapshot = null; + if (OnRowCreate.HasDelegate) await OnRowCreate.InvokeAsync(item); + StateHasChanged(); + } - return default; + private void SnapshotEdit(TItem item) + { + _editSnapshot = new Dictionary(); + foreach (var col in _columns.Where(ColumnEditable)) + _editSnapshot[col.Id] = col.GetValue(item); } - // Normalizes all the different ways of configuring a data source so they have common GridItemsProvider-shaped API - private async ValueTask> ResolveItemsRequestAsync(BitDataGridItemsProviderRequest request) + internal async Task CommitEditAsync() { - if (ItemsProvider is not null) + if (_editItem is null) return; + var item = _editItem; + _editItem = default; + _pendingNew = default; + _editSnapshot = null; + _isNewItem = false; + if (OnRowSave.HasDelegate) await OnRowSave.InvokeAsync(item); + await RefreshAsync(); + } + + internal async Task CancelEditAsync() + { + if (_editItem is null) return; + var item = _editItem; + if (!_isNewItem && _editSnapshot is not null) { - return await ItemsProvider(request); + foreach (var (colId, value) in _editSnapshot) + if (_columnsById.TryGetValue(colId, out var col)) + col.Accessor?.SetValue(item, value); } - else if (Items is not null) + _editItem = default; + _pendingNew = default; + _editSnapshot = null; + _isNewItem = false; + if (OnRowCancel.HasDelegate) await OnRowCancel.InvokeAsync(item); + StateHasChanged(); + } + + internal async Task DeleteRowAsync(TItem item) + { + if (OnRowDelete.HasDelegate) await OnRowDelete.InvokeAsync(item); + _selected.Remove(item); + await RefreshAsync(); + } + + internal void SetEditValue(BitDataGridColumn column, object? value) + { + if (_editItem is null) return; + column.Accessor?.SetValue(_editItem, value); + } + + // ---------------------------------------------------------- Resizing + internal void StartResize(BitDataGridColumn column, double clientX) + { + _resizingColumn = column; + _resizeStartX = clientX; + _resizeStartWidth = column.ResizedWidth ?? ParseInitialWidth(column); + StateHasChanged(); + } + + internal void OnResizeMove(double clientX) + { + if (_resizingColumn is null) return; + var delta = clientX - _resizeStartX; + if (Direction == BitDataGridDirection.Rtl) delta = -delta; + var newWidth = Math.Max(_resizingColumn.MinWidth, _resizeStartWidth + delta); + if (_resizingColumn.MaxWidth is { } max) newWidth = Math.Min(max, newWidth); + _resizingColumn.ResizedWidth = newWidth; + StateHasChanged(); + } + + internal void EndResize() + { + _resizingColumn = null; + StateHasChanged(); + } + + internal bool IsResizing => _resizingColumn is not null; + + private static double ParseInitialWidth(BitDataGridColumn column) + { + if (!string.IsNullOrEmpty(column.Width) && column.Width.EndsWith("px") + && double.TryParse(column.Width[..^2], NumberStyles.Any, CultureInfo.InvariantCulture, out var px)) + return px; + return 150; + } + + // -------------------------------------------------------- Reordering + internal void StartColumnDrag(BitDataGridColumn column) => _dragColumn = column; + + internal void DropColumn(BitDataGridColumn target) + { + if (_dragColumn is null || _dragColumn == target) { _dragColumn = null; return; } + var from = _columns.IndexOf(_dragColumn); + var to = _columns.IndexOf(target); + if (from < 0 || to < 0) { _dragColumn = null; return; } + _columns.RemoveAt(from); + _columns.Insert(to, _dragColumn); + _dragColumn = null; + StateHasChanged(); + } + + internal bool ColumnResizable(BitDataGridColumn column) => column.Resizable ?? Resizable; + internal bool ColumnReorderable(BitDataGridColumn column) => column.Reorderable ?? Reorderable; + + // ----------------------------------------------------- Row reordering + internal void StartRowDrag(TItem row) => _dragRow = row; + + internal async Task DropRowAsync(TItem target) + { + if (_dragRow is null || EqualityComparer.Default.Equals(_dragRow, target)) { _dragRow = default; return; } + + var dragged = _dragRow; + _dragRow = default; + + // Determine indices within the bound source. + if (Items is IList list) { - var totalItemCount = _asyncQueryExecutor is null ? Items.Count() : await _asyncQueryExecutor.CountAsync(Items); - var result = request.ApplySorting(Items).Skip(request.StartIndex); - if (request.Count.HasValue) + var from = list.IndexOf(dragged); + var to = list.IndexOf(target); + if (from < 0 || to < 0) return; + + if (!list.IsReadOnly) { - result = result.Take(request.Count.Value); + list.RemoveAt(from); + list.Insert(to, dragged); } - var resultArray = _asyncQueryExecutor is null ? result.ToArray() : await _asyncQueryExecutor.ToArrayAsync(result); - return BitDataGridItemsProviderResult.From(resultArray, totalItemCount); + + if (OnRowReorder.HasDelegate) + await OnRowReorder.InvokeAsync(new BitDataGridRowReorderEventArgs + { + DraggedItem = dragged, + TargetItem = target, + FromIndex = from, + ToIndex = to + }); + + await RefreshAsync(); } - else + else if (OnRowReorder.HasDelegate) { - return BitDataGridItemsProviderResult.From(Array.Empty(), 0); + await OnRowReorder.InvokeAsync(new BitDataGridRowReorderEventArgs + { + DraggedItem = dragged, + TargetItem = target, + FromIndex = -1, + ToIndex = -1 + }); } } - private string AriaSortValue(BitDataGridColumnBase column) - => _sortByColumn == column - ? (_sortByAscending ? "ascending" : "descending") - : "none"; + // -------------------------------------------------------- Cell events + internal async Task HandleCellClickAsync(BitDataGridColumn column, TItem item, MouseEventArgs e) + { + if (OnCellClick.HasDelegate) + await OnCellClick.InvokeAsync(MakeCellArgs(column, item, e)); + } + + internal async Task HandleCellDoubleClickAsync(BitDataGridColumn column, TItem item, MouseEventArgs e) + { + if (OnCellDoubleClick.HasDelegate) + await OnCellDoubleClick.InvokeAsync(MakeCellArgs(column, item, e)); + } + + internal async Task HandleCellContextMenuAsync(BitDataGridColumn column, TItem item, MouseEventArgs e) + { + if (OnCellContextMenu.HasDelegate) + await OnCellContextMenu.InvokeAsync(MakeCellArgs(column, item, e)); + } + + internal bool HasCellEvents => OnCellClick.HasDelegate || OnCellDoubleClick.HasDelegate || OnCellContextMenu.HasDelegate; + + private BitDataGridCellEventArgs MakeCellArgs(BitDataGridColumn column, TItem item, MouseEventArgs e) + => new() { Item = item, Column = column, Value = column.GetValue(item), Mouse = e }; - private string? ColumnHeaderClass(BitDataGridColumnBase column) - => _sortByColumn == column - ? $"{ColumnClass(column)} {(_sortByAscending ? "bit-dtg-csa" : "bit-dtg-csd")}" - : ColumnClass(column); + // ------------------------------------------------- Keyboard cell navigation + /// The flat, ordered list of rows the keyboard navigation moves across. + internal IReadOnlyList NavigableRows => _pageItems; - private string GridClass() - => $"bit-dtg {Class} {((IsLoading && LoadingTemplate is null) ? "loading" : null)}".Trim(); + internal bool IsCellFocused(TItem item, int colIndex) + => _focusedRow is not null && KeyEquals(_focusedRow, item) && _focusedCol == colIndex; - private void CloseColumnOptions() + /// Roving tabindex: only one cell is in the tab order at a time. + internal int CellTabIndex(TItem item, int colIndex) { - _displayOptionsForColumn = null; + if (_focusedRow is not null) + return IsCellFocused(item, colIndex) ? 0 : -1; + var rows = NavigableRows; + return rows.Count > 0 && KeyEquals(rows[0], item) && colIndex == 0 ? 0 : -1; } - private string? GetRowClass(TGridItem item) + /// Records the focused cell when the user clicks/tabs into it (no re-focus needed). + internal void SetFocusedCell(TItem item, int colIndex) { - var classes = new List(); + if (IsCellFocused(item, colIndex)) return; + _focusedRow = item; + _focusedCol = colIndex; + StateHasChanged(); + } - if (RowClass is not null) - { - classes.Add(RowClass); - } + internal bool ShouldFocusCell(TItem item, int colIndex) => _focusPending && IsCellFocused(item, colIndex); + internal void ClearFocusPending() => _focusPending = false; - if (RowClassSelector is not null) + /// Requests that the currently focused cell regain DOM focus on the next render + /// (e.g. after leaving inline edit mode via the keyboard). + internal void RefocusFocusedCell() + { + if (_focusedRow is null) return; + _focusPending = true; + StateHasChanged(); + } + + internal async Task HandleCellKeyDownAsync(TItem item, int colIndex, KeyboardEventArgs e) + { + var rows = NavigableRows; + if (rows.Count == 0) return; + var colCount = VisibleColumns.Count; + if (colCount == 0) return; + + var rowIdx = IndexOfRow(rows, item); + if (rowIdx < 0) rowIdx = 0; + + int row = rowIdx, col = colIndex; + var rtl = Direction == BitDataGridDirection.Rtl; + var handled = true; + + switch (e.Key) { - classes.Add(RowClassSelector(item)); + case "ArrowRight": col += rtl ? -1 : 1; break; + case "ArrowLeft": col += rtl ? 1 : -1; break; + case "ArrowDown": row += 1; break; + case "ArrowUp": row -= 1; break; + case "Home": if (e.CtrlKey) { row = 0; col = 0; } else col = 0; break; + case "End": if (e.CtrlKey) { row = rows.Count - 1; col = colCount - 1; } else col = colCount - 1; break; + case "PageDown": row += 10; break; + case "PageUp": row -= 10; break; + case "Enter": + case "F2": + var ec = VisibleColumns[Math.Clamp(col, 0, colCount - 1)]; + if (ColumnEditable(ec)) BeginEdit(item); + return; + case "Escape": + if (_editItem is not null) await CancelEditAsync(); + return; + default: handled = false; break; } + if (!handled) return; - return classes.Any() ? string.Join(' ', classes) : null; + row = Math.Clamp(row, 0, rows.Count - 1); + col = Math.Clamp(col, 0, colCount - 1); + _focusedRow = rows[row]; + _focusedCol = col; + _focusPending = true; + StateHasChanged(); } - private string? GetRowStyle(TGridItem item) + private int IndexOfRow(IReadOnlyList rows, TItem item) { - var styles = new List(); + for (int i = 0; i < rows.Count; i++) + if (KeyEquals(rows[i], item)) return i; + return -1; + } - if (RowStyle is not null) - { - styles.Add(RowStyle); - } + // ----------------------------------------------------- Column spanning + /// Resolves the effective column span for a data cell (clamped to remaining columns). + internal int ResolveColSpan(BitDataGridColumn column, TItem item) + { + if (column.ColSpan is null) return 1; + var span = column.ColSpan(item) ?? 1; + if (span < 1) span = 1; + var cols = VisibleColumns; + var idx = cols.ToList().IndexOf(column); + if (idx < 0) return 1; + return Math.Min(span, cols.Count - idx); + } - if (RowStyleSelector is not null) + // ------------------------------------------------- Column header groups + internal bool HasColumnGroups => VisibleColumns.Any(c => !string.IsNullOrEmpty(c.Group)); + + /// Builds the contiguous spans of the grouped header row (group name + column count). + internal IReadOnlyList<(string? Name, int Span)> ColumnGroupSpans() + { + var spans = new List<(string?, int)>(); + string? current = null; + int count = 0; + bool started = false; + foreach (var col in VisibleColumns) { - styles.Add(RowStyleSelector(item)); + var name = string.IsNullOrEmpty(col.Group) ? null : col.Group; + if (started && name == current) + { + count++; + } + else + { + if (started) spans.Add((current, count)); + current = name; + count = 1; + started = true; + } } + if (started) spans.Add((current, count)); + return spans; + } - return styles.Any() ? string.Join(';', styles) : null; + // ------------------------------------------------------------- Paging + internal async Task GoToPageAsync(int page) + { + _currentPage = Math.Clamp(page, 1, TotalPages); + await RefreshAsync(); + } + + internal async Task SetPageSizeAsync(int size) + { + PageSize = size; + _effectivePageSize = Math.Max(1, size); + _currentPage = 1; + await RefreshAsync(); } + // ------------------------------------------------------- Column chooser + internal void ToggleColumnChooser() { _showColumnChooserPanel = !_showColumnChooserPanel; StateHasChanged(); } + internal async Task SetColumnVisibilityAsync(BitDataGridColumn column, bool visible) + { + column.Visible = visible; + await RefreshAsync(); + } + // ----------------------------------------------------------- Identity + private object GetKey(TItem item) => KeyField?.Invoke(item) ?? item!; + private bool KeyEquals(TItem a, TItem b) + => KeyField is not null ? Equals(KeyField(a), KeyField(b)) : EqualityComparer.Default.Equals(a, b); - private static string? ColumnClass(BitDataGridColumnBase column) => column.Align switch + // ----------------------------------------------------------- CSV export + /// Builds a CSV string of the current (filtered/sorted) data. + public string ToCsv() { - BitDataGridAlign.Center => $"bit-dtg-cjc {column.Class}", - BitDataGridAlign.Right => $"bit-dtg-cje {column.Class}", - _ => column.Class, - }; + var cols = VisibleColumns.Where(c => c.HasField).ToList(); + var sb = new System.Text.StringBuilder(); + sb.AppendLine(string.Join(",", cols.Select(c => Escape(c.DisplayTitle)))); + var rows = IsServerMode ? _pageItems : _view; + foreach (var item in rows) + sb.AppendLine(string.Join(",", cols.Select(c => Escape(c.GetFormattedValue(item))))); + return sb.ToString(); + + static string Escape(string v) + => v.Contains(',') || v.Contains('"') || v.Contains('\n') + ? "\"" + v.Replace("\"", "\"\"") + "\"" + : v; + } + // ----------------------------------------------------- Layout helpers + internal bool HasSelectColumn => SelectionMode == BitDataGridSelectionMode.Multiple; + internal bool HasDetailColumn => DetailTemplate is not null; + internal bool HasCommandColumn => Editable; + internal bool HasReorderColumn => RowReorderable; + private const double ReorderColWidth = 36; + private const double DetailColWidth = 44; + private const double SelectColWidth = 44; + private double DetailOffset => HasReorderColumn ? ReorderColWidth : 0; + private double SelectOffset => DetailOffset + (HasDetailColumn ? DetailColWidth : 0); - /// - public async ValueTask DisposeAsync() + internal string ReorderStickyStyle => "left:0;"; + internal string DetailStickyStyle => $"left:{DetailOffset.ToString(CultureInfo.InvariantCulture)}px;"; + internal string SelectStickyStyle => $"left:{SelectOffset.ToString(CultureInfo.InvariantCulture)}px;"; + + private string ColumnWidthToken(BitDataGridColumn column) { - await DisposeAsync(true); - GC.SuppressFinalize(this); + if (column.ResizedWidth is { } w) return $"{w.ToString(CultureInfo.InvariantCulture)}px"; + if (!string.IsNullOrEmpty(column.Width)) + return column.MaxWidth is { } mx + ? $"minmax({column.MinWidth}px, min({column.Width}, {mx}px))" + : column.Width!; + return column.MaxWidth is { } max + ? $"minmax({column.MinWidth}px, min(1fr, {max}px))" + : $"minmax({Math.Max(120, column.MinWidth)}px, 1fr)"; } - protected virtual async ValueTask DisposeAsync(bool disposing) + /// Resolves the height (in px) for a given row, honouring . + internal float ResolveRowHeight(TItem item) => RowHeightSelector?.Invoke(item) ?? RowHeight; + + /// Builds the CSS grid template-columns value for the whole row layout. + private string BuildGridTemplate() { - if (_disposed || disposing is false) return; + var parts = new List(); + if (HasReorderColumn) parts.Add($"{ReorderColWidth.ToString(CultureInfo.InvariantCulture)}px"); + if (HasDetailColumn) parts.Add($"{DetailColWidth.ToString(CultureInfo.InvariantCulture)}px"); + if (HasSelectColumn) parts.Add($"{SelectColWidth.ToString(CultureInfo.InvariantCulture)}px"); + foreach (var c in VisibleColumns) parts.Add(ColumnWidthToken(c)); + if (HasCommandColumn) parts.Add("minmax(150px, max-content)"); + return string.Join(" ", parts); + } - _pendingDataLoadCancellationTokenSource?.Cancel(); - _pendingDataLoadCancellationTokenSource?.Dispose(); + private int TotalColumnSpan => + VisibleColumns.Count + (HasReorderColumn ? 1 : 0) + (HasDetailColumn ? 1 : 0) + (HasSelectColumn ? 1 : 0) + (HasCommandColumn ? 1 : 0); - _currentPageItemsChanged.Dispose(); + private string HeaderCellClass(BitDataGridColumn column) + { + var c = "bit-dtg-hcell " + AlignClass(column.Align); + if (column.Frozen) c += " bit-dtg-sticky"; + if (ColumnSortable(column)) c += " bit-dtg-sortable"; + if (!string.IsNullOrEmpty(column.HeaderClass)) c += " " + column.HeaderClass; + return c; + } - try - { - if (_jsEventDisposable is not null) - { - await _jsEventDisposable.InvokeVoidAsync("stop"); - await _jsEventDisposable.DisposeAsync(); - } + private string RootClasses() + { + var c = "bit-dtg"; + if (Bordered) c += " bit-dtg-bordered"; + if (Striped) c += " bit-dtg-striped"; + if (Hoverable) c += " bit-dtg-hoverable"; + if (Direction == BitDataGridDirection.Rtl) c += " bit-dtg-rtl"; + if (!string.IsNullOrEmpty(Class)) c += " " + Class; + return c; + } - //if (_jsModule is not null) - //{ - // await _jsModule.DisposeAsync(); - //} - } - catch (JSDisconnectedException) - { - // The JS side may routinely be gone already if the reason we're disposing is that - // the client disconnected. This is not an error. - } - catch (JSException ex) + internal static string AlignClass(BitDataGridColumnAlign a) => a switch + { + BitDataGridColumnAlign.Center => "bit-dtg-center", + BitDataGridColumnAlign.Right => "bit-dtg-right", + _ => "" + }; + + private double SpecialStickyWidth => (HasReorderColumn ? ReorderColWidth : 0) + (HasDetailColumn ? DetailColWidth : 0) + (HasSelectColumn ? SelectColWidth : 0); + + private double ColumnPixelWidth(BitDataGridColumn column) + { + if (column.ResizedWidth is { } w) return w; + return ParseInitialWidth(column); + } + + /// Sticky left offset (in px) for a frozen data column. + internal double FrozenOffset(BitDataGridColumn column) + { + double offset = SpecialStickyWidth; + foreach (var c in VisibleColumns) { - // it seems it's safe to just ignore this exception here. - // otherwise it will blow up the MAUI app in a page refresh for example. - Console.WriteLine(ex.Message); + if (c == column) break; + if (c.Frozen) offset += ColumnPixelWidth(c); } + return offset; + } - _disposed = true; + internal string FrozenStyle(BitDataGridColumn column) + { + if (!column.Frozen) return string.Empty; + var edge = Direction == BitDataGridDirection.Rtl ? "right" : "left"; + return $"{edge}:{FrozenOffset(column).ToString(CultureInfo.InvariantCulture)}px;"; } + + private string AggregateLabel(BitDataGridAggregateResult agg) => agg.Type switch + { + BitDataGridAggregateType.Sum => $"Σ {agg.FormattedValue}", + BitDataGridAggregateType.Average => $"avg {agg.FormattedValue}", + BitDataGridAggregateType.Count => $"count {agg.FormattedValue}", + BitDataGridAggregateType.Min => $"min {agg.FormattedValue}", + BitDataGridAggregateType.Max => $"max {agg.FormattedValue}", + _ => agg.FormattedValue + }; } + diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.scss b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.scss index 6120f345bc..e49890814a 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.scss +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.scss @@ -1,139 +1,325 @@ +/* ============================================================ + BitDataGrid - styles + Uses CSS custom properties + light-dark() for theming. + ============================================================ */ + .bit-dtg { - width: 100%; - --bit-dtg-col-gap: 1rem; + /* Theme tokens (override these to customize) */ + --bit-dtg-font: 14px/1.4 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; + --bit-dtg-color: light-dark(#1f2328, #e6e6e6); + --bit-dtg-muted: light-dark(#656d76, #9aa4ad); + --bit-dtg-bg: light-dark(#ffffff, #1c1f24); + --bit-dtg-header-bg: light-dark(#f6f8fa, #22262c); + --bit-dtg-row-hover: light-dark(#f3f6fb, #262b32); + --bit-dtg-row-selected: light-dark(#dcebff, #173b5e); + --bit-dtg-border: light-dark(#d8dee4, #3a4048); + --bit-dtg-accent: light-dark(#0969da, #4493f8); + --bit-dtg-danger: light-dark(#cf222e, #f85149); + --bit-dtg-radius: 8px; + --bit-dtg-cell-pad: 8px 10px; + + color-scheme: light dark; + font: var(--bit-dtg-font); + color: var(--bit-dtg-color); + background: var(--bit-dtg-bg); + border-radius: var(--bit-dtg-radius); + display: flex; + flex-direction: column; + position: relative; + box-sizing: border-box; +} + +.bit-dtg *, .bit-dtg *::before, .bit-dtg *::after { box-sizing: border-box; } + +.bit-dtg.bit-dtg-bordered { border: 1px solid var(--bit-dtg-border); } - th { - position: relative; - } +/* ---------------------------------------------------------- Toolbar */ +.bit-dtg-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + padding: 8px; + border-bottom: 1px solid var(--bit-dtg-border); + flex-wrap: wrap; +} +.bit-dtg-toolbar-start, .bit-dtg-toolbar-end { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } + +.bit-dtg-column-chooser { + display: flex; + flex-wrap: wrap; + gap: 12px; + padding: 10px; + border-bottom: 1px solid var(--bit-dtg-border); + background: var(--bit-dtg-header-bg); +} +.bit-dtg-chooser-item { display: inline-flex; gap: 6px; align-items: center; cursor: pointer; } - > thead > tr > th { - font-weight: normal; - } +/* ---------------------------------------------------------- Viewport */ +.bit-dtg-viewport { overflow: auto; position: relative; } - &.loading > tbody { - opacity: 0.25; - transition-delay: 25ms; - transition: opacity linear 100ms; - } +.bit-dtg-table { display: block; min-width: 100%; } + +/* ---------------------------------------------------------- Rows */ +.bit-dtg-row { + display: grid; + grid-template-columns: var(--bit-dtg-template); + align-items: stretch; + border-bottom: 1px solid var(--bit-dtg-border); +} - > tbody > tr > td { - padding: 0.1rem calc(0.4rem + var(--bit-dtg-col-gap)) 0.1rem 0.4rem; - } +.bit-dtg-header { + position: sticky; + top: 0; + z-index: 3; } +.bit-dtg-header-row, .bit-dtg-filter-row { background: var(--bit-dtg-header-bg); } +.bit-dtg-filter-row { border-bottom: 1px solid var(--bit-dtg-border); } -.bit-dtg-hct { +.bit-dtg-hcell { display: flex; - position: relative; align-items: center; - padding-inline-end: var(--bit-dtg-col-gap); + gap: 4px; + padding: var(--bit-dtg-cell-pad); + font-weight: 600; + position: relative; + background: var(--bit-dtg-header-bg); + border-right: 1px solid transparent; + user-select: none; + overflow: hidden; } -.bit-dtg-cop { - z-index: 1; - padding: 1rem; - border: 1px solid; - position: absolute; - inset-inline-start: 0; - border-color: var(--bit-clr-brd-pri); - background-color: var(--bit-clr-bg-sec); +.bit-dtg-cell { + padding: var(--bit-dtg-cell-pad); + display: flex; + align-items: center; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + background: var(--bit-dtg-bg); } -.bit-dtg-cob { - width: 1.5rem; - background: unset; +.bit-dtg-bordered .bit-dtg-cell, .bit-dtg-bordered .bit-dtg-hcell { border-right: 1px solid var(--bit-dtg-border); } + +.bit-dtg-center { justify-content: center; text-align: center; } +.bit-dtg-right { justify-content: flex-end; text-align: right; } + +/* Striping & hover */ +.bit-dtg-striped .bit-dtg-body .bit-dtg-row:nth-child(even) .bit-dtg-cell { background: light-dark(#fbfcfd, #20242a); } +.bit-dtg-hoverable .bit-dtg-body .bit-dtg-row:hover .bit-dtg-cell { background: var(--bit-dtg-row-hover); } + +/* Selection & editing must win over both striping and hover (in every row position) */ +.bit-dtg-row.bit-dtg-selected .bit-dtg-cell, +.bit-dtg-striped .bit-dtg-body .bit-dtg-row.bit-dtg-selected:nth-child(even) .bit-dtg-cell, +.bit-dtg-hoverable .bit-dtg-body .bit-dtg-row.bit-dtg-selected:hover .bit-dtg-cell { background: var(--bit-dtg-row-selected); } +.bit-dtg-row.bit-dtg-editing .bit-dtg-cell, +.bit-dtg-striped .bit-dtg-body .bit-dtg-row.bit-dtg-editing:nth-child(even) .bit-dtg-cell, +.bit-dtg-hoverable .bit-dtg-body .bit-dtg-row.bit-dtg-editing:hover .bit-dtg-cell { background: light-dark(#fff8e6, #2e2a17); } - &::before { - content: "\E712"; - font-style: normal; - font-weight: normal; - display: inline-block; - font-family: 'Fabric MDL2 bit BlazorUI Extras'; - } +/* Sticky / frozen columns */ +.bit-dtg-sticky { position: sticky; z-index: 2; } +.bit-dtg-header .bit-dtg-sticky { z-index: 4; } +.bit-dtg-rtl .bit-dtg-sticky { right: auto; } + +.bit-dtg-cell-detail, .bit-dtg-cell-select { justify-content: center; } + +/* ---------------------------------------------------------- Header sorting */ +.bit-dtg-sortable .bit-dtg-htext { cursor: pointer; } +.bit-dtg-htext { display: inline-flex; align-items: center; gap: 4px; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.bit-dtg-sort-icon { font-size: 10px; color: var(--bit-dtg-accent); } +.bit-dtg-sort-priority { + font-size: 9px; + background: var(--bit-dtg-accent); + color: #fff; + border-radius: 8px; + padding: 0 4px; + line-height: 14px; } -.bit-dtg-drg { - width: 1rem; - cursor: ew-resize; +/* Resizer handle */ +.bit-dtg-resizer { position: absolute; - inset-block-end: 0; - inset-block-start: 0; - inset-inline-end: calc(var(--bit-dtg-col-gap)/2 - 0.5rem); - - &::after { - content: ' '; - position: absolute; - inset-block-end: 5px; - inset-block-start: 5px; - inset-inline-start: 0.5rem; - border-color: var(--bit-clr-brd-pri); - border-inline-start: 1px solid black; - } -} - -.bit-dtg-srt { - width: 1rem; - height: 1rem; - opacity: 0.5; - align-self: center; - text-align: center; + top: 0; + right: 0; + width: 7px; + height: 100%; + cursor: col-resize; + touch-action: none; } +.bit-dtg-rtl .bit-dtg-resizer { right: auto; left: 0; } +.bit-dtg-resizer:hover { background: var(--bit-dtg-accent); opacity: .5; } -.bit-dtg-csa .bit-dtg-srt::before, -.bit-dtg-csd .bit-dtg-srt::before { - content: "\E96F"; - font-style: normal; - font-weight: normal; - display: inline-block; - transform: rotate(90deg); - font-family: 'Fabric MDL2 bit BlazorUI Extras'; +.bit-dtg-resize-overlay { + position: fixed; + inset: 0; + z-index: 9999; + cursor: col-resize; + touch-action: none; } -.bit-dtg-csd .bit-dtg-srt { - transform: scaleY(-1) translateY(-2px); +/* ---------------------------------------------------------- Group rows */ +.bit-dtg-group-row { border-bottom: 1px solid var(--bit-dtg-border); } +.bit-dtg-group-cell { + grid-column: 1 / -1; + display: flex; + align-items: center; + gap: 10px; + padding: var(--bit-dtg-cell-pad); + background: light-dark(#eef2f6, #262b32); } +.bit-dtg-group-count { color: var(--bit-dtg-muted); } +.bit-dtg-group-agg { color: var(--bit-dtg-muted); font-size: 12px; } + +/* Nested groups: subtle indentation shading by level */ +.bit-dtg-group-row .bit-dtg-group-cell { border-left: 3px solid transparent; } +.bit-dtg-group-row[style*="--bit-dtg-group-level:0"] .bit-dtg-group-cell { border-left-color: var(--bit-dtg-accent); } -.bit-dtg-ctl { - gap: 0.4rem; - flex-grow: 1; +/* ----------------------------------------------- Grouped column headers */ +.bit-dtg-group-header-row { background: var(--bit-dtg-header-bg); border-bottom: 1px solid var(--bit-dtg-border); } +.bit-dtg-group-header { + font-weight: 700; + background: var(--bit-dtg-header-bg); + border-right: 1px solid var(--bit-dtg-border); display: flex; - min-width: 0px; - font-size: 1rem; - font-weight: bold; - padding: 0.1rem 0.4rem; + align-items: center; + justify-content: center; + padding: var(--bit-dtg-cell-pad); } +.bit-dtg-group-header-empty { background: transparent; border-right: none; } -button.bit-dtg-ctl { - border: none; - color: inherit; - cursor: pointer; - background: none; - position: relative; +/* ---------------------------------------------------------- Row reorder */ +.bit-dtg-cell-reorder { display: flex; align-items: center; justify-content: center; } +.bit-dtg-drag-handle { cursor: grab; color: var(--bit-dtg-muted); user-select: none; } +.bit-dtg-drag-handle:active { cursor: grabbing; } +.bit-dtg-row[draggable="true"] { cursor: grab; } + +/* ---------------------------------------------------------- Detail rows */ +.bit-dtg-detail-row { border-bottom: 1px solid var(--bit-dtg-border); } +.bit-dtg-detail-content { padding: 12px 16px; background: light-dark(#fbfcfd, #1a1d22); } + +/* ---------------------------------------------------------- Tree view */ +.bit-dtg-tree-indent { display: inline-block; flex: 0 0 auto; } +.bit-dtg-tree-toggle { flex: 0 0 auto; width: 20px; text-align: center; } +.bit-dtg-tree-leaf { display: inline-block; width: 20px; flex: 0 0 auto; } + +/* ---------------------------------------------------------- Cell navigation */ +/* The focus ring is driven solely by real DOM :focus so exactly one cell can + ever be outlined (state and DOM focus are kept in sync via @key + FocusAsync). */ +.bit-dtg-cell[tabindex]:focus { outline: 2px solid var(--bit-dtg-accent); outline-offset: -2px; z-index: 1; } +.bit-dtg-cell[tabindex]:focus-visible { outline: 2px solid var(--bit-dtg-accent); outline-offset: -2px; z-index: 1; } + +/* ---------------------------------------------------------- Footer */ +.bit-dtg-footer { position: sticky; bottom: 0; z-index: 3; } +.bit-dtg-footer-row .bit-dtg-cell { + background: var(--bit-dtg-header-bg); + font-weight: 600; + border-top: 2px solid var(--bit-dtg-border); } -.bit-dtg-ctt { - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; +/* ---------------------------------------------------------- Empty / loading */ +.bit-dtg-empty, .bit-dtg-loading { + padding: 32px; + text-align: center; + color: var(--bit-dtg-muted); +} +.bit-dtg-spinner { + display: inline-block; + width: 14px; height: 14px; + border: 2px solid var(--bit-dtg-muted); + border-top-color: transparent; + border-radius: 50%; + animation: bit-dtg-spin .7s linear infinite; + vertical-align: middle; } +@keyframes bit-dtg-spin { to { transform: rotate(360deg); } } -.bit-dtg-cjc { +/* ------------------------------------------ Infinite-scroll placeholder */ +.bit-dtg-placeholder-cell { display: flex; align-items: center; } +.bit-dtg-skeleton { + display: block; + width: 60%; + height: 10px; + border-radius: 6px; + background: linear-gradient(90deg, + var(--bit-dtg-border) 25%, + light-dark(#eef1f4, #2a2e35) 37%, + var(--bit-dtg-border) 63%); + background-size: 400% 100%; + animation: bit-dtg-shimmer 1.2s ease-in-out infinite; +} +@keyframes bit-dtg-shimmer { 0% { background-position: 100% 0; } 100% { background-position: 0 0; } } +.bit-dtg-infinite-end { + padding: 14px; text-align: center; + color: var(--bit-dtg-muted); + font-size: 13px; +} - .bit-dtg-ctl { - justify-content: center; - } +/* ---------------------------------------------------------- Editors */ +.bit-dtg-editor, .bit-dtg-filter-input { + width: 100%; + padding: 4px 6px; + border: 1px solid var(--bit-dtg-border); + border-radius: 4px; + background: var(--bit-dtg-bg); + color: var(--bit-dtg-color); + font: inherit; } +.bit-dtg-editor-check { width: auto; } +.bit-dtg-filter-input { font-weight: 400; } -.bit-dtg-cje { - text-align: end; +/* ---------------------------------------------------------- Buttons */ +.bit-dtg-btn { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 5px 10px; + border: 1px solid var(--bit-dtg-border); + border-radius: 6px; + background: var(--bit-dtg-bg); + color: var(--bit-dtg-color); + font: inherit; + cursor: pointer; + text-decoration: none; + line-height: 1.2; +} +.bit-dtg-btn:hover:not(:disabled) { background: var(--bit-dtg-row-hover); } +.bit-dtg-btn:disabled { opacity: .45; cursor: default; } +.bit-dtg-btn-primary { background: var(--bit-dtg-accent); border-color: var(--bit-dtg-accent); color: #fff; } +.bit-dtg-btn-danger { color: var(--bit-dtg-danger); border-color: var(--bit-dtg-danger); } +.bit-dtg-cell-command { gap: 6px; } - .bit-dtg-ctl { - flex-direction: row-reverse; - } +.bit-dtg-icon-btn { + background: none; + border: none; + color: var(--bit-dtg-color); + cursor: pointer; + font-size: 13px; + padding: 2px 4px; + border-radius: 4px; } +.bit-dtg-icon-btn:hover { background: var(--bit-dtg-row-hover); } +.bit-dtg-group-btn.bit-dtg-active { color: var(--bit-dtg-accent); } -.bit-dtg-plh::after { - opacity: 0.75; - content: '\2026'; +/* ---------------------------------------------------------- Pager */ +.bit-dtg-pager { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + padding: 8px; + border-top: 1px solid var(--bit-dtg-border); + flex-wrap: wrap; +} +.bit-dtg-pager-info { display: flex; align-items: center; gap: 10px; color: var(--bit-dtg-muted); } +.bit-dtg-pager-buttons { display: flex; align-items: center; gap: 4px; } +.bit-dtg-pager-current { padding: 0 8px; } +.bit-dtg-page-size { + padding: 4px 6px; + border: 1px solid var(--bit-dtg-border); + border-radius: 6px; + background: var(--bit-dtg-bg); + color: var(--bit-dtg-color); + font: inherit; } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.ts b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.ts index 1b8c80517a..d3183ef246 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.ts +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.ts @@ -1,101 +1,37 @@ namespace BitBlazorUI { export class DataGrid { - public static init(tableElement: any) { - DataGrid.enableColumnResizing(tableElement); - - const bodyClickHandler = (event: any) => { - const columnOptionsElement = tableElement.tHead.querySelector('.bit-dtg-cop'); - if (columnOptionsElement && event.composedPath().indexOf(columnOptionsElement) < 0) { - tableElement.dispatchEvent(new CustomEvent('closecolumnoptions', { bubbles: true })); + // Infinite scrolling is the one feature that genuinely needs to read scroll + // position (which Blazor's scroll EventArgs do not expose), so this watches + // the viewport and notifies .NET when the user nears the end. + public static initInfiniteScroll(viewport: HTMLElement, dotNetRef: DotNetObject, threshold: number) { + const distance = threshold ?? 200; + let ticking = false; + + const check = () => { + ticking = false; + if (!viewport) return; + const remaining = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight; + if (remaining <= distance) { + dotNetRef.invokeMethodAsync('OnInfiniteScrollNearEndAsync'); } }; - const keyDownHandler = (event: any) => { - const columnOptionsElement = tableElement.tHead.querySelector('.bit-dtg-cop'); - if (columnOptionsElement && event.key === "Escape") { - tableElement.dispatchEvent(new CustomEvent('closecolumnoptions', { bubbles: true })); + + const onScroll = () => { + if (!ticking) { + ticking = true; + requestAnimationFrame(check); } }; - document.body.addEventListener('click', bodyClickHandler); - document.body.addEventListener('mousedown', bodyClickHandler); // Otherwise it seems strange that it doesn't go away until you release the mouse button - document.body.addEventListener('keydown', keyDownHandler); + viewport.addEventListener('scroll', onScroll, { passive: true }); + // Initial check so a first batch that doesn't fill the viewport keeps loading. + setTimeout(check, 0); return { - stop: () => { - document.body.removeEventListener('click', bodyClickHandler); - document.body.removeEventListener('mousedown', bodyClickHandler); - document.body.removeEventListener('keydown', keyDownHandler); - } + check: () => check(), + scrollToTop: () => { if (viewport) viewport.scrollTop = 0; }, + dispose: () => viewport.removeEventListener('scroll', onScroll) }; } - - public static checkColumnOptionsPosition(tableElement: any) { - const colOptions = tableElement.tHead && tableElement.tHead.querySelector('.bit-dtg-cop'); // Only match within *our* thead, not nested tables - if (colOptions) { - // We want the options popup to be positioned over the grid, not overflowing on either side, because it's possible that - // beyond either side is off-screen or outside the scroll range of an ancestor - const gridRect = tableElement.getBoundingClientRect(); - const optionsRect = colOptions.getBoundingClientRect(); - const leftOverhang = Math.max(0, gridRect.left - optionsRect.left); - const rightOverhang = Math.max(0, optionsRect.right - gridRect.right); - if (leftOverhang || rightOverhang) { - // In the unlikely event that it overhangs both sides, we'll center it - const applyOffset = leftOverhang && rightOverhang ? (leftOverhang - rightOverhang) / 2 : (leftOverhang - rightOverhang); - colOptions.style.transform = `translateX(${applyOffset}px)`; - } - - colOptions.scrollIntoViewIfNeeded(); - - const autoFocusElem = colOptions.querySelector('[autofocus]'); - if (autoFocusElem) { - autoFocusElem.focus(); - } - } - } - - private static enableColumnResizing(tableElement: any) { - tableElement.tHead.querySelectorAll('.bit-dtg-drg').forEach((handle: any) => { - handle.addEventListener('mousedown', handleMouseDown); - if ('ontouchstart' in window) { - handle.addEventListener('touchstart', handleMouseDown); - } - - function handleMouseDown(evt: any) { - evt.preventDefault(); - evt.stopPropagation(); - - const th = handle.parentElement; - const startPageX = evt.touches ? evt.touches[0].pageX : evt.pageX; - const originalColumnWidth = th.offsetWidth; - const rtlMultiplier = window.getComputedStyle(th, null).getPropertyValue('direction') === 'rtl' ? -1 : 1; - let updatedColumnWidth = 0; - - function handleMouseMove(evt: any) { - evt.stopPropagation(); - const newPageX = evt.touches ? evt.touches[0].pageX : evt.pageX; - const nextWidth = originalColumnWidth + (newPageX - startPageX) * rtlMultiplier; - if (Math.abs(nextWidth - updatedColumnWidth) > 0) { - updatedColumnWidth = nextWidth; - th.style.width = `${updatedColumnWidth}px`; - } - } - - function handleMouseUp() { - document.body.removeEventListener('mousemove', handleMouseMove); - document.body.removeEventListener('mouseup', handleMouseUp); - document.body.removeEventListener('touchmove', handleMouseMove); - document.body.removeEventListener('touchend', handleMouseUp); - } - - if (window.TouchEvent && evt instanceof TouchEvent) { - document.body.addEventListener('touchmove', handleMouseMove, { passive: true }); - document.body.addEventListener('touchend', handleMouseUp, { passive: true }); - } else { - document.body.addEventListener('mousemove', handleMouseMove, { passive: true }); - document.body.addEventListener('mouseup', handleMouseUp, { passive: true }); - } - } - }); - } } } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridCell.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridCell.razor new file mode 100644 index 0000000000..b1bb273890 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridCell.razor @@ -0,0 +1,72 @@ +@typeparam TItem +@namespace Bit.BlazorUI + +@* A single data cell. Each cell owns one stable element with an unconditional @@ref so + keyboard navigation can move DOM focus via Blazor's FocusAsync without conditional + reference-capture frames (which break render-tree diffing). *@ +
+ @ChildContent +
+ +@code { + [Parameter, EditorRequired] public BitDataGrid Grid { get; set; } = default!; + [Parameter, EditorRequired] public TItem Item { get; set; } = default!; + [Parameter, EditorRequired] public BitDataGridColumn Column { get; set; } = default!; + [Parameter] public int ColIndex { get; set; } + [Parameter] public string? CssClass { get; set; } + [Parameter] public string? Style { get; set; } + [Parameter] public RenderFragment? ChildContent { get; set; } + + private ElementReference _el; + + private int? TabIndex => Grid.CellNavigation ? Grid.CellTabIndex(Item, ColIndex) : (int?)null; + private bool Editing => Grid.IsEditing(Item); + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (Grid.CellNavigation && Grid.ShouldFocusCell(Item, ColIndex)) + { + Grid.ClearFocusPending(); + try { await _el.FocusAsync(); } catch { /* element may have been removed */ } + } + } + + private Task HandleClick(MouseEventArgs e) => Grid.HandleCellClickAsync(Column, Item, e); + private Task HandleDoubleClick(MouseEventArgs e) => Grid.HandleCellDoubleClickAsync(Column, Item, e); + private Task HandleContextMenu(MouseEventArgs e) => Grid.HandleCellContextMenuAsync(Column, Item, e); + + private void HandleFocusIn() + { + if (Grid.CellNavigation) Grid.SetFocusedCell(Item, ColIndex); + } + + private async Task HandleKeyDown(KeyboardEventArgs e) + { + if (!Grid.CellNavigation) return; + + // While inline-editing, the cell only handles the edit lifecycle keys; everything + // else (typing, caret movement) belongs to the editor input. + if (Editing) + { + if (e.Key == "Escape") + { + await Grid.CancelEditAsync(); + Grid.RefocusFocusedCell(); + } + else if (e.Key == "Enter") + { + await Grid.CommitEditAsync(); + Grid.RefocusFocusedCell(); + } + return; + } + + await Grid.HandleCellKeyDownAsync(Item, ColIndex, e); + } +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridCellEditor.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridCellEditor.razor new file mode 100644 index 0000000000..8721c04a84 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridCellEditor.razor @@ -0,0 +1,61 @@ +@typeparam TItem +@namespace Bit.BlazorUI + +@switch (Column.EffectiveDataType) +{ + case BitDataGridColumnDataType.Boolean: + + break; + + case BitDataGridColumnDataType.Number: + + break; + + case BitDataGridColumnDataType.Date: + + break; + + case BitDataGridColumnDataType.Enum: + + break; + + default: + + break; +} + +@code { + [Parameter, EditorRequired] public BitDataGrid Grid { get; set; } = default!; + [Parameter, EditorRequired] public BitDataGridColumn Column { get; set; } = default!; + [Parameter, EditorRequired] public TItem Item { get; set; } = default!; + + private object? Value => Column.GetValue(Item); + private string GetString() => Value?.ToString() ?? string.Empty; + private bool GetBool() => Value is bool b && b; + + private string GetDate() + { + return Value switch + { + DateTime dt => dt.ToString("yyyy-MM-dd"), + DateOnly d => d.ToString("yyyy-MM-dd"), + DateTimeOffset dto => dto.ToString("yyyy-MM-dd"), + _ => string.Empty + }; + } + + private void Set(object? raw) => Grid.SetEditValue(Column, raw); +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridColumn.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridColumn.cs new file mode 100644 index 0000000000..971187db9c --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridColumn.cs @@ -0,0 +1,164 @@ +using Microsoft.AspNetCore.Components; + +namespace Bit.BlazorUI; + +/// +/// Defines a column inside a . Place these as child +/// content of the grid. A column can be bound to a property via +/// or be a purely template-driven column. +/// +/// The row item type. +public class BitDataGridColumn : ComponentBase, IDisposable +{ + [CascadingParameter] internal BitDataGrid? Grid { get; set; } + + /// Name of the property this column is bound to. Supports nested paths ("Address.City"). + [Parameter] public string? Field { get; set; } + + /// Stable identifier for the column. Defaults to . + [Parameter] public string? ColumnId { get; set; } + + /// Header text. Defaults to a humanized . + [Parameter] public string? Title { get; set; } + + /// CSS width, e.g. "120px" or "20%". When null the column shares remaining space. + [Parameter] public string? Width { get; set; } + + [Parameter] public int MinWidth { get; set; } = 60; + + /// Maximum width in pixels the column can be resized to. When null the column is unbounded. + [Parameter] public int? MaxWidth { get; set; } + + [Parameter] public bool? Sortable { get; set; } + + /// + /// When true, the first click on the header sorts descending instead of ascending. + /// Mirrors react-data-grid's sortDescendingFirst. + /// + [Parameter] public bool SortDescendingFirst { get; set; } + [Parameter] public bool? Filterable { get; set; } + [Parameter] public bool? Resizable { get; set; } + [Parameter] public bool? Reorderable { get; set; } + [Parameter] public bool? Editable { get; set; } + [Parameter] public bool? Groupable { get; set; } + + /// Pin the column to the start edge so it stays visible while scrolling horizontally. + [Parameter] public bool Frozen { get; set; } + + /// + /// Optional header group name. Consecutive columns sharing the same value are rendered + /// under a single spanning header cell. Mirrors react-data-grid's column groups. + /// + [Parameter] public string? Group { get; set; } + + /// + /// Optional per-row column span. Returns how many columns the cell should occupy + /// (>= 1), or null/1 for no spanning. Mirrors react-data-grid's colSpan. + /// + [Parameter] public Func? ColSpan { get; set; } + + [Parameter] public bool Visible { get; set; } = true; + + [Parameter] public BitDataGridColumnAlign Align { get; set; } = BitDataGridColumnAlign.Left; + + /// A .NET format string applied to the value (e.g. "C2", "yyyy-MM-dd"). + [Parameter] public string? Format { get; set; } + + [Parameter] public BitDataGridColumnDataType DataType { get; set; } = BitDataGridColumnDataType.Auto; + + [Parameter] public BitDataGridAggregateType Aggregate { get; set; } = BitDataGridAggregateType.None; + + /// Format string for the aggregate value. Falls back to . + [Parameter] public string? AggregateFormat { get; set; } + + [Parameter] public string? HeaderClass { get; set; } + [Parameter] public string? CellClass { get; set; } + + /// Custom rendering for a data cell. + [Parameter] public RenderFragment? Template { get; set; } + + /// Custom rendering for the header cell content. + [Parameter] public RenderFragment? HeaderTemplate { get; set; } + + /// Custom editor rendered when the row/cell is in edit mode. + [Parameter] public RenderFragment? EditTemplate { get; set; } + + /// Custom rendering for the footer/aggregate cell. + [Parameter] public RenderFragment? FooterTemplate { get; set; } + + // ---- Runtime state (managed by the grid) ---- + + /// Current resolved width applied via inline style (set by resizing). + internal double? ResizedWidth { get; set; } + + internal BitDataGridPropertyAccessor? Accessor { get; private set; } + + internal string Id => ColumnId ?? Field ?? $"col-{GetHashCode():x}"; + + internal string DisplayTitle => Title ?? Humanize(Field) ?? Id; + + internal bool HasField => !string.IsNullOrEmpty(Field); + + internal BitDataGridColumnDataType EffectiveDataType + { + get + { + if (DataType != BitDataGridColumnDataType.Auto) return DataType; + if (Accessor is null) return BitDataGridColumnDataType.Text; + var t = Accessor.UnderlyingType; + if (t == typeof(bool)) return BitDataGridColumnDataType.Boolean; + if (t.IsEnum) return BitDataGridColumnDataType.Enum; + if (t == typeof(DateTime) || t == typeof(DateOnly) || t == typeof(DateTimeOffset)) return BitDataGridColumnDataType.Date; + if (t == typeof(int) || t == typeof(long) || t == typeof(short) || t == typeof(byte) + || t == typeof(double) || t == typeof(float) || t == typeof(decimal)) + return BitDataGridColumnDataType.Number; + return BitDataGridColumnDataType.Text; + } + } + + protected override void OnInitialized() + { + if (Grid is null) + throw new InvalidOperationException($"{nameof(BitDataGridColumn)} must be used inside a {nameof(BitDataGrid)}."); + Grid.AddColumn(this); + } + + protected override void OnParametersSet() + { + if (HasField) + Accessor = BitDataGridPropertyAccessor.For(Field!); + } + + public void Dispose() => Grid?.RemoveColumn(this); + + internal object? GetValue(TItem item) => Accessor?.GetValue(item); + + internal string GetFormattedValue(TItem item) + { + var value = GetValue(item); + return FormatValue(value); + } + + internal string FormatValue(object? value) + { + if (value is null) return string.Empty; + if (!string.IsNullOrEmpty(Format) && value is IFormattable f) + return f.ToString(Format, System.Globalization.CultureInfo.CurrentCulture); + return value.ToString() ?? string.Empty; + } + + private static string? Humanize(string? field) + { + if (string.IsNullOrEmpty(field)) return null; + var name = field.Contains('.') ? field[(field.LastIndexOf('.') + 1)..] : field; + var sb = new System.Text.StringBuilder(name.Length + 4); + for (int i = 0; i < name.Length; i++) + { + var c = name[i]; + if (i > 0 && char.IsUpper(c) && (!char.IsUpper(name[i - 1]) || (i + 1 < name.Length && char.IsLower(name[i + 1])))) + sb.Append(' '); + sb.Append(i == 0 ? char.ToUpperInvariant(c) : c); + } + return sb.ToString(); + } +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridJsRuntimeExtensions.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridJsRuntimeExtensions.cs deleted file mode 100644 index 684418b1a8..0000000000 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridJsRuntimeExtensions.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace Bit.BlazorUI; - -internal static class BitDataGridJsRuntimeExtensions -{ - public static async ValueTask BitDataGridInit(this IJSRuntime jsRuntime, ElementReference tableElement) - { - return await jsRuntime.Invoke("BitBlazorUI.DataGrid.init", tableElement); - } - - public static async ValueTask BitDataGridCheckColumnOptionsPosition(this IJSRuntime jsRuntime, ElementReference tableElement) - { - await jsRuntime.InvokeVoid("BitBlazorUI.DataGrid.checkColumnOptionsPosition", tableElement); - } -} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridRow.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridRow.razor new file mode 100644 index 0000000000..08dd2678e5 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridRow.razor @@ -0,0 +1,147 @@ +@typeparam TItem +@namespace Bit.BlazorUI + +
+ + @if (Grid.RowReorderable) + { +
+ +
+ } + + @if (Grid.HasDetailColumn) + { +
+ +
+ } + + @if (Grid.HasSelectColumn) + { +
+ +
+ } + + @{ + var cols = Grid.VisibleColumns; + var skip = 0; + } + @for (int ci = 0; ci < cols.Count; ci++) + { + if (skip > 0) { skip--; continue; } + var column = cols[ci]; + var colIndex = ci; + var span = Grid.ResolveColSpan(column, Item); + if (span > 1) skip = span - 1; + var cellStyle = Grid.FrozenStyle(column) + (span > 1 ? $"grid-column:span {span};" : ""); + + + @CellContent(column, colIndex) + + } + + @if (Grid.HasCommandColumn) + { +
+ @if (Editing) + { + + + } + else + { + + + } +
+ } +
+ +@if (Grid.HasDetailColumn && Grid.IsDetailExpanded(Item) && Grid.DetailTemplate is not null) +{ +
+
+ @Grid.DetailTemplate(Item) +
+
+} + +@code { + [Parameter, EditorRequired] public BitDataGrid Grid { get; set; } = default!; + [Parameter, EditorRequired] public TItem Item { get; set; } = default!; + + private bool Selected => Grid.IsRowSelected(Item); + private bool Editing => Grid.IsEditing(Item); + + private RenderFragment CellContent(BitDataGridColumn column, int colIndex) => @ + @if (Grid.IsTreeMode && colIndex == 0) + { + + @if (Grid.TreeHasChildren(Item)) + { + + } + else + { + + } + } + @if (Editing && Grid.ColumnEditable(column)) + { + @if (column.EditTemplate is not null) + { + @column.EditTemplate(Item) + } + else + { + + } + } + else if (column.Template is not null) + { + @column.Template(Item) + } + else + { + @column.GetFormattedValue(Item) + } + ; + + private string RowClass + { + get + { + var c = "bit-dtg-row"; + if (Selected) c += " bit-dtg-selected"; + if (Editing) c += " bit-dtg-editing"; + return c; + } + } + + private string RowStyle => + $"grid-template-columns:var(--bit-dtg-template);min-height:{Grid.ResolveRowHeight(Item).ToString(System.Globalization.CultureInfo.InvariantCulture)}px;"; + + private string CellClass(BitDataGridColumn column) + { + var c = "bit-dtg-cell " + BitDataGrid.AlignClass(column.Align); + if (column.Frozen) c += " bit-dtg-sticky"; + if (!string.IsNullOrEmpty(column.CellClass)) c += " " + column.CellClass; + return c; + } + + private Task OnRowClick() => Grid.HandleRowClickAsync(Item); +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Columns/BitDataGridColumnBase.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Columns/BitDataGridColumnBase.razor.cs deleted file mode 100644 index b2553fe66f..0000000000 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Columns/BitDataGridColumnBase.razor.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Bit.BlazorUI; - -/// -/// An abstract base class for columns in a . -/// -/// The type of data represented by each row in the grid. -public abstract partial class BitDataGridColumnBase -{ -} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Columns/IBitDataGridSortBuilderColumn.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Columns/IBitDataGridSortBuilderColumn.cs deleted file mode 100644 index a83cb32fdc..0000000000 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Columns/IBitDataGridSortBuilderColumn.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace Bit.BlazorUI; - -/// -/// An interface that, if implemented by a subclass, allows a -/// to understand the sorting rules associated with that column. -/// -/// If a subclass does not implement this, that column can still be marked as sortable and can -/// be the current sort column, but its sorting logic cannot be applied to the data queries automatically. The developer would be -/// responsible for implementing that sorting logic separately inside their . -/// -/// The type of data represented by each row in the grid. -public interface IBitDataGridSortBuilderColumn -{ - /// - /// Gets the sorting rules associated with the column. - /// - public BitDataGridSort? SortBuilder { get; } -} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridDataProcessor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridDataProcessor.cs new file mode 100644 index 0000000000..3d28a24a1e --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridDataProcessor.cs @@ -0,0 +1,239 @@ +using System.Globalization; + +namespace Bit.BlazorUI; + +/// +/// Client-side data pipeline: filtering, multi-sorting, grouping and aggregation. +/// +public static class BitDataGridDataProcessor +{ + public static IReadOnlyList Filter( + IEnumerable source, + IReadOnlyList filters, + IReadOnlyDictionary> columns) + { + if (filters.Count == 0) + return source as IReadOnlyList ?? source.ToList(); + + var query = source; + foreach (var filter in filters) + { + if (!columns.TryGetValue(filter.ColumnId, out var column) || column.Accessor is null) + continue; + var f = filter; + var col = column; + query = query.Where(item => Matches(col.Accessor!.GetValue(item), f)); + } + return query.ToList(); + } + + public static IReadOnlyList Sort( + IReadOnlyList source, + IReadOnlyList sorts, + IReadOnlyDictionary> columns) + { + var active = sorts.Where(s => s.Direction != BitDataGridSortDirection.None).OrderBy(s => s.Priority).ToList(); + if (active.Count == 0) return source; + + IOrderedEnumerable? ordered = null; + foreach (var sort in active) + { + if (!columns.TryGetValue(sort.ColumnId, out var column) || column.Accessor is null) + continue; + var accessor = column.Accessor; + Func key = item => accessor.GetValue(item); + var comparer = BitDataGridValueComparer.Instance; + if (ordered is null) + { + ordered = sort.Direction == BitDataGridSortDirection.Ascending + ? source.OrderBy(key, comparer) + : source.OrderByDescending(key, comparer); + } + else + { + ordered = sort.Direction == BitDataGridSortDirection.Ascending + ? ordered.ThenBy(key, comparer) + : ordered.ThenByDescending(key, comparer); + } + } + return ordered?.ToList() ?? source; + } + + public static List> Group( + IReadOnlyList source, + IReadOnlyList groups, + IReadOnlyDictionary> columns) + { + if (groups.Count == 0) return new List>(); + return BuildGroups(source, groups, columns, 0, string.Empty); + } + + private static List> BuildGroups( + IReadOnlyList source, + IReadOnlyList groups, + IReadOnlyDictionary> columns, + int level, + string parentPath) + { + var result = new List>(); + var descriptor = groups[level]; + if (!columns.TryGetValue(descriptor.ColumnId, out var column) || column.Accessor is null) + return result; + + var grouped = source + .GroupBy(item => column.Accessor!.GetValue(item)) + .Select(g => + { + var keyText = column.FormatValue(g.Key); + var items = g.ToList(); + var path = $"{parentPath}/{level}:{keyText}"; + var group = new BitDataGridGroup + { + ColumnId = descriptor.ColumnId, + Key = g.Key, + KeyText = keyText, + Level = level, + Path = path, + Items = items + }; + if (level + 1 < groups.Count) + group.SubGroups.AddRange(BuildGroups(items, groups, columns, level + 1, path)); + group.Aggregates.AddRange(Aggregate(items, columns.Values)); + return group; + }); + + grouped = descriptor.Direction == BitDataGridSortDirection.Descending + ? grouped.OrderByDescending(g => g.Key, BitDataGridValueComparer.Instance) + : grouped.OrderBy(g => g.Key, BitDataGridValueComparer.Instance); + + result = grouped.ToList(); + return result; + } + + public static List Aggregate( + IReadOnlyList source, + IEnumerable> columns) + { + var results = new List(); + foreach (var column in columns) + { + if (column.Aggregate == BitDataGridAggregateType.None || column.Accessor is null) continue; + var value = ComputeAggregate(source, column); + var format = column.AggregateFormat ?? column.Format; + var formatted = value is IFormattable fmt && !string.IsNullOrEmpty(format) + ? fmt.ToString(format, CultureInfo.CurrentCulture) + : value?.ToString() ?? string.Empty; + results.Add(new BitDataGridAggregateResult + { + ColumnId = column.Id, + Type = column.Aggregate, + Value = value, + FormattedValue = formatted + }); + } + return results; + } + + private static object? ComputeAggregate(IReadOnlyList source, BitDataGridColumn column) + { + var accessor = column.Accessor!; + switch (column.Aggregate) + { + case BitDataGridAggregateType.Count: + return source.Count; + case BitDataGridAggregateType.Sum: + case BitDataGridAggregateType.Average: + { + double sum = 0; int n = 0; + foreach (var item in source) + { + if (TryToDouble(accessor.GetValue(item), out var d)) { sum += d; n++; } + } + if (column.Aggregate == BitDataGridAggregateType.Sum) return sum; + return n == 0 ? 0d : sum / n; + } + case BitDataGridAggregateType.Min: + case BitDataGridAggregateType.Max: + { + object? best = null; + foreach (var item in source) + { + var v = accessor.GetValue(item); + if (v is null) continue; + if (best is null) { best = v; continue; } + var cmp = BitDataGridValueComparer.Instance.Compare(v, best); + if (column.Aggregate == BitDataGridAggregateType.Min ? cmp < 0 : cmp > 0) best = v; + } + return best; + } + default: + return null; + } + } + + private static bool TryToDouble(object? value, out double result) + { + result = 0; + if (value is null) return false; + try { result = Convert.ToDouble(value, CultureInfo.InvariantCulture); return true; } + catch { return false; } + } + + private static bool Matches(object? value, BitDataGridFilterDescriptor filter) + { + switch (filter.Operator) + { + case BitDataGridFilterOperator.IsEmpty: + return value is null || string.IsNullOrEmpty(value.ToString()); + case BitDataGridFilterOperator.IsNotEmpty: + return value is not null && !string.IsNullOrEmpty(value.ToString()); + } + + if (filter.Value is null) + return true; + + // Numeric / comparable operators + if (filter.Operator is BitDataGridFilterOperator.GreaterThan or BitDataGridFilterOperator.GreaterThanOrEqual + or BitDataGridFilterOperator.LessThan or BitDataGridFilterOperator.LessThanOrEqual + or BitDataGridFilterOperator.Equals or BitDataGridFilterOperator.NotEquals) + { + var cmp = BitDataGridValueComparer.Instance.Compare(value, CoerceToValueType(value, filter.Value)); + return filter.Operator switch + { + BitDataGridFilterOperator.GreaterThan => cmp > 0, + BitDataGridFilterOperator.GreaterThanOrEqual => cmp >= 0, + BitDataGridFilterOperator.LessThan => cmp < 0, + BitDataGridFilterOperator.LessThanOrEqual => cmp <= 0, + BitDataGridFilterOperator.Equals => cmp == 0, + BitDataGridFilterOperator.NotEquals => cmp != 0, + _ => true + }; + } + + // String operators + var text = value?.ToString() ?? string.Empty; + var term = filter.Value.ToString() ?? string.Empty; + return filter.Operator switch + { + BitDataGridFilterOperator.Contains => text.Contains(term, StringComparison.OrdinalIgnoreCase), + BitDataGridFilterOperator.DoesNotContain => !text.Contains(term, StringComparison.OrdinalIgnoreCase), + BitDataGridFilterOperator.StartsWith => text.StartsWith(term, StringComparison.OrdinalIgnoreCase), + BitDataGridFilterOperator.EndsWith => text.EndsWith(term, StringComparison.OrdinalIgnoreCase), + _ => true + }; + } + + private static object? CoerceToValueType(object? sample, object filterValue) + { + if (sample is null) return filterValue; + var target = Nullable.GetUnderlyingType(sample.GetType()) ?? sample.GetType(); + if (target.IsInstanceOfType(filterValue)) return filterValue; + try + { + if (target.IsEnum) + return filterValue is string s ? Enum.Parse(target, s, true) : Enum.ToObject(target, filterValue); + return Convert.ChangeType(filterValue, target, CultureInfo.CurrentCulture); + } + catch { return filterValue; } + } +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridGroup.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridGroup.cs new file mode 100644 index 0000000000..bd89355c41 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridGroup.cs @@ -0,0 +1,32 @@ +namespace Bit.BlazorUI; + +/// +/// A materialized group of rows produced by grouping. Groups can be nested to any depth +/// (multi-level grouping); leaf groups carry the actual while parent +/// groups carry . +/// +public sealed class BitDataGridGroup +{ + public required string ColumnId { get; init; } + public required object? Key { get; init; } + public string KeyText { get; init; } = string.Empty; + + /// Zero-based nesting depth (0 = top level). + public int Level { get; init; } + + /// Stable, unique path identifying this group across the whole tree (used for collapse state). + public required string Path { get; init; } + + /// All rows that fall under this group (across nested subgroups). + public List Items { get; init; } = new(); + + /// Child groups when this group is further grouped; empty for leaf groups. + public List> SubGroups { get; init; } = new(); + + public List Aggregates { get; init; } = new(); + + public bool HasSubGroups => SubGroups.Count > 0; + + /// Total number of leaf rows in this group. + public int Count => Items.Count; +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridPropertyAccessor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridPropertyAccessor.cs new file mode 100644 index 0000000000..dfe90d733a --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridPropertyAccessor.cs @@ -0,0 +1,111 @@ +using System.Collections.Concurrent; +using System.Linq.Expressions; +using System.Reflection; + +namespace Bit.BlazorUI; + +/// +/// Builds and caches fast compiled delegates to read and write a property on +/// by name, supporting nested paths like "Address.City". +/// +public sealed class BitDataGridPropertyAccessor +{ + private static readonly ConcurrentDictionary> Cache = new(); + + public string Path { get; } + public Type PropertyType { get; } + public Type UnderlyingType { get; } + public bool CanWrite { get; } + + private readonly Func _getter; + private readonly Action? _setter; + + private BitDataGridPropertyAccessor(string path, Type propertyType, bool canWrite, + Func getter, Action? setter) + { + Path = path; + PropertyType = propertyType; + UnderlyingType = Nullable.GetUnderlyingType(propertyType) ?? propertyType; + CanWrite = canWrite; + _getter = getter; + _setter = setter; + } + + public object? GetValue(TItem item) => _getter(item); + + public void SetValue(TItem item, object? value) + { + if (_setter is null) return; + _setter(item, ConvertValue(value)); + } + + /// Coerces an arbitrary value into the property's type. + public object? ConvertValue(object? value) + { + if (value is null) + return PropertyType.IsValueType && Nullable.GetUnderlyingType(PropertyType) is null + ? Activator.CreateInstance(PropertyType) + : null; + + if (PropertyType.IsInstanceOfType(value)) + return value; + + var target = UnderlyingType; + try + { + if (target.IsEnum) + return value is string s ? Enum.Parse(target, s, true) : Enum.ToObject(target, value); + if (target == typeof(Guid)) + return value is Guid g ? g : Guid.Parse(value.ToString()!); + if (target == typeof(DateOnly)) + return value is DateOnly d ? d : DateOnly.Parse(value.ToString()!); + if (target == typeof(TimeOnly)) + return value is TimeOnly t ? t : TimeOnly.Parse(value.ToString()!); + return Convert.ChangeType(value, target); + } + catch + { + return PropertyType.IsValueType && Nullable.GetUnderlyingType(PropertyType) is null + ? Activator.CreateInstance(PropertyType) + : null; + } + } + + public static BitDataGridPropertyAccessor For(string path) + => Cache.GetOrAdd(path, Build); + + private static BitDataGridPropertyAccessor Build(string path) + { + var param = Expression.Parameter(typeof(TItem), "x"); + Expression body = param; + PropertyInfo? lastProp = null; + + foreach (var segment in path.Split('.', StringSplitOptions.RemoveEmptyEntries)) + { + var prop = body.Type.GetProperty(segment, + BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase) + ?? throw new ArgumentException($"Property '{segment}' not found on type '{body.Type.Name}'."); + body = Expression.Property(body, prop); + lastProp = prop; + } + + var propertyType = body.Type; + + // Getter: x => (object)x.Path (with null-safety on the object boxing) + var getterBody = Expression.Convert(body, typeof(object)); + var getter = Expression.Lambda>(getterBody, param).Compile(); + + // Setter (only for a simple, writable, single-level-or-nested property) + Action? setter = null; + var canWrite = lastProp is { CanWrite: true }; + if (canWrite) + { + var valueParam = Expression.Parameter(typeof(object), "v"); + var convertedValue = Expression.Convert(valueParam, propertyType); + var assign = Expression.Assign(body, convertedValue); + setter = Expression.Lambda>(assign, param, valueParam).Compile(); + } + + return new BitDataGridPropertyAccessor(path, propertyType, canWrite, getter, setter); + } +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridValueComparer.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridValueComparer.cs new file mode 100644 index 0000000000..c486f81b6a --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridValueComparer.cs @@ -0,0 +1,25 @@ +namespace Bit.BlazorUI; + +/// Null-safe comparer that orders nulls first and falls back to string comparison. +internal sealed class BitDataGridValueComparer : IComparer +{ + public static readonly BitDataGridValueComparer Instance = new(); + + public int Compare(object? x, object? y) + { + if (ReferenceEquals(x, y)) return 0; + if (x is null) return -1; + if (y is null) return 1; + + if (x is IComparable cx && x.GetType() == y.GetType()) + return cx.CompareTo(y); + + if (x is IComparable cx2) + { + try { return cx2.CompareTo(Convert.ChangeType(y, x.GetType())); } + catch { /* fall through */ } + } + + return string.Compare(x.ToString(), y.ToString(), StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/ItemsProvider/BitDataGridItemsProvider.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/ItemsProvider/BitDataGridItemsProvider.cs deleted file mode 100644 index 4b2fdf604f..0000000000 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/ItemsProvider/BitDataGridItemsProvider.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Bit.BlazorUI; - -/// -/// A callback that provides data for a . -/// -/// The type of data represented by each row in the grid. -/// Parameters describing the data being requested. -/// A that gives the data to be displayed. -public delegate ValueTask> BitDataGridItemsProvider(BitDataGridItemsProviderRequest request); diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridAggregateResult.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridAggregateResult.cs new file mode 100644 index 0000000000..b068190d7a --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridAggregateResult.cs @@ -0,0 +1,10 @@ +namespace Bit.BlazorUI; + +/// Holds the computed aggregate value for a column footer or group. +public sealed class BitDataGridAggregateResult +{ + public required string ColumnId { get; init; } + public BitDataGridAggregateType Type { get; init; } + public object? Value { get; init; } + public string FormattedValue { get; init; } = string.Empty; +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridAggregateType.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridAggregateType.cs new file mode 100644 index 0000000000..0593a59a75 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridAggregateType.cs @@ -0,0 +1,12 @@ +namespace Bit.BlazorUI; + +/// Built-in aggregate functions for summary/footer rows. +public enum BitDataGridAggregateType +{ + None = 0, + Sum, + Average, + Count, + Min, + Max +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridCellEventArgs.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridCellEventArgs.cs new file mode 100644 index 0000000000..5f18e7518e --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridCellEventArgs.cs @@ -0,0 +1,26 @@ +using Microsoft.AspNetCore.Components.Web; + +namespace Bit.BlazorUI; + +/// +/// Arguments passed to cell-level event callbacks (OnCellClick, OnCellDoubleClick, +/// OnCellContextMenu). Mirrors react-data-grid's CellMouseArgs. +/// +/// The row item type. +public sealed class BitDataGridCellEventArgs +{ + public required TItem Item { get; init; } + public required BitDataGridColumn Column { get; init; } + + /// The column field/identifier for convenience. + public string ColumnId => Column.Id; + + /// The column's display title (header text). + public string ColumnTitle => Column.DisplayTitle; + + /// The raw value of the cell. + public object? Value { get; init; } + + /// The underlying browser mouse event. + public MouseEventArgs Mouse { get; init; } = new(); +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridColumnAlign.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridColumnAlign.cs new file mode 100644 index 0000000000..faba537a21 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridColumnAlign.cs @@ -0,0 +1,9 @@ +namespace Bit.BlazorUI; + +/// Horizontal alignment of cell content. +public enum BitDataGridColumnAlign +{ + Left = 0, + Center, + Right +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridColumnDataType.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridColumnDataType.cs new file mode 100644 index 0000000000..1d0efe3f07 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridColumnDataType.cs @@ -0,0 +1,12 @@ +namespace Bit.BlazorUI; + +/// The kind of editor/filter rendered for a column based on its data type. +public enum BitDataGridColumnDataType +{ + Auto = 0, + Text, + Number, + Boolean, + Date, + Enum +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridDirection.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridDirection.cs new file mode 100644 index 0000000000..d36a7dbcfc --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridDirection.cs @@ -0,0 +1,8 @@ +namespace Bit.BlazorUI; + +/// Text direction for the grid. +public enum BitDataGridDirection +{ + Ltr = 0, + Rtl +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridFilterDescriptor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridFilterDescriptor.cs new file mode 100644 index 0000000000..1111571a98 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridFilterDescriptor.cs @@ -0,0 +1,9 @@ +namespace Bit.BlazorUI; + +/// Describes a filter applied to a single column. +public sealed class BitDataGridFilterDescriptor +{ + public required string ColumnId { get; init; } + public BitDataGridFilterOperator Operator { get; set; } = BitDataGridFilterOperator.Contains; + public object? Value { get; set; } +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridFilterOperator.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridFilterOperator.cs new file mode 100644 index 0000000000..ff0633186a --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridFilterOperator.cs @@ -0,0 +1,18 @@ +namespace Bit.BlazorUI; + +/// Comparison operators available for column filtering. +public enum BitDataGridFilterOperator +{ + Contains = 0, + DoesNotContain, + StartsWith, + EndsWith, + Equals, + NotEquals, + GreaterThan, + GreaterThanOrEqual, + LessThan, + LessThanOrEqual, + IsEmpty, + IsNotEmpty +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridGroupDescriptor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridGroupDescriptor.cs new file mode 100644 index 0000000000..97d07393c3 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridGroupDescriptor.cs @@ -0,0 +1,8 @@ +namespace Bit.BlazorUI; + +/// Describes a grouping applied to a column. +public sealed class BitDataGridGroupDescriptor +{ + public required string ColumnId { get; init; } + public BitDataGridSortDirection Direction { get; set; } = BitDataGridSortDirection.Ascending; +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridPagerPosition.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridPagerPosition.cs new file mode 100644 index 0000000000..eba33572c1 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridPagerPosition.cs @@ -0,0 +1,9 @@ +namespace Bit.BlazorUI; + +/// Where the pager is rendered relative to the grid. +public enum BitDataGridPagerPosition +{ + Bottom = 0, + Top, + TopAndBottom +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridReadRequest.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridReadRequest.cs new file mode 100644 index 0000000000..77e1c15e62 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridReadRequest.cs @@ -0,0 +1,21 @@ +namespace Bit.BlazorUI; + +/// +/// Describes the data the grid needs from an external/server-side source. +/// Passed to the grid's OnRead callback so callers can perform their own +/// sorting, filtering and paging (e.g. against a database). +/// +public sealed class BitDataGridReadRequest +{ + /// Zero-based number of items to skip (for paging/virtualization). + public int Skip { get; init; } + + /// Maximum number of items to return. null means "all". + public int? Take { get; init; } + + public IReadOnlyList Sorts { get; init; } = Array.Empty(); + + public IReadOnlyList Filters { get; init; } = Array.Empty(); + + public CancellationToken CancellationToken { get; init; } +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridReadResult.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridReadResult.cs new file mode 100644 index 0000000000..ae9db2b23e --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridReadResult.cs @@ -0,0 +1,18 @@ +namespace Bit.BlazorUI; + +/// Result returned from a grid's OnRead callback. +/// The row item type. +public sealed class BitDataGridReadResult +{ + public BitDataGridReadResult(IReadOnlyList items, int totalCount) + { + Items = items; + TotalCount = totalCount; + } + + /// The items for the current page/window. + public IReadOnlyList Items { get; } + + /// The total number of items matching the current filters (across all pages). + public int TotalCount { get; } +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridRowReorderEventArgs.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridRowReorderEventArgs.cs new file mode 100644 index 0000000000..a9c314e6d5 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridRowReorderEventArgs.cs @@ -0,0 +1,14 @@ +namespace Bit.BlazorUI; + +/// +/// Arguments raised when a row is reordered via drag-and-drop. Mirrors the intent of +/// react-data-grid's row reordering example. +/// +/// The row item type. +public sealed class BitDataGridRowReorderEventArgs +{ + public required TItem DraggedItem { get; init; } + public required TItem TargetItem { get; init; } + public required int FromIndex { get; init; } + public required int ToIndex { get; init; } +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridSelectionMode.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridSelectionMode.cs new file mode 100644 index 0000000000..17472ca016 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridSelectionMode.cs @@ -0,0 +1,9 @@ +namespace Bit.BlazorUI; + +/// How rows can be selected in the grid. +public enum BitDataGridSelectionMode +{ + None = 0, + Single = 1, + Multiple = 2 +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridSortDescriptor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridSortDescriptor.cs new file mode 100644 index 0000000000..f583e686c7 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridSortDescriptor.cs @@ -0,0 +1,10 @@ +namespace Bit.BlazorUI; + +/// Describes the sort state applied to a single column. +public sealed class BitDataGridSortDescriptor +{ + public required string ColumnId { get; init; } + public BitDataGridSortDirection Direction { get; set; } = BitDataGridSortDirection.Ascending; + /// Priority for multi-column sorting (1 = primary). + public int Priority { get; set; } +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridSortDirection.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridSortDirection.cs new file mode 100644 index 0000000000..a15eb0b3a5 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridSortDirection.cs @@ -0,0 +1,9 @@ +namespace Bit.BlazorUI; + +/// Sort direction for a column. +public enum BitDataGridSortDirection +{ + None = 0, + Ascending = 1, + Descending = 2 +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor new file mode 100644 index 0000000000..a31d80c43a --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor @@ -0,0 +1,138 @@ +@namespace Bit.BlazorUI +@typeparam TGridItem + + + @{ + StartCollectingColumns(); + } + @(Columns ?? ChildContent) + + @{ + FinishCollectingColumns(); + } + + + + + + @_renderColumnHeaders + + + + @if (Virtualize) + { + + } + else + { + if (IsLoading && LoadingTemplate is not null) + { + + + + } + else + { + @_renderNonVirtualizedRows + } + } + +
+ @LoadingTemplate +
+
+
+ +@code { + private void RenderNonVirtualizedRows(RenderTreeBuilder __builder) + { + var initialRowIndex = 2; // aria-rowindex is 1-based, plus the first row is the header + var rowIndex = initialRowIndex; + foreach (var item in _currentNonVirtualizedViewItems) + { + RenderRow(__builder, rowIndex++, item); + } + + // When pagination is enabled, by default ensure we render the exact number of expected rows per page, + // even if there aren't enough data items. This avoids the layout jumping on the last page. + // Consider making this optional. + if (Pagination is not null) + { + while (rowIndex++ < initialRowIndex + Pagination.ItemsPerPage) + { + + } + } + } + + private void RenderRow(RenderTreeBuilder __builder, int rowIndex, TGridItem item) + { + if (RowTemplate is null) + { + RenderOriginalRow(__builder, rowIndex, item); + } + else + { + var args = new BitQuickGridRowTemplateArgs + { + RowIndex = rowIndex, + RowItem = item, + OriginalRow = (builder) => RenderOriginalRow(builder, rowIndex, item) + }; + __builder.AddContent(0, RowTemplate(args)); + } + } + + private void RenderOriginalRow(RenderTreeBuilder __builder, int rowIndex, TGridItem item) + { + + @foreach (var col in _columns) + { + + @{ + col.CellContent(__builder, item); + } + + } + + } + + private void RenderPlaceholderRow(RenderTreeBuilder __builder, PlaceholderContext placeholderContext) + { + + @foreach (var col in _columns) + { + + @{ + col.RenderPlaceholderContent(__builder, placeholderContext); + } + + } + + } + + private void RenderColumnHeaders(RenderTreeBuilder __builder) + { + foreach (var col in _columns) + { + +
@col.HeaderContent
+ + @if (col == _displayOptionsForColumn) + { +
@col.ColumnOptions
+ } + + @if (ResizableColumns) + { +
+ } + + } + } +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor.cs new file mode 100644 index 0000000000..bc0f485587 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor.cs @@ -0,0 +1,536 @@ +// a fork from the Blazor QuickGrid at https://github.com/dotnet/aspnetcore/tree/main/src/Components/QuickGrid + +namespace Bit.BlazorUI; + +/// +/// BitQuickGrid is a robust way to display an information-rich collection of items, and allow people to sort, and filter the content. +/// +/// The type of data represented by each row in the grid. +[CascadingTypeParameter(nameof(TGridItem))] +public partial class BitQuickGrid : IAsyncDisposable +{ + private bool _disposed; + private int _ariaBodyRowCount; + private ElementReference _tableReference; + private Virtualize<(int, TGridItem)>? _virtualizeComponent; + private ICollection _currentNonVirtualizedViewItems = Array.Empty(); + + // IQueryable only exposes synchronous query APIs. IAsyncQueryExecutor is an adapter that lets us invoke any + // async query APIs that might be available. We have built-in support for using EF Core's async query APIs. + private IAsyncQueryExecutor? _asyncQueryExecutor; + + // We cascade the InternalGridContext to descendants, which in turn call it to add themselves to _columns + // This happens on every render so that the column list can be updated dynamically + private InternalGridContext _internalGridContext; + private List> _columns; + private bool _collectingColumns; // Columns might re-render themselves arbitrarily. We only want to capture them at a defined time. + + // Tracking state for options and sorting + private BitQuickGridColumnBase? _displayOptionsForColumn; + private BitQuickGridColumnBase? _sortByColumn; + private bool _sortByAscending; + private bool _checkColumnOptionsPosition; + + // The associated ES6 module, which uses document-level event listeners + //private IJSObjectReference? _jsModule; + private IJSObjectReference? _jsEventDisposable; + + // Caches of method->delegate conversions + private readonly RenderFragment _renderColumnHeaders; + private readonly RenderFragment _renderNonVirtualizedRows; + + // We try to minimize the number of times we query the items provider, since queries may be expensive + // We only re-query when the developer calls RefreshDataAsync, or if we know something's changed, such + // as sort order, the pagination state, or the data source itself. These fields help us detect when + // things have changed, and to discard earlier load attempts that were superseded. + private int? _lastRefreshedPaginationStateHash; + private object? _lastAssignedItemsOrProvider; + private CancellationTokenSource? _pendingDataLoadCancellationTokenSource; + + // If the PaginationState mutates, it raises this event. We use it to trigger a re-render. + private readonly EventCallbackSubscriber _currentPageItemsChanged; + + + + [Inject] private IJSRuntime _js { get; set; } = default!; + [Inject] private IServiceProvider _services { get; set; } = default!; + + + + /// + /// Constructs an instance of . + /// + public BitQuickGrid() + { + _columns = new(); + _internalGridContext = new(this); + _currentPageItemsChanged = new(EventCallback.Factory.Create(this, RefreshDataCoreAsync)); + _renderColumnHeaders = RenderColumnHeaders; + _renderNonVirtualizedRows = RenderNonVirtualizedRows; + + // 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 + var columnsFirstCollectedSubscriber = new EventCallbackSubscriber( + EventCallback.Factory.Create(this, RefreshDataCoreAsync)); + columnsFirstCollectedSubscriber.SubscribeOrMove(_internalGridContext.ColumnsFirstCollected); + } + + private bool IsLoading => _pendingDataLoadCancellationTokenSource is not null; + + + + /// + /// Defines the child components of this instance. For example, you may define columns by adding + /// components derived from the base class. + /// + [Parameter] public RenderFragment? ChildContent { get; set; } + + /// + /// An optional CSS class name. If given, this will be included in the class attribute of the rendered table. + /// + [Parameter] public string? Class { get; set; } + + /// + /// Alias of the ChildContent parameter. + /// + [Parameter] public RenderFragment? Columns { get; set; } + + /// + /// Optionally defines a value for @key on each rendered row. Typically this should be used to specify a + /// unique identifier, such as a primary key value, for each data item. + /// + /// This allows the grid to preserve the association between row elements and data items based on their + /// unique identifiers, even when the TGridItem instances are replaced by new copies (for + /// example, after a new query against the underlying data store). + /// + /// If not set, the @key will be the TGridItem instance itself. + /// + [Parameter] public Func ItemKey { get; set; } = x => x!; + + /// + /// A queryable source of data for the grid. + /// + /// This could be in-memory data converted to queryable using the + /// extension method, + /// or an EntityFramework DataSet or an derived from it. + /// + /// You should supply either or , but not both. + /// + [Parameter] public IQueryable? Items { get; set; } + + /// + /// 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 + /// size and to ensure accurate scrolling. + /// + [Parameter] public float ItemSize { get; set; } = 50; + + /// + /// A callback that supplies data for the rid. + /// + /// You should supply either or , but not both. + /// + [Parameter] public BitQuickGridItemsProvider? ItemsProvider { get; set; } + + /// + /// The custom template to render while loading the new items. + /// + [Parameter] public RenderFragment? LoadingTemplate { get; set; } + + /// + /// Optionally links this instance with a model, + /// causing the grid to fetch and render only the current page of data. + /// + /// This is normally used in conjunction with a component or some other UI logic + /// that displays and updates the supplied instance. + /// + [Parameter] public BitQuickGridPaginationState? Pagination { get; set; } + + /// + /// If true, renders draggable handles around the column headers, allowing the user to resize the columns + /// manually. Size changes are not persisted. + /// + [Parameter] public bool ResizableColumns { get; set; } + + /// + /// The CSS class of all rows of the data grid. + /// + [Parameter] public string? RowClass { get; set; } + + /// + /// The function to generate the CSS class of each row of the data grid. + /// + [Parameter] public Func? RowClassSelector { get; set; } + + /// + /// The CSS style of all row of the data grid. + /// + [Parameter] public string? RowStyle { get; set; } + + /// + /// The function to generate the CSS style of each row of the data grid. + /// + [Parameter] public Func? RowStyleSelector { get; set; } + + /// + /// Optional template to customize row rendering. Receives with + /// set to the default row content; call it to render the original cells or replace with custom content. + /// + [Parameter] public RenderFragment>? RowTemplate { get; set; } + + /// + /// A theme name, with default value "default". This affects which styling rules match the table. + /// + [Parameter] public string? Theme { get; set; } = "default"; + + /// + /// If true, the grid will be rendered with virtualization. This is normally used in conjunction with + /// scrolling and causes the grid to fetch and render only the data around the current scroll viewport. + /// This can greatly improve the performance when scrolling through large data sets. + /// + /// If you use , you should supply a value for and must + /// ensure that every row renders with the same constant height. + /// + /// Generally it's preferable not to use if the amount of data being rendered + /// is small or if you are using pagination. + /// + [Parameter] public bool Virtualize { get; set; } + + + + /// + /// Sets the grid's current sort column to the specified . + /// + /// The column that defines the new sort order. + /// The direction of sorting. If the value is , then it will toggle the direction on each call. + /// A representing the completion of the operation. + public Task SortByColumnAsync(BitQuickGridColumnBase column, BitQuickGridSortDirection direction = BitQuickGridSortDirection.Auto) + { + _sortByAscending = direction switch + { + BitQuickGridSortDirection.Ascending => true, + BitQuickGridSortDirection.Descending => false, + BitQuickGridSortDirection.Auto => _sortByColumn == column ? !_sortByAscending : true, + _ => throw new NotSupportedException($"Unknown sort direction {direction}"), + }; + + _sortByColumn = column; + + StateHasChanged(); // We want to see the updated sort order in the header, even before the data query is completed + return RefreshDataAsync(); + } + + /// + /// Displays the UI for the specified column, closing any other column + /// options UI that was previously displayed. + /// + /// The column whose options are to be displayed, if any are available. + public void ShowColumnOptions(BitQuickGridColumnBase column) + { + _displayOptionsForColumn = column; + _checkColumnOptionsPosition = true; // Triggers a call to JS to position the options element, apply autofocus, and any other setup + StateHasChanged(); + } + + /// + /// Instructs the grid to re-fetch and render the current data from the supplied data source + /// (either or ). + /// + /// A that represents the completion of the operation. + public async Task RefreshDataAsync() + { + await RefreshDataCoreAsync(); + StateHasChanged(); + } + + + + // Invoked by descendant columns at a special time during rendering + internal void AddColumn(BitQuickGridColumnBase column, BitQuickGridSortDirection? isDefaultSortDirection) + { + if (_collectingColumns) + { + _columns.Add(column); + + if (_sortByColumn is null && isDefaultSortDirection.HasValue) + { + _sortByColumn = column; + _sortByAscending = isDefaultSortDirection.Value != BitQuickGridSortDirection.Descending; + } + } + } + + + + /// + protected override Task OnParametersSetAsync() + { + // The associated pagination state may have been added/removed/replaced + _currentPageItemsChanged.SubscribeOrMove(Pagination?.CurrentPageItemsChanged); + + if (Items is not null && ItemsProvider is not null) + { + throw new InvalidOperationException($"BitQuickGrid requires one of {nameof(Items)} or {nameof(ItemsProvider)}, but both were specified."); + } + + // Perform a re-query only if the data source or something else has changed + var _newItemsOrItemsProvider = Items ?? (object?)ItemsProvider; + var dataSourceHasChanged = _newItemsOrItemsProvider != _lastAssignedItemsOrProvider; + if (dataSourceHasChanged) + { + _lastAssignedItemsOrProvider = _newItemsOrItemsProvider; + _asyncQueryExecutor = AsyncQueryExecutorSupplier.GetAsyncQueryExecutor(_services, Items); + } + + var mustRefreshData = dataSourceHasChanged + || (Pagination?.GetHashCode() != _lastRefreshedPaginationStateHash); + + // We don't want to trigger the first data load until we've collected the initial set of columns, + // because they might perform some action like setting the default sort order, so it would be wasteful + // to have to re-query immediately + return (_columns.Count > 0 && mustRefreshData) ? RefreshDataCoreAsync() : Task.CompletedTask; + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + _jsEventDisposable = await _js.BitQuickGridInit(_tableReference); + } + + if (_checkColumnOptionsPosition && _displayOptionsForColumn is not null) + { + _checkColumnOptionsPosition = false; + _ = _js.BitQuickGridCheckColumnOptionsPosition(_tableReference); + } + } + + + + private void StartCollectingColumns() + { + _columns.Clear(); + _collectingColumns = true; + } + + private void FinishCollectingColumns() + { + _collectingColumns = false; + } + + // 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. + private async Task RefreshDataCoreAsync() + { + // Move into a "loading" state, cancelling any earlier-but-still-pending load + _pendingDataLoadCancellationTokenSource?.Cancel(); + var thisLoadCts = _pendingDataLoadCancellationTokenSource = new CancellationTokenSource(); + + if (_virtualizeComponent is not null) + { + // If we're using Virtualize, we have to go through its RefreshDataAsync API otherwise: + // (1) It won't know to update its own internal state if the provider output has changed + // (2) We won't know what slice of data to query for + await _virtualizeComponent.RefreshDataAsync(); + _pendingDataLoadCancellationTokenSource = null; + } + else + { + // If we're not using Virtualize, we build and execute a request against the items provider directly + _lastRefreshedPaginationStateHash = Pagination?.GetHashCode(); + var startIndex = Pagination is null ? 0 : (Pagination.CurrentPageIndex * Pagination.ItemsPerPage); + var request = new BitQuickGridItemsProviderRequest( + startIndex, Pagination?.ItemsPerPage, _sortByColumn, _sortByAscending, thisLoadCts.Token); + var result = await ResolveItemsRequestAsync(request); + if (!thisLoadCts.IsCancellationRequested) + { + _currentNonVirtualizedViewItems = result.Items; + _ariaBodyRowCount = _currentNonVirtualizedViewItems.Count; + Pagination?.SetTotalItemCountAsync(result.TotalItemCount); + _pendingDataLoadCancellationTokenSource = null; + } + } + } + + // Gets called both by RefreshDataCoreAsync and directly by the Virtualize child component during scrolling + private async ValueTask> ProvideVirtualizedItems(ItemsProviderRequest request) + { + _lastRefreshedPaginationStateHash = Pagination?.GetHashCode(); + + // Debounce the requests. This eliminates a lot of redundant queries at the cost of slight lag after interactions. + // TODO: Consider making this configurable, or smarter (e.g., doesn't delay on first call in a batch, then the amount + // of delay increases if you rapidly issue repeated requests, such as when scrolling a long way) + await Task.Delay(100); + if (request.CancellationToken.IsCancellationRequested) + { + return default; + } + + // Combine the query parameters from Virtualize with the ones from PaginationState + var startIndex = request.StartIndex; + var count = request.Count; + if (Pagination is not null) + { + startIndex += Pagination.CurrentPageIndex * Pagination.ItemsPerPage; + count = Math.Min(request.Count, Pagination.ItemsPerPage - request.StartIndex); + } + + var providerRequest = new BitQuickGridItemsProviderRequest( + startIndex, count, _sortByColumn, _sortByAscending, request.CancellationToken); + var providerResult = await ResolveItemsRequestAsync(providerRequest); + + if (!request.CancellationToken.IsCancellationRequested) + { + // ARIA's rowcount is part of the UI, so it should reflect what the human user regards as the number of rows in the table, + // not the number of physical elements. For virtualization this means what's in the entire scrollable range, not just + // the current viewport. In the case where you're also paginating then it means what's conceptually on the current page. + // TODO: This currently assumes we always want to expand the last page to have ItemsPerPage rows, but the experience might + // be better if we let the last page only be as big as its number of actual rows. + _ariaBodyRowCount = Pagination is null ? providerResult.TotalItemCount : Pagination.ItemsPerPage; + + Pagination?.SetTotalItemCountAsync(providerResult.TotalItemCount); + + // We're supplying the row index along with each row's data because we need it for aria-rowindex, and we have to account for + // the virtualized start index. It might be more performant just to have some _latestQueryRowStartIndex field, but we'd have + // to make sure it doesn't get out of sync with the rows being rendered. + return new ItemsProviderResult<(int, TGridItem)>( + items: providerResult.Items.Select((x, i) => ValueTuple.Create(i + request.StartIndex + 2, x)), + totalItemCount: _ariaBodyRowCount); + } + + return default; + } + + // Normalizes all the different ways of configuring a data source so they have common GridItemsProvider-shaped API + private async ValueTask> ResolveItemsRequestAsync(BitQuickGridItemsProviderRequest request) + { + if (ItemsProvider is not null) + { + return await ItemsProvider(request); + } + else if (Items is not null) + { + var totalItemCount = _asyncQueryExecutor is null ? Items.Count() : await _asyncQueryExecutor.CountAsync(Items); + var result = request.ApplySorting(Items).Skip(request.StartIndex); + if (request.Count.HasValue) + { + result = result.Take(request.Count.Value); + } + var resultArray = _asyncQueryExecutor is null ? result.ToArray() : await _asyncQueryExecutor.ToArrayAsync(result); + return BitQuickGridItemsProviderResult.From(resultArray, totalItemCount); + } + else + { + return BitQuickGridItemsProviderResult.From(Array.Empty(), 0); + } + } + + private string AriaSortValue(BitQuickGridColumnBase column) + => _sortByColumn == column + ? (_sortByAscending ? "ascending" : "descending") + : "none"; + + private string? ColumnHeaderClass(BitQuickGridColumnBase column) + => _sortByColumn == column + ? $"{ColumnClass(column)} {(_sortByAscending ? "bit-qkg-csa" : "bit-qkg-csd")}" + : ColumnClass(column); + + private string GridClass() + => $"bit-qkg {Class} {((IsLoading && LoadingTemplate is null) ? "loading" : null)}".Trim(); + + private void CloseColumnOptions() + { + _displayOptionsForColumn = null; + } + + private string? GetRowClass(TGridItem item) + { + var classes = new List(); + + if (RowClass is not null) + { + classes.Add(RowClass); + } + + if (RowClassSelector is not null) + { + classes.Add(RowClassSelector(item)); + } + + return classes.Any() ? string.Join(' ', classes) : null; + } + + private string? GetRowStyle(TGridItem item) + { + var styles = new List(); + + if (RowStyle is not null) + { + styles.Add(RowStyle); + } + + if (RowStyleSelector is not null) + { + styles.Add(RowStyleSelector(item)); + } + + return styles.Any() ? string.Join(';', styles) : null; + } + + + + private static string? ColumnClass(BitQuickGridColumnBase column) => column.Align switch + { + BitQuickGridAlign.Center => $"bit-qkg-cjc {column.Class}", + BitQuickGridAlign.Right => $"bit-qkg-cje {column.Class}", + _ => column.Class, + }; + + + + + /// + public async ValueTask DisposeAsync() + { + await DisposeAsync(true); + GC.SuppressFinalize(this); + } + + protected virtual async ValueTask DisposeAsync(bool disposing) + { + if (_disposed || disposing is false) return; + + _pendingDataLoadCancellationTokenSource?.Cancel(); + _pendingDataLoadCancellationTokenSource?.Dispose(); + + _currentPageItemsChanged.Dispose(); + + try + { + if (_jsEventDisposable is not null) + { + await _jsEventDisposable.InvokeVoidAsync("stop"); + await _jsEventDisposable.DisposeAsync(); + } + + //if (_jsModule is not null) + //{ + // await _jsModule.DisposeAsync(); + //} + } + catch (JSDisconnectedException) + { + // The JS side may routinely be gone already if the reason we're disposing is that + // the client disconnected. This is not an error. + } + catch (JSException ex) + { + // it seems it's safe to just ignore this exception here. + // otherwise it will blow up the MAUI app in a page refresh for example. + Console.WriteLine(ex.Message); + } + + _disposed = true; + } +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.scss b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.scss new file mode 100644 index 0000000000..ad3d541e97 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.scss @@ -0,0 +1,139 @@ +.bit-qkg { + width: 100%; + --bit-qkg-col-gap: 1rem; + + th { + position: relative; + } + + > thead > tr > th { + font-weight: normal; + } + + &.loading > tbody { + opacity: 0.25; + transition-delay: 25ms; + transition: opacity linear 100ms; + } + + > tbody > tr > td { + padding: 0.1rem calc(0.4rem + var(--bit-qkg-col-gap)) 0.1rem 0.4rem; + } +} + + +.bit-qkg-hct { + display: flex; + position: relative; + align-items: center; + padding-inline-end: var(--bit-qkg-col-gap); +} + +.bit-qkg-cop { + z-index: 1; + padding: 1rem; + border: 1px solid; + position: absolute; + inset-inline-start: 0; + border-color: var(--bit-clr-brd-pri); + background-color: var(--bit-clr-bg-sec); +} + +.bit-qkg-cob { + width: 1.5rem; + background: unset; + + &::before { + content: "\E712"; + font-style: normal; + font-weight: normal; + display: inline-block; + font-family: 'Fabric MDL2 bit BlazorUI Extras'; + } +} + +.bit-qkg-drg { + width: 1rem; + cursor: ew-resize; + position: absolute; + inset-block-end: 0; + inset-block-start: 0; + inset-inline-end: calc(var(--bit-qkg-col-gap)/2 - 0.5rem); + + &::after { + content: ' '; + position: absolute; + inset-block-end: 5px; + inset-block-start: 5px; + inset-inline-start: 0.5rem; + border-color: var(--bit-clr-brd-pri); + border-inline-start: 1px solid black; + } +} + +.bit-qkg-srt { + width: 1rem; + height: 1rem; + opacity: 0.5; + align-self: center; + text-align: center; +} + +.bit-qkg-csa .bit-qkg-srt::before, +.bit-qkg-csd .bit-qkg-srt::before { + content: "\E96F"; + font-style: normal; + font-weight: normal; + display: inline-block; + transform: rotate(90deg); + font-family: 'Fabric MDL2 bit BlazorUI Extras'; +} + +.bit-qkg-csd .bit-qkg-srt { + transform: scaleY(-1) translateY(-2px); +} + +.bit-qkg-ctl { + gap: 0.4rem; + flex-grow: 1; + display: flex; + min-width: 0px; + font-size: 1rem; + font-weight: bold; + padding: 0.1rem 0.4rem; +} + +button.bit-qkg-ctl { + border: none; + color: inherit; + cursor: pointer; + background: none; + position: relative; +} + +.bit-qkg-ctt { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.bit-qkg-cjc { + text-align: center; + + .bit-qkg-ctl { + justify-content: center; + } +} + +.bit-qkg-cje { + text-align: end; + + .bit-qkg-ctl { + flex-direction: row-reverse; + } +} + +.bit-qkg-plh::after { + opacity: 0.75; + content: '\2026'; +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.ts b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.ts new file mode 100644 index 0000000000..873dcd8d3f --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.ts @@ -0,0 +1,101 @@ +namespace BitBlazorUI { + export class QuickGrid { + public static init(tableElement: any) { + QuickGrid.enableColumnResizing(tableElement); + + const bodyClickHandler = (event: any) => { + const columnOptionsElement = tableElement.tHead.querySelector('.bit-qkg-cop'); + if (columnOptionsElement && event.composedPath().indexOf(columnOptionsElement) < 0) { + tableElement.dispatchEvent(new CustomEvent('closecolumnoptions', { bubbles: true })); + } + }; + const keyDownHandler = (event: any) => { + const columnOptionsElement = tableElement.tHead.querySelector('.bit-qkg-cop'); + if (columnOptionsElement && event.key === "Escape") { + tableElement.dispatchEvent(new CustomEvent('closecolumnoptions', { bubbles: true })); + } + }; + + document.body.addEventListener('click', bodyClickHandler); + document.body.addEventListener('mousedown', bodyClickHandler); // Otherwise it seems strange that it doesn't go away until you release the mouse button + document.body.addEventListener('keydown', keyDownHandler); + + return { + stop: () => { + document.body.removeEventListener('click', bodyClickHandler); + document.body.removeEventListener('mousedown', bodyClickHandler); + document.body.removeEventListener('keydown', keyDownHandler); + } + }; + } + + public static checkColumnOptionsPosition(tableElement: any) { + const colOptions = tableElement.tHead && tableElement.tHead.querySelector('.bit-qkg-cop'); // Only match within *our* thead, not nested tables + if (colOptions) { + // We want the options popup to be positioned over the grid, not overflowing on either side, because it's possible that + // beyond either side is off-screen or outside the scroll range of an ancestor + const gridRect = tableElement.getBoundingClientRect(); + const optionsRect = colOptions.getBoundingClientRect(); + const leftOverhang = Math.max(0, gridRect.left - optionsRect.left); + const rightOverhang = Math.max(0, optionsRect.right - gridRect.right); + if (leftOverhang || rightOverhang) { + // In the unlikely event that it overhangs both sides, we'll center it + const applyOffset = leftOverhang && rightOverhang ? (leftOverhang - rightOverhang) / 2 : (leftOverhang - rightOverhang); + colOptions.style.transform = `translateX(${applyOffset}px)`; + } + + colOptions.scrollIntoViewIfNeeded(); + + const autoFocusElem = colOptions.querySelector('[autofocus]'); + if (autoFocusElem) { + autoFocusElem.focus(); + } + } + } + + private static enableColumnResizing(tableElement: any) { + tableElement.tHead.querySelectorAll('.bit-qkg-drg').forEach((handle: any) => { + handle.addEventListener('mousedown', handleMouseDown); + if ('ontouchstart' in window) { + handle.addEventListener('touchstart', handleMouseDown); + } + + function handleMouseDown(evt: any) { + evt.preventDefault(); + evt.stopPropagation(); + + const th = handle.parentElement; + const startPageX = evt.touches ? evt.touches[0].pageX : evt.pageX; + const originalColumnWidth = th.offsetWidth; + const rtlMultiplier = window.getComputedStyle(th, null).getPropertyValue('direction') === 'rtl' ? -1 : 1; + let updatedColumnWidth = 0; + + function handleMouseMove(evt: any) { + evt.stopPropagation(); + const newPageX = evt.touches ? evt.touches[0].pageX : evt.pageX; + const nextWidth = originalColumnWidth + (newPageX - startPageX) * rtlMultiplier; + if (Math.abs(nextWidth - updatedColumnWidth) > 0) { + updatedColumnWidth = nextWidth; + th.style.width = `${updatedColumnWidth}px`; + } + } + + function handleMouseUp() { + document.body.removeEventListener('mousemove', handleMouseMove); + document.body.removeEventListener('mouseup', handleMouseUp); + document.body.removeEventListener('touchmove', handleMouseMove); + document.body.removeEventListener('touchend', handleMouseUp); + } + + if (window.TouchEvent && evt instanceof TouchEvent) { + document.body.addEventListener('touchmove', handleMouseMove, { passive: true }); + document.body.addEventListener('touchend', handleMouseUp, { passive: true }); + } else { + document.body.addEventListener('mousemove', handleMouseMove, { passive: true }); + document.body.addEventListener('mouseup', handleMouseUp, { passive: true }); + } + } + }); + } + } +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGridJsRuntimeExtensions.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGridJsRuntimeExtensions.cs new file mode 100644 index 0000000000..dca4e28a30 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGridJsRuntimeExtensions.cs @@ -0,0 +1,14 @@ +namespace Bit.BlazorUI; + +internal static class BitQuickGridJsRuntimeExtensions +{ + public static async ValueTask BitQuickGridInit(this IJSRuntime jsRuntime, ElementReference tableElement) + { + return await jsRuntime.Invoke("BitBlazorUI.QuickGrid.init", tableElement); + } + + public static async ValueTask BitQuickGridCheckColumnOptionsPosition(this IJSRuntime jsRuntime, ElementReference tableElement) + { + await jsRuntime.InvokeVoid("BitBlazorUI.QuickGrid.checkColumnOptionsPosition", tableElement); + } +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridRowTemplateArgs.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGridRowTemplateArgs.cs similarity index 82% rename from src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridRowTemplateArgs.cs rename to src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGridRowTemplateArgs.cs index 724790881d..5118b94612 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridRowTemplateArgs.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGridRowTemplateArgs.cs @@ -1,10 +1,10 @@ -namespace Bit.BlazorUI; +namespace Bit.BlazorUI; /// -/// Arguments passed to the render fragment. +/// Arguments passed to the render fragment. /// /// The type of data represented by each row in the grid. -public class BitDataGridRowTemplateArgs +public class BitQuickGridRowTemplateArgs { /// /// A render fragment that produces the original row markup (the default <tr> with all column cells). diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridSortDirection.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGridSortDirection.cs similarity index 58% rename from src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridSortDirection.cs rename to src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGridSortDirection.cs index f3e1661cad..0136b25a3b 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridSortDirection.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGridSortDirection.cs @@ -1,9 +1,9 @@ -namespace Bit.BlazorUI; +namespace Bit.BlazorUI; /// -/// Describes the direction in which a column is sorted. +/// Describes the direction in which a column is sorted. /// -public enum BitDataGridSortDirection +public enum BitQuickGridSortDirection { /// /// Ascending order. @@ -16,7 +16,7 @@ public enum BitDataGridSortDirection Descending, /// - /// Automatic sort order. When used with , + /// Automatic sort order. When used with , /// the sort order will automatically toggle between and on successive calls, and /// resets to whenever the specified column is changed. /// diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Columns/BitDataGridAlign.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridAlign.cs similarity index 73% rename from src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Columns/BitDataGridAlign.cs rename to src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridAlign.cs index c5e0a87a5d..f71d0eb676 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Columns/BitDataGridAlign.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridAlign.cs @@ -1,9 +1,9 @@ -namespace Bit.BlazorUI; +namespace Bit.BlazorUI; /// -/// Describes alignment for a column. +/// Describes alignment for a column. /// -public enum BitDataGridAlign +public enum BitQuickGridAlign { /// /// Justifies the content against the start of the container. diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Columns/BitDataGridColumnBase.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridColumnBase.razor similarity index 73% rename from src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Columns/BitDataGridColumnBase.razor rename to src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridColumnBase.razor index 06932f2bf3..ff912cf56b 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Columns/BitDataGridColumnBase.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridColumnBase.razor @@ -23,28 +23,28 @@ /// /// If specified, controls the justification of table header and body cells for this column. /// - [Parameter] public BitDataGridAlign Align { get; set; } + [Parameter] public BitQuickGridAlign Align { get; set; } /// /// An optional template for this column's header cell. If not specified, the default header template /// includes the along with any applicable sort indicators and options buttons. /// - [Parameter] public RenderFragment>? HeaderTemplate { get; set; } + [Parameter] public RenderFragment>? HeaderTemplate { get; set; } /// /// If specified, indicates that this column has this associated options UI. A button to display this /// UI will be included in the header cell by default. /// /// If is used, it is left up to that template to render any relevant - /// "show options" UI and invoke the grid's ). + /// "show options" UI and invoke the grid's ). /// [Parameter] public RenderFragment? ColumnOptions { get; set; } /// /// Indicates whether the data should be sortable by this column. /// - /// The default value may vary according to the column type (for example, a - /// is sortable by default if any parameter is specified). + /// The default value may vary according to the column type (for example, a + /// is sortable by default if any parameter is specified). /// [Parameter] public bool? Sortable { get; set; } @@ -52,7 +52,7 @@ /// If specified and not null, indicates that this column represents the initial sort order /// for the grid. The supplied value controls the default sort direction. /// - [Parameter] public BitDataGridSortDirection? IsDefaultSort { get; set; } + [Parameter] public BitQuickGridSortDirection? IsDefaultSort { get; set; } /// /// If specified, virtualized grids will use this template to render cells whose data has not yet been loaded. @@ -60,9 +60,9 @@ [Parameter] public RenderFragment? PlaceholderTemplate { get; set; } /// - /// Gets a reference to the enclosing . + /// Gets a reference to the enclosing . /// - public BitDataGrid Grid => InternalGridContext.Grid; + public BitQuickGrid Grid => InternalGridContext.Grid; /// /// Overridden by derived components to provide rendering logic for the column's cells. @@ -81,8 +81,8 @@ /// /// Get a value indicating whether this column should act as sortable if no value was set for the - /// parameter. The default behavior is not to be - /// sortable unless is true. + /// parameter. The default behavior is not to be + /// sortable unless is true. /// /// Derived components may override this to implement alternative default sortability rules. /// @@ -90,9 +90,9 @@ protected virtual bool IsSortableByDefault() => false; /// - /// Constructs an instance of . + /// Constructs an instance of . /// - public BitDataGridColumnBase() + public BitQuickGridColumnBase() { HeaderContent = RenderDefaultHeaderContent; } @@ -105,28 +105,28 @@ } else { - @if (ColumnOptions is not null && Align != BitDataGridAlign.Right) + @if (ColumnOptions is not null && Align != BitQuickGridAlign.Right) { - + } if (Sortable.HasValue ? Sortable.Value : IsSortableByDefault()) { - } else { -
-
@Title
+
+
@Title
} - @if (ColumnOptions is not null && Align == BitDataGridAlign.Right) + @if (ColumnOptions is not null && Align == BitQuickGridAlign.Right) { - + } } } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridColumnBase.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridColumnBase.razor.cs new file mode 100644 index 0000000000..d0dc409a5f --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridColumnBase.razor.cs @@ -0,0 +1,9 @@ +namespace Bit.BlazorUI; + +/// +/// An abstract base class for columns in a . +/// +/// The type of data represented by each row in the grid. +public abstract partial class BitQuickGridColumnBase +{ +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Columns/BitDataGridPropertyColumn.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridPropertyColumn.cs similarity index 81% rename from src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Columns/BitDataGridPropertyColumn.cs rename to src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridPropertyColumn.cs index 3fe85ed0bc..dd743b2f6b 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Columns/BitDataGridPropertyColumn.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridPropertyColumn.cs @@ -1,17 +1,17 @@ -using System.Linq.Expressions; +using System.Linq.Expressions; namespace Bit.BlazorUI; /// -/// Represents a column whose cells display a single value. +/// Represents a column whose cells display a single value. /// /// The type of data represented by each row in the grid. /// The type of the value being displayed in the column's cells. -public class BitDataGridPropertyColumn : BitDataGridColumnBase, IBitDataGridSortBuilderColumn +public class BitQuickGridPropertyColumn : BitQuickGridColumnBase, IBitQuickGridSortBuilderColumn { private Expression>? _lastAssignedProperty; private Func? _cellTextFunc; - private BitDataGridSort? _sortBuilder; + private BitQuickGridSort? _sortBuilder; /// /// Defines the value to be displayed in this column's cells. @@ -25,7 +25,7 @@ public class BitDataGridPropertyColumn : BitDataGridColumnBase /// [Parameter] public string? Format { get; set; } - BitDataGridSort? IBitDataGridSortBuilderColumn.SortBuilder => _sortBuilder; + BitQuickGridSort? IBitQuickGridSortBuilderColumn.SortBuilder => _sortBuilder; /// @@ -54,7 +54,7 @@ protected override void OnParametersSet() _cellTextFunc = item => compiledPropertyExpression!(item)?.ToString(); } - _sortBuilder = BitDataGridSort.ByAscending(Property); + _sortBuilder = BitQuickGridSort.ByAscending(Property); } if (Title is null && Property.Body is MemberExpression memberExpression) diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Columns/BitDataGridSort.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridSort.cs similarity index 68% rename from src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Columns/BitDataGridSort.cs rename to src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridSort.cs index 1c6b7c8a2c..6fa34588af 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Columns/BitDataGridSort.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridSort.cs @@ -1,12 +1,12 @@ -using System.Linq.Expressions; +using System.Linq.Expressions; namespace Bit.BlazorUI; /// -/// Represents a sort order specification used within . +/// Represents a sort order specification used within . /// /// The type of data represented by each row in the grid. -public class BitDataGridSort +public class BitQuickGridSort { private const string ExpressionNotRepresentableMessage = "The supplied expression can't be represented as a property name for sorting. Only simple member expressions, such as @(x => x.SomeProperty), can be converted to property names."; @@ -16,10 +16,10 @@ public class BitDataGridSort private (LambdaExpression, bool) _firstExpression; private List<(LambdaExpression, bool)>? _thenExpressions; - private IReadOnlyCollection<(string PropertyName, BitDataGridSortDirection Direction)>? _cachedPropertyListAscending; - private IReadOnlyCollection<(string PropertyName, BitDataGridSortDirection Direction)>? _cachedPropertyListDescending; + private IReadOnlyCollection<(string PropertyName, BitQuickGridSortDirection Direction)>? _cachedPropertyListAscending; + private IReadOnlyCollection<(string PropertyName, BitQuickGridSortDirection Direction)>? _cachedPropertyListDescending; - internal BitDataGridSort(Func, bool, IOrderedQueryable> first, (LambdaExpression, bool) firstExpression) + internal BitQuickGridSort(Func, bool, IOrderedQueryable> first, (LambdaExpression, bool) firstExpression) { _first = first; _firstExpression = firstExpression; @@ -28,32 +28,32 @@ internal BitDataGridSort(Func, bool, IOrderedQueryable - /// Produces a instance that sorts according to the specified , ascending. + /// Produces a instance that sorts according to the specified , ascending. ///
/// The type of the expression's value. /// An expression defining how a set of instances are to be sorted. - /// A instance representing the specified sorting rule. - public static BitDataGridSort ByAscending(Expression> expression) - => new BitDataGridSort((queryable, asc) => asc ? queryable.OrderBy(expression) : queryable.OrderByDescending(expression), + /// A instance representing the specified sorting rule. + public static BitQuickGridSort ByAscending(Expression> expression) + => new BitQuickGridSort((queryable, asc) => asc ? queryable.OrderBy(expression) : queryable.OrderByDescending(expression), (expression, true)); /// - /// Produces a instance that sorts according to the specified , descending. + /// Produces a instance that sorts according to the specified , descending. /// /// The type of the expression's value. /// An expression defining how a set of instances are to be sorted. - /// A instance representing the specified sorting rule. - public static BitDataGridSort ByDescending(Expression> expression) - => new BitDataGridSort((queryable, asc) => asc ? queryable.OrderByDescending(expression) : queryable.OrderBy(expression), + /// A instance representing the specified sorting rule. + public static BitQuickGridSort ByDescending(Expression> expression) + => new BitQuickGridSort((queryable, asc) => asc ? queryable.OrderByDescending(expression) : queryable.OrderBy(expression), (expression, false)); /// - /// Updates a instance by appending a further sorting rule. + /// Updates a instance by appending a further sorting rule. /// /// The type of the expression's value. /// An expression defining how a set of instances are to be sorted. - /// A instance representing the specified sorting rule. - public BitDataGridSort ThenAscending(Expression> expression) + /// A instance representing the specified sorting rule. + public BitQuickGridSort ThenAscending(Expression> expression) { _then ??= new(); _thenExpressions ??= new(); @@ -65,12 +65,12 @@ public BitDataGridSort ThenAscending(Expression } /// - /// Updates a instance by appending a further sorting rule. + /// Updates a instance by appending a further sorting rule. /// /// The type of the expression's value. /// An expression defining how a set of instances are to be sorted. - /// A instance representing the specified sorting rule. - public BitDataGridSort ThenDescending(Expression> expression) + /// A instance representing the specified sorting rule. + public BitQuickGridSort ThenDescending(Expression> expression) { _then ??= new(); _thenExpressions ??= new(); @@ -96,7 +96,7 @@ internal IOrderedQueryable Apply(IQueryable queryable, boo return orderedQueryable; } - internal IReadOnlyCollection<(string PropertyName, BitDataGridSortDirection Direction)> ToPropertyList(bool ascending) + internal IReadOnlyCollection<(string PropertyName, BitQuickGridSortDirection Direction)> ToPropertyList(bool ascending) { if (ascending) { @@ -110,16 +110,16 @@ internal IOrderedQueryable Apply(IQueryable queryable, boo } } - private IReadOnlyCollection<(string PropertyName, BitDataGridSortDirection Direction)> BuildPropertyList(bool ascending) + private IReadOnlyCollection<(string PropertyName, BitQuickGridSortDirection Direction)> BuildPropertyList(bool ascending) { - var result = new List<(string, BitDataGridSortDirection)>(); - result.Add((ToPropertyName(_firstExpression.Item1), (_firstExpression.Item2 ^ ascending) ? BitDataGridSortDirection.Descending : BitDataGridSortDirection.Ascending)); + var result = new List<(string, BitQuickGridSortDirection)>(); + result.Add((ToPropertyName(_firstExpression.Item1), (_firstExpression.Item2 ^ ascending) ? BitQuickGridSortDirection.Descending : BitQuickGridSortDirection.Ascending)); if (_thenExpressions is not null) { foreach (var (thenLambda, thenAscending) in _thenExpressions) { - result.Add((ToPropertyName(thenLambda), (thenAscending ^ ascending) ? BitDataGridSortDirection.Descending : BitDataGridSortDirection.Ascending)); + result.Add((ToPropertyName(thenLambda), (thenAscending ^ ascending) ? BitQuickGridSortDirection.Descending : BitQuickGridSortDirection.Ascending)); } } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Columns/BitDataGridTemplateColumn.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridTemplateColumn.cs similarity index 65% rename from src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Columns/BitDataGridTemplateColumn.cs rename to src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridTemplateColumn.cs index 2e7cb1f594..5f75dfc146 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Columns/BitDataGridTemplateColumn.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridTemplateColumn.cs @@ -1,10 +1,10 @@ -namespace Bit.BlazorUI; +namespace Bit.BlazorUI; /// -/// Represents a column whose cells render a supplied template. +/// Represents a column whose cells render a supplied template. /// /// The type of data represented by each row in the grid. -public class BitDataGridTemplateColumn : BitDataGridColumnBase, IBitDataGridSortBuilderColumn +public class BitQuickGridTemplateColumn : BitQuickGridColumnBase, IBitQuickGridSortBuilderColumn { private readonly static RenderFragment EmptyChildContent = _ => builder => { }; @@ -16,9 +16,9 @@ public class BitDataGridTemplateColumn : BitDataGridColumnBase /// Optionally specifies sorting rules for this column. ///
- [Parameter] public BitDataGridSort? SortBy { get; set; } + [Parameter] public BitQuickGridSort? SortBy { get; set; } - BitDataGridSort? IBitDataGridSortBuilderColumn.SortBuilder => SortBy; + BitQuickGridSort? IBitQuickGridSortBuilderColumn.SortBuilder => SortBy; /// protected internal override void CellContent(RenderTreeBuilder builder, TGridItem item) diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/IBitQuickGridSortBuilderColumn.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/IBitQuickGridSortBuilderColumn.cs new file mode 100644 index 0000000000..fee3035a48 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/IBitQuickGridSortBuilderColumn.cs @@ -0,0 +1,18 @@ +namespace Bit.BlazorUI; + +/// +/// An interface that, if implemented by a subclass, allows a +/// to understand the sorting rules associated with that column. +/// +/// If a subclass does not implement this, that column can still be marked as sortable and can +/// be the current sort column, but its sorting logic cannot be applied to the data queries automatically. The developer would be +/// responsible for implementing that sorting logic separately inside their . +/// +/// The type of data represented by each row in the grid. +public interface IBitQuickGridSortBuilderColumn +{ + /// + /// Gets the sorting rules associated with the column. + /// + public BitQuickGridSort? SortBuilder { get; } +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/AsyncQueryExecutorSupplier.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Infrastructure/AsyncQueryExecutorSupplier.cs similarity index 91% rename from src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/AsyncQueryExecutorSupplier.cs rename to src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Infrastructure/AsyncQueryExecutorSupplier.cs index 1646a3fd0c..8a4a5693c6 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/AsyncQueryExecutorSupplier.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Infrastructure/AsyncQueryExecutorSupplier.cs @@ -1,4 +1,4 @@ -using System.Collections.Concurrent; +using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.DependencyInjection; @@ -9,7 +9,7 @@ internal static class AsyncQueryExecutorSupplier // The primary goal with this is to ensure that: // - If you're using EF Core, then we resolve queries efficiently using its ToXyzAsync async extensions and don't // just fall back on the synchronous IQueryable ToXyz calls - // - ... but without BitDataGrid referencing Microsoft.EntityFramework directly. That's because it would bring in + // - ... but without BitQuickGrid referencing Microsoft.EntityFramework directly. That's because it would bring in // heavy dependencies you may not be using (and relying on trimming isn't enough, as it's still desirable to have // heavy unused dependencies for Blazor Server). // @@ -33,7 +33,7 @@ internal static class AsyncQueryExecutorSupplier var providerType = queryable.Provider?.GetType(); if (providerType is not null && IsEntityFrameworkProviderTypeCache.GetOrAdd(providerType, IsEntityFrameworkProviderType)) { - throw new InvalidOperationException($"The supplied {nameof(IQueryable)} is provided by Entity Framework. To query it efficiently, you must reference the package Microsoft.AspNetCore.Components.BitDataGrid.EntityFrameworkAdapter and call AddBitDataGridEntityFrameworkAdapter on your service collection."); + throw new InvalidOperationException($"The supplied {nameof(IQueryable)} is provided by Entity Framework. To query it efficiently, you must reference the package Microsoft.AspNetCore.Components.BitQuickGrid.EntityFrameworkAdapter and call AddBitQuickGridEntityFrameworkAdapter on your service collection."); } } else if (executor.IsSupported(queryable)) diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridColumnsCollectedNotifier.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Infrastructure/BitQuickGridColumnsCollectedNotifier.cs similarity index 76% rename from src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridColumnsCollectedNotifier.cs rename to src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Infrastructure/BitQuickGridColumnsCollectedNotifier.cs index 2cee8d2cc2..9f9d1b21a9 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridColumnsCollectedNotifier.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Infrastructure/BitQuickGridColumnsCollectedNotifier.cs @@ -1,6 +1,6 @@ -namespace Bit.BlazorUI; +namespace Bit.BlazorUI; -// One awkwardness of the way BitDataGrid collects its list of child columns is that, during OnParametersSetAsync, +// One awkwardness of the way BitQuickGrid collects its list of child columns is that, during OnParametersSetAsync, // it only knows about the set of columns that were present on the *previous* render. If it's going to trigger a // data load during OnParametersSetAsync, that operation can't depend on the current set of columns as it might // have changed, or might even still be empty (i.e., on the first render). @@ -9,10 +9,10 @@ // // - In the future, we could implement the long-wanted feature of being able to query the contents of a RenderFragment // separately from rendering. Then the whole trick of collection-during-rendering would not be needed. -// - Or, we could factor out most of BitDataGrid's internals into some new component BitDataGridCore. The parent component, -// BitDataGrid, would then only be responsible for collecting columns followed by rendering BitDataGridCore. So each time -// BitDataGridCore renders, we'd already have the latest set of columns -// - Drawback: since BitDataGrid has public API, it's much messier to have to forward all of that to some new child type. +// - Or, we could factor out most of BitQuickGrid's internals into some new component BitQuickGridCore. The parent component, +// BitQuickGrid, would then only be responsible for collecting columns followed by rendering BitQuickGridCore. So each time +// BitQuickGridCore renders, we'd already have the latest set of columns +// - Drawback: since BitQuickGrid has public API, it's much messier to have to forward all of that to some new child type. // - However, this is arguably the most correct solution in general (at least until option 1 above is implemented) // - Or, we could decide it's enough to fix this on the first render (since that's the only time we're going to guarantee // to apply a default sort order), and then as a special case put in some extra component in the render flow that raises @@ -27,7 +27,7 @@ /// For internal use only. Do not use. ///
/// For internal use only. Do not use. -public class BitDataGridColumnsCollectedNotifier : IComponent +public class BitQuickGridColumnsCollectedNotifier : IComponent { private bool _isFirstRender = true; diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridDefer.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Infrastructure/BitQuickGridDefer.cs similarity index 74% rename from src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridDefer.cs rename to src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Infrastructure/BitQuickGridDefer.cs index 89863aa00f..bb63e0958d 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridDefer.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Infrastructure/BitQuickGridDefer.cs @@ -1,12 +1,12 @@ -namespace Bit.BlazorUI; +namespace Bit.BlazorUI; -// This is used by BitDataGrid to move its body rendering to the end of the render queue so we can collect +// This is used by BitQuickGrid to move its body rendering to the end of the render queue so we can collect // the list of child columns first. It has to be public only because it's used from .razor logic. /// /// For internal use only. Do not use. /// -public class BitDataGridDefer : ComponentBase +public class BitQuickGridDefer : ComponentBase { /// /// For internal use only. Do not use. diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/EventCallbackSubscribable.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Infrastructure/EventCallbackSubscribable.cs similarity index 97% rename from src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/EventCallbackSubscribable.cs rename to src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Infrastructure/EventCallbackSubscribable.cs index 8eef149125..3e54e76c25 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/EventCallbackSubscribable.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Infrastructure/EventCallbackSubscribable.cs @@ -1,4 +1,4 @@ -namespace Bit.BlazorUI; +namespace Bit.BlazorUI; /// /// Represents an event that you may subscribe to. This differs from normal C# events in that the handlers diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/EventCallbackSubscriber.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Infrastructure/EventCallbackSubscriber.cs similarity index 98% rename from src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/EventCallbackSubscriber.cs rename to src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Infrastructure/EventCallbackSubscriber.cs index 21e6df218e..7dfe8827d1 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/EventCallbackSubscriber.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Infrastructure/EventCallbackSubscriber.cs @@ -1,4 +1,4 @@ -namespace Bit.BlazorUI; +namespace Bit.BlazorUI; /// /// Represents a subscriber that may be subscribe to an . diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/EventHandlers.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Infrastructure/EventHandlers.cs similarity index 64% rename from src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/EventHandlers.cs rename to src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Infrastructure/EventHandlers.cs index 786cbac454..22482feff7 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/EventHandlers.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Infrastructure/EventHandlers.cs @@ -1,7 +1,7 @@ -namespace Bit.BlazorUI; +namespace Bit.BlazorUI; /// -/// Configures event handlers for . +/// Configures event handlers for . /// [EventHandler("onclosecolumnoptions", typeof(EventArgs), enableStopPropagation: true, enablePreventDefault: true)] public static class EventHandlers diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/IAsyncQueryExecutor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Infrastructure/IAsyncQueryExecutor.cs similarity index 98% rename from src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/IAsyncQueryExecutor.cs rename to src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Infrastructure/IAsyncQueryExecutor.cs index 2079609e64..62cc08de3e 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/IAsyncQueryExecutor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Infrastructure/IAsyncQueryExecutor.cs @@ -1,4 +1,4 @@ -namespace Bit.BlazorUI; +namespace Bit.BlazorUI; /// /// Provides methods for asynchronous evaluation of queries against an . diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/InternalGridContext.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Infrastructure/InternalGridContext.cs similarity index 71% rename from src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/InternalGridContext.cs rename to src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Infrastructure/InternalGridContext.cs index 6b862e9bb2..1c66112664 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/InternalGridContext.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Infrastructure/InternalGridContext.cs @@ -1,13 +1,13 @@ -namespace Bit.BlazorUI; +namespace Bit.BlazorUI; // The grid cascades this so that descendant columns can talk back to it. It's an internal type // so that it doesn't show up by mistake in unrelated components. internal class InternalGridContext { - public BitDataGrid Grid { get; } + public BitQuickGrid Grid { get; } public EventCallbackSubscribable ColumnsFirstCollected { get; } = new(); - public InternalGridContext(BitDataGrid grid) + public InternalGridContext(BitQuickGrid grid) { Grid = grid; } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/ItemsProvider/BitQuickGridItemsProvider.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/ItemsProvider/BitQuickGridItemsProvider.cs new file mode 100644 index 0000000000..15365486e7 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/ItemsProvider/BitQuickGridItemsProvider.cs @@ -0,0 +1,9 @@ +namespace Bit.BlazorUI; + +/// +/// A callback that provides data for a . +/// +/// The type of data represented by each row in the grid. +/// Parameters describing the data being requested. +/// A that gives the data to be displayed. +public delegate ValueTask> BitQuickGridItemsProvider(BitQuickGridItemsProviderRequest request); diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/ItemsProvider/BitDataGridItemsProviderRequest.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/ItemsProvider/BitQuickGridItemsProviderRequest.cs similarity index 68% rename from src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/ItemsProvider/BitDataGridItemsProviderRequest.cs rename to src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/ItemsProvider/BitQuickGridItemsProviderRequest.cs index dc1d99cd98..c6a307167c 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/ItemsProvider/BitDataGridItemsProviderRequest.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/ItemsProvider/BitQuickGridItemsProviderRequest.cs @@ -1,10 +1,10 @@ -namespace Bit.BlazorUI; +namespace Bit.BlazorUI; /// -/// Parameters for data to be supplied by a 's . +/// Parameters for data to be supplied by a 's . /// /// The type of data represented by each row in the grid. -public struct BitDataGridItemsProviderRequest +public struct BitQuickGridItemsProviderRequest { /// /// The zero-based index of the first item to be supplied. @@ -22,7 +22,7 @@ public struct BitDataGridItemsProviderRequest /// Rather than inferring the sort rules manually, you should normally call either /// or , since they also account for and automatically. /// - public BitDataGridColumnBase? SortByColumn { get; } + public BitQuickGridColumnBase? SortByColumn { get; } /// /// Specifies the current sort direction. @@ -37,8 +37,8 @@ public struct BitDataGridItemsProviderRequest /// public CancellationToken CancellationToken { get; } - internal BitDataGridItemsProviderRequest( - int startIndex, int? count, BitDataGridColumnBase? sortByColumn, bool sortByAscending, + internal BitQuickGridItemsProviderRequest( + int startIndex, int? count, BitQuickGridColumnBase? sortByColumn, bool sortByAscending, CancellationToken cancellationToken) { StartIndex = startIndex; @@ -51,14 +51,14 @@ internal BitDataGridItemsProviderRequest( /// /// Applies the request's sorting rules to the supplied . /// - /// Note that this only works if the current implements , + /// Note that this only works if the current implements , /// otherwise it will throw. /// /// An . /// A new representing the with sorting rules applied. public IQueryable ApplySorting(IQueryable source) => SortByColumn switch { - IBitDataGridSortBuilderColumn sbc => sbc.SortBuilder?.Apply(source, SortByAscending) ?? source, + IBitQuickGridSortBuilderColumn sbc => sbc.SortBuilder?.Apply(source, SortByAscending) ?? source, null => source, _ => throw new NotSupportedException(ColumnNotSortableMessage(SortByColumn)), }; @@ -66,17 +66,17 @@ internal BitDataGridItemsProviderRequest( /// /// Produces a collection of (property name, direction) pairs representing the sorting rules. /// - /// Note that this only works if the current implements , + /// Note that this only works if the current implements , /// otherwise it will throw. /// /// A collection of (property name, direction) pairs representing the sorting rules - public IReadOnlyCollection<(string PropertyName, BitDataGridSortDirection Direction)> GetSortByProperties() => SortByColumn switch + public IReadOnlyCollection<(string PropertyName, BitQuickGridSortDirection Direction)> GetSortByProperties() => SortByColumn switch { - IBitDataGridSortBuilderColumn sbc => sbc.SortBuilder?.ToPropertyList(SortByAscending) ?? Array.Empty<(string, BitDataGridSortDirection)>(), - null => Array.Empty<(string, BitDataGridSortDirection)>(), + IBitQuickGridSortBuilderColumn sbc => sbc.SortBuilder?.ToPropertyList(SortByAscending) ?? Array.Empty<(string, BitQuickGridSortDirection)>(), + null => Array.Empty<(string, BitQuickGridSortDirection)>(), _ => throw new NotSupportedException(ColumnNotSortableMessage(SortByColumn)), }; - private static string ColumnNotSortableMessage(BitDataGridColumnBase col) - => $"The current sort column is of type '{col.GetType().FullName}', which does not implement {nameof(IBitDataGridSortBuilderColumn)}, so its sorting rules cannot be applied automatically."; + private static string ColumnNotSortableMessage(BitQuickGridColumnBase col) + => $"The current sort column is of type '{col.GetType().FullName}', which does not implement {nameof(IBitQuickGridSortBuilderColumn)}, so its sorting rules cannot be applied automatically."; } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/ItemsProvider/BitDataGridItemsProviderResult.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/ItemsProvider/BitQuickGridItemsProviderResult.cs similarity index 60% rename from src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/ItemsProvider/BitDataGridItemsProviderResult.cs rename to src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/ItemsProvider/BitQuickGridItemsProviderResult.cs index 2e2599c827..97431d0e01 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/ItemsProvider/BitDataGridItemsProviderResult.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/ItemsProvider/BitQuickGridItemsProviderResult.cs @@ -1,10 +1,10 @@ -namespace Bit.BlazorUI; +namespace Bit.BlazorUI; /// -/// Holds data being supplied to a 's . +/// Holds data being supplied to a 's . /// /// The type of data represented by each row in the grid. -public struct BitDataGridItemsProviderResult +public struct BitQuickGridItemsProviderResult { /// /// The items being supplied. @@ -20,11 +20,11 @@ public struct BitDataGridItemsProviderResult public int TotalItemCount { get; set; } /// - /// Constructs an instance of . + /// Constructs an instance of . /// /// The items being supplied. /// The total number of items that exist. See for details. - public BitDataGridItemsProviderResult(ICollection items, int totalItemCount) + public BitQuickGridItemsProviderResult(ICollection items, int totalItemCount) { Items = items; TotalItemCount = totalItemCount; @@ -32,18 +32,18 @@ public BitDataGridItemsProviderResult(ICollection items, int totalIte } /// -/// Provides convenience methods for constructing instances. +/// Provides convenience methods for constructing instances. /// -public static class BitDataGridItemsProviderResult +public static class BitQuickGridItemsProviderResult { // This is just to provide generic type inference, so you don't have to specify TGridItem yet again. /// - /// Supplies an instance of . + /// Supplies an instance of . /// /// The type of data represented by each row in the grid. /// The items being supplied. - /// The total number of items that exist. See for details. - /// An instance of . - public static BitDataGridItemsProviderResult From(ICollection items, int totalItemCount) => new(items, totalItemCount); + /// The total number of items that exist. See for details. + /// An instance of . + public static BitQuickGridItemsProviderResult From(ICollection items, int totalItemCount) => new(items, totalItemCount); } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Pagination/BitDataGridPaginationState.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Pagination/BitQuickGridPaginationState.cs similarity index 81% rename from src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Pagination/BitDataGridPaginationState.cs rename to src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Pagination/BitQuickGridPaginationState.cs index 05b66e352e..341736b798 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Pagination/BitDataGridPaginationState.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Pagination/BitQuickGridPaginationState.cs @@ -1,9 +1,9 @@ -namespace Bit.BlazorUI; +namespace Bit.BlazorUI; /// -/// Holds state to represent pagination in a . +/// Holds state to represent pagination in a . /// -public class BitDataGridPaginationState +public class BitQuickGridPaginationState { /// /// Gets the current zero-based page index. To set it, call . @@ -22,7 +22,7 @@ public class BitDataGridPaginationState /// /// Gets the total number of items across all pages, if known. The value will be null until an - /// associated assigns a value after loading data. + /// associated assigns a value after loading data. /// public int? TotalItemCount { get; private set; } @@ -31,15 +31,15 @@ public class BitDataGridPaginationState /// public event EventHandler? TotalItemCountChanged; - internal EventCallbackSubscribable CurrentPageItemsChanged { get; } = new(); - internal EventCallbackSubscribable TotalItemCountChangedSubscribable { get; } = new(); + internal EventCallbackSubscribable CurrentPageItemsChanged { get; } = new(); + internal EventCallbackSubscribable TotalItemCountChangedSubscribable { get; } = new(); /// public override int GetHashCode() => HashCode.Combine(ItemsPerPage, CurrentPageIndex, TotalItemCount); /// - /// Sets the current page index, and notifies any associated + /// Sets the current page index, and notifies any associated /// to fetch and render updated data. /// /// The new, zero-based page index. @@ -50,7 +50,7 @@ public Task SetCurrentPageIndexAsync(int pageIndex) return CurrentPageItemsChanged.InvokeCallbacksAsync(this); } - // Can be internal because this only needs to be called by BitDataGrid itself, not any custom pagination UI components. + // Can be internal because this only needs to be called by BitQuickGrid itself, not any custom pagination UI components. internal Task SetTotalItemCountAsync(int totalItemCount) { if (totalItemCount == TotalItemCount) diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Pagination/BitDataGridPaginator.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Pagination/BitQuickGridPaginator.razor similarity index 100% rename from src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Pagination/BitDataGridPaginator.razor rename to src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Pagination/BitQuickGridPaginator.razor diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Pagination/BitDataGridPaginator.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Pagination/BitQuickGridPaginator.razor.cs similarity index 69% rename from src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Pagination/BitDataGridPaginator.razor.cs rename to src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Pagination/BitQuickGridPaginator.razor.cs index 140c9a2847..eaa26867cd 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Pagination/BitDataGridPaginator.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Pagination/BitQuickGridPaginator.razor.cs @@ -1,11 +1,11 @@ -namespace Bit.BlazorUI; +namespace Bit.BlazorUI; /// -/// A component that provides a user interface for . +/// A component that provides a user interface for . /// -public partial class BitDataGridPaginator : IDisposable +public partial class BitQuickGridPaginator : IDisposable { - private readonly EventCallbackSubscriber _totalItemCountChanged; + private readonly EventCallbackSubscriber _totalItemCountChanged; /// /// The title of the go to first page button. @@ -30,35 +30,35 @@ public partial class BitDataGridPaginator : IDisposable /// /// Optionally supplies a format for rendering the page count summary. /// - [Parameter] public Func? SummaryFormat { get; set; } + [Parameter] public Func? SummaryFormat { get; set; } /// /// Optionally supplies a template for rendering the page count summary. /// - [Parameter] public RenderFragment? SummaryTemplate { get; set; } + [Parameter] public RenderFragment? SummaryTemplate { get; set; } /// /// The optional custom format for the main text of the paginator in the middle of it. /// - [Parameter] public Func? TextFormat { get; set; } + [Parameter] public Func? TextFormat { get; set; } /// /// The optional custom template for the main text of the paginator in the middle of it. /// - [Parameter] public RenderFragment? TextTemplate { get; set; } + [Parameter] public RenderFragment? TextTemplate { get; set; } /// - /// Specifies the associated . This parameter is required. + /// Specifies the associated . This parameter is required. /// - [Parameter, EditorRequired] public BitDataGridPaginationState Value { get; set; } = default!; + [Parameter, EditorRequired] public BitQuickGridPaginationState Value { get; set; } = default!; /// - /// Constructs an instance of . + /// Constructs an instance of . /// - public BitDataGridPaginator() + public BitQuickGridPaginator() { // The "total item count" handler doesn't need to do anything except cause this component to re-render - _totalItemCountChanged = new(new EventCallback(this, null)); + _totalItemCountChanged = new(new EventCallback(this, null)); } private Task GoFirstAsync() => GoToPageAsync(0); diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Pagination/BitDataGridPaginator.scss b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Pagination/BitQuickGridPaginator.scss similarity index 100% rename from src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Pagination/BitDataGridPaginator.scss rename to src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Pagination/BitQuickGridPaginator.scss diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Styles/extra-components.scss b/src/BlazorUI/Bit.BlazorUI.Extras/Styles/extra-components.scss index ca854411fa..5b99550080 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Styles/extra-components.scss +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Styles/extra-components.scss @@ -1,7 +1,8 @@ @import "../Components/AccordionList/BitAccordionList.scss"; @import "../Components/AppShell/BitAppShell.scss"; @import "../Components/DataGrid/BitDataGrid.scss"; -@import "../Components/DataGrid/Pagination/BitDataGridPaginator.scss"; +@import "../Components/QuickGrid/BitQuickGrid.scss"; +@import "../Components/QuickGrid/Pagination/BitQuickGridPaginator.scss"; @import "../Components/ErrorBoundary/BitErrorBoundary.scss"; @import "../Components/Flag/BitFlag.scss"; @import "../Components/InfiniteScrolling/BitInfiniteScrolling.scss"; diff --git a/src/BlazorUI/Demo/Bit.BlazorUI.Demo.Shared/Dtos/AppJsonContext.cs b/src/BlazorUI/Demo/Bit.BlazorUI.Demo.Shared/Dtos/AppJsonContext.cs index f2d4a0b5d4..1512537865 100644 --- a/src/BlazorUI/Demo/Bit.BlazorUI.Demo.Shared/Dtos/AppJsonContext.cs +++ b/src/BlazorUI/Demo/Bit.BlazorUI.Demo.Shared/Dtos/AppJsonContext.cs @@ -1,4 +1,4 @@ -using Bit.BlazorUI.Demo.Shared.Dtos.DataGridDemo; +using Bit.BlazorUI.Demo.Shared.Dtos.QuickGridDemo; namespace Bit.BlazorUI.Demo.Shared.Dtos; diff --git a/src/BlazorUI/Demo/Bit.BlazorUI.Demo.Shared/Dtos/DataGridDemo/Openfda.cs b/src/BlazorUI/Demo/Bit.BlazorUI.Demo.Shared/Dtos/DataGridDemo/Openfda.cs deleted file mode 100644 index 81654f3e8a..0000000000 --- a/src/BlazorUI/Demo/Bit.BlazorUI.Demo.Shared/Dtos/DataGridDemo/Openfda.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace Bit.BlazorUI.Demo.Shared.Dtos.DataGridDemo; - -public class Openfda -{ -} diff --git a/src/BlazorUI/Demo/Bit.BlazorUI.Demo.Shared/Dtos/DataGridDemo/FoodRecall.cs b/src/BlazorUI/Demo/Bit.BlazorUI.Demo.Shared/Dtos/QuickGridDemo/FoodRecall.cs similarity index 97% rename from src/BlazorUI/Demo/Bit.BlazorUI.Demo.Shared/Dtos/DataGridDemo/FoodRecall.cs rename to src/BlazorUI/Demo/Bit.BlazorUI.Demo.Shared/Dtos/QuickGridDemo/FoodRecall.cs index f3a19dba52..095549494f 100644 --- a/src/BlazorUI/Demo/Bit.BlazorUI.Demo.Shared/Dtos/DataGridDemo/FoodRecall.cs +++ b/src/BlazorUI/Demo/Bit.BlazorUI.Demo.Shared/Dtos/QuickGridDemo/FoodRecall.cs @@ -1,4 +1,4 @@ -namespace Bit.BlazorUI.Demo.Shared.Dtos.DataGridDemo; +namespace Bit.BlazorUI.Demo.Shared.Dtos.QuickGridDemo; public class FoodRecall { diff --git a/src/BlazorUI/Demo/Bit.BlazorUI.Demo.Shared/Dtos/DataGridDemo/FoodRecallQueryResult.cs b/src/BlazorUI/Demo/Bit.BlazorUI.Demo.Shared/Dtos/QuickGridDemo/FoodRecallQueryResult.cs similarity index 77% rename from src/BlazorUI/Demo/Bit.BlazorUI.Demo.Shared/Dtos/DataGridDemo/FoodRecallQueryResult.cs rename to src/BlazorUI/Demo/Bit.BlazorUI.Demo.Shared/Dtos/QuickGridDemo/FoodRecallQueryResult.cs index ccdb96087c..c40bb820ad 100644 --- a/src/BlazorUI/Demo/Bit.BlazorUI.Demo.Shared/Dtos/DataGridDemo/FoodRecallQueryResult.cs +++ b/src/BlazorUI/Demo/Bit.BlazorUI.Demo.Shared/Dtos/QuickGridDemo/FoodRecallQueryResult.cs @@ -1,4 +1,4 @@ -namespace Bit.BlazorUI.Demo.Shared.Dtos.DataGridDemo; +namespace Bit.BlazorUI.Demo.Shared.Dtos.QuickGridDemo; public class FoodRecallQueryResult { diff --git a/src/BlazorUI/Demo/Bit.BlazorUI.Demo.Shared/Dtos/DataGridDemo/Meta.cs b/src/BlazorUI/Demo/Bit.BlazorUI.Demo.Shared/Dtos/QuickGridDemo/Meta.cs similarity index 87% rename from src/BlazorUI/Demo/Bit.BlazorUI.Demo.Shared/Dtos/DataGridDemo/Meta.cs rename to src/BlazorUI/Demo/Bit.BlazorUI.Demo.Shared/Dtos/QuickGridDemo/Meta.cs index 83eece581b..5457853bee 100644 --- a/src/BlazorUI/Demo/Bit.BlazorUI.Demo.Shared/Dtos/DataGridDemo/Meta.cs +++ b/src/BlazorUI/Demo/Bit.BlazorUI.Demo.Shared/Dtos/QuickGridDemo/Meta.cs @@ -1,4 +1,4 @@ -namespace Bit.BlazorUI.Demo.Shared.Dtos.DataGridDemo; +namespace Bit.BlazorUI.Demo.Shared.Dtos.QuickGridDemo; public class Meta { diff --git a/src/BlazorUI/Demo/Bit.BlazorUI.Demo.Shared/Dtos/QuickGridDemo/Openfda.cs b/src/BlazorUI/Demo/Bit.BlazorUI.Demo.Shared/Dtos/QuickGridDemo/Openfda.cs new file mode 100644 index 0000000000..e0c646d637 --- /dev/null +++ b/src/BlazorUI/Demo/Bit.BlazorUI.Demo.Shared/Dtos/QuickGridDemo/Openfda.cs @@ -0,0 +1,5 @@ +namespace Bit.BlazorUI.Demo.Shared.Dtos.QuickGridDemo; + +public class Openfda +{ +} diff --git a/src/BlazorUI/Demo/Bit.BlazorUI.Demo.Shared/Dtos/DataGridDemo/Results.cs b/src/BlazorUI/Demo/Bit.BlazorUI.Demo.Shared/Dtos/QuickGridDemo/Results.cs similarity index 79% rename from src/BlazorUI/Demo/Bit.BlazorUI.Demo.Shared/Dtos/DataGridDemo/Results.cs rename to src/BlazorUI/Demo/Bit.BlazorUI.Demo.Shared/Dtos/QuickGridDemo/Results.cs index 475337b6dc..50fbc50616 100644 --- a/src/BlazorUI/Demo/Bit.BlazorUI.Demo.Shared/Dtos/DataGridDemo/Results.cs +++ b/src/BlazorUI/Demo/Bit.BlazorUI.Demo.Shared/Dtos/QuickGridDemo/Results.cs @@ -1,4 +1,4 @@ -namespace Bit.BlazorUI.Demo.Shared.Dtos.DataGridDemo; +namespace Bit.BlazorUI.Demo.Shared.Dtos.QuickGridDemo; public class Results { diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor index b1968f43bc..e48586ad08 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor @@ -1,14 +1,13 @@ @page "/components/datagrid" @page "/components/data-grid" @inherits AppComponentBase -@using Demo.Shared.Dtos.DataGridDemo - - - - - - - - - - - - - - - - -
-
- - - - - - - - @(context.Code) - - - - - - - - - -
- - @(state.CurrentPageIndex + 1) / @(state.LastPageIndex + 1) - -
-
- - -
- - - - - - - - -
-
- -
-
- - -
- - - - - -
-
- -
-
- - -
- - - - - - - - - Loading items... - - - - -
- - @(state.CurrentPageIndex + 1) / @(state.LastPageIndex + 1) - -
- - -
- The BitDataGrid does not have the Responsive feature built-in, - but you can achieve some level of responsiveness like what we did in this sample: -
- -
- -
- - - - - - - - -
-
- - -
See the RowTemplate parameter in action:
- -
- -
- - - - - - - - - - - - - @args.OriginalRow - @if (expandedRowTemplateCodes.Contains(args.RowItem.Code)) + + +
+ Bind a collection, declare columns, and you get sorting out of the box. + Click a header to sort ascending → descending → unsorted. + Hold Ctrl (or ⌘) and click additional headers for multi-column sorting. +
+
+ + + + + + + + +
+ + +
+ A quick-filter row sits under the header; the pager controls page size and navigation. + Type in any filter box to narrow results (case-insensitive contains). +
+
+ + + + + + + + +
+ + +
+ Single or multiple selection with a select-all header checkbox and two-way binding. +
+
+ + Single + Multiple + @selectedProducts.Count selected + +
+ + + + + + +
+ + +
+ Add, edit, save, cancel and delete rows with type-aware editors (text, number, checkbox, date, enum). + Use EditTemplate on a column to supply your own editor. +
+
+ + + + + + + + + + +
+ @editStatus +
+ + +
+ Click the ⊞ button in a groupable column header to group by it (click again to ungroup). + Each group is collapsible and shows its own aggregates; the footer shows grand totals. + Group by both Category and Supplier to see multi-level grouping. +
+
+ + + + + + + + + +
+ + +
+ Customize any cell, header or footer, and expand rows to reveal detail content. + Click the ▸ toggle on the left of a row to expand its detail panel. +
+
+ + + +
Supplier
@p.Supplier
+
Released
@p.ReleaseDate.ToString("D")
+
Inventory value
@((p.Price * p.Stock).ToString("C2"))
+
Status
@(p.Discontinued ? "Discontinued" : "Active")
+
+
+ + + 📦 Product + + + + + + + + Total: @agg.FormattedValue + + + + + + +
+
+ + +
+ Drag column edges to resize, drag headers to reorder, and pin columns in place. + The ID and Name columns are frozen, staying visible while you scroll horizontally. +
+
+ + + + + + + + + + +
+ + +
+ Set Group on consecutive columns to render them under a single spanning header cell. +
+
+ + + + + + + + + +
+ + +
+ Provide a ColSpan function on a column to let a single cell span several columns based on its row. + Discontinued rows span the Name cell over Category; premium rows span Price over Stock. +
+
+ + + + + + + + + + + +
+ + +
+ Render very large datasets smoothly — only visible rows hit the DOM. + Virtualization requires a fixed Height and RowHeight.
+
+ + 1k rows + 10k rows + 100k rows + @virtualProducts.Count.ToString("N0") rows + +
+ + + + + + + + + +
+ + +
+ Set the OnRead callback to take over sorting/filtering/paging (e.g. against a database). + This example simulates a backend with a small delay. +
+
+ + + + + + + + +
+ @serverLastRequest +
+ + +
+ Set OnLoadMore instead of binding Items. The grid appends the next batch + automatically as the user scrolls toward the end — with no total count or paging UI. A fixed + Height is required. +
+
+ + + + + + + + + +
+ @infiniteLog +
+ + +
+ Set the ChildrenSelector parameter to a function that returns each item's direct children. + The grid then treats Items as the root nodes and renders expand/collapse toggles with indentation. +
+
+ + Expand all + Collapse all + +
+ + + + + + +
+ + +
+ The DetailTemplate can render anything — including another BitDataGrid. + Each supplier expands to show a nested, sortable grid of the products it provides. +
+
+ + + +
Products: @supplier.Products.Count
+
Total stock: @supplier.Products.Sum(p => p.Stock).ToString("N0")
+
Avg price: @supplier.Products.Average(p => p.Price).ToString("C2")
+
Active: @supplier.Products.Count(p => !p.Discontinued)
+
+
+ + + + + + + +
+ + + + + + +
+
+ + +
+ Set RowReorderable="true" to show a drag handle on each row. Grab the ⠿ handle and drop it + onto another row. The grid reorders the bound list in place and raises OnRowReorder. +
+
+ + + + + + +
+ @reorderLog +
+ + +
+ The grid raises OnCellClick, OnCellDoubleClick and OnCellContextMenu + with the row, column and the underlying mouse event. +
+
+ + + + + + +
+ @cellEventStatus +
+ + +
+ Set CellNavigation="true" to enable a roving tab stop. Use arrow keys, Home/End, + Ctrl+Home/End, PageUp/PageDown to move, and Enter/F2 to edit (Esc to cancel). +
+
+ + + + + + + + +
+ + +
+ Pass RowHeightSelector a function that returns the desired height (in pixels) for a given row. + Here premium products (price over $500) get a taller row. +
+
+ + + + + + + + + +
+ + +
+ Supply an EmptyTemplate to customize the placeholder shown when there is no data, + or rely on the built-in "No records to display" message. +
+
+ + Clear data + Load data + +
+ + + +
📭
+ Nothing here yet + Try loading the sample data or adjusting your filters. + Load sample data +
+
+ + + + + + +
+
+ + +
+ The grid is themed through CSS custom properties and the light-dark() function. + Override any --bit-dtg-* token to create your own theme, toggle borders/striping, or switch to RTL. +
+
+ + Default + Emerald + Grape + @(rtl ? "LTR" : "RTL") + Borders: @(bordered ? "on" : "off") + Striping: @(striped ? "on" : "off") + +
+ + + + + + +
+ + +@* Theme tokens must be global: Class="@theme" is applied to the grid root rendered by the Extras component. *@ + diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.cs index 90210e52fe..43d0a760cd 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.cs @@ -1,681 +1,249 @@ -using Bit.BlazorUI.Demo.Client.Core.Components; -using Bit.BlazorUI.Demo.Shared.Dtos.DataGridDemo; +using Bit.BlazorUI.Demo.Client.Core.Components; namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.DataGrid; public partial class BitDataGridDemo : AppComponentBase { - private readonly List componentParameters = - [ - new() - { - Name = "ChildContent", - Type = "RenderFragment?", - DefaultValue = "null", - Description = @"Defines the child components of this instance. - For example, you may define columns by adding components derived from the BitDataGridColumnBase.", - }, - new() - { - Name = "Class", - Type = "string?", - DefaultValue = "null", - Description = "An optional CSS class name. If given, this will be included in the class attribute of the rendered table.", - }, - new() - { - Name = "Columns", - Type = "RenderFragment?", - DefaultValue = "null", - Description = "Alias of the ChildContent parameter.", - }, - new() - { - Name = "ItemKey", - Type = "Func", - DefaultValue = "x => x!", - Description = @"Optionally defines a value for @key on each rendered row. Typically this should be used to specify a - unique identifier, such as a primary key value, for each data item. - This allows the grid to preserve the association between row elements and data items based on their - unique identifiers, even when the TGridItem instances are replaced by new copies (for example, after a new query against the underlying data store). - If not set, the @key will be the TGridItem instance itself.", - }, - new() - { - Name = "Items", - Type = "IQueryable?", - DefaultValue = "null", - Description = @"A queryable source of data for the grid. - This could be in-memory data converted to queryable using the - System.Linq.Queryable.AsQueryable(System.Collections.IEnumerable) extension method, - or an EntityFramework DataSet or an IQueryable derived from it. - You should supply either Items or ItemsProvider, but not both.", - }, - new() - { - Name = "ItemSize", - Type = "float", - DefaultValue = "50", - Description = @"This is applicable only when using Virtualize. 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 - size and to ensure accurate scrolling.", - }, - new() - { - Name = "ItemsProvider", - Type = "BitDataGridItemsProvider?", - DefaultValue = "null", - Description = @"A callback that supplies data for the rid. - You should supply either Items or ItemsProvider, but not both.", - }, - new() - { - Name = "LoadingTemplate", - Type = "RenderFragment?", - DefaultValue = "null", - Description = "The custom template to render while loading the new items.", - }, - new() - { - Name = "Pagination", - Type = "BitDataGridPaginationState?", - DefaultValue = "null", - Description = @"Optionally links this BitDataGrid instance with a BitDataGridPaginationState model, - causing the grid to fetch and render only the current page of data. - This is normally used in conjunction with a Paginator component or some other UI logic - that displays and updates the supplied BitDataGridPaginationState instance.", - LinkType = LinkType.Link, - Href = "#pagination-state", - }, - new() - { - Name = "ResizableColumns", - Type = "bool", - DefaultValue = "false", - Description = @"If true, renders draggable handles around the column headers, allowing the user to resize the columns - manually. Size changes are not persisted.", - }, - new() - { - Name = "RowClass", - Type = "string?", - DefaultValue = "null", - Description = @"The CSS class of all rows of the data grid.", - }, - new() - { - Name = "RowClassSelector", - Type = "Func?", - DefaultValue = "null", - Description = @"The function to generate the CSS class of each row of the data grid.", - }, - new() - { - Name = "RowStyle", - Type = "string?", - DefaultValue = "null", - Description = @"The CSS style of all rows of the data grid.", - }, - new() - { - Name = "RowStyleSelector", - Type = "Func?", - DefaultValue = "null", - Description = @"The function to generate the CSS style of each row of the data grid.", - }, - new() - { - Name = "RowTemplate", - Type = "RenderFragment>?", - DefaultValue = "null", - Description = @"Optional template to customize row rendering. Receives BitDataGridRowTemplateArgs with OriginalRow - set to the default row content; render it to include the original row, or omit to replace entirely.", - LinkType = LinkType.Link, - Href = "#row-template-args", - }, - new() - { - Name = "Theme", - Type = "string?", - DefaultValue = "default", - Description = @"A theme name, with default value ""default"". This affects which styling rules match the table.", - }, - new() - { - Name = "Virtualize", - Type = "bool", - DefaultValue = "false", - Description = @"If true, the grid will be rendered with virtualization. This is normally used in conjunction with - scrolling and causes the grid to fetch and render only the data around the current scroll viewport. - This can greatly improve the performance when scrolling through large data sets.", - } - ]; - - private readonly List componentSubClasses = - [ - new() - { - Id = "BitDataGridColumnBase", - Title = "BitDataGridColumnBase", - Description = "BitDataGrid has two built-in column types, BitDataGridPropertyColumn and BitDataGridTemplateColumn. You can also create your own column types by subclassing ColumnBase he BitDataGridColumnBase type, which all column must derive from, offers some common parameters", - Parameters= - [ - new() - { - Name = "Title", - Type = "string?", - DefaultValue = "null", - Description = "Title text for the column. This is rendered automatically if HeaderTemplate is not used.", - }, - new() - { - Name = "Class", - Type = "string?", - DefaultValue = "null", - Description = "An optional CSS class name. If specified, this is included in the class attribute of table header and body cells for this column.", - }, - new() - { - Name = "Align", - Type = "BitDataGridAlign?", - DefaultValue = "null", - Description = "If specified, controls the justification of table header and body cells for this column.", - }, - new() - { - Name = "HeaderTemplate", - Type = "RenderFragment>?", - DefaultValue = "null", - Description = @"An optional template for this column's header cell. If not specified, the default header template - includes the Title along with any applicable sort indicators and options buttons.", - }, - new() - { - Name = "ColumnOptions", - Type = "RenderFragment>?", - DefaultValue = "null", - Description = @"If specified, indicates that this column has this associated options UI. A button to display this - UI will be included in the header cell by default. - If HeaderTemplate is used, it is left up to that template to render any relevant - ""show options"" UI and invoke the grid's BitDataGrid.ShowColumnOptions(BitDataGridColumnBase)).", - }, - new() - { - Name = "Sortable", - Type = "bool?", - DefaultValue = "null", - Description = @"Indicates whether the data should be sortable by this column. - The default value may vary according to the column type (for example, a BitDataGridTemplateColumn - is sortable by default if any BitDataGridTemplateColumn.SortBy parameter is specified).", - }, - new() - { - Name = "IsDefaultSort", - Type = "BitDataGridSortDirection?", - DefaultValue = "null", - Description = "If specified and not null, indicates that this column represents the initial sort order for the grid. The supplied value controls the default sort direction.", - }, - new() - { - Name = "PlaceholderTemplate", - Type = "RenderFragment?", - DefaultValue = "null", - Description = "If specified, virtualized grids will use this template to render cells whose data has not yet been loaded.", - } - ], - - }, - new() - { - Id="BitDataGridPropertyColumn", - Title = "BitDataGridPropertyColumn", - Description = "It is for displaying a single value specified by the parameter Property. This column infers sorting rules automatically, and uses the property's name as its title if not otherwise set.", - Parameters= - [ - new() - { - Name = "Property", - Type = "Expression>", - Description = "Defines the value to be displayed in this column's cells.", - }, - new() - { - Name = "Format", - Type = "string?", - DefaultValue = "null", - Description = "Optionally specifies a format string for the value. Using this requires the TProp type to implement IFormattable.", - }, - ], - }, - new() - { - Id = "BitDataGridTemplateColumn", - Title = "BitDataGridTemplateColumn", - Description = @"It uses arbitrary Razor fragments to supply contents for its cells. It can't infer the column's title or sort order automatically. also it's possible to add arbitrary Blazor components to your table cells. Remember that rendering many components, or many event handlers, can impact the performance of your grid. One way to mitigate this issue is by paginating or virtualizing your grid", - Parameters = - [ - new() - { - Name = "ChildContent", - Type = "RenderFragment", - Description = @"Specifies the content to be rendered for each row in the table.", - }, - new() - { - Name = "SortBy", - Type = "BitDataGridSort?", - DefaultValue = "null", - Description = "Optionally specifies sorting rules for this column.", - }, - ], - }, - new() - { - Id = "BitDataGridPaginator", - Title = "BitDataGridPaginator", - Description = "A component that provides a user interface for pagination.", - Parameters= - [ - new() - { - Name = "GoToFirstButtonTitle", - Type = "string", - DefaultValue = "Go to first page", - Description = "The title of the go to first page button.", - }, - new() - { - Name = "GoToPrevButtonTitle", - Type = "string", - DefaultValue = "Go to previous page", - Description = "The title of the go to previous page button.", - }, - new() - { - Name = "GoToNextButtonTitle", - Type = "string", - DefaultValue = "Go to next page", - Description = "The title of the go to next page button.", - }, - new() - { - Name = "GoToLastButtonTitle", - Type = "string", - DefaultValue = "Go to last page", - Description = "The title of the go to last page button.", - }, - new() - { - Name = "SummaryFormat", - Type = "Func?", - DefaultValue = "null", - Description = "Optionally supplies a format for rendering the page count summary.", - LinkType = LinkType.Link, - Href = "#pagination-state" - }, - new() - { - Name = "SummaryTemplate", - Type = "RenderFragment?", - DefaultValue = "null", - Description = "Optionally supplies a template for rendering the page count summary.", - LinkType = LinkType.Link, - Href = "#pagination-state" - }, - new() - { - Name = "TextFormat", - Type = "Func?", - DefaultValue = "null", - Description = "The optional custom format for the main text of the paginator in the middle of it.", - LinkType = LinkType.Link, - Href = "#pagination-state" - }, - new() - { - Name = "TextTemplate", - Type = "RenderFragment?", - DefaultValue = "null", - Description = "The optional custom template for the main text of the paginator in the middle of it.", - LinkType = LinkType.Link, - Href = "#pagination-state" - }, - new() - { - Name = "Value", - Type = "BitDataGridPaginationState", - DefaultValue = "", - Description = "Specifies the associated pagination state. This parameter is required.", - LinkType = LinkType.Link, - Href = "#pagination-state" - }, - ], - - }, - new() - { - Id = "pagination-state", - Title = "BitDataGridPaginationState", - Description = "A component that provides a user interface for pagination.", - Parameters= - [ - new() - { - Name = "CurrentPageIndex", - Type = "int", - DefaultValue = "0", - Description = "Gets the current zero-based page index.", - }, - new() - { - Name = "ItemsPerPage", - Type = "int", - DefaultValue = "10", - Description = "Gets or sets the number of items on each page.", - }, - new() - { - Name = "LastPageIndex", - Type = "int?", - DefaultValue = "null", - Description = "Gets the zero-based index of the last page, if known. The value will be null until TotalItemCount is known.", - }, - new() - { - Name = "TotalItemCount", - Type = "int?", - DefaultValue = "null", - Description = "Gets the total number of items across all pages, if known. The value will be null until an associated BitDataGrid assigns a value after loading data.", - }, - new() - { - Name = "TotalItemCountChanged", - Type = "EventHandler?", - DefaultValue = "null", - Description = "An event that is raised when the total item count has changed.", - }, - ], - - }, - new() - { - Id = "row-template-args", - Title = "BitDataGridRowTemplateArgs", - Description = "Arguments passed to the RowTemplate render fragment.", - Parameters = - [ - new() - { - Name = "OriginalRow", - Type = "RenderFragment?", - DefaultValue = "null", - Description = "A render fragment that produces the original row markup (the default with all column cells). Render this to include the default row, or omit to replace entirely.", - }, - new() - { - Name = "RowIndex", - Type = "int", - DefaultValue = "0", - Description = "The 1-based row index used for accessibility (e.g. aria-rowindex).", - }, - new() - { - Name = "RowItem", - Type = "TGridItem", - DefaultValue = "", - Description = "The data item for this row.", - }, - ], - }, - ]; - - private readonly List componentSubEnums = - [ - new() - { - Id = "BitDataGridAlign", - Name = "BitDataGridAlign", - Description = "Describes alignment for a BitDataGrid column.", - Items = - [ - new() - { - Name = "Left", - Value = "0", - Description = "Justifies the content against the start of the container." - }, - new() - { - Name = "Center", - Value = "1", - Description = "Justifies the content at the center of the container." - }, - new() - { - Name = "Right", - Value = "2", - Description = "Justifies the content at the end of the container." - }, - - ] - }, - ]; - - - - private static readonly CountryModel[] _countries = - [ - new CountryModel { Code = "AR", Name = "Argentina", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 2 } }, - new CountryModel { Code = "AM", Name = "Armenia", Medals = new MedalsModel { Gold = 0, Silver = 2, Bronze = 2 } }, - new CountryModel { Code = "AU", Name = "Australia", Medals = new MedalsModel { Gold = 17, Silver = 7, Bronze = 22 } }, - new CountryModel { Code = "AT", Name = "Austria", Medals = new MedalsModel { Gold = 1, Silver = 1, Bronze = 5 } }, - new CountryModel { Code = "AZ", Name = "Azerbaijan", Medals = new MedalsModel { Gold = 0, Silver = 3, Bronze = 4 } }, - new CountryModel { Code = "BS", Name = "Bahamas", Medals = new MedalsModel { Gold = 2, Silver = 0, Bronze = 0 } }, - new CountryModel { Code = "BH", Name = "Bahrain", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, - new CountryModel { Code = "BY", Name = "Belarus", Medals = new MedalsModel { Gold = 1, Silver = 3, Bronze = 3 } }, - new CountryModel { Code = "BE", Name = "Belgium", Medals = new MedalsModel { Gold = 3, Silver = 1, Bronze = 3 } }, - new CountryModel { Code = "BM", Name = "Bermuda", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 0 } }, - new CountryModel { Code = "BW", Name = "Botswana", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = "BR", Name = "Brazil", Medals = new MedalsModel { Gold = 7, Silver = 6, Bronze = 8 } }, - new CountryModel { Code = "BF", Name = "Burkina Faso", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = "CA", Name = "Canada", Medals = new MedalsModel { Gold = 7, Silver = 6, Bronze = 11 } }, - new CountryModel { Code = "TW", Name = "Chinese Taipei", Medals = new MedalsModel { Gold = 2, Silver = 4, Bronze = 6 } }, - new CountryModel { Code = "CO", Name = "Colombia", Medals = new MedalsModel { Gold = 0, Silver = 4, Bronze = 1 } }, - new CountryModel { Code = "CI", Name = "Côte d'Ivoire", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = "HR", Name = "Croatia", Medals = new MedalsModel { Gold = 3, Silver = 3, Bronze = 2 } }, - new CountryModel { Code = "CU", Name = "Cuba", Medals = new MedalsModel { Gold = 7, Silver = 3, Bronze = 5 } }, - new CountryModel { Code = "CZ", Name = "Czech Republic", Medals = new MedalsModel { Gold = 4, Silver = 4, Bronze = 3 } }, - new CountryModel { Code = "DK", Name = "Denmark", Medals = new MedalsModel { Gold = 3, Silver = 4, Bronze = 4 } }, - new CountryModel { Code = "DO", Name = "Dominican Republic", Medals = new MedalsModel { Gold = 0, Silver = 3, Bronze = 2 } }, - new CountryModel { Code = "EC", Name = "Ecuador", Medals = new MedalsModel { Gold = 2, Silver = 1, Bronze = 0 } }, - new CountryModel { Code = "EE", Name = "Estonia", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = "ET", Name = "Ethiopia", Medals = new MedalsModel { Gold = 1, Silver = 1, Bronze = 2 } }, - new CountryModel { Code = "FJ", Name = "Fiji", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = "FI", Name = "Finland", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 2 } }, - new CountryModel { Code = "FR", Name = "France", Medals = new MedalsModel { Gold = 10, Silver = 12, Bronze = 11 } }, - new CountryModel { Code = "GE", Name = "Georgia", Medals = new MedalsModel { Gold = 2, Silver = 5, Bronze = 1 } }, - new CountryModel { Code = "DE", Name = "Germany", Medals = new MedalsModel { Gold = 10, Silver = 11, Bronze = 16 } }, - new CountryModel { Code = "GH", Name = "Ghana", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = "GB", Name = "Great Britain", Medals = new MedalsModel { Gold = 22, Silver = 21, Bronze = 22 } }, - new CountryModel { Code = "GR", Name = "Greece", Medals = new MedalsModel { Gold = 2, Silver = 1, Bronze = 1 } }, - new CountryModel { Code = "GD", Name = "Grenada", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = "HK", Name = "Hong Kong, China", Medals = new MedalsModel { Gold = 1, Silver = 2, Bronze = 3 } }, - new CountryModel { Code = "HU", Name = "Hungary", Medals = new MedalsModel { Gold = 6, Silver = 7, Bronze = 7 } }, - new CountryModel { Code = "ID", Name = "Indonesia", Medals = new MedalsModel { Gold = 1, Silver = 1, Bronze = 3 } }, - new CountryModel { Code = "IE", Name = "Ireland", Medals = new MedalsModel { Gold = 2, Silver = 0, Bronze = 2 } }, - new CountryModel { Code = "IR", Name = "Iran", Medals = new MedalsModel { Gold = 3, Silver = 2, Bronze = 2 } }, - new CountryModel { Code = "IL", Name = "Israel", Medals = new MedalsModel { Gold = 2, Silver = 0, Bronze = 2 } }, - new CountryModel { Code = "IT", Name = "Italy", Medals = new MedalsModel { Gold = 10, Silver = 10, Bronze = 20 } }, - new CountryModel { Code = "JM", Name = "Jamaica", Medals = new MedalsModel { Gold = 4, Silver = 1, Bronze = 4 } }, - new CountryModel { Code = "JO", Name = "Jordan", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 1 } }, - new CountryModel { Code = "KZ", Name = "Kazakhstan", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 8 } }, - new CountryModel { Code = "KE", Name = "Kenya", Medals = new MedalsModel { Gold = 4, Silver = 4, Bronze = 2 } }, - new CountryModel { Code = "XK", Name = "Kosovo", Medals = new MedalsModel { Gold = 2, Silver = 0, Bronze = 0 } }, - new CountryModel { Code = "KW", Name = "Kuwait", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = "LV", Name = "Latvia", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = "LT", Name = "Lithuania", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, - new CountryModel { Code = "MY", Name = "Malaysia", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 1 } }, - new CountryModel { Code = "MX", Name = "Mexico", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 4 } }, - new CountryModel { Code = "MA", Name = "Morocco", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 0 } }, - new CountryModel { Code = "NA", Name = "Namibia", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, - new CountryModel { Code = "NL", Name = "Netherlands", Medals = new MedalsModel { Gold = 10, Silver = 12, Bronze = 14 } }, - new CountryModel { Code = "NZ", Name = "New Zealand", Medals = new MedalsModel { Gold = 7, Silver = 6, Bronze = 7 } }, - new CountryModel { Code = "MK", Name = "North Macedonia", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, - new CountryModel { Code = "NO", Name = "Norway", Medals = new MedalsModel { Gold = 4, Silver = 2, Bronze = 2 } }, - new CountryModel { Code = "PH", Name = "Philippines", Medals = new MedalsModel { Gold = 1, Silver = 2, Bronze = 1 } }, - new CountryModel { Code = "PL", Name = "Poland", Medals = new MedalsModel { Gold = 4, Silver = 5, Bronze = 5 } }, - new CountryModel { Code = "PT", Name = "Portugal", Medals = new MedalsModel { Gold = 1, Silver = 1, Bronze = 2 } }, - new CountryModel { Code = "PR", Name = "Puerto Rico", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 0 } }, - new CountryModel { Code = "QA", Name = "Qatar", Medals = new MedalsModel { Gold = 2, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = "KR", Name = "Republic of Korea", Medals = new MedalsModel { Gold = 6, Silver = 4, Bronze = 10 } }, - new CountryModel { Code = "MD", Name = "Republic of Moldova", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = "RO", Name = "Romania", Medals = new MedalsModel { Gold = 1, Silver = 3, Bronze = 0 } }, - new CountryModel { Code = "SM", Name = "San Marino", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 2 } }, - new CountryModel { Code = "SA", Name = "Saudi Arabia", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, - new CountryModel { Code = "RS", Name = "Serbia", Medals = new MedalsModel { Gold = 3, Silver = 1, Bronze = 5 } }, - new CountryModel { Code = "SK", Name = "Slovakia", Medals = new MedalsModel { Gold = 1, Silver = 2, Bronze = 1 } }, - new CountryModel { Code = "SI", Name = "Slovenia", Medals = new MedalsModel { Gold = 3, Silver = 1, Bronze = 1 } }, - new CountryModel { Code = "ZA", Name = "South Africa", Medals = new MedalsModel { Gold = 1, Silver = 2, Bronze = 0 } }, - new CountryModel { Code = "ES", Name = "Spain", Medals = new MedalsModel { Gold = 3, Silver = 8, Bronze = 6 } }, - new CountryModel { Code = "SE", Name = "Sweden", Medals = new MedalsModel { Gold = 3, Silver = 6, Bronze = 0 } }, - new CountryModel { Code = "CH", Name = "Switzerland", Medals = new MedalsModel { Gold = 3, Silver = 4, Bronze = 6 } }, - new CountryModel { Code = "SY", Name = "Syrian Arab Republic", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = "TH", Name = "Thailand", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = "TR", Name = "Turkey", Medals = new MedalsModel { Gold = 2, Silver = 2, Bronze = 9 } }, - new CountryModel { Code = "TM", Name = "Turkmenistan", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, - new CountryModel { Code = "UA", Name = "Ukraine", Medals = new MedalsModel { Gold = 1, Silver = 6, Bronze = 12 } }, - new CountryModel { Code = "US", Name = "United States of America", Medals = new MedalsModel { Gold = 39, Silver = 41, Bronze = 33 } }, - new CountryModel { Code = "UZ", Name = "Uzbekistan", Medals = new MedalsModel { Gold = 3, Silver = 0, Bronze = 2 } }, - new CountryModel { Code = "VE", Name = "Venezuela", Medals = new MedalsModel { Gold = 1, Silver = 3, Bronze = 0 } }, - ]; - - - - private IQueryable allCountries = default!; - private BitDataGrid dataGrid = default!; - private BitDataGrid productsDataGrid = default!; - private BitDataGridItemsProvider foodRecallProvider = default!; - private BitDataGridItemsProvider productsItemsProvider = default!; - private BitDataGridPaginationState pagination1 = new() { ItemsPerPage = 7 }; - private BitDataGridPaginationState pagination2 = new() { ItemsPerPage = 7 }; - private BitDataGridPaginationState pagination3 = new() { ItemsPerPage = 7 }; - private BitDataGridPaginationState pagination6 = new() { ItemsPerPage = 7 }; - private BitDataGridPaginationState pagination7 = new() { ItemsPerPage = 7 }; - - private HashSet expandedRowTemplateCodes = []; - - private void ToggleRowRendererExpand(string code) - { - if (expandedRowTemplateCodes.Remove(code)) return; - - expandedRowTemplateCodes.Add(code); - } + // example 1 - basic & sorting + private readonly List basicProducts = SampleData.Generate(50); + + // example 2 - filtering & paging + private readonly List filterProducts = SampleData.Generate(200); + + // example 3 - selection + private readonly List selectionProducts = SampleData.Generate(60); + private BitDataGridSelectionMode selectionMode = BitDataGridSelectionMode.Multiple; + private IReadOnlyList selectedProducts = new List(); + + // example 4 - editing + private readonly List editProducts = SampleData.Generate(25); + private int nextId; + private string editStatus = ""; + + // example 5 - grouping + private readonly List groupProducts = SampleData.Generate(80); + + // example 6 - templates + private readonly List templateProducts = SampleData.Generate(30); + + // example 7 - columns resize/reorder/freeze + private readonly List columnsProducts = SampleData.Generate(40); + + // example 8 - column groups + private readonly List columnGroupsProducts = SampleData.Generate(40); - private IQueryable? FilteredItems1 => allCountries?.Where(x => x.Name.Contains(typicalSampleNameFilter1 ?? string.Empty, StringComparison.CurrentCultureIgnoreCase)); - private IQueryable? FilteredItems2 => allCountries?.Where(x => x.Name.Contains(typicalSampleNameFilter2 ?? string.Empty, StringComparison.CurrentCultureIgnoreCase)); + // example 9 - column spanning + private readonly List spanningProducts = SampleData.Generate(40); - string typicalSampleNameFilter1 = string.Empty; - string typicalSampleNameFilter2 = string.Empty; + // example 10 - virtualization + private List virtualProducts = SampleData.Generate(10_000); - string _virtualSampleNameFilter = string.Empty; - string VirtualSampleNameFilter + // example 11 - server-side + private readonly List serverAll = SampleData.Generate(523); + private bool serverLoading; + private string serverLastRequest = ""; + + // example 12 - infinite scrolling + private readonly List infiniteAll = SampleData.Generate(2_000); + private string infiniteLog = "Scroll down to load more…"; + private int infiniteRequests; + + // example 13 - tree view + private readonly List fileRoots = FileSystemData.Build(); + private BitDataGrid? treeGrid; + + // example 14 - master detail + private readonly List suppliers = BuildSuppliers(); + + // example 15 - row reordering + private readonly List reorderProducts = SampleData.Generate(12); + private string? reorderLog; + + // example 16 - cell events + private readonly List cellEventsProducts = SampleData.Generate(40); + private string cellEventStatus = "Click, double-click or right-click any cell."; + + // example 17 - cell navigation + private readonly List cellNavProducts = SampleData.Generate(40); + + // example 18 - variable row height + private readonly List variableHeightProducts = SampleData.Generate(40); + + // example 19 - empty state + private readonly List emptyData = SampleData.Generate(25); + private readonly List emptyNone = new(); + private bool emptyHasData; + private List EmptyCurrent => emptyHasData ? emptyData : emptyNone; + + // example 20 - theming + private readonly List themeProducts = SampleData.Generate(60); + private string theme = ""; + private bool rtl; + private bool bordered = true; + private bool striped = true; + + + protected override Task OnInitAsync() { - get => _virtualSampleNameFilter; - set - { - _virtualSampleNameFilter = value; - _ = dataGrid.RefreshDataAsync(); - } + nextId = editProducts.Max(p => p.Id) + 1; + return base.OnInitAsync(); } - string _odataSampleNameFilter = string.Empty; - string ODataSampleNameFilter + // ---- editing handlers ---- + private Product CreateProduct() => new() { - get => _odataSampleNameFilter; - set - { - _odataSampleNameFilter = value; - _ = productsDataGrid.RefreshDataAsync(); - } + Id = nextId++, + Name = "New product", + Category = Category.Electronics, + Price = 0, + Stock = 0, + Rating = 3, + ReleaseDate = DateTime.Today + }; + + private void OnCreate(Product p) => editStatus = $"Adding new product #{p.Id}…"; + + private void OnSave(Product p) + { + if (!editProducts.Contains(p)) editProducts.Insert(0, p); + editStatus = $"Saved {p.Name} (#{p.Id})."; } + private void OnDelete(Product p) + { + editProducts.Remove(p); + editStatus = $"Deleted #{p.Id}."; + } + + + // ---- column spanning helpers ---- + private int? NameSpan(Product p) => p.Discontinued ? 2 : null; + private int? PriceSpan(Product p) => p.Price > 800 ? 2 : null; - protected override async Task OnInitAsync() + // ---- server-side ---- + private async Task> LoadServerData(BitDataGridReadRequest request) { - allCountries = _countries.AsQueryable(); + serverLoading = true; + await InvokeAsync(StateHasChanged); + await Task.Delay(250); - foodRecallProvider = async req => + IEnumerable query = serverAll; + + foreach (var f in request.Filters) { - try + var term = f.Value?.ToString() ?? ""; + query = f.ColumnId switch { - var query = new Dictionary - { - { "search", $"recalling_firm:\"{_virtualSampleNameFilter}\"" }, - { "skip", req.StartIndex }, - { "limit", req.Count } - }; + nameof(Product.Name) => query.Where(p => p.Name.Contains(term, StringComparison.OrdinalIgnoreCase)), + nameof(Product.Category) => query.Where(p => p.Category.ToString().Contains(term, StringComparison.OrdinalIgnoreCase)), + nameof(Product.Supplier) => query.Where(p => p.Supplier.Contains(term, StringComparison.OrdinalIgnoreCase)), + nameof(Product.Price) => query.Where(p => p.Price.ToString().Contains(term)), + nameof(Product.Stock) => query.Where(p => p.Stock.ToString().Contains(term)), + _ => query + }; + } - var sort = req.GetSortByProperties().SingleOrDefault(); + var sort = request.Sorts.FirstOrDefault(); + if (sort is not null) + { + Func key = sort.ColumnId switch + { + nameof(Product.Name) => p => p.Name, + nameof(Product.Category) => p => p.Category, + nameof(Product.Supplier) => p => p.Supplier, + nameof(Product.Price) => p => p.Price, + nameof(Product.Stock) => p => p.Stock, + _ => p => p.Id + }; + query = sort.Direction == BitDataGridSortDirection.Descending + ? query.OrderByDescending(key) + : query.OrderBy(key); + } - if (sort != default) - { - var sortByColumnName = sort.PropertyName switch - { - nameof(FoodRecall.ReportDate) => "report_date", - _ => throw new InvalidOperationException() - }; + var filtered = query.ToList(); + var total = filtered.Count; + var items = filtered.Skip(request.Skip).Take(request.Take ?? total).ToList(); - query.Add("sort", $"{sortByColumnName}:{(sort.Direction == BitDataGridSortDirection.Ascending ? "asc" : "desc")}"); - } + serverLastRequest = $"Last request → skip {request.Skip}, take {request.Take}, sorts: {request.Sorts.Count}, filters: {request.Filters.Count}, total: {total}"; + serverLoading = false; + return new BitDataGridReadResult(items, total); + } - var url = NavigationManager.GetUriWithQueryParameters("https://api.fda.gov/food/enforcement.json", query); - var data = await HttpClient.GetFromJsonAsync(url, AppJsonContext.Default.FoodRecallQueryResult, req.CancellationToken); + // ---- infinite scrolling ---- + private async Task> LoadMore(BitDataGridReadRequest request) + { + await Task.Delay(350); - return BitDataGridItemsProviderResult.From(data!.Results!, data!.Meta!.Results!.Total); - } - catch - { - return BitDataGridItemsProviderResult.From([], 0); - } - }; + IEnumerable query = infiniteAll; - productsItemsProvider = async req => + var sort = request.Sorts.FirstOrDefault(); + if (sort is not null) { - try + Func key = sort.ColumnId switch { - // https://docs.microsoft.com/en-us/odata/concepts/queryoptions-overview + nameof(Product.Name) => p => p.Name, + nameof(Product.Category) => p => p.Category, + nameof(Product.Supplier) => p => p.Supplier, + nameof(Product.Price) => p => p.Price, + nameof(Product.Stock) => p => p.Stock, + nameof(Product.Rating) => p => p.Rating, + _ => p => p.Id + }; + query = sort.Direction == BitDataGridSortDirection.Descending + ? query.OrderByDescending(key) + : query.OrderBy(key); + } - var query = new Dictionary() - { - { "$top", req.Count ?? 50 }, - { "$skip", req.StartIndex } - }; + var batch = query.Skip(request.Skip).Take(request.Take ?? 40).ToList(); - if (string.IsNullOrEmpty(_odataSampleNameFilter) is false) - { - query.Add("$filter", $"contains(Name,'{_odataSampleNameFilter}')"); - } + infiniteRequests++; + var end = request.Skip + batch.Count; + infiniteLog = $"Batch #{infiniteRequests} → loaded rows {request.Skip + 1}–{end} ({batch.Count} rows)"; + await InvokeAsync(StateHasChanged); - if (req.GetSortByProperties().Any()) - { - query.Add("$orderby", string.Join(", ", req.GetSortByProperties().Select(p => $"{p.PropertyName} {(p.Direction == BitDataGridSortDirection.Ascending ? "asc" : "desc")}"))); - } + return new BitDataGridReadResult(batch, 0); + } - var url = NavigationManager.GetUriWithQueryParameters("api/Products/GetProducts", query); - var data = await HttpClient.GetFromJsonAsync(url, AppJsonContext.Default.PagedResultProductDto); + // ---- tree view ---- + private async Task ExpandAll() { if (treeGrid is not null) await treeGrid.ExpandAllAsync(); } + private async Task CollapseAll() { if (treeGrid is not null) await treeGrid.CollapseAllAsync(); } - return BitDataGridItemsProviderResult.From(data!.Items!, data!.TotalCount); - } - catch + + // ---- master detail ---- + private static List BuildSuppliers() => + SampleData.Generate(240) + .GroupBy(p => p.Supplier) + .Select(g => new SupplierModel { - return BitDataGridItemsProviderResult.From(new List { }, 0); - } - }; + Name = g.Key, + Products = g.OrderBy(p => p.Name).ToList() + }) + .OrderBy(s => s.Name) + .ToList(); + - await base.OnInitAsync(); + // ---- row reordering ---- + private void OnReorder(BitDataGridRowReorderEventArgs e) + { + reorderLog = $"{e.DraggedItem.Name} moved from #{e.FromIndex + 1} to #{e.ToIndex + 1}"; } + + + // ---- cell events ---- + private void OnCellClick(BitDataGridCellEventArgs e) + => cellEventStatus = $"Clicked {e.ColumnTitle} = \"{e.Value}\" on {e.Item.Name}"; + + private void OnCellDoubleClick(BitDataGridCellEventArgs e) + => cellEventStatus = $"Double-clicked {e.ColumnTitle} on {e.Item.Name}"; + + private void OnCellContextMenu(BitDataGridCellEventArgs e) + => cellEventStatus = $"Right-clicked {e.ColumnTitle} on {e.Item.Name} at ({e.Mouse.ClientX}, {e.Mouse.ClientY})"; + + + // ---- variable row height ---- + private float RowHeight(Product p) => p.Price > 500 ? 64f : 36f; } diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.params.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.params.cs new file mode 100644 index 0000000000..5b10070974 --- /dev/null +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.params.cs @@ -0,0 +1,267 @@ +namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.DataGrid; + +public partial class BitDataGridDemo +{ + private readonly List componentParameters = + [ + new() { Name = "Items", Type = "IEnumerable?", DefaultValue = "null", Description = "The data source bound to the grid for client-side processing." }, + new() { Name = "OnRead", Type = "Func>>?", DefaultValue = "null", Description = "Server-side data callback. When set, the grid delegates sort/filter/page to the caller." }, + new() { Name = "OnLoadMore", Type = "Func>>?", DefaultValue = "null", Description = "Infinite-scrolling data callback. Loads rows in batches and appends the next batch as the user scrolls toward the end." }, + new() { Name = "LoadMoreBatchSize", Type = "int", DefaultValue = "50", Description = "Number of rows fetched per batch in infinite-scrolling mode." }, + new() { Name = "ChildContent", Type = "RenderFragment?", DefaultValue = "null", Description = "Column definitions and other declarative children." }, + new() { Name = "Loading", Type = "bool", DefaultValue = "false", Description = "Shows a loading overlay while data is being fetched." }, + new() { Name = "KeyField", Type = "Func?", DefaultValue = "null", Description = "Optional key selector used for selection/edit identity. Defaults to reference equality." }, + new() { Name = "ChildrenSelector", Type = "Func?>?", DefaultValue = "null", Description = "Child selector that turns the grid into a hierarchical tree grid." }, + new() { Name = "TreeInitiallyExpanded", Type = "bool", DefaultValue = "false", Description = "When tree mode is active, controls whether nodes start expanded." }, + new() { Name = "Class", Type = "string?", DefaultValue = "null", Description = "Custom CSS class for the root element. Use it to apply theme tokens." }, + new() { Name = "Style", Type = "string?", DefaultValue = "null", Description = "Custom inline style for the root element." }, + new() { Name = "Height", Type = "string?", DefaultValue = "null", Description = "Height of the scroll viewport, e.g. \"480px\". Required for virtualization and infinite scrolling." }, + new() { Name = "Striped", Type = "bool", DefaultValue = "true", Description = "Renders alternate-row striping." }, + new() { Name = "Hoverable", Type = "bool", DefaultValue = "true", Description = "Highlights the row under the pointer." }, + new() { Name = "Bordered", Type = "bool", DefaultValue = "true", Description = "Renders cell borders." }, + new() { Name = "ShowHeader", Type = "bool", DefaultValue = "true", Description = "Renders the header row." }, + new() { Name = "ShowFooter", Type = "bool", DefaultValue = "false", Description = "Renders the footer/aggregate row." }, + new() { Name = "Direction", Type = "BitDataGridDirection", DefaultValue = "BitDataGridDirection.Ltr", Description = "Text direction (LTR/RTL)." }, + new() { Name = "Sortable", Type = "bool", DefaultValue = "true", Description = "Enables column sorting by clicking headers." }, + new() { Name = "MultiSort", Type = "bool", DefaultValue = "true", Description = "Enables multi-column sorting via Ctrl/⌘+click with priority badges." }, + new() { Name = "Filterable", Type = "bool", DefaultValue = "false", Description = "Renders a per-column quick-filter row." }, + new() { Name = "Resizable", Type = "bool", DefaultValue = "false", Description = "Enables column resizing by dragging header edges." }, + new() { Name = "Reorderable", Type = "bool", DefaultValue = "false", Description = "Enables column reordering via native drag-and-drop." }, + new() { Name = "Groupable", Type = "bool", DefaultValue = "false", Description = "Enables grouping via a header button on groupable columns." }, + new() { Name = "ShowToolbar", Type = "bool", DefaultValue = "false", Description = "Renders the toolbar area." }, + new() { Name = "ShowColumnChooser", Type = "bool", DefaultValue = "false", Description = "Renders a column show/hide chooser in the toolbar." }, + new() { Name = "ShowCsvExport", Type = "bool", DefaultValue = "false", Description = "Renders a CSV export link of the current view." }, + new() { Name = "CellNavigation", Type = "bool", DefaultValue = "false", Description = "Enables keyboard cell navigation with a roving tabindex." }, + new() { Name = "RowReorderable", Type = "bool", DefaultValue = "false", Description = "Enables drag-and-drop row reordering." }, + new() { Name = "OnRowReorder", Type = "EventCallback>", DefaultValue = "", Description = "Raised when a row is dropped onto another row during reordering." }, + new() { Name = "SelectionMode", Type = "BitDataGridSelectionMode", DefaultValue = "BitDataGridSelectionMode.None", Description = "How rows can be selected (None/Single/Multiple)." }, + new() { Name = "SelectedItems", Type = "IReadOnlyList?", DefaultValue = "null", Description = "The selected items (supports two-way binding)." }, + new() { Name = "SelectedItemsChanged", Type = "EventCallback>", DefaultValue = "", Description = "Raised when the selection changes." }, + new() { Name = "OnRowClick", Type = "EventCallback", DefaultValue = "", Description = "Raised when a row is clicked." }, + new() { Name = "OnCellClick", Type = "EventCallback>", DefaultValue = "", Description = "Raised when a data cell is clicked." }, + new() { Name = "OnCellDoubleClick", Type = "EventCallback>", DefaultValue = "", Description = "Raised when a data cell is double-clicked." }, + new() { Name = "OnCellContextMenu", Type = "EventCallback>", DefaultValue = "", Description = "Raised when a data cell is right-clicked." }, + new() { Name = "IsRowSelectionDisabled", Type = "Func?", DefaultValue = "null", Description = "Predicate returning true when a given row may not be selected." }, + new() { Name = "Pageable", Type = "bool", DefaultValue = "false", Description = "Enables paging with a pager UI." }, + new() { Name = "PageSize", Type = "int", DefaultValue = "20", Description = "The number of rows per page." }, + new() { Name = "PageSizeOptions", Type = "int[]", DefaultValue = "{ 10, 20, 50, 100 }", Description = "The page-size options offered in the pager dropdown." }, + new() { Name = "PagerPosition", Type = "BitDataGridPagerPosition", DefaultValue = "BitDataGridPagerPosition.Bottom", Description = "Where the pager renders relative to the grid." }, + new() { Name = "Virtualize", Type = "bool", DefaultValue = "false", Description = "Renders only the visible rows for large datasets. Requires a fixed Height and RowHeight." }, + new() { Name = "RowHeight", Type = "float", DefaultValue = "36", Description = "Uniform row height in pixels (required when virtualizing)." }, + new() { Name = "RowHeightSelector", Type = "Func?", DefaultValue = "null", Description = "Optional per-row height selector (ignored while virtualizing)." }, + new() { Name = "Editable", Type = "bool", DefaultValue = "false", Description = "Enables inline editing with a command column." }, + new() { Name = "NewItemFactory", Type = "Func?", DefaultValue = "null", Description = "Factory used by the toolbar Add button to create a new row." }, + new() { Name = "OnRowSave", Type = "EventCallback", DefaultValue = "", Description = "Raised when an edited row is saved." }, + new() { Name = "OnRowCancel", Type = "EventCallback", DefaultValue = "", Description = "Raised when an edit is cancelled." }, + new() { Name = "OnRowDelete", Type = "EventCallback", DefaultValue = "", Description = "Raised when a row is deleted." }, + new() { Name = "OnRowCreate", Type = "EventCallback", DefaultValue = "", Description = "Raised when a new row is created." }, + new() { Name = "EmptyTemplate", Type = "RenderFragment?", DefaultValue = "null", Description = "Custom content rendered when there is no data." }, + new() { Name = "ToolbarTemplate", Type = "RenderFragment?", DefaultValue = "null", Description = "Custom content rendered in the toolbar's start area." }, + new() { Name = "DetailTemplate", Type = "RenderFragment?", DefaultValue = "null", Description = "Expandable master-detail content rendered under a row." }, + ]; + + private readonly List componentSubClasses = + [ + new() + { + Id = "BitDataGridColumn", + Title = "BitDataGridColumn", + Description = "Defines a column inside a BitDataGrid. Place these as child content of the grid.", + Parameters = + [ + new() { Name = "Field", Type = "string?", DefaultValue = "null", Description = "Name of the property this column is bound to. Supports nested paths (\"Address.City\")." }, + new() { Name = "ColumnId", Type = "string?", DefaultValue = "null", Description = "Stable identifier for the column. Defaults to Field." }, + new() { Name = "Title", Type = "string?", DefaultValue = "null", Description = "Header text. Defaults to a humanized Field." }, + new() { Name = "Width", Type = "string?", DefaultValue = "null", Description = "CSS width, e.g. \"120px\" or \"20%\". When null the column shares remaining space." }, + new() { Name = "MinWidth", Type = "int", DefaultValue = "60", Description = "Minimum width in pixels the column can be resized to." }, + new() { Name = "MaxWidth", Type = "int?", DefaultValue = "null", Description = "Maximum width in pixels the column can be resized to." }, + new() { Name = "Sortable", Type = "bool?", DefaultValue = "null", Description = "Overrides the grid-level Sortable for this column." }, + new() { Name = "SortDescendingFirst", Type = "bool", DefaultValue = "false", Description = "When true, the first click on the header sorts descending instead of ascending." }, + new() { Name = "Filterable", Type = "bool?", DefaultValue = "null", Description = "Overrides the grid-level Filterable for this column." }, + new() { Name = "Resizable", Type = "bool?", DefaultValue = "null", Description = "Overrides the grid-level Resizable for this column." }, + new() { Name = "Reorderable", Type = "bool?", DefaultValue = "null", Description = "Overrides the grid-level Reorderable for this column." }, + new() { Name = "Editable", Type = "bool?", DefaultValue = "null", Description = "Overrides the grid-level Editable for this column." }, + new() { Name = "Groupable", Type = "bool?", DefaultValue = "null", Description = "Overrides the grid-level Groupable for this column." }, + new() { Name = "Frozen", Type = "bool", DefaultValue = "false", Description = "Pins the column to the start edge so it stays visible while scrolling horizontally." }, + new() { Name = "Group", Type = "string?", DefaultValue = "null", Description = "Optional header group name. Consecutive columns sharing the same value render under a single spanning header cell." }, + new() { Name = "ColSpan", Type = "Func?", DefaultValue = "null", Description = "Optional per-row column span." }, + new() { Name = "Visible", Type = "bool", DefaultValue = "true", Description = "Whether the column is visible." }, + new() { Name = "Align", Type = "BitDataGridColumnAlign", DefaultValue = "BitDataGridColumnAlign.Left", Description = "Horizontal alignment of cell content." }, + new() { Name = "Format", Type = "string?", DefaultValue = "null", Description = "A .NET format string applied to the value (e.g. \"C2\", \"yyyy-MM-dd\")." }, + new() { Name = "DataType", Type = "BitDataGridColumnDataType", DefaultValue = "BitDataGridColumnDataType.Auto", Description = "The data type used to pick the editor/filter." }, + new() { Name = "Aggregate", Type = "BitDataGridAggregateType", DefaultValue = "BitDataGridAggregateType.None", Description = "The footer/group aggregate function." }, + new() { Name = "AggregateFormat", Type = "string?", DefaultValue = "null", Description = "Format string for the aggregate value. Falls back to Format." }, + new() { Name = "Template", Type = "RenderFragment?", DefaultValue = "null", Description = "Custom rendering for a data cell." }, + new() { Name = "HeaderTemplate", Type = "RenderFragment?", DefaultValue = "null", Description = "Custom rendering for the header cell content." }, + new() { Name = "EditTemplate", Type = "RenderFragment?", DefaultValue = "null", Description = "Custom editor rendered when the row/cell is in edit mode." }, + new() { Name = "FooterTemplate", Type = "RenderFragment?", DefaultValue = "null", Description = "Custom rendering for the footer/aggregate cell." }, + ], + }, + new() + { + Id = "BitDataGridReadRequest", + Title = "BitDataGridReadRequest", + Description = "Describes the data the grid needs from a server-side/infinite source (passed to OnRead/OnLoadMore).", + Parameters = + [ + new() { Name = "Skip", Type = "int", DefaultValue = "0", Description = "Zero-based number of items to skip." }, + new() { Name = "Take", Type = "int?", DefaultValue = "null", Description = "Maximum number of items to return (null means all)." }, + new() { Name = "Sorts", Type = "IReadOnlyList", DefaultValue = "[]", Description = "The active sort descriptors ordered by priority." }, + new() { Name = "Filters", Type = "IReadOnlyList", DefaultValue = "[]", Description = "The active filter descriptors." }, + ], + }, + new() + { + Id = "BitDataGridReadResult", + Title = "BitDataGridReadResult", + Description = "Result returned from a grid's OnRead/OnLoadMore callback.", + Parameters = + [ + new() { Name = "Items", Type = "IReadOnlyList", DefaultValue = "", Description = "The items for the current page/window." }, + new() { Name = "TotalCount", Type = "int", DefaultValue = "", Description = "The total number of items matching the current filters (ignored in infinite mode)." }, + ], + }, + new() + { + Id = "BitDataGridCellEventArgs", + Title = "BitDataGridCellEventArgs", + Description = "Arguments passed to cell-level event callbacks.", + Parameters = + [ + new() { Name = "Item", Type = "TItem", DefaultValue = "", Description = "The row item." }, + new() { Name = "Column", Type = "BitDataGridColumn", DefaultValue = "", Description = "The column the cell belongs to." }, + new() { Name = "ColumnId", Type = "string", DefaultValue = "", Description = "The column field/identifier." }, + new() { Name = "ColumnTitle", Type = "string", DefaultValue = "", Description = "The column's display title." }, + new() { Name = "Value", Type = "object?", DefaultValue = "null", Description = "The raw value of the cell." }, + new() { Name = "Mouse", Type = "MouseEventArgs", DefaultValue = "", Description = "The underlying browser mouse event." }, + ], + }, + new() + { + Id = "BitDataGridRowReorderEventArgs", + Title = "BitDataGridRowReorderEventArgs", + Description = "Arguments raised when a row is reordered via drag-and-drop.", + Parameters = + [ + new() { Name = "DraggedItem", Type = "TItem", DefaultValue = "", Description = "The dragged row item." }, + new() { Name = "TargetItem", Type = "TItem", DefaultValue = "", Description = "The drop-target row item." }, + new() { Name = "FromIndex", Type = "int", DefaultValue = "", Description = "The original index of the dragged item." }, + new() { Name = "ToIndex", Type = "int", DefaultValue = "", Description = "The destination index." }, + ], + }, + ]; + + private readonly List componentSubEnums = + [ + new() + { + Id = "BitDataGridColumnAlign", + Name = "BitDataGridColumnAlign", + Description = "Horizontal alignment of cell content.", + Items = + [ + new() { Name = "Left", Value = "0" }, + new() { Name = "Center", Value = "1" }, + new() { Name = "Right", Value = "2" }, + ] + }, + new() + { + Id = "BitDataGridSortDirection", + Name = "BitDataGridSortDirection", + Description = "Sort direction for a column.", + Items = + [ + new() { Name = "None", Value = "0" }, + new() { Name = "Ascending", Value = "1" }, + new() { Name = "Descending", Value = "2" }, + ] + }, + new() + { + Id = "BitDataGridSelectionMode", + Name = "BitDataGridSelectionMode", + Description = "How rows can be selected in the grid.", + Items = + [ + new() { Name = "None", Value = "0" }, + new() { Name = "Single", Value = "1" }, + new() { Name = "Multiple", Value = "2" }, + ] + }, + new() + { + Id = "BitDataGridAggregateType", + Name = "BitDataGridAggregateType", + Description = "Built-in aggregate functions for summary/footer rows.", + Items = + [ + new() { Name = "None", Value = "0" }, + new() { Name = "Sum", Value = "1" }, + new() { Name = "Average", Value = "2" }, + new() { Name = "Count", Value = "3" }, + new() { Name = "Min", Value = "4" }, + new() { Name = "Max", Value = "5" }, + ] + }, + new() + { + Id = "BitDataGridPagerPosition", + Name = "BitDataGridPagerPosition", + Description = "Where the pager is rendered relative to the grid.", + Items = + [ + new() { Name = "Bottom", Value = "0" }, + new() { Name = "Top", Value = "1" }, + new() { Name = "TopAndBottom", Value = "2" }, + ] + }, + new() + { + Id = "BitDataGridDirection", + Name = "BitDataGridDirection", + Description = "Text direction for the grid.", + Items = + [ + new() { Name = "Ltr", Value = "0" }, + new() { Name = "Rtl", Value = "1" }, + ] + }, + new() + { + Id = "BitDataGridColumnDataType", + Name = "BitDataGridColumnDataType", + Description = "The kind of editor/filter rendered for a column based on its data type.", + Items = + [ + new() { Name = "Auto", Value = "0" }, + new() { Name = "Text", Value = "1" }, + new() { Name = "Number", Value = "2" }, + new() { Name = "Boolean", Value = "3" }, + new() { Name = "Date", Value = "4" }, + new() { Name = "Enum", Value = "5" }, + ] + }, + new() + { + Id = "BitDataGridFilterOperator", + Name = "BitDataGridFilterOperator", + Description = "Comparison operators available for column filtering.", + Items = + [ + new() { Name = "Contains", Value = "0" }, + new() { Name = "DoesNotContain", Value = "1" }, + new() { Name = "StartsWith", Value = "2" }, + new() { Name = "EndsWith", Value = "3" }, + new() { Name = "Equals", Value = "4" }, + new() { Name = "NotEquals", Value = "5" }, + new() { Name = "GreaterThan", Value = "6" }, + new() { Name = "GreaterThanOrEqual", Value = "7" }, + new() { Name = "LessThan", Value = "8" }, + new() { Name = "LessThanOrEqual", Value = "9" }, + new() { Name = "IsEmpty", Value = "10" }, + new() { Name = "IsNotEmpty", Value = "11" }, + ] + }, + ]; +} diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.samples.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.samples.cs index d3f51b3e27..0968c0bda3 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.samples.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.samples.cs @@ -1,1239 +1,283 @@ -using Bit.BlazorUI.Demo.Client.Core.Components; - namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.DataGrid; -public partial class BitDataGridDemo : AppComponentBase +public partial class BitDataGridDemo { private readonly string example1RazorCode = @" - - c.Name)"" Sortable=""true"" IsDefaultSort=""BitDataGridSortDirection.Ascending""> - - {{""autofocus"", true}})"" /> - - - c.Medals.Gold)"" Sortable=""true"" /> - c.Medals.Silver)"" Sortable=""true"" /> - c.Medals.Bronze)"" Sortable=""true"" /> - c.Medals.Total)"" Sortable=""true"" /> - -"; + + + + + + + +"; private readonly string example1CsharpCode = @" -private IQueryable allCountries; -private string typicalSampleNameFilter = string.Empty; -private BitDataGridPaginationState pagination = new() { ItemsPerPage = 7 }; -private IQueryable FilteredItems => - allCountries?.Where(x => x.Name.Contains(typicalSampleNameFilter ?? string.Empty, StringComparison.CurrentCultureIgnoreCase)); - -protected override async Task OnInitializedAsync() -{ - allCountries = _countries.AsQueryable(); -} - -private static readonly CountryModel[] _countries = -[ - new CountryModel { Code = ""AR"", Name = ""Argentina"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 2 } }, - new CountryModel { Code = ""AM"", Name = ""Armenia"", Medals = new MedalsModel { Gold = 0, Silver = 2, Bronze = 2 } }, - new CountryModel { Code = ""AU"", Name = ""Australia"", Medals = new MedalsModel { Gold = 17, Silver = 7, Bronze = 22 } }, - new CountryModel { Code = ""AT"", Name = ""Austria"", Medals = new MedalsModel { Gold = 1, Silver = 1, Bronze = 5 } }, - new CountryModel { Code = ""AZ"", Name = ""Azerbaijan"", Medals = new MedalsModel { Gold = 0, Silver = 3, Bronze = 4 } }, - new CountryModel { Code = ""BS"", Name = ""Bahamas"", Medals = new MedalsModel { Gold = 2, Silver = 0, Bronze = 0 } }, - new CountryModel { Code = ""BH"", Name = ""Bahrain"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, - new CountryModel { Code = ""BY"", Name = ""Belarus"", Medals = new MedalsModel { Gold = 1, Silver = 3, Bronze = 3 } }, - new CountryModel { Code = ""BE"", Name = ""Belgium"", Medals = new MedalsModel { Gold = 3, Silver = 1, Bronze = 3 } }, - new CountryModel { Code = ""BM"", Name = ""Bermuda"", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 0 } }, - new CountryModel { Code = ""BW"", Name = ""Botswana"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = ""BR"", Name = ""Brazil"", Medals = new MedalsModel { Gold = 7, Silver = 6, Bronze = 8 } }, - new CountryModel { Code = ""BF"", Name = ""Burkina Faso"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = ""CA"", Name = ""Canada"", Medals = new MedalsModel { Gold = 7, Silver = 6, Bronze = 11 } }, - new CountryModel { Code = ""TW"", Name = ""Chinese Taipei"", Medals = new MedalsModel { Gold = 2, Silver = 4, Bronze = 6 } }, - new CountryModel { Code = ""CO"", Name = ""Colombia"", Medals = new MedalsModel { Gold = 0, Silver = 4, Bronze = 1 } }, - new CountryModel { Code = ""CI"", Name = ""Côte d'Ivoire"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = ""HR"", Name = ""Croatia"", Medals = new MedalsModel { Gold = 3, Silver = 3, Bronze = 2 } }, - new CountryModel { Code = ""CU"", Name = ""Cuba"", Medals = new MedalsModel { Gold = 7, Silver = 3, Bronze = 5 } }, - new CountryModel { Code = ""CZ"", Name = ""Czech Republic"", Medals = new MedalsModel { Gold = 4, Silver = 4, Bronze = 3 } }, - new CountryModel { Code = ""DK"", Name = ""Denmark"", Medals = new MedalsModel { Gold = 3, Silver = 4, Bronze = 4 } }, - new CountryModel { Code = ""DO"", Name = ""Dominican Republic"", Medals = new MedalsModel { Gold = 0, Silver = 3, Bronze = 2 } }, - new CountryModel { Code = ""EC"", Name = ""Ecuador"", Medals = new MedalsModel { Gold = 2, Silver = 1, Bronze = 0 } }, - new CountryModel { Code = ""EE"", Name = ""Estonia"", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = ""ET"", Name = ""Ethiopia"", Medals = new MedalsModel { Gold = 1, Silver = 1, Bronze = 2 } }, - new CountryModel { Code = ""FJ"", Name = ""Fiji"", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = ""FI"", Name = ""Finland"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 2 } }, - new CountryModel { Code = ""FR"", Name = ""France"", Medals = new MedalsModel { Gold = 10, Silver = 12, Bronze = 11 } }, - new CountryModel { Code = ""GE"", Name = ""Georgia"", Medals = new MedalsModel { Gold = 2, Silver = 5, Bronze = 1 } }, - new CountryModel { Code = ""DE"", Name = ""Germany"", Medals = new MedalsModel { Gold = 10, Silver = 11, Bronze = 16 } }, - new CountryModel { Code = ""GH"", Name = ""Ghana"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = ""GB"", Name = ""Great Britain"", Medals = new MedalsModel { Gold = 22, Silver = 21, Bronze = 22 } }, - new CountryModel { Code = ""GR"", Name = ""Greece"", Medals = new MedalsModel { Gold = 2, Silver = 1, Bronze = 1 } }, - new CountryModel { Code = ""GD"", Name = ""Grenada"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = ""HK"", Name = ""Hong Kong, China"", Medals = new MedalsModel { Gold = 1, Silver = 2, Bronze = 3 } }, - new CountryModel { Code = ""HU"", Name = ""Hungary"", Medals = new MedalsModel { Gold = 6, Silver = 7, Bronze = 7 } }, - new CountryModel { Code = ""ID"", Name = ""Indonesia"", Medals = new MedalsModel { Gold = 1, Silver = 1, Bronze = 3 } }, - new CountryModel { Code = ""IE"", Name = ""Ireland"", Medals = new MedalsModel { Gold = 2, Silver = 0, Bronze = 2 } }, - new CountryModel { Code = ""IR"", Name = ""Iran"", Medals = new MedalsModel { Gold = 3, Silver = 2, Bronze = 2 } }, - new CountryModel { Code = ""IL"", Name = ""Israel"", Medals = new MedalsModel { Gold = 2, Silver = 0, Bronze = 2 } }, - new CountryModel { Code = ""IT"", Name = ""Italy"", Medals = new MedalsModel { Gold = 10, Silver = 10, Bronze = 20 } }, - new CountryModel { Code = ""JM"", Name = ""Jamaica"", Medals = new MedalsModel { Gold = 4, Silver = 1, Bronze = 4 } }, - new CountryModel { Code = ""JO"", Name = ""Jordan"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 1 } }, - new CountryModel { Code = ""KZ"", Name = ""Kazakhstan"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 8 } }, - new CountryModel { Code = ""KE"", Name = ""Kenya"", Medals = new MedalsModel { Gold = 4, Silver = 4, Bronze = 2 } }, - new CountryModel { Code = ""XK"", Name = ""Kosovo"", Medals = new MedalsModel { Gold = 2, Silver = 0, Bronze = 0 } }, - new CountryModel { Code = ""KW"", Name = ""Kuwait"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = ""LV"", Name = ""Latvia"", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = ""LT"", Name = ""Lithuania"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, - new CountryModel { Code = ""MY"", Name = ""Malaysia"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 1 } }, - new CountryModel { Code = ""MX"", Name = ""Mexico"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 4 } }, - new CountryModel { Code = ""MA"", Name = ""Morocco"", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 0 } }, - new CountryModel { Code = ""NA"", Name = ""Namibia"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, - new CountryModel { Code = ""NL"", Name = ""Netherlands"", Medals = new MedalsModel { Gold = 10, Silver = 12, Bronze = 14 } }, - new CountryModel { Code = ""NZ"", Name = ""New Zealand"", Medals = new MedalsModel { Gold = 7, Silver = 6, Bronze = 7 } }, - new CountryModel { Code = ""MK"", Name = ""North Macedonia"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, - new CountryModel { Code = ""NO"", Name = ""Norway"", Medals = new MedalsModel { Gold = 4, Silver = 2, Bronze = 2 } }, - new CountryModel { Code = ""PH"", Name = ""Philippines"", Medals = new MedalsModel { Gold = 1, Silver = 2, Bronze = 1 } }, - new CountryModel { Code = ""PL"", Name = ""Poland"", Medals = new MedalsModel { Gold = 4, Silver = 5, Bronze = 5 } }, - new CountryModel { Code = ""PT"", Name = ""Portugal"", Medals = new MedalsModel { Gold = 1, Silver = 1, Bronze = 2 } }, - new CountryModel { Code = ""PR"", Name = ""Puerto Rico"", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 0 } }, - new CountryModel { Code = ""QA"", Name = ""Qatar"", Medals = new MedalsModel { Gold = 2, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = ""KR"", Name = ""Republic of Korea"", Medals = new MedalsModel { Gold = 6, Silver = 4, Bronze = 10 } }, - new CountryModel { Code = ""MD"", Name = ""Republic of Moldova"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = ""RO"", Name = ""Romania"", Medals = new MedalsModel { Gold = 1, Silver = 3, Bronze = 0 } }, - new CountryModel { Code = ""SM"", Name = ""San Marino"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 2 } }, - new CountryModel { Code = ""SA"", Name = ""Saudi Arabia"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, - new CountryModel { Code = ""RS"", Name = ""Serbia"", Medals = new MedalsModel { Gold = 3, Silver = 1, Bronze = 5 } }, - new CountryModel { Code = ""SK"", Name = ""Slovakia"", Medals = new MedalsModel { Gold = 1, Silver = 2, Bronze = 1 } }, - new CountryModel { Code = ""SI"", Name = ""Slovenia"", Medals = new MedalsModel { Gold = 3, Silver = 1, Bronze = 1 } }, - new CountryModel { Code = ""ZA"", Name = ""South Africa"", Medals = new MedalsModel { Gold = 1, Silver = 2, Bronze = 0 } }, - new CountryModel { Code = ""ES"", Name = ""Spain"", Medals = new MedalsModel { Gold = 3, Silver = 8, Bronze = 6 } }, - new CountryModel { Code = ""SE"", Name = ""Sweden"", Medals = new MedalsModel { Gold = 3, Silver = 6, Bronze = 0 } }, - new CountryModel { Code = ""CH"", Name = ""Switzerland"", Medals = new MedalsModel { Gold = 3, Silver = 4, Bronze = 6 } }, - new CountryModel { Code = ""SY"", Name = ""Syrian Arab Republic"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = ""TH"", Name = ""Thailand"", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = ""TR"", Name = ""Turkey"", Medals = new MedalsModel { Gold = 2, Silver = 2, Bronze = 9 } }, - new CountryModel { Code = ""TM"", Name = ""Turkmenistan"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, - new CountryModel { Code = ""UA"", Name = ""Ukraine"", Medals = new MedalsModel { Gold = 1, Silver = 6, Bronze = 12 } }, - new CountryModel { Code = ""US"", Name = ""United States of America"", Medals = new MedalsModel { Gold = 39, Silver = 41, Bronze = 33 } }, - new CountryModel { Code = ""UZ"", Name = ""Uzbekistan"", Medals = new MedalsModel { Gold = 3, Silver = 0, Bronze = 2 } }, - new CountryModel { Code = ""VE"", Name = ""Venezuela"", Medals = new MedalsModel { Gold = 1, Silver = 3, Bronze = 0 } }, -]; - -public class CountryModel -{ - public string Code { get; set; } - public string Name { get; set; } - public MedalsModel Medals { get; set; } -} - -public class MedalsModel -{ - public int Gold { get; set; } - public int Silver { get; set; } - public int Bronze { get; set; } - public int Total => Gold + Silver + Bronze; -}"; +private List products = SampleData.Generate(50);"; private readonly string example2RazorCode = @" - - -
-
- - c.Name)"" IsDefaultSort=""BitDataGridSortDirection.Ascending"" Sortable=""true"" Class=""wide""> - - {{""autofocus"", true}})"" /> - - - - - - c.Medals.Gold)"" Sortable=""true"" /> - c.Medals.Silver)"" Sortable=""true"" /> - c.Medals.Bronze)"" Sortable=""true"" /> - - - - - -
- $""Total: {v.TotalItemCount}"")""> - @(state.CurrentPageIndex + 1) / @(state.LastPageIndex + 1) - -
"; + + + + + + + +"; private readonly string example2CsharpCode = @" -private IQueryable allCountries; -private string typicalSampleNameFilter = string.Empty; -private BitDataGridPaginationState pagination = new() { ItemsPerPage = 7 }; -private IQueryable FilteredItems - => allCountries?.Where(x => x.Name.Contains(typicalSampleNameFilter ?? string.Empty, StringComparison.CurrentCultureIgnoreCase)); - -protected override async Task OnInitializedAsync() -{ - allCountries = _countries.AsQueryable(); -} - -private static readonly CountryModel[] _countries = -[ - new CountryModel { Code = ""AR"", Name = ""Argentina"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 2 } }, - new CountryModel { Code = ""AM"", Name = ""Armenia"", Medals = new MedalsModel { Gold = 0, Silver = 2, Bronze = 2 } }, - new CountryModel { Code = ""AU"", Name = ""Australia"", Medals = new MedalsModel { Gold = 17, Silver = 7, Bronze = 22 } }, - new CountryModel { Code = ""AT"", Name = ""Austria"", Medals = new MedalsModel { Gold = 1, Silver = 1, Bronze = 5 } }, - new CountryModel { Code = ""AZ"", Name = ""Azerbaijan"", Medals = new MedalsModel { Gold = 0, Silver = 3, Bronze = 4 } }, - new CountryModel { Code = ""BS"", Name = ""Bahamas"", Medals = new MedalsModel { Gold = 2, Silver = 0, Bronze = 0 } }, - new CountryModel { Code = ""BH"", Name = ""Bahrain"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, - new CountryModel { Code = ""BY"", Name = ""Belarus"", Medals = new MedalsModel { Gold = 1, Silver = 3, Bronze = 3 } }, - new CountryModel { Code = ""BE"", Name = ""Belgium"", Medals = new MedalsModel { Gold = 3, Silver = 1, Bronze = 3 } }, - new CountryModel { Code = ""BM"", Name = ""Bermuda"", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 0 } }, - new CountryModel { Code = ""BW"", Name = ""Botswana"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = ""BR"", Name = ""Brazil"", Medals = new MedalsModel { Gold = 7, Silver = 6, Bronze = 8 } }, - new CountryModel { Code = ""BF"", Name = ""Burkina Faso"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = ""CA"", Name = ""Canada"", Medals = new MedalsModel { Gold = 7, Silver = 6, Bronze = 11 } }, - new CountryModel { Code = ""TW"", Name = ""Chinese Taipei"", Medals = new MedalsModel { Gold = 2, Silver = 4, Bronze = 6 } }, - new CountryModel { Code = ""CO"", Name = ""Colombia"", Medals = new MedalsModel { Gold = 0, Silver = 4, Bronze = 1 } }, - new CountryModel { Code = ""CI"", Name = ""Côte d'Ivoire"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = ""HR"", Name = ""Croatia"", Medals = new MedalsModel { Gold = 3, Silver = 3, Bronze = 2 } }, - new CountryModel { Code = ""CU"", Name = ""Cuba"", Medals = new MedalsModel { Gold = 7, Silver = 3, Bronze = 5 } }, - new CountryModel { Code = ""CZ"", Name = ""Czech Republic"", Medals = new MedalsModel { Gold = 4, Silver = 4, Bronze = 3 } }, - new CountryModel { Code = ""DK"", Name = ""Denmark"", Medals = new MedalsModel { Gold = 3, Silver = 4, Bronze = 4 } }, - new CountryModel { Code = ""DO"", Name = ""Dominican Republic"", Medals = new MedalsModel { Gold = 0, Silver = 3, Bronze = 2 } }, - new CountryModel { Code = ""EC"", Name = ""Ecuador"", Medals = new MedalsModel { Gold = 2, Silver = 1, Bronze = 0 } }, - new CountryModel { Code = ""EE"", Name = ""Estonia"", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = ""ET"", Name = ""Ethiopia"", Medals = new MedalsModel { Gold = 1, Silver = 1, Bronze = 2 } }, - new CountryModel { Code = ""FJ"", Name = ""Fiji"", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = ""FI"", Name = ""Finland"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 2 } }, - new CountryModel { Code = ""FR"", Name = ""France"", Medals = new MedalsModel { Gold = 10, Silver = 12, Bronze = 11 } }, - new CountryModel { Code = ""GE"", Name = ""Georgia"", Medals = new MedalsModel { Gold = 2, Silver = 5, Bronze = 1 } }, - new CountryModel { Code = ""DE"", Name = ""Germany"", Medals = new MedalsModel { Gold = 10, Silver = 11, Bronze = 16 } }, - new CountryModel { Code = ""GH"", Name = ""Ghana"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = ""GB"", Name = ""Great Britain"", Medals = new MedalsModel { Gold = 22, Silver = 21, Bronze = 22 } }, - new CountryModel { Code = ""GR"", Name = ""Greece"", Medals = new MedalsModel { Gold = 2, Silver = 1, Bronze = 1 } }, - new CountryModel { Code = ""GD"", Name = ""Grenada"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = ""HK"", Name = ""Hong Kong, China"", Medals = new MedalsModel { Gold = 1, Silver = 2, Bronze = 3 } }, - new CountryModel { Code = ""HU"", Name = ""Hungary"", Medals = new MedalsModel { Gold = 6, Silver = 7, Bronze = 7 } }, - new CountryModel { Code = ""ID"", Name = ""Indonesia"", Medals = new MedalsModel { Gold = 1, Silver = 1, Bronze = 3 } }, - new CountryModel { Code = ""IE"", Name = ""Ireland"", Medals = new MedalsModel { Gold = 2, Silver = 0, Bronze = 2 } }, - new CountryModel { Code = ""IR"", Name = ""Iran"", Medals = new MedalsModel { Gold = 3, Silver = 2, Bronze = 2 } }, - new CountryModel { Code = ""IL"", Name = ""Israel"", Medals = new MedalsModel { Gold = 2, Silver = 0, Bronze = 2 } }, - new CountryModel { Code = ""IT"", Name = ""Italy"", Medals = new MedalsModel { Gold = 10, Silver = 10, Bronze = 20 } }, - new CountryModel { Code = ""JM"", Name = ""Jamaica"", Medals = new MedalsModel { Gold = 4, Silver = 1, Bronze = 4 } }, - new CountryModel { Code = ""JO"", Name = ""Jordan"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 1 } }, - new CountryModel { Code = ""KZ"", Name = ""Kazakhstan"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 8 } }, - new CountryModel { Code = ""KE"", Name = ""Kenya"", Medals = new MedalsModel { Gold = 4, Silver = 4, Bronze = 2 } }, - new CountryModel { Code = ""XK"", Name = ""Kosovo"", Medals = new MedalsModel { Gold = 2, Silver = 0, Bronze = 0 } }, - new CountryModel { Code = ""KW"", Name = ""Kuwait"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = ""LV"", Name = ""Latvia"", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = ""LT"", Name = ""Lithuania"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, - new CountryModel { Code = ""MY"", Name = ""Malaysia"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 1 } }, - new CountryModel { Code = ""MX"", Name = ""Mexico"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 4 } }, - new CountryModel { Code = ""MA"", Name = ""Morocco"", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 0 } }, - new CountryModel { Code = ""NA"", Name = ""Namibia"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, - new CountryModel { Code = ""NL"", Name = ""Netherlands"", Medals = new MedalsModel { Gold = 10, Silver = 12, Bronze = 14 } }, - new CountryModel { Code = ""NZ"", Name = ""New Zealand"", Medals = new MedalsModel { Gold = 7, Silver = 6, Bronze = 7 } }, - new CountryModel { Code = ""MK"", Name = ""North Macedonia"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, - new CountryModel { Code = ""NO"", Name = ""Norway"", Medals = new MedalsModel { Gold = 4, Silver = 2, Bronze = 2 } }, - new CountryModel { Code = ""PH"", Name = ""Philippines"", Medals = new MedalsModel { Gold = 1, Silver = 2, Bronze = 1 } }, - new CountryModel { Code = ""PL"", Name = ""Poland"", Medals = new MedalsModel { Gold = 4, Silver = 5, Bronze = 5 } }, - new CountryModel { Code = ""PT"", Name = ""Portugal"", Medals = new MedalsModel { Gold = 1, Silver = 1, Bronze = 2 } }, - new CountryModel { Code = ""PR"", Name = ""Puerto Rico"", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 0 } }, - new CountryModel { Code = ""QA"", Name = ""Qatar"", Medals = new MedalsModel { Gold = 2, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = ""KR"", Name = ""Republic of Korea"", Medals = new MedalsModel { Gold = 6, Silver = 4, Bronze = 10 } }, - new CountryModel { Code = ""MD"", Name = ""Republic of Moldova"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = ""RO"", Name = ""Romania"", Medals = new MedalsModel { Gold = 1, Silver = 3, Bronze = 0 } }, - new CountryModel { Code = ""SM"", Name = ""San Marino"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 2 } }, - new CountryModel { Code = ""SA"", Name = ""Saudi Arabia"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, - new CountryModel { Code = ""RS"", Name = ""Serbia"", Medals = new MedalsModel { Gold = 3, Silver = 1, Bronze = 5 } }, - new CountryModel { Code = ""SK"", Name = ""Slovakia"", Medals = new MedalsModel { Gold = 1, Silver = 2, Bronze = 1 } }, - new CountryModel { Code = ""SI"", Name = ""Slovenia"", Medals = new MedalsModel { Gold = 3, Silver = 1, Bronze = 1 } }, - new CountryModel { Code = ""ZA"", Name = ""South Africa"", Medals = new MedalsModel { Gold = 1, Silver = 2, Bronze = 0 } }, - new CountryModel { Code = ""ES"", Name = ""Spain"", Medals = new MedalsModel { Gold = 3, Silver = 8, Bronze = 6 } }, - new CountryModel { Code = ""SE"", Name = ""Sweden"", Medals = new MedalsModel { Gold = 3, Silver = 6, Bronze = 0 } }, - new CountryModel { Code = ""CH"", Name = ""Switzerland"", Medals = new MedalsModel { Gold = 3, Silver = 4, Bronze = 6 } }, - new CountryModel { Code = ""SY"", Name = ""Syrian Arab Republic"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = ""TH"", Name = ""Thailand"", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = ""TR"", Name = ""Turkey"", Medals = new MedalsModel { Gold = 2, Silver = 2, Bronze = 9 } }, - new CountryModel { Code = ""TM"", Name = ""Turkmenistan"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, - new CountryModel { Code = ""UA"", Name = ""Ukraine"", Medals = new MedalsModel { Gold = 1, Silver = 6, Bronze = 12 } }, - new CountryModel { Code = ""US"", Name = ""United States of America"", Medals = new MedalsModel { Gold = 39, Silver = 41, Bronze = 33 } }, - new CountryModel { Code = ""UZ"", Name = ""Uzbekistan"", Medals = new MedalsModel { Gold = 3, Silver = 0, Bronze = 2 } }, - new CountryModel { Code = ""VE"", Name = ""Venezuela"", Medals = new MedalsModel { Gold = 1, Silver = 3, Bronze = 0 } }, -]; - -public class CountryModel -{ - public string Code { get; set; } - public string Name { get; set; } - public MedalsModel Medals { get; set; } -} - -public class MedalsModel -{ - public int Gold { get; set; } - public int Silver { get; set; } - public int Bronze { get; set; } - public int Total => Gold + Silver + Bronze; -} -"; +private List products = SampleData.Generate(200);"; private readonly string example3RazorCode = @" -@using System.Text.Json; -@inject HttpClient HttpClient -@inject NavigationManager NavManager - - - -
- - c.EventId)"" /> - c.State)"" /> - c.City)"" /> - c.RecallingFirm)"" Title=""Company"" /> - c.Status)"" /> - c.ReportDate)"" Title=""Report Date"" Sortable=""true"" /> - -
-
- -
"; + + + + + +"; private readonly string example3CsharpCode = @" -BitDataGrid? dataGrid; -string _virtualSampleNameFilter = string.Empty; -BitDataGridItemsProvider foodRecallProvider; - -string VirtualSampleNameFilter -{ - get => _virtualSampleNameFilter; - set - { - _virtualSampleNameFilter = value; - _ = dataGrid.RefreshDataAsync(); - } -} - -protected override async Task OnInitializedAsync() -{ - foodRecallProvider = async req => - { - try - { - var query = new Dictionary - { - { ""search"",$""recalling_firm:\""{_virtualSampleNameFilter}\"" }, - { ""skip"", req.StartIndex }, - { ""limit"", req.Count } - }; - - var sort = req.GetSortByProperties().SingleOrDefault(); - - if (sort != default) - { - var sortByColumnName = sort.PropertyName switch - { - nameof(FoodRecall.ReportDate) => ""report_date"", - _ => throw new InvalidOperationException() - }; - - query.Add(""sort"", $""{sortByColumnName}:{(sort.Direction == BitDataGridSortDirection.Ascending ? ""asc"" : ""desc"")}""); - } - - var url = NavManager.GetUriWithQueryParameters(""https://api.fda.gov/food/enforcement.json"", query); - - var data = await HttpClient.GetFromJsonAsync(url, AppJsonContext.Default.FoodRecallQueryResult, req.CancellationToken); - - return BitDataGridItemsProviderResult.From( - items: data!.Results, - totalItemCount: data!.Meta.Results.Total); - } - catch - { - return BitDataGridItemsProviderResult.From(new List { }, 0); - } - }; -} - -//https://devblogs.microsoft.com/dotnet/try-the-new-system-text-json-source-generator/ -[JsonSerializable(typeof(FoodRecallQueryResult))] -[JsonSerializable(typeof(Meta))] -[JsonSerializable(typeof(FoodRecall))] -[JsonSerializable(typeof(Results))] -[JsonSerializable(typeof(Openfda))] -public partial class AppJsonContext : JsonSerializerContext -{ - -} - -public class FoodRecallQueryResult -{ - [JsonPropertyName(""meta"")] - public Meta? Meta { get; set; } - - [JsonPropertyName(""results"")] - public List? Results { get; set; } -} - -public class Meta -{ - [JsonPropertyName(""disclaimer"")] - public string? Disclaimer { get; set; } - - [JsonPropertyName(""terms"")] - public string? Terms { get; set; } - - [JsonPropertyName(""license"")] - public string? License { get; set; } - - [JsonPropertyName(""last_updated"")] - public string? LastUpdated { get; set; } - - [JsonPropertyName(""results"")] - public Results? Results { get; set; } -} - -public class FoodRecall -{ - [JsonPropertyName(""country"")] - public string? CountryModel { get; set; } - - [JsonPropertyName(""city"")] - public string? City { get; set; } - - [JsonPropertyName(""address_1"")] - public string? Address1 { get; set; } - - [JsonPropertyName(""reason_for_recall"")] - public string? ReasonForRecall { get; set; } - - [JsonPropertyName(""address_2"")] - public string? Address2 { get; set; } - - [JsonPropertyName(""product_quantity"")] - public string? ProductQuantity { get; set; } - - [JsonPropertyName(""code_info"")] - public string? CodeInfo { get; set; } - - [JsonPropertyName(""center_classification_date"")] - public string? CenterClassificationDate { get; set; } - - [JsonPropertyName(""distribution_pattern"")] - public string? DistributionPattern { get; set; } - - [JsonPropertyName(""state"")] - public string? State { get; set; } - - [JsonPropertyName(""product_description"")] - public string? ProductDescription { get; set; } - - [JsonPropertyName(""report_date"")] - public string? ReportDate { get; set; } - - [JsonPropertyName(""classification"")] - public string? Classification { get; set; } - - [JsonPropertyName(""openfda"")] - public Openfda? Openfda { get; set; } - - [JsonPropertyName(""recalling_firm"")] - public string? RecallingFirm { get; set; } - - [JsonPropertyName(""recall_number"")] - public string? RecallNumber { get; set; } - - [JsonPropertyName(""initial_firm_notification"")] - public string? InitialFirmNotification { get; set; } - - [JsonPropertyName(""product_type"")] - public string? ProductType { get; set; } - - [JsonPropertyName(""event_id"")] - public string? EventId { get; set; } - - [JsonPropertyName(""more_code_info"")] - public string? MoreCodeInfo { get; set; } - - [JsonPropertyName(""recall_initiation_date"")] - public string? RecallInitiationDate { get; set; } - - [JsonPropertyName(""postal_code"")] - public string? PostalCode { get; set; } - - [JsonPropertyName(""voluntary_mandated"")] - public string? VoluntaryMandated { get; set; } - - [JsonPropertyName(""status"")] - public string? Status { get; set; } -} - -public class Results -{ - [JsonPropertyName(""skip"")] - public int Skip { get; set; } - - [JsonPropertyName(""limit"")] - public int Limit { get; set; } - - [JsonPropertyName(""total"")] - public int Total { get; set; } -} - -public class Openfda -{ -} -"; +private List products = SampleData.Generate(60); +private BitDataGridSelectionMode selectionMode = BitDataGridSelectionMode.Multiple; +private IReadOnlyList selected = new List();"; private readonly string example4RazorCode = @" -@using System.Text.Json; -@inject HttpClient HttpClient -@inject NavigationManager NavManager - - - -
- p.Id)"" TGridItem=""ProductDto"" Virtualize> - p.Id)"" Sortable=""true"" IsDefaultSort=""BitDataGridSortDirection.Ascending"" /> - p.Name)"" Sortable=""true"" /> - p.Price)"" Sortable=""true"" /> - -
-
- -
"; + p.Id""> + + + + + +"; private readonly string example4CsharpCode = @" +private List products = SampleData.Generate(25); -// ========== Server code ========== - -// To make following aspnetcore controller work, simply change services.AddControllers(); to services.AddControllers().AddOData(options => options.EnableQueryFeatures()) -// Note that this need Microsoft.AspNetCore.OData nuget package to be installed - -[ApiController] -[Route(""[controller]/[action]"")] -public class ProductsController : ControllerBase -{ - private static readonly Random _random = new Random(); - - private static readonly ProductDto[] _products = Enumerable.Range(1, 500_000) - .Select(i => new ProductDto { Id = i, Name = Guid.NewGuid().ToString(""N""), Price = _random.Next(1, 100) }) - .ToArray(); - - [HttpGet] - public async Task> GetProducts(ODataQueryOptions odataQuery, CancellationToken cancellationToken) - { - var query = _products.AsQueryable(); - - query = (IQueryable)odataQuery.ApplyTo(query, ignoreQueryOptions: AllowedQueryOptions.Top | AllowedQueryOptions.Skip); - - var totalCount = query.Count(); - - if (odataQuery.Skip is not null) - query = query.Skip(odataQuery.Skip.Value); - - query = query.Take(odataQuery.Top?.Value ?? 50); - - return new PagedResult(query.ToArray(), totalCount); - } -} - - -// ========== Shared code ========== - - -//https://devblogs.microsoft.com/dotnet/try-the-new-system-text-json-source-generator/ -[JsonSerializable(typeof(PagedResult))] -public partial class AppJsonContext : JsonSerializerContext -{ - -} - -public class ProductDto -{ - public int Id { get; set; } - - public string Name { get; set; } - - public decimal Price { get; set; } -} - -public class PagedResult -{ - public IList? Items { get; set; } - - public int TotalCount { get; set; } - - public PagedResult(IList items, int totalCount) - { - Items = items; - TotalCount = totalCount; - } - - public PagedResult() - { - - } -} - - -// ========== Client code ========== - - -BitDataGrid? productsDataGrid; -string _odataSampleNameFilter = string.Empty; -BitDataGridItemsProvider productsItemsProvider; - -string ODataSampleNameFilter -{ - get => _odataSampleNameFilter; - set - { - _odataSampleNameFilter = value; - _ = productsDataGrid.RefreshDataAsync(); - } -} - -protected override async Task OnInitializedAsync() -{ - productsItemsProvider = async req => - { - try - { - // https://docs.microsoft.com/en-us/odata/concepts/queryoptions-overview - - var query = new Dictionary() - { - { ""$top"", req.Count ?? 50 }, - { ""$skip"", req.StartIndex } - }; - - if (string.IsNullOrEmpty(_odataSampleNameFilter) is false) - { - query.Add(""$filter"", $""contains(Name,'{_odataSampleNameFilter}')""); - } - - if (req.GetSortByProperties().Any()) - { - query.Add(""$orderby"", string.Join("", "", req.GetSortByProperties().Select(p => $""{p.PropertyName} {(p.Direction == BitDataGridSortDirection.Ascending ? ""asc"" : ""desc"")}""))); - } - - var url = NavManager.GetUriWithQueryParameters(""api/Products/GetProducts"", query); - - var data = await HttpClient.GetFromJsonAsync(url, AppJsonContext.Default.PagedResultProductDto); - - return BitDataGridItemsProviderResult.From(data!.Items, (int)data!.TotalCount); - } - catch - { - return BitDataGridItemsProviderResult.From(new List { }, 0); - } - }; -}"; +private Product CreateProduct() => new() { Id = NextId(), Name = ""New product"", Category = Category.Electronics }; +private void OnCreate(Product p) { /* ... */ } +private void OnSave(Product p) { if (!products.Contains(p)) products.Insert(0, p); } +private void OnDelete(Product p) => products.Remove(p);"; private readonly string example5RazorCode = @" -@using System.Text.Json; -@inject HttpClient HttpClient -@inject NavigationManager NavManager - - - -
- p.Id)"" TGridItem=""ProductDto"" Pagination=""pagination""> - - p.Id)"" Sortable=""true"" IsDefaultSort=""BitDataGridSortDirection.Ascending"" /> - p.Name)"" Sortable=""true"" /> - p.Price)"" Sortable=""true"" /> - - - - Loading items... - - - - -
- $""Total: {v.TotalItemCount?.ToString(""N0"")}"")""> - @(state.CurrentPageIndex + 1) / @(state.LastPageIndex + 1) -"; + + + + + + +"; private readonly string example5CsharpCode = @" - -// ========== Server code ========== - -// To make following aspnetcore controller work, simply change services.AddControllers(); to services.AddControllers().AddOData(options => options.EnableQueryFeatures()) -// Note that this need Microsoft.AspNetCore.OData nuget package to be installed - -[ApiController] -[Route(""[controller]/[action]"")] -public class ProductsController : ControllerBase -{ - private static readonly Random _random = new Random(); - - private static readonly ProductDto[] _products = Enumerable.Range(1, 500_000) - .Select(i => new ProductDto { Id = i, Name = Guid.NewGuid().ToString(""N""), Price = _random.Next(1, 100) }) - .ToArray(); - - [HttpGet] - public async Task> GetProducts(ODataQueryOptions odataQuery, CancellationToken cancellationToken) - { - var query = _products.AsQueryable(); - - query = (IQueryable)odataQuery.ApplyTo(query, ignoreQueryOptions: AllowedQueryOptions.Top | AllowedQueryOptions.Skip); - - var totalCount = query.Count(); - - if (odataQuery.Skip is not null) - { - query = query.Skip(odataQuery.Skip.Value); - } - - query = query.Take(odataQuery.Top?.Value ?? 50); - - return new PagedResult(query.ToArray(), totalCount); - } -} - - -// ========== Shared code ========== - - -//https://devblogs.microsoft.com/dotnet/try-the-new-system-text-json-source-generator/ -[JsonSerializable(typeof(PagedResult))] -public partial class AppJsonContext : JsonSerializerContext -{ - -} - -public class ProductDto -{ - public int Id { get; set; } - - public string Name { get; set; } - - public decimal Price { get; set; } -} - -public class PagedResult -{ - public IList? Items { get; set; } - - public int TotalCount { get; set; } - - public PagedResult(IList items, int totalCount) - { - Items = items; - TotalCount = totalCount; - } - - public PagedResult() - { - - } -} - - -// ========== Client code ========== - - -BitDataGrid? productsDataGrid; -BitDataGridItemsProvider productsItemsProvider; -BitDataGridPaginationState pagination = new() { ItemsPerPage = 7 }; - -protected override async Task OnInitializedAsync() -{ - productsItemsProvider = async req => - { - try - { - // https://docs.microsoft.com/en-us/odata/concepts/queryoptions-overview - - var query = new Dictionary() - { - { ""$top"", req.Count ?? 50 }, - { ""$skip"", req.StartIndex } - }; - - if (req.GetSortByProperties().Any()) - { - query.Add(""$orderby"", string.Join("", "", req.GetSortByProperties().Select(p => $""{p.PropertyName} {(p.Direction == BitDataGridSortDirection.Ascending ? ""asc"" : ""desc"")}""))); - } - - var url = NavManager.GetUriWithQueryParameters(""api/Products/GetProducts"", query); - - var data = await HttpClient.GetFromJsonAsync(url, AppJsonContext.Default.PagedResultProductDto); - - return BitDataGridItemsProviderResult.From(data!.Items, (int)data!.TotalCount); - } - catch - { - return BitDataGridItemsProviderResult.From(new List { }, 0); - } - }; -}"; +private List products = SampleData.Generate(80);"; private readonly string example6RazorCode = @" - - -
- - c.Name)"" /> - c.Medals.Gold)"" /> - c.Medals.Silver)"" /> - c.Medals.Bronze)"" /> - c.Medals.Total)"" /> - - -
"; + + +
Supplier: @p.Supplier
+
+ + + 📦 Product + + + Total: @agg.FormattedValue + + + + + +
"; private readonly string example6CsharpCode = @" -private IQueryable allCountries; -private BitDataGridPaginationState pagination = new() { ItemsPerPage = 7 }; - -protected override async Task OnInitializedAsync() -{ - allCountries = _countries.AsQueryable(); -} - -private static readonly CountryModel[] _countries = -[ - new CountryModel { Code = ""AR"", Name = ""Argentina"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 2 } }, - new CountryModel { Code = ""AM"", Name = ""Armenia"", Medals = new MedalsModel { Gold = 0, Silver = 2, Bronze = 2 } }, - new CountryModel { Code = ""AU"", Name = ""Australia"", Medals = new MedalsModel { Gold = 17, Silver = 7, Bronze = 22 } }, - new CountryModel { Code = ""AT"", Name = ""Austria"", Medals = new MedalsModel { Gold = 1, Silver = 1, Bronze = 5 } }, - new CountryModel { Code = ""AZ"", Name = ""Azerbaijan"", Medals = new MedalsModel { Gold = 0, Silver = 3, Bronze = 4 } }, - new CountryModel { Code = ""BS"", Name = ""Bahamas"", Medals = new MedalsModel { Gold = 2, Silver = 0, Bronze = 0 } }, - new CountryModel { Code = ""BH"", Name = ""Bahrain"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, - new CountryModel { Code = ""BY"", Name = ""Belarus"", Medals = new MedalsModel { Gold = 1, Silver = 3, Bronze = 3 } }, - new CountryModel { Code = ""BE"", Name = ""Belgium"", Medals = new MedalsModel { Gold = 3, Silver = 1, Bronze = 3 } }, - new CountryModel { Code = ""BM"", Name = ""Bermuda"", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 0 } }, - new CountryModel { Code = ""BW"", Name = ""Botswana"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = ""BR"", Name = ""Brazil"", Medals = new MedalsModel { Gold = 7, Silver = 6, Bronze = 8 } }, - new CountryModel { Code = ""BF"", Name = ""Burkina Faso"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = ""CA"", Name = ""Canada"", Medals = new MedalsModel { Gold = 7, Silver = 6, Bronze = 11 } }, - new CountryModel { Code = ""TW"", Name = ""Chinese Taipei"", Medals = new MedalsModel { Gold = 2, Silver = 4, Bronze = 6 } }, - new CountryModel { Code = ""CO"", Name = ""Colombia"", Medals = new MedalsModel { Gold = 0, Silver = 4, Bronze = 1 } }, - new CountryModel { Code = ""CI"", Name = ""Côte d'Ivoire"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = ""HR"", Name = ""Croatia"", Medals = new MedalsModel { Gold = 3, Silver = 3, Bronze = 2 } }, - new CountryModel { Code = ""CU"", Name = ""Cuba"", Medals = new MedalsModel { Gold = 7, Silver = 3, Bronze = 5 } }, - new CountryModel { Code = ""CZ"", Name = ""Czech Republic"", Medals = new MedalsModel { Gold = 4, Silver = 4, Bronze = 3 } }, - new CountryModel { Code = ""DK"", Name = ""Denmark"", Medals = new MedalsModel { Gold = 3, Silver = 4, Bronze = 4 } }, - new CountryModel { Code = ""DO"", Name = ""Dominican Republic"", Medals = new MedalsModel { Gold = 0, Silver = 3, Bronze = 2 } }, - new CountryModel { Code = ""EC"", Name = ""Ecuador"", Medals = new MedalsModel { Gold = 2, Silver = 1, Bronze = 0 } }, - new CountryModel { Code = ""EE"", Name = ""Estonia"", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = ""ET"", Name = ""Ethiopia"", Medals = new MedalsModel { Gold = 1, Silver = 1, Bronze = 2 } }, - new CountryModel { Code = ""FJ"", Name = ""Fiji"", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = ""FI"", Name = ""Finland"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 2 } }, - new CountryModel { Code = ""FR"", Name = ""France"", Medals = new MedalsModel { Gold = 10, Silver = 12, Bronze = 11 } }, - new CountryModel { Code = ""GE"", Name = ""Georgia"", Medals = new MedalsModel { Gold = 2, Silver = 5, Bronze = 1 } }, - new CountryModel { Code = ""DE"", Name = ""Germany"", Medals = new MedalsModel { Gold = 10, Silver = 11, Bronze = 16 } }, - new CountryModel { Code = ""GH"", Name = ""Ghana"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = ""GB"", Name = ""Great Britain"", Medals = new MedalsModel { Gold = 22, Silver = 21, Bronze = 22 } }, - new CountryModel { Code = ""GR"", Name = ""Greece"", Medals = new MedalsModel { Gold = 2, Silver = 1, Bronze = 1 } }, - new CountryModel { Code = ""GD"", Name = ""Grenada"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = ""HK"", Name = ""Hong Kong, China"", Medals = new MedalsModel { Gold = 1, Silver = 2, Bronze = 3 } }, - new CountryModel { Code = ""HU"", Name = ""Hungary"", Medals = new MedalsModel { Gold = 6, Silver = 7, Bronze = 7 } }, - new CountryModel { Code = ""ID"", Name = ""Indonesia"", Medals = new MedalsModel { Gold = 1, Silver = 1, Bronze = 3 } }, - new CountryModel { Code = ""IE"", Name = ""Ireland"", Medals = new MedalsModel { Gold = 2, Silver = 0, Bronze = 2 } }, - new CountryModel { Code = ""IR"", Name = ""Iran"", Medals = new MedalsModel { Gold = 3, Silver = 2, Bronze = 2 } }, - new CountryModel { Code = ""IL"", Name = ""Israel"", Medals = new MedalsModel { Gold = 2, Silver = 0, Bronze = 2 } }, - new CountryModel { Code = ""IT"", Name = ""Italy"", Medals = new MedalsModel { Gold = 10, Silver = 10, Bronze = 20 } }, - new CountryModel { Code = ""JM"", Name = ""Jamaica"", Medals = new MedalsModel { Gold = 4, Silver = 1, Bronze = 4 } }, - new CountryModel { Code = ""JO"", Name = ""Jordan"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 1 } }, - new CountryModel { Code = ""KZ"", Name = ""Kazakhstan"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 8 } }, - new CountryModel { Code = ""KE"", Name = ""Kenya"", Medals = new MedalsModel { Gold = 4, Silver = 4, Bronze = 2 } }, - new CountryModel { Code = ""XK"", Name = ""Kosovo"", Medals = new MedalsModel { Gold = 2, Silver = 0, Bronze = 0 } }, - new CountryModel { Code = ""KW"", Name = ""Kuwait"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = ""LV"", Name = ""Latvia"", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = ""LT"", Name = ""Lithuania"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, - new CountryModel { Code = ""MY"", Name = ""Malaysia"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 1 } }, - new CountryModel { Code = ""MX"", Name = ""Mexico"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 4 } }, - new CountryModel { Code = ""MA"", Name = ""Morocco"", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 0 } }, - new CountryModel { Code = ""NA"", Name = ""Namibia"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, - new CountryModel { Code = ""NL"", Name = ""Netherlands"", Medals = new MedalsModel { Gold = 10, Silver = 12, Bronze = 14 } }, - new CountryModel { Code = ""NZ"", Name = ""New Zealand"", Medals = new MedalsModel { Gold = 7, Silver = 6, Bronze = 7 } }, - new CountryModel { Code = ""MK"", Name = ""North Macedonia"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, - new CountryModel { Code = ""NO"", Name = ""Norway"", Medals = new MedalsModel { Gold = 4, Silver = 2, Bronze = 2 } }, - new CountryModel { Code = ""PH"", Name = ""Philippines"", Medals = new MedalsModel { Gold = 1, Silver = 2, Bronze = 1 } }, - new CountryModel { Code = ""PL"", Name = ""Poland"", Medals = new MedalsModel { Gold = 4, Silver = 5, Bronze = 5 } }, - new CountryModel { Code = ""PT"", Name = ""Portugal"", Medals = new MedalsModel { Gold = 1, Silver = 1, Bronze = 2 } }, - new CountryModel { Code = ""PR"", Name = ""Puerto Rico"", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 0 } }, - new CountryModel { Code = ""QA"", Name = ""Qatar"", Medals = new MedalsModel { Gold = 2, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = ""KR"", Name = ""Republic of Korea"", Medals = new MedalsModel { Gold = 6, Silver = 4, Bronze = 10 } }, - new CountryModel { Code = ""MD"", Name = ""Republic of Moldova"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = ""RO"", Name = ""Romania"", Medals = new MedalsModel { Gold = 1, Silver = 3, Bronze = 0 } }, - new CountryModel { Code = ""SM"", Name = ""San Marino"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 2 } }, - new CountryModel { Code = ""SA"", Name = ""Saudi Arabia"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, - new CountryModel { Code = ""RS"", Name = ""Serbia"", Medals = new MedalsModel { Gold = 3, Silver = 1, Bronze = 5 } }, - new CountryModel { Code = ""SK"", Name = ""Slovakia"", Medals = new MedalsModel { Gold = 1, Silver = 2, Bronze = 1 } }, - new CountryModel { Code = ""SI"", Name = ""Slovenia"", Medals = new MedalsModel { Gold = 3, Silver = 1, Bronze = 1 } }, - new CountryModel { Code = ""ZA"", Name = ""South Africa"", Medals = new MedalsModel { Gold = 1, Silver = 2, Bronze = 0 } }, - new CountryModel { Code = ""ES"", Name = ""Spain"", Medals = new MedalsModel { Gold = 3, Silver = 8, Bronze = 6 } }, - new CountryModel { Code = ""SE"", Name = ""Sweden"", Medals = new MedalsModel { Gold = 3, Silver = 6, Bronze = 0 } }, - new CountryModel { Code = ""CH"", Name = ""Switzerland"", Medals = new MedalsModel { Gold = 3, Silver = 4, Bronze = 6 } }, - new CountryModel { Code = ""SY"", Name = ""Syrian Arab Republic"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = ""TH"", Name = ""Thailand"", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = ""TR"", Name = ""Turkey"", Medals = new MedalsModel { Gold = 2, Silver = 2, Bronze = 9 } }, - new CountryModel { Code = ""TM"", Name = ""Turkmenistan"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, - new CountryModel { Code = ""UA"", Name = ""Ukraine"", Medals = new MedalsModel { Gold = 1, Silver = 6, Bronze = 12 } }, - new CountryModel { Code = ""US"", Name = ""United States of America"", Medals = new MedalsModel { Gold = 39, Silver = 41, Bronze = 33 } }, - new CountryModel { Code = ""UZ"", Name = ""Uzbekistan"", Medals = new MedalsModel { Gold = 3, Silver = 0, Bronze = 2 } }, - new CountryModel { Code = ""VE"", Name = ""Venezuela"", Medals = new MedalsModel { Gold = 1, Silver = 3, Bronze = 0 } }, -]; - -public class CountryModel -{ - public string Code { get; set; } - public string Name { get; set; } - public MedalsModel Medals { get; set; } -} - -public class MedalsModel -{ - public int Gold { get; set; } - public int Silver { get; set; } - public int Bronze { get; set; } - public int Total => Gold + Silver + Bronze; -}"; +private List products = SampleData.Generate(30);"; private readonly string example7RazorCode = @" - - -
- - - - ToggleRowRendererExpand(context.Code))"" /> - - c.Name)"" /> - c.Medals.Gold)"" /> - c.Medals.Silver)"" /> - c.Medals.Bronze)"" /> - c.Medals.Total)"" /> - - - @args.OriginalRow - @if (expandedRowTemplateCodes.Contains(args.RowItem.Code)) - { - - -
- Additional data: - Code: [@args.RowItem.Code] - - Gold: [@args.RowItem.Medals.Gold], - Silver: [@args.RowItem.Medals.Silver], - Bronze: [@args.RowItem.Medals.Bronze] - (Total: @args.RowItem.Medals.Total) -
- - - } -
-
- -
"; + + + + + +"; private readonly string example7CsharpCode = @" -private IQueryable allCountries; -private BitDataGridPaginationState pagination = new() { ItemsPerPage = 7 }; - -protected override async Task OnInitializedAsync() -{ - allCountries = _countries.AsQueryable(); -} - -private HashSet expandedRowTemplateCodes = []; - -private void ToggleRowRendererExpand(string code) -{ - if (expandedRowTemplateCodes.Remove(code)) return; - - expandedRowTemplateCodes.Add(code); -} +private List products = SampleData.Generate(40);"; + + private readonly string example8RazorCode = @" + + + + + + + + +"; + private readonly string example8CsharpCode = @" +private List products = SampleData.Generate(40);"; + + private readonly string example9RazorCode = @" + + + + + + + +"; + private readonly string example9CsharpCode = @" +private List products = SampleData.Generate(40); + +private int? NameSpan(Product p) => p.Discontinued ? 2 : null; +private int? PriceSpan(Product p) => p.Price > 800 ? 2 : null;"; + + private readonly string example10RazorCode = @" + + + + +"; + private readonly string example10CsharpCode = @" +private List products = SampleData.Generate(10_000);"; + + private readonly string example11RazorCode = @" + + + + +"; + private readonly string example11CsharpCode = @" +async Task> LoadData(BitDataGridReadRequest request) +{ + // request.Sorts, request.Filters, request.Skip, request.Take + var page = await Backend.QueryAsync(request); + return new BitDataGridReadResult(page.Items, page.TotalCount); +}"; -private static readonly CountryModel[] _countries = -[ - new CountryModel { Code = ""AR"", Name = ""Argentina"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 2 } }, - new CountryModel { Code = ""AM"", Name = ""Armenia"", Medals = new MedalsModel { Gold = 0, Silver = 2, Bronze = 2 } }, - new CountryModel { Code = ""AU"", Name = ""Australia"", Medals = new MedalsModel { Gold = 17, Silver = 7, Bronze = 22 } }, - new CountryModel { Code = ""AT"", Name = ""Austria"", Medals = new MedalsModel { Gold = 1, Silver = 1, Bronze = 5 } }, - new CountryModel { Code = ""AZ"", Name = ""Azerbaijan"", Medals = new MedalsModel { Gold = 0, Silver = 3, Bronze = 4 } }, - new CountryModel { Code = ""BS"", Name = ""Bahamas"", Medals = new MedalsModel { Gold = 2, Silver = 0, Bronze = 0 } }, - new CountryModel { Code = ""BH"", Name = ""Bahrain"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, - new CountryModel { Code = ""BY"", Name = ""Belarus"", Medals = new MedalsModel { Gold = 1, Silver = 3, Bronze = 3 } }, - new CountryModel { Code = ""BE"", Name = ""Belgium"", Medals = new MedalsModel { Gold = 3, Silver = 1, Bronze = 3 } }, - new CountryModel { Code = ""BM"", Name = ""Bermuda"", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 0 } }, - new CountryModel { Code = ""BW"", Name = ""Botswana"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = ""BR"", Name = ""Brazil"", Medals = new MedalsModel { Gold = 7, Silver = 6, Bronze = 8 } }, - new CountryModel { Code = ""BF"", Name = ""Burkina Faso"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = ""CA"", Name = ""Canada"", Medals = new MedalsModel { Gold = 7, Silver = 6, Bronze = 11 } }, - new CountryModel { Code = ""TW"", Name = ""Chinese Taipei"", Medals = new MedalsModel { Gold = 2, Silver = 4, Bronze = 6 } }, - new CountryModel { Code = ""CO"", Name = ""Colombia"", Medals = new MedalsModel { Gold = 0, Silver = 4, Bronze = 1 } }, - new CountryModel { Code = ""CI"", Name = ""Côte d'Ivoire"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = ""HR"", Name = ""Croatia"", Medals = new MedalsModel { Gold = 3, Silver = 3, Bronze = 2 } }, - new CountryModel { Code = ""CU"", Name = ""Cuba"", Medals = new MedalsModel { Gold = 7, Silver = 3, Bronze = 5 } }, - new CountryModel { Code = ""CZ"", Name = ""Czech Republic"", Medals = new MedalsModel { Gold = 4, Silver = 4, Bronze = 3 } }, - new CountryModel { Code = ""DK"", Name = ""Denmark"", Medals = new MedalsModel { Gold = 3, Silver = 4, Bronze = 4 } }, - new CountryModel { Code = ""DO"", Name = ""Dominican Republic"", Medals = new MedalsModel { Gold = 0, Silver = 3, Bronze = 2 } }, - new CountryModel { Code = ""EC"", Name = ""Ecuador"", Medals = new MedalsModel { Gold = 2, Silver = 1, Bronze = 0 } }, - new CountryModel { Code = ""EE"", Name = ""Estonia"", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = ""ET"", Name = ""Ethiopia"", Medals = new MedalsModel { Gold = 1, Silver = 1, Bronze = 2 } }, - new CountryModel { Code = ""FJ"", Name = ""Fiji"", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = ""FI"", Name = ""Finland"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 2 } }, - new CountryModel { Code = ""FR"", Name = ""France"", Medals = new MedalsModel { Gold = 10, Silver = 12, Bronze = 11 } }, - new CountryModel { Code = ""GE"", Name = ""Georgia"", Medals = new MedalsModel { Gold = 2, Silver = 5, Bronze = 1 } }, - new CountryModel { Code = ""DE"", Name = ""Germany"", Medals = new MedalsModel { Gold = 10, Silver = 11, Bronze = 16 } }, - new CountryModel { Code = ""GH"", Name = ""Ghana"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = ""GB"", Name = ""Great Britain"", Medals = new MedalsModel { Gold = 22, Silver = 21, Bronze = 22 } }, - new CountryModel { Code = ""GR"", Name = ""Greece"", Medals = new MedalsModel { Gold = 2, Silver = 1, Bronze = 1 } }, - new CountryModel { Code = ""GD"", Name = ""Grenada"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = ""HK"", Name = ""Hong Kong, China"", Medals = new MedalsModel { Gold = 1, Silver = 2, Bronze = 3 } }, - new CountryModel { Code = ""HU"", Name = ""Hungary"", Medals = new MedalsModel { Gold = 6, Silver = 7, Bronze = 7 } }, - new CountryModel { Code = ""ID"", Name = ""Indonesia"", Medals = new MedalsModel { Gold = 1, Silver = 1, Bronze = 3 } }, - new CountryModel { Code = ""IE"", Name = ""Ireland"", Medals = new MedalsModel { Gold = 2, Silver = 0, Bronze = 2 } }, - new CountryModel { Code = ""IR"", Name = ""Iran"", Medals = new MedalsModel { Gold = 3, Silver = 2, Bronze = 2 } }, - new CountryModel { Code = ""IL"", Name = ""Israel"", Medals = new MedalsModel { Gold = 2, Silver = 0, Bronze = 2 } }, - new CountryModel { Code = ""IT"", Name = ""Italy"", Medals = new MedalsModel { Gold = 10, Silver = 10, Bronze = 20 } }, - new CountryModel { Code = ""JM"", Name = ""Jamaica"", Medals = new MedalsModel { Gold = 4, Silver = 1, Bronze = 4 } }, - new CountryModel { Code = ""JO"", Name = ""Jordan"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 1 } }, - new CountryModel { Code = ""KZ"", Name = ""Kazakhstan"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 8 } }, - new CountryModel { Code = ""KE"", Name = ""Kenya"", Medals = new MedalsModel { Gold = 4, Silver = 4, Bronze = 2 } }, - new CountryModel { Code = ""XK"", Name = ""Kosovo"", Medals = new MedalsModel { Gold = 2, Silver = 0, Bronze = 0 } }, - new CountryModel { Code = ""KW"", Name = ""Kuwait"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = ""LV"", Name = ""Latvia"", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = ""LT"", Name = ""Lithuania"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, - new CountryModel { Code = ""MY"", Name = ""Malaysia"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 1 } }, - new CountryModel { Code = ""MX"", Name = ""Mexico"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 4 } }, - new CountryModel { Code = ""MA"", Name = ""Morocco"", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 0 } }, - new CountryModel { Code = ""NA"", Name = ""Namibia"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, - new CountryModel { Code = ""NL"", Name = ""Netherlands"", Medals = new MedalsModel { Gold = 10, Silver = 12, Bronze = 14 } }, - new CountryModel { Code = ""NZ"", Name = ""New Zealand"", Medals = new MedalsModel { Gold = 7, Silver = 6, Bronze = 7 } }, - new CountryModel { Code = ""MK"", Name = ""North Macedonia"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, - new CountryModel { Code = ""NO"", Name = ""Norway"", Medals = new MedalsModel { Gold = 4, Silver = 2, Bronze = 2 } }, - new CountryModel { Code = ""PH"", Name = ""Philippines"", Medals = new MedalsModel { Gold = 1, Silver = 2, Bronze = 1 } }, - new CountryModel { Code = ""PL"", Name = ""Poland"", Medals = new MedalsModel { Gold = 4, Silver = 5, Bronze = 5 } }, - new CountryModel { Code = ""PT"", Name = ""Portugal"", Medals = new MedalsModel { Gold = 1, Silver = 1, Bronze = 2 } }, - new CountryModel { Code = ""PR"", Name = ""Puerto Rico"", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 0 } }, - new CountryModel { Code = ""QA"", Name = ""Qatar"", Medals = new MedalsModel { Gold = 2, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = ""KR"", Name = ""Republic of Korea"", Medals = new MedalsModel { Gold = 6, Silver = 4, Bronze = 10 } }, - new CountryModel { Code = ""MD"", Name = ""Republic of Moldova"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = ""RO"", Name = ""Romania"", Medals = new MedalsModel { Gold = 1, Silver = 3, Bronze = 0 } }, - new CountryModel { Code = ""SM"", Name = ""San Marino"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 2 } }, - new CountryModel { Code = ""SA"", Name = ""Saudi Arabia"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, - new CountryModel { Code = ""RS"", Name = ""Serbia"", Medals = new MedalsModel { Gold = 3, Silver = 1, Bronze = 5 } }, - new CountryModel { Code = ""SK"", Name = ""Slovakia"", Medals = new MedalsModel { Gold = 1, Silver = 2, Bronze = 1 } }, - new CountryModel { Code = ""SI"", Name = ""Slovenia"", Medals = new MedalsModel { Gold = 3, Silver = 1, Bronze = 1 } }, - new CountryModel { Code = ""ZA"", Name = ""South Africa"", Medals = new MedalsModel { Gold = 1, Silver = 2, Bronze = 0 } }, - new CountryModel { Code = ""ES"", Name = ""Spain"", Medals = new MedalsModel { Gold = 3, Silver = 8, Bronze = 6 } }, - new CountryModel { Code = ""SE"", Name = ""Sweden"", Medals = new MedalsModel { Gold = 3, Silver = 6, Bronze = 0 } }, - new CountryModel { Code = ""CH"", Name = ""Switzerland"", Medals = new MedalsModel { Gold = 3, Silver = 4, Bronze = 6 } }, - new CountryModel { Code = ""SY"", Name = ""Syrian Arab Republic"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = ""TH"", Name = ""Thailand"", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 1 } }, - new CountryModel { Code = ""TR"", Name = ""Turkey"", Medals = new MedalsModel { Gold = 2, Silver = 2, Bronze = 9 } }, - new CountryModel { Code = ""TM"", Name = ""Turkmenistan"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, - new CountryModel { Code = ""UA"", Name = ""Ukraine"", Medals = new MedalsModel { Gold = 1, Silver = 6, Bronze = 12 } }, - new CountryModel { Code = ""US"", Name = ""United States of America"", Medals = new MedalsModel { Gold = 39, Silver = 41, Bronze = 33 } }, - new CountryModel { Code = ""UZ"", Name = ""Uzbekistan"", Medals = new MedalsModel { Gold = 3, Silver = 0, Bronze = 2 } }, - new CountryModel { Code = ""VE"", Name = ""Venezuela"", Medals = new MedalsModel { Gold = 1, Silver = 3, Bronze = 0 } }, -]; + private readonly string example12RazorCode = @" + + + + +"; + private readonly string example12CsharpCode = @" +async Task> LoadMore(BitDataGridReadRequest request) +{ + var batch = Query.Skip(request.Skip).Take(request.Take ?? 40).ToList(); + // Return fewer rows than requested to signal the end of the data. + return new BitDataGridReadResult(batch, 0); +}"; -public class CountryModel -{ - public string Code { get; set; } - public string Name { get; set; } - public MedalsModel Medals { get; set; } -} + private readonly string example13RazorCode = @" + n.Children"" TreeInitiallyExpanded=""true"" + KeyField=""n => n.Id"" @ref=""grid""> + + + +"; + private readonly string example13CsharpCode = @" +private List roots = FileSystemData.Build(); +private BitDataGrid? grid; + +private async Task ExpandAll() { if (grid is not null) await grid.ExpandAllAsync(); } +private async Task CollapseAll() { if (grid is not null) await grid.CollapseAllAsync(); }"; + + private readonly string example14RazorCode = @" + + + + + + + + + + + +"; + private readonly string example14CsharpCode = @" +private List suppliers = BuildSuppliers();"; + + private readonly string example15RazorCode = @" + + + +"; + private readonly string example15CsharpCode = @" +void OnReorder(BitDataGridRowReorderEventArgs e) +{ + // e.DraggedItem, e.FromIndex, e.ToIndex +}"; -public class MedalsModel -{ - public int Gold { get; set; } - public int Silver { get; set; } - public int Bronze { get; set; } - public int Total => Gold + Silver + Bronze; + private readonly string example16RazorCode = @" + + + +"; + private readonly string example16CsharpCode = @" +void OnCellClick(BitDataGridCellEventArgs e) { /* e.Item, e.ColumnTitle, e.Value */ } +void OnCellContextMenu(BitDataGridCellEventArgs e) { /* e.Mouse.ClientX / e.Mouse.ClientY */ }"; + + private readonly string example17RazorCode = @" + + + + +"; + private readonly string example17CsharpCode = @" +private List products = SampleData.Generate(40);"; + + private readonly string example18RazorCode = @" + + + + + +"; + private readonly string example18CsharpCode = @" +private float RowHeight(Product p) => p.Price > 500 ? 64f : 36f;"; + + private readonly string example19RazorCode = @" + + +
Nothing here yet. Try loading the sample data or adjusting your filters.
+
+ + + + +
"; + private readonly string example19CsharpCode = @" +private List items = new(); // empty"; + + private readonly string example20RazorCode = @" + + + + +"; + private readonly string example20CsharpCode = @" +/* Override CSS tokens to create a theme: */ +.theme-emerald { + --bit-dtg-accent: #0f9d58; + --bit-dtg-header-bg: light-dark(#e7f6ee, #10241a); + --bit-dtg-row-selected: light-dark(#c9efda, #14402a); }"; } diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.scss b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.scss index c77b87df9d..651a88ebf5 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.scss +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.scss @@ -1,131 +1,9 @@ -.custom-grid { - border: 1px solid; - - .container { - overflow: auto; - } - - .flag { - vertical-align: middle; - } - - ::deep { - table { - border-spacing: 0; - } - - tr { - height: 42px; - - &:nth-child(even) { - background: var(--bit-clr-bg-sec); - } - - &:nth-child(odd) { - background: var(--bit-clr-bg-pri); - } - } - - th { - border-bottom: 1px solid; - background-color: var(--bit-clr-bg-sec); - border-bottom-color: var(--bit-clr-brd-sec); - - &:not(:last-child) { - border-right: 1px solid; - border-right-color: var(--bit-clr-brd-sec); - } - } - - td { - border-bottom: 1px solid var(--bit-clr-brd-sec); - - &:not(:last-child) { - border-right: 1px solid var(--bit-clr-brd-sec); - } - } - - .bit-dtg-drg::after { - border-left: unset; - } - - .wide { - width: 220px; - } - } +.span-banner { + font-weight: 600; + color: var(--bit-clr-pri); } -.grid { - height: 15rem; - overflow-y: auto; - - ::deep { - thead { - top: 0; - z-index: 1; - position: sticky; - background-color: var(--bit-clr-bg-sec); - } - - tr { - height: 2rem; - } - - td { - max-width: 0; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - } - } -} - -.search-panel { - max-width: 15rem; - margin-top: 2rem; -} - -.responsive-grid { - ::deep { - table { - border-collapse: collapse; - } - - tr { - padding: 1rem; - margin-bottom: 1rem; - border: 1px solid var(--bit-clr-brd-sec); - } - - @media (max-width: 600px) { - table { - width: 100%; - } - - table, thead, tbody, th, td, tr, td { - display: block; - } - - thead tr { - display: none; - } - - td::before { - font-weight: bold; - content: attr(data-title) " : "; - } - } - } -} - -.row-template-grid { - ::deep { - .row-template-expand-col { - width: 2rem; - } - - .row-template-detail { - padding: 0.5rem; - } - } +.muted { + color: var(--bit-clr-fg-sec); + font-size: 0.75rem; } diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/FileSystemData.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/FileSystemData.cs new file mode 100644 index 0000000000..0c36160679 --- /dev/null +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/FileSystemData.cs @@ -0,0 +1,74 @@ +namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.DataGrid; + +/// A node in a file-system style hierarchy used by the Tree View demo. +public class FileNode +{ + public int Id { get; set; } + public string Name { get; set; } = ""; + public string Kind { get; set; } = "Folder"; + public long Size { get; set; } + public DateTime Modified { get; set; } + public List Children { get; set; } = new(); +} + +public static class FileSystemData +{ + /// Builds a small, deterministic folder/file tree. + public static List Build() + { + var id = 0; + var baseDate = new DateTime(2025, 1, 1); + + FileNode Folder(string name, params FileNode[] children) + { + var node = new FileNode + { + Id = ++id, + Name = name, + Kind = "Folder", + Modified = baseDate.AddDays(id), + Children = children.ToList() + }; + node.Size = node.Children.Sum(c => c.Size); + return node; + } + + FileNode File(string name, long size) => new() + { + Id = ++id, + Name = name, + Kind = "File", + Size = size, + Modified = baseDate.AddDays(id) + }; + + return new List + { + Folder("src", + Folder("BitDataGrid", + File("BitDataGrid.razor", 24_500), + File("BitDataGrid.razor.cs", 41_200), + Folder("Models", + File("BitDataGridColumnAlign.cs", 320), + File("BitDataGridSortDescriptor.cs", 540), + File("BitDataGridFilterOperator.cs", 610)), + Folder("Infrastructure", + File("BitDataGridDataProcessor.cs", 8_900), + File("BitDataGridPropertyAccessor.cs", 3_400))), + Folder("BitDataGrid.Demo", + File("Program.cs", 1_200), + Folder("Components", + File("App.razor", 760), + File("Routes.razor", 280)))), + Folder("docs", + File("README.md", 6_400), + File("CHANGELOG.md", 2_100)), + Folder("assets", + File("logo.svg", 4_800), + File("styles.css", 12_300), + File("favicon.ico", 1_150)), + File("LICENSE", 1_070), + File(".gitignore", 410) + }; + } +} diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/Product.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/Product.cs new file mode 100644 index 0000000000..086bfe42a4 --- /dev/null +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/Product.cs @@ -0,0 +1,27 @@ +namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.DataGrid; + +public enum Category +{ + Electronics, + Books, + Clothing, + Home, + Toys, + Sports, + Grocery +} + +public class Product +{ + public int Id { get; set; } + public string Name { get; set; } = ""; + public Category Category { get; set; } + public decimal Price { get; set; } + public int Stock { get; set; } + public double Rating { get; set; } + public bool Discontinued { get; set; } + public DateTime ReleaseDate { get; set; } + public string Supplier { get; set; } = ""; + + public Product Clone() => (Product)MemberwiseClone(); +} diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/SampleData.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/SampleData.cs new file mode 100644 index 0000000000..9415dc288b --- /dev/null +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/SampleData.cs @@ -0,0 +1,35 @@ +namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.DataGrid; + +public static class SampleData +{ + private static readonly string[] Adjectives = + { "Ultra", "Premium", "Eco", "Smart", "Classic", "Pro", "Mini", "Mega", "Vintage", "Modern", "Deluxe", "Compact" }; + private static readonly string[] Nouns = + { "Widget", "Gadget", "Speaker", "Notebook", "Jacket", "Lamp", "Blender", "Drone", "Backpack", "Sneaker", "Camera", "Mug" }; + private static readonly string[] Suppliers = + { "Acme Corp", "Globex", "Initech", "Umbrella", "Soylent", "Stark Industries", "Wayne Enterprises", "Wonka Inc" }; + + /// Deterministic generator so demos are reproducible. + public static List Generate(int count, int seed = 42) + { + var rng = new Random(seed); + var categories = Enum.GetValues(); + var list = new List(count); + for (int i = 1; i <= count; i++) + { + list.Add(new Product + { + Id = i, + Name = $"{Adjectives[rng.Next(Adjectives.Length)]} {Nouns[rng.Next(Nouns.Length)]} {rng.Next(100, 999)}", + Category = categories[rng.Next(categories.Length)], + Price = Math.Round((decimal)(rng.NextDouble() * 990 + 5), 2), + Stock = rng.Next(0, 500), + Rating = Math.Round(rng.NextDouble() * 4 + 1, 1), + Discontinued = rng.Next(0, 5) == 0, + ReleaseDate = DateTime.Today.AddDays(-rng.Next(0, 2000)), + Supplier = Suppliers[rng.Next(Suppliers.Length)] + }); + } + return list; + } +} diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/SupplierModel.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/SupplierModel.cs new file mode 100644 index 0000000000..1ed39b06f9 --- /dev/null +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/SupplierModel.cs @@ -0,0 +1,10 @@ +namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.DataGrid; + +public sealed class SupplierModel +{ + public string Name { get; set; } = ""; + public List Products { get; set; } = new(); + public int ProductCount => Products.Count; + public int TotalStock => Products.Sum(p => p.Stock); + public decimal AveragePrice => Products.Count == 0 ? 0 : Math.Round(Products.Average(p => p.Price), 2); +} diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor new file mode 100644 index 0000000000..96251f979c --- /dev/null +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor @@ -0,0 +1,198 @@ +@page "/components/quickgrid" +@page "/components/quick-grid" +@inherits AppComponentBase +@using Demo.Shared.Dtos.QuickGridDemo + + + + + + + To use this component, you need to install the + + + + NuGet package, as described in the Optional steps of the + Getting started page. + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + + @(context.Code) + + + + + + + + + +
+ + @(state.CurrentPageIndex + 1) / @(state.LastPageIndex + 1) + +
+
+ + +
+ + + + + + + + +
+
+ +
+
+ + +
+ + + + + +
+
+ +
+
+ + +
+ + + + + + + + + Loading items... + + + + +
+ + @(state.CurrentPageIndex + 1) / @(state.LastPageIndex + 1) + +
+ + +
+ The BitQuickGrid does not have the Responsive feature built-in, + but you can achieve some level of responsiveness like what we did in this sample: +
+ +
+ +
+ + + + + + + + +
+
+ + +
See the RowTemplate parameter in action:
+ +
+ +
+ + + + + + + + + + + + + @args.OriginalRow + @if (expandedRowTemplateCodes.Contains(args.RowItem.Code)) + { + + +
+ Additional data: + Code: [@args.RowItem.Code] - + Gold: [@args.RowItem.Medals.Gold], + Silver: [@args.RowItem.Medals.Silver], + Bronze: [@args.RowItem.Medals.Bronze] + (Total: @args.RowItem.Medals.Total) +
+ + + } +
+
+ +
+
+ +
+
diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.cs new file mode 100644 index 0000000000..c2ad9c0cb2 --- /dev/null +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.cs @@ -0,0 +1,681 @@ +using Bit.BlazorUI.Demo.Client.Core.Components; +using Bit.BlazorUI.Demo.Shared.Dtos.QuickGridDemo; + +namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.QuickGrid; + +public partial class BitQuickGridDemo : AppComponentBase +{ + private readonly List componentParameters = + [ + new() + { + Name = "ChildContent", + Type = "RenderFragment?", + DefaultValue = "null", + Description = @"Defines the child components of this instance. + For example, you may define columns by adding components derived from the BitQuickGridColumnBase.", + }, + new() + { + Name = "Class", + Type = "string?", + DefaultValue = "null", + Description = "An optional CSS class name. If given, this will be included in the class attribute of the rendered table.", + }, + new() + { + Name = "Columns", + Type = "RenderFragment?", + DefaultValue = "null", + Description = "Alias of the ChildContent parameter.", + }, + new() + { + Name = "ItemKey", + Type = "Func", + DefaultValue = "x => x!", + Description = @"Optionally defines a value for @key on each rendered row. Typically this should be used to specify a + unique identifier, such as a primary key value, for each data item. + This allows the grid to preserve the association between row elements and data items based on their + unique identifiers, even when the TGridItem instances are replaced by new copies (for example, after a new query against the underlying data store). + If not set, the @key will be the TGridItem instance itself.", + }, + new() + { + Name = "Items", + Type = "IQueryable?", + DefaultValue = "null", + Description = @"A queryable source of data for the grid. + This could be in-memory data converted to queryable using the + System.Linq.Queryable.AsQueryable(System.Collections.IEnumerable) extension method, + or an EntityFramework DataSet or an IQueryable derived from it. + You should supply either Items or ItemsProvider, but not both.", + }, + new() + { + Name = "ItemSize", + Type = "float", + DefaultValue = "50", + Description = @"This is applicable only when using Virtualize. 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 + size and to ensure accurate scrolling.", + }, + new() + { + Name = "ItemsProvider", + Type = "BitQuickGridItemsProvider?", + DefaultValue = "null", + Description = @"A callback that supplies data for the rid. + You should supply either Items or ItemsProvider, but not both.", + }, + new() + { + Name = "LoadingTemplate", + Type = "RenderFragment?", + DefaultValue = "null", + Description = "The custom template to render while loading the new items.", + }, + new() + { + Name = "Pagination", + Type = "BitQuickGridPaginationState?", + DefaultValue = "null", + Description = @"Optionally links this BitQuickGrid instance with a BitQuickGridPaginationState model, + causing the grid to fetch and render only the current page of data. + This is normally used in conjunction with a Paginator component or some other UI logic + that displays and updates the supplied BitQuickGridPaginationState instance.", + LinkType = LinkType.Link, + Href = "#pagination-state", + }, + new() + { + Name = "ResizableColumns", + Type = "bool", + DefaultValue = "false", + Description = @"If true, renders draggable handles around the column headers, allowing the user to resize the columns + manually. Size changes are not persisted.", + }, + new() + { + Name = "RowClass", + Type = "string?", + DefaultValue = "null", + Description = @"The CSS class of all rows of the data grid.", + }, + new() + { + Name = "RowClassSelector", + Type = "Func?", + DefaultValue = "null", + Description = @"The function to generate the CSS class of each row of the data grid.", + }, + new() + { + Name = "RowStyle", + Type = "string?", + DefaultValue = "null", + Description = @"The CSS style of all rows of the data grid.", + }, + new() + { + Name = "RowStyleSelector", + Type = "Func?", + DefaultValue = "null", + Description = @"The function to generate the CSS style of each row of the data grid.", + }, + new() + { + Name = "RowTemplate", + Type = "RenderFragment>?", + DefaultValue = "null", + Description = @"Optional template to customize row rendering. Receives BitQuickGridRowTemplateArgs with OriginalRow + set to the default row content; render it to include the original row, or omit to replace entirely.", + LinkType = LinkType.Link, + Href = "#row-template-args", + }, + new() + { + Name = "Theme", + Type = "string?", + DefaultValue = "default", + Description = @"A theme name, with default value ""default"". This affects which styling rules match the table.", + }, + new() + { + Name = "Virtualize", + Type = "bool", + DefaultValue = "false", + Description = @"If true, the grid will be rendered with virtualization. This is normally used in conjunction with + scrolling and causes the grid to fetch and render only the data around the current scroll viewport. + This can greatly improve the performance when scrolling through large data sets.", + } + ]; + + private readonly List componentSubClasses = + [ + new() + { + Id = "BitQuickGridColumnBase", + Title = "BitQuickGridColumnBase", + Description = "BitQuickGrid has two built-in column types, BitQuickGridPropertyColumn and BitQuickGridTemplateColumn. You can also create your own column types by subclassing ColumnBase he BitQuickGridColumnBase type, which all column must derive from, offers some common parameters", + Parameters= + [ + new() + { + Name = "Title", + Type = "string?", + DefaultValue = "null", + Description = "Title text for the column. This is rendered automatically if HeaderTemplate is not used.", + }, + new() + { + Name = "Class", + Type = "string?", + DefaultValue = "null", + Description = "An optional CSS class name. If specified, this is included in the class attribute of table header and body cells for this column.", + }, + new() + { + Name = "Align", + Type = "BitQuickGridAlign?", + DefaultValue = "null", + Description = "If specified, controls the justification of table header and body cells for this column.", + }, + new() + { + Name = "HeaderTemplate", + Type = "RenderFragment>?", + DefaultValue = "null", + Description = @"An optional template for this column's header cell. If not specified, the default header template + includes the Title along with any applicable sort indicators and options buttons.", + }, + new() + { + Name = "ColumnOptions", + Type = "RenderFragment>?", + DefaultValue = "null", + Description = @"If specified, indicates that this column has this associated options UI. A button to display this + UI will be included in the header cell by default. + If HeaderTemplate is used, it is left up to that template to render any relevant + ""show options"" UI and invoke the grid's BitQuickGrid.ShowColumnOptions(BitQuickGridColumnBase)).", + }, + new() + { + Name = "Sortable", + Type = "bool?", + DefaultValue = "null", + Description = @"Indicates whether the data should be sortable by this column. + The default value may vary according to the column type (for example, a BitQuickGridTemplateColumn + is sortable by default if any BitQuickGridTemplateColumn.SortBy parameter is specified).", + }, + new() + { + Name = "IsDefaultSort", + Type = "BitQuickGridSortDirection?", + DefaultValue = "null", + Description = "If specified and not null, indicates that this column represents the initial sort order for the grid. The supplied value controls the default sort direction.", + }, + new() + { + Name = "PlaceholderTemplate", + Type = "RenderFragment?", + DefaultValue = "null", + Description = "If specified, virtualized grids will use this template to render cells whose data has not yet been loaded.", + } + ], + + }, + new() + { + Id="BitQuickGridPropertyColumn", + Title = "BitQuickGridPropertyColumn", + Description = "It is for displaying a single value specified by the parameter Property. This column infers sorting rules automatically, and uses the property's name as its title if not otherwise set.", + Parameters= + [ + new() + { + Name = "Property", + Type = "Expression>", + Description = "Defines the value to be displayed in this column's cells.", + }, + new() + { + Name = "Format", + Type = "string?", + DefaultValue = "null", + Description = "Optionally specifies a format string for the value. Using this requires the TProp type to implement IFormattable.", + }, + ], + }, + new() + { + Id = "BitQuickGridTemplateColumn", + Title = "BitQuickGridTemplateColumn", + Description = @"It uses arbitrary Razor fragments to supply contents for its cells. It can't infer the column's title or sort order automatically. also it's possible to add arbitrary Blazor components to your table cells. Remember that rendering many components, or many event handlers, can impact the performance of your grid. One way to mitigate this issue is by paginating or virtualizing your grid", + Parameters = + [ + new() + { + Name = "ChildContent", + Type = "RenderFragment", + Description = @"Specifies the content to be rendered for each row in the table.", + }, + new() + { + Name = "SortBy", + Type = "BitQuickGridSort?", + DefaultValue = "null", + Description = "Optionally specifies sorting rules for this column.", + }, + ], + }, + new() + { + Id = "BitQuickGridPaginator", + Title = "BitQuickGridPaginator", + Description = "A component that provides a user interface for pagination.", + Parameters= + [ + new() + { + Name = "GoToFirstButtonTitle", + Type = "string", + DefaultValue = "Go to first page", + Description = "The title of the go to first page button.", + }, + new() + { + Name = "GoToPrevButtonTitle", + Type = "string", + DefaultValue = "Go to previous page", + Description = "The title of the go to previous page button.", + }, + new() + { + Name = "GoToNextButtonTitle", + Type = "string", + DefaultValue = "Go to next page", + Description = "The title of the go to next page button.", + }, + new() + { + Name = "GoToLastButtonTitle", + Type = "string", + DefaultValue = "Go to last page", + Description = "The title of the go to last page button.", + }, + new() + { + Name = "SummaryFormat", + Type = "Func?", + DefaultValue = "null", + Description = "Optionally supplies a format for rendering the page count summary.", + LinkType = LinkType.Link, + Href = "#pagination-state" + }, + new() + { + Name = "SummaryTemplate", + Type = "RenderFragment?", + DefaultValue = "null", + Description = "Optionally supplies a template for rendering the page count summary.", + LinkType = LinkType.Link, + Href = "#pagination-state" + }, + new() + { + Name = "TextFormat", + Type = "Func?", + DefaultValue = "null", + Description = "The optional custom format for the main text of the paginator in the middle of it.", + LinkType = LinkType.Link, + Href = "#pagination-state" + }, + new() + { + Name = "TextTemplate", + Type = "RenderFragment?", + DefaultValue = "null", + Description = "The optional custom template for the main text of the paginator in the middle of it.", + LinkType = LinkType.Link, + Href = "#pagination-state" + }, + new() + { + Name = "Value", + Type = "BitQuickGridPaginationState", + DefaultValue = "", + Description = "Specifies the associated pagination state. This parameter is required.", + LinkType = LinkType.Link, + Href = "#pagination-state" + }, + ], + + }, + new() + { + Id = "pagination-state", + Title = "BitQuickGridPaginationState", + Description = "A component that provides a user interface for pagination.", + Parameters= + [ + new() + { + Name = "CurrentPageIndex", + Type = "int", + DefaultValue = "0", + Description = "Gets the current zero-based page index.", + }, + new() + { + Name = "ItemsPerPage", + Type = "int", + DefaultValue = "10", + Description = "Gets or sets the number of items on each page.", + }, + new() + { + Name = "LastPageIndex", + Type = "int?", + DefaultValue = "null", + Description = "Gets the zero-based index of the last page, if known. The value will be null until TotalItemCount is known.", + }, + new() + { + Name = "TotalItemCount", + Type = "int?", + DefaultValue = "null", + Description = "Gets the total number of items across all pages, if known. The value will be null until an associated BitQuickGrid assigns a value after loading data.", + }, + new() + { + Name = "TotalItemCountChanged", + Type = "EventHandler?", + DefaultValue = "null", + Description = "An event that is raised when the total item count has changed.", + }, + ], + + }, + new() + { + Id = "row-template-args", + Title = "BitQuickGridRowTemplateArgs", + Description = "Arguments passed to the RowTemplate render fragment.", + Parameters = + [ + new() + { + Name = "OriginalRow", + Type = "RenderFragment?", + DefaultValue = "null", + Description = "A render fragment that produces the original row markup (the default with all column cells). Render this to include the default row, or omit to replace entirely.", + }, + new() + { + Name = "RowIndex", + Type = "int", + DefaultValue = "0", + Description = "The 1-based row index used for accessibility (e.g. aria-rowindex).", + }, + new() + { + Name = "RowItem", + Type = "TGridItem", + DefaultValue = "", + Description = "The data item for this row.", + }, + ], + }, + ]; + + private readonly List componentSubEnums = + [ + new() + { + Id = "BitQuickGridAlign", + Name = "BitQuickGridAlign", + Description = "Describes alignment for a BitQuickGrid column.", + Items = + [ + new() + { + Name = "Left", + Value = "0", + Description = "Justifies the content against the start of the container." + }, + new() + { + Name = "Center", + Value = "1", + Description = "Justifies the content at the center of the container." + }, + new() + { + Name = "Right", + Value = "2", + Description = "Justifies the content at the end of the container." + }, + + ] + }, + ]; + + + + private static readonly CountryModel[] _countries = + [ + new CountryModel { Code = "AR", Name = "Argentina", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 2 } }, + new CountryModel { Code = "AM", Name = "Armenia", Medals = new MedalsModel { Gold = 0, Silver = 2, Bronze = 2 } }, + new CountryModel { Code = "AU", Name = "Australia", Medals = new MedalsModel { Gold = 17, Silver = 7, Bronze = 22 } }, + new CountryModel { Code = "AT", Name = "Austria", Medals = new MedalsModel { Gold = 1, Silver = 1, Bronze = 5 } }, + new CountryModel { Code = "AZ", Name = "Azerbaijan", Medals = new MedalsModel { Gold = 0, Silver = 3, Bronze = 4 } }, + new CountryModel { Code = "BS", Name = "Bahamas", Medals = new MedalsModel { Gold = 2, Silver = 0, Bronze = 0 } }, + new CountryModel { Code = "BH", Name = "Bahrain", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, + new CountryModel { Code = "BY", Name = "Belarus", Medals = new MedalsModel { Gold = 1, Silver = 3, Bronze = 3 } }, + new CountryModel { Code = "BE", Name = "Belgium", Medals = new MedalsModel { Gold = 3, Silver = 1, Bronze = 3 } }, + new CountryModel { Code = "BM", Name = "Bermuda", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 0 } }, + new CountryModel { Code = "BW", Name = "Botswana", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = "BR", Name = "Brazil", Medals = new MedalsModel { Gold = 7, Silver = 6, Bronze = 8 } }, + new CountryModel { Code = "BF", Name = "Burkina Faso", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = "CA", Name = "Canada", Medals = new MedalsModel { Gold = 7, Silver = 6, Bronze = 11 } }, + new CountryModel { Code = "TW", Name = "Chinese Taipei", Medals = new MedalsModel { Gold = 2, Silver = 4, Bronze = 6 } }, + new CountryModel { Code = "CO", Name = "Colombia", Medals = new MedalsModel { Gold = 0, Silver = 4, Bronze = 1 } }, + new CountryModel { Code = "CI", Name = "Côte d'Ivoire", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = "HR", Name = "Croatia", Medals = new MedalsModel { Gold = 3, Silver = 3, Bronze = 2 } }, + new CountryModel { Code = "CU", Name = "Cuba", Medals = new MedalsModel { Gold = 7, Silver = 3, Bronze = 5 } }, + new CountryModel { Code = "CZ", Name = "Czech Republic", Medals = new MedalsModel { Gold = 4, Silver = 4, Bronze = 3 } }, + new CountryModel { Code = "DK", Name = "Denmark", Medals = new MedalsModel { Gold = 3, Silver = 4, Bronze = 4 } }, + new CountryModel { Code = "DO", Name = "Dominican Republic", Medals = new MedalsModel { Gold = 0, Silver = 3, Bronze = 2 } }, + new CountryModel { Code = "EC", Name = "Ecuador", Medals = new MedalsModel { Gold = 2, Silver = 1, Bronze = 0 } }, + new CountryModel { Code = "EE", Name = "Estonia", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = "ET", Name = "Ethiopia", Medals = new MedalsModel { Gold = 1, Silver = 1, Bronze = 2 } }, + new CountryModel { Code = "FJ", Name = "Fiji", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = "FI", Name = "Finland", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 2 } }, + new CountryModel { Code = "FR", Name = "France", Medals = new MedalsModel { Gold = 10, Silver = 12, Bronze = 11 } }, + new CountryModel { Code = "GE", Name = "Georgia", Medals = new MedalsModel { Gold = 2, Silver = 5, Bronze = 1 } }, + new CountryModel { Code = "DE", Name = "Germany", Medals = new MedalsModel { Gold = 10, Silver = 11, Bronze = 16 } }, + new CountryModel { Code = "GH", Name = "Ghana", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = "GB", Name = "Great Britain", Medals = new MedalsModel { Gold = 22, Silver = 21, Bronze = 22 } }, + new CountryModel { Code = "GR", Name = "Greece", Medals = new MedalsModel { Gold = 2, Silver = 1, Bronze = 1 } }, + new CountryModel { Code = "GD", Name = "Grenada", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = "HK", Name = "Hong Kong, China", Medals = new MedalsModel { Gold = 1, Silver = 2, Bronze = 3 } }, + new CountryModel { Code = "HU", Name = "Hungary", Medals = new MedalsModel { Gold = 6, Silver = 7, Bronze = 7 } }, + new CountryModel { Code = "ID", Name = "Indonesia", Medals = new MedalsModel { Gold = 1, Silver = 1, Bronze = 3 } }, + new CountryModel { Code = "IE", Name = "Ireland", Medals = new MedalsModel { Gold = 2, Silver = 0, Bronze = 2 } }, + new CountryModel { Code = "IR", Name = "Iran", Medals = new MedalsModel { Gold = 3, Silver = 2, Bronze = 2 } }, + new CountryModel { Code = "IL", Name = "Israel", Medals = new MedalsModel { Gold = 2, Silver = 0, Bronze = 2 } }, + new CountryModel { Code = "IT", Name = "Italy", Medals = new MedalsModel { Gold = 10, Silver = 10, Bronze = 20 } }, + new CountryModel { Code = "JM", Name = "Jamaica", Medals = new MedalsModel { Gold = 4, Silver = 1, Bronze = 4 } }, + new CountryModel { Code = "JO", Name = "Jordan", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 1 } }, + new CountryModel { Code = "KZ", Name = "Kazakhstan", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 8 } }, + new CountryModel { Code = "KE", Name = "Kenya", Medals = new MedalsModel { Gold = 4, Silver = 4, Bronze = 2 } }, + new CountryModel { Code = "XK", Name = "Kosovo", Medals = new MedalsModel { Gold = 2, Silver = 0, Bronze = 0 } }, + new CountryModel { Code = "KW", Name = "Kuwait", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = "LV", Name = "Latvia", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = "LT", Name = "Lithuania", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, + new CountryModel { Code = "MY", Name = "Malaysia", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 1 } }, + new CountryModel { Code = "MX", Name = "Mexico", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 4 } }, + new CountryModel { Code = "MA", Name = "Morocco", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 0 } }, + new CountryModel { Code = "NA", Name = "Namibia", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, + new CountryModel { Code = "NL", Name = "Netherlands", Medals = new MedalsModel { Gold = 10, Silver = 12, Bronze = 14 } }, + new CountryModel { Code = "NZ", Name = "New Zealand", Medals = new MedalsModel { Gold = 7, Silver = 6, Bronze = 7 } }, + new CountryModel { Code = "MK", Name = "North Macedonia", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, + new CountryModel { Code = "NO", Name = "Norway", Medals = new MedalsModel { Gold = 4, Silver = 2, Bronze = 2 } }, + new CountryModel { Code = "PH", Name = "Philippines", Medals = new MedalsModel { Gold = 1, Silver = 2, Bronze = 1 } }, + new CountryModel { Code = "PL", Name = "Poland", Medals = new MedalsModel { Gold = 4, Silver = 5, Bronze = 5 } }, + new CountryModel { Code = "PT", Name = "Portugal", Medals = new MedalsModel { Gold = 1, Silver = 1, Bronze = 2 } }, + new CountryModel { Code = "PR", Name = "Puerto Rico", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 0 } }, + new CountryModel { Code = "QA", Name = "Qatar", Medals = new MedalsModel { Gold = 2, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = "KR", Name = "Republic of Korea", Medals = new MedalsModel { Gold = 6, Silver = 4, Bronze = 10 } }, + new CountryModel { Code = "MD", Name = "Republic of Moldova", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = "RO", Name = "Romania", Medals = new MedalsModel { Gold = 1, Silver = 3, Bronze = 0 } }, + new CountryModel { Code = "SM", Name = "San Marino", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 2 } }, + new CountryModel { Code = "SA", Name = "Saudi Arabia", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, + new CountryModel { Code = "RS", Name = "Serbia", Medals = new MedalsModel { Gold = 3, Silver = 1, Bronze = 5 } }, + new CountryModel { Code = "SK", Name = "Slovakia", Medals = new MedalsModel { Gold = 1, Silver = 2, Bronze = 1 } }, + new CountryModel { Code = "SI", Name = "Slovenia", Medals = new MedalsModel { Gold = 3, Silver = 1, Bronze = 1 } }, + new CountryModel { Code = "ZA", Name = "South Africa", Medals = new MedalsModel { Gold = 1, Silver = 2, Bronze = 0 } }, + new CountryModel { Code = "ES", Name = "Spain", Medals = new MedalsModel { Gold = 3, Silver = 8, Bronze = 6 } }, + new CountryModel { Code = "SE", Name = "Sweden", Medals = new MedalsModel { Gold = 3, Silver = 6, Bronze = 0 } }, + new CountryModel { Code = "CH", Name = "Switzerland", Medals = new MedalsModel { Gold = 3, Silver = 4, Bronze = 6 } }, + new CountryModel { Code = "SY", Name = "Syrian Arab Republic", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = "TH", Name = "Thailand", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = "TR", Name = "Turkey", Medals = new MedalsModel { Gold = 2, Silver = 2, Bronze = 9 } }, + new CountryModel { Code = "TM", Name = "Turkmenistan", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, + new CountryModel { Code = "UA", Name = "Ukraine", Medals = new MedalsModel { Gold = 1, Silver = 6, Bronze = 12 } }, + new CountryModel { Code = "US", Name = "United States of America", Medals = new MedalsModel { Gold = 39, Silver = 41, Bronze = 33 } }, + new CountryModel { Code = "UZ", Name = "Uzbekistan", Medals = new MedalsModel { Gold = 3, Silver = 0, Bronze = 2 } }, + new CountryModel { Code = "VE", Name = "Venezuela", Medals = new MedalsModel { Gold = 1, Silver = 3, Bronze = 0 } }, + ]; + + + + private IQueryable allCountries = default!; + private BitQuickGrid dataGrid = default!; + private BitQuickGrid productsDataGrid = default!; + private BitQuickGridItemsProvider foodRecallProvider = default!; + private BitQuickGridItemsProvider productsItemsProvider = default!; + private BitQuickGridPaginationState pagination1 = new() { ItemsPerPage = 7 }; + private BitQuickGridPaginationState pagination2 = new() { ItemsPerPage = 7 }; + private BitQuickGridPaginationState pagination3 = new() { ItemsPerPage = 7 }; + private BitQuickGridPaginationState pagination6 = new() { ItemsPerPage = 7 }; + private BitQuickGridPaginationState pagination7 = new() { ItemsPerPage = 7 }; + + private HashSet expandedRowTemplateCodes = []; + + private void ToggleRowRendererExpand(string code) + { + if (expandedRowTemplateCodes.Remove(code)) return; + + expandedRowTemplateCodes.Add(code); + } + + private IQueryable? FilteredItems1 => allCountries?.Where(x => x.Name.Contains(typicalSampleNameFilter1 ?? string.Empty, StringComparison.CurrentCultureIgnoreCase)); + private IQueryable? FilteredItems2 => allCountries?.Where(x => x.Name.Contains(typicalSampleNameFilter2 ?? string.Empty, StringComparison.CurrentCultureIgnoreCase)); + + string typicalSampleNameFilter1 = string.Empty; + string typicalSampleNameFilter2 = string.Empty; + + string _virtualSampleNameFilter = string.Empty; + string VirtualSampleNameFilter + { + get => _virtualSampleNameFilter; + set + { + _virtualSampleNameFilter = value; + _ = dataGrid.RefreshDataAsync(); + } + } + + string _odataSampleNameFilter = string.Empty; + + string ODataSampleNameFilter + { + get => _odataSampleNameFilter; + set + { + _odataSampleNameFilter = value; + _ = productsDataGrid.RefreshDataAsync(); + } + } + + + + protected override async Task OnInitAsync() + { + allCountries = _countries.AsQueryable(); + + foodRecallProvider = async req => + { + try + { + var query = new Dictionary + { + { "search", $"recalling_firm:\"{_virtualSampleNameFilter}\"" }, + { "skip", req.StartIndex }, + { "limit", req.Count } + }; + + var sort = req.GetSortByProperties().SingleOrDefault(); + + if (sort != default) + { + var sortByColumnName = sort.PropertyName switch + { + nameof(FoodRecall.ReportDate) => "report_date", + _ => throw new InvalidOperationException() + }; + + query.Add("sort", $"{sortByColumnName}:{(sort.Direction == BitQuickGridSortDirection.Ascending ? "asc" : "desc")}"); + } + + var url = NavigationManager.GetUriWithQueryParameters("https://api.fda.gov/food/enforcement.json", query); + + var data = await HttpClient.GetFromJsonAsync(url, AppJsonContext.Default.FoodRecallQueryResult, req.CancellationToken); + + return BitQuickGridItemsProviderResult.From(data!.Results!, data!.Meta!.Results!.Total); + } + catch + { + return BitQuickGridItemsProviderResult.From([], 0); + } + }; + + productsItemsProvider = async req => + { + try + { + // https://docs.microsoft.com/en-us/odata/concepts/queryoptions-overview + + var query = new Dictionary() + { + { "$top", req.Count ?? 50 }, + { "$skip", req.StartIndex } + }; + + if (string.IsNullOrEmpty(_odataSampleNameFilter) is false) + { + query.Add("$filter", $"contains(Name,'{_odataSampleNameFilter}')"); + } + + if (req.GetSortByProperties().Any()) + { + query.Add("$orderby", string.Join(", ", req.GetSortByProperties().Select(p => $"{p.PropertyName} {(p.Direction == BitQuickGridSortDirection.Ascending ? "asc" : "desc")}"))); + } + + var url = NavigationManager.GetUriWithQueryParameters("api/Products/GetProducts", query); + + var data = await HttpClient.GetFromJsonAsync(url, AppJsonContext.Default.PagedResultProductDto); + + return BitQuickGridItemsProviderResult.From(data!.Items!, data!.TotalCount); + } + catch + { + return BitQuickGridItemsProviderResult.From(new List { }, 0); + } + }; + + await base.OnInitAsync(); + } +} diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.samples.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.samples.cs new file mode 100644 index 0000000000..6a25848a2b --- /dev/null +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.samples.cs @@ -0,0 +1,1239 @@ +using Bit.BlazorUI.Demo.Client.Core.Components; + +namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.QuickGrid; + +public partial class BitQuickGridDemo : AppComponentBase +{ + private readonly string example1RazorCode = @" + + c.Name)"" Sortable=""true"" IsDefaultSort=""BitQuickGridSortDirection.Ascending""> + + {{""autofocus"", true}})"" /> + + + c.Medals.Gold)"" Sortable=""true"" /> + c.Medals.Silver)"" Sortable=""true"" /> + c.Medals.Bronze)"" Sortable=""true"" /> + c.Medals.Total)"" Sortable=""true"" /> + +"; + private readonly string example1CsharpCode = @" +private IQueryable allCountries; +private string typicalSampleNameFilter = string.Empty; +private BitQuickGridPaginationState pagination = new() { ItemsPerPage = 7 }; +private IQueryable FilteredItems => + allCountries?.Where(x => x.Name.Contains(typicalSampleNameFilter ?? string.Empty, StringComparison.CurrentCultureIgnoreCase)); + +protected override async Task OnInitializedAsync() +{ + allCountries = _countries.AsQueryable(); +} + +private static readonly CountryModel[] _countries = +[ + new CountryModel { Code = ""AR"", Name = ""Argentina"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 2 } }, + new CountryModel { Code = ""AM"", Name = ""Armenia"", Medals = new MedalsModel { Gold = 0, Silver = 2, Bronze = 2 } }, + new CountryModel { Code = ""AU"", Name = ""Australia"", Medals = new MedalsModel { Gold = 17, Silver = 7, Bronze = 22 } }, + new CountryModel { Code = ""AT"", Name = ""Austria"", Medals = new MedalsModel { Gold = 1, Silver = 1, Bronze = 5 } }, + new CountryModel { Code = ""AZ"", Name = ""Azerbaijan"", Medals = new MedalsModel { Gold = 0, Silver = 3, Bronze = 4 } }, + new CountryModel { Code = ""BS"", Name = ""Bahamas"", Medals = new MedalsModel { Gold = 2, Silver = 0, Bronze = 0 } }, + new CountryModel { Code = ""BH"", Name = ""Bahrain"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, + new CountryModel { Code = ""BY"", Name = ""Belarus"", Medals = new MedalsModel { Gold = 1, Silver = 3, Bronze = 3 } }, + new CountryModel { Code = ""BE"", Name = ""Belgium"", Medals = new MedalsModel { Gold = 3, Silver = 1, Bronze = 3 } }, + new CountryModel { Code = ""BM"", Name = ""Bermuda"", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 0 } }, + new CountryModel { Code = ""BW"", Name = ""Botswana"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = ""BR"", Name = ""Brazil"", Medals = new MedalsModel { Gold = 7, Silver = 6, Bronze = 8 } }, + new CountryModel { Code = ""BF"", Name = ""Burkina Faso"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = ""CA"", Name = ""Canada"", Medals = new MedalsModel { Gold = 7, Silver = 6, Bronze = 11 } }, + new CountryModel { Code = ""TW"", Name = ""Chinese Taipei"", Medals = new MedalsModel { Gold = 2, Silver = 4, Bronze = 6 } }, + new CountryModel { Code = ""CO"", Name = ""Colombia"", Medals = new MedalsModel { Gold = 0, Silver = 4, Bronze = 1 } }, + new CountryModel { Code = ""CI"", Name = ""Côte d'Ivoire"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = ""HR"", Name = ""Croatia"", Medals = new MedalsModel { Gold = 3, Silver = 3, Bronze = 2 } }, + new CountryModel { Code = ""CU"", Name = ""Cuba"", Medals = new MedalsModel { Gold = 7, Silver = 3, Bronze = 5 } }, + new CountryModel { Code = ""CZ"", Name = ""Czech Republic"", Medals = new MedalsModel { Gold = 4, Silver = 4, Bronze = 3 } }, + new CountryModel { Code = ""DK"", Name = ""Denmark"", Medals = new MedalsModel { Gold = 3, Silver = 4, Bronze = 4 } }, + new CountryModel { Code = ""DO"", Name = ""Dominican Republic"", Medals = new MedalsModel { Gold = 0, Silver = 3, Bronze = 2 } }, + new CountryModel { Code = ""EC"", Name = ""Ecuador"", Medals = new MedalsModel { Gold = 2, Silver = 1, Bronze = 0 } }, + new CountryModel { Code = ""EE"", Name = ""Estonia"", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = ""ET"", Name = ""Ethiopia"", Medals = new MedalsModel { Gold = 1, Silver = 1, Bronze = 2 } }, + new CountryModel { Code = ""FJ"", Name = ""Fiji"", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = ""FI"", Name = ""Finland"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 2 } }, + new CountryModel { Code = ""FR"", Name = ""France"", Medals = new MedalsModel { Gold = 10, Silver = 12, Bronze = 11 } }, + new CountryModel { Code = ""GE"", Name = ""Georgia"", Medals = new MedalsModel { Gold = 2, Silver = 5, Bronze = 1 } }, + new CountryModel { Code = ""DE"", Name = ""Germany"", Medals = new MedalsModel { Gold = 10, Silver = 11, Bronze = 16 } }, + new CountryModel { Code = ""GH"", Name = ""Ghana"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = ""GB"", Name = ""Great Britain"", Medals = new MedalsModel { Gold = 22, Silver = 21, Bronze = 22 } }, + new CountryModel { Code = ""GR"", Name = ""Greece"", Medals = new MedalsModel { Gold = 2, Silver = 1, Bronze = 1 } }, + new CountryModel { Code = ""GD"", Name = ""Grenada"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = ""HK"", Name = ""Hong Kong, China"", Medals = new MedalsModel { Gold = 1, Silver = 2, Bronze = 3 } }, + new CountryModel { Code = ""HU"", Name = ""Hungary"", Medals = new MedalsModel { Gold = 6, Silver = 7, Bronze = 7 } }, + new CountryModel { Code = ""ID"", Name = ""Indonesia"", Medals = new MedalsModel { Gold = 1, Silver = 1, Bronze = 3 } }, + new CountryModel { Code = ""IE"", Name = ""Ireland"", Medals = new MedalsModel { Gold = 2, Silver = 0, Bronze = 2 } }, + new CountryModel { Code = ""IR"", Name = ""Iran"", Medals = new MedalsModel { Gold = 3, Silver = 2, Bronze = 2 } }, + new CountryModel { Code = ""IL"", Name = ""Israel"", Medals = new MedalsModel { Gold = 2, Silver = 0, Bronze = 2 } }, + new CountryModel { Code = ""IT"", Name = ""Italy"", Medals = new MedalsModel { Gold = 10, Silver = 10, Bronze = 20 } }, + new CountryModel { Code = ""JM"", Name = ""Jamaica"", Medals = new MedalsModel { Gold = 4, Silver = 1, Bronze = 4 } }, + new CountryModel { Code = ""JO"", Name = ""Jordan"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 1 } }, + new CountryModel { Code = ""KZ"", Name = ""Kazakhstan"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 8 } }, + new CountryModel { Code = ""KE"", Name = ""Kenya"", Medals = new MedalsModel { Gold = 4, Silver = 4, Bronze = 2 } }, + new CountryModel { Code = ""XK"", Name = ""Kosovo"", Medals = new MedalsModel { Gold = 2, Silver = 0, Bronze = 0 } }, + new CountryModel { Code = ""KW"", Name = ""Kuwait"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = ""LV"", Name = ""Latvia"", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = ""LT"", Name = ""Lithuania"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, + new CountryModel { Code = ""MY"", Name = ""Malaysia"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 1 } }, + new CountryModel { Code = ""MX"", Name = ""Mexico"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 4 } }, + new CountryModel { Code = ""MA"", Name = ""Morocco"", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 0 } }, + new CountryModel { Code = ""NA"", Name = ""Namibia"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, + new CountryModel { Code = ""NL"", Name = ""Netherlands"", Medals = new MedalsModel { Gold = 10, Silver = 12, Bronze = 14 } }, + new CountryModel { Code = ""NZ"", Name = ""New Zealand"", Medals = new MedalsModel { Gold = 7, Silver = 6, Bronze = 7 } }, + new CountryModel { Code = ""MK"", Name = ""North Macedonia"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, + new CountryModel { Code = ""NO"", Name = ""Norway"", Medals = new MedalsModel { Gold = 4, Silver = 2, Bronze = 2 } }, + new CountryModel { Code = ""PH"", Name = ""Philippines"", Medals = new MedalsModel { Gold = 1, Silver = 2, Bronze = 1 } }, + new CountryModel { Code = ""PL"", Name = ""Poland"", Medals = new MedalsModel { Gold = 4, Silver = 5, Bronze = 5 } }, + new CountryModel { Code = ""PT"", Name = ""Portugal"", Medals = new MedalsModel { Gold = 1, Silver = 1, Bronze = 2 } }, + new CountryModel { Code = ""PR"", Name = ""Puerto Rico"", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 0 } }, + new CountryModel { Code = ""QA"", Name = ""Qatar"", Medals = new MedalsModel { Gold = 2, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = ""KR"", Name = ""Republic of Korea"", Medals = new MedalsModel { Gold = 6, Silver = 4, Bronze = 10 } }, + new CountryModel { Code = ""MD"", Name = ""Republic of Moldova"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = ""RO"", Name = ""Romania"", Medals = new MedalsModel { Gold = 1, Silver = 3, Bronze = 0 } }, + new CountryModel { Code = ""SM"", Name = ""San Marino"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 2 } }, + new CountryModel { Code = ""SA"", Name = ""Saudi Arabia"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, + new CountryModel { Code = ""RS"", Name = ""Serbia"", Medals = new MedalsModel { Gold = 3, Silver = 1, Bronze = 5 } }, + new CountryModel { Code = ""SK"", Name = ""Slovakia"", Medals = new MedalsModel { Gold = 1, Silver = 2, Bronze = 1 } }, + new CountryModel { Code = ""SI"", Name = ""Slovenia"", Medals = new MedalsModel { Gold = 3, Silver = 1, Bronze = 1 } }, + new CountryModel { Code = ""ZA"", Name = ""South Africa"", Medals = new MedalsModel { Gold = 1, Silver = 2, Bronze = 0 } }, + new CountryModel { Code = ""ES"", Name = ""Spain"", Medals = new MedalsModel { Gold = 3, Silver = 8, Bronze = 6 } }, + new CountryModel { Code = ""SE"", Name = ""Sweden"", Medals = new MedalsModel { Gold = 3, Silver = 6, Bronze = 0 } }, + new CountryModel { Code = ""CH"", Name = ""Switzerland"", Medals = new MedalsModel { Gold = 3, Silver = 4, Bronze = 6 } }, + new CountryModel { Code = ""SY"", Name = ""Syrian Arab Republic"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = ""TH"", Name = ""Thailand"", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = ""TR"", Name = ""Turkey"", Medals = new MedalsModel { Gold = 2, Silver = 2, Bronze = 9 } }, + new CountryModel { Code = ""TM"", Name = ""Turkmenistan"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, + new CountryModel { Code = ""UA"", Name = ""Ukraine"", Medals = new MedalsModel { Gold = 1, Silver = 6, Bronze = 12 } }, + new CountryModel { Code = ""US"", Name = ""United States of America"", Medals = new MedalsModel { Gold = 39, Silver = 41, Bronze = 33 } }, + new CountryModel { Code = ""UZ"", Name = ""Uzbekistan"", Medals = new MedalsModel { Gold = 3, Silver = 0, Bronze = 2 } }, + new CountryModel { Code = ""VE"", Name = ""Venezuela"", Medals = new MedalsModel { Gold = 1, Silver = 3, Bronze = 0 } }, +]; + +public class CountryModel +{ + public string Code { get; set; } + public string Name { get; set; } + public MedalsModel Medals { get; set; } +} + +public class MedalsModel +{ + public int Gold { get; set; } + public int Silver { get; set; } + public int Bronze { get; set; } + public int Total => Gold + Silver + Bronze; +}"; + + private readonly string example2RazorCode = @" + + +
+
+ + c.Name)"" IsDefaultSort=""BitQuickGridSortDirection.Ascending"" Sortable=""true"" Class=""wide""> + + {{""autofocus"", true}})"" /> + + + + + + c.Medals.Gold)"" Sortable=""true"" /> + c.Medals.Silver)"" Sortable=""true"" /> + c.Medals.Bronze)"" Sortable=""true"" /> + + + + + +
+ $""Total: {v.TotalItemCount}"")""> + @(state.CurrentPageIndex + 1) / @(state.LastPageIndex + 1) + +
"; + private readonly string example2CsharpCode = @" +private IQueryable allCountries; +private string typicalSampleNameFilter = string.Empty; +private BitQuickGridPaginationState pagination = new() { ItemsPerPage = 7 }; +private IQueryable FilteredItems + => allCountries?.Where(x => x.Name.Contains(typicalSampleNameFilter ?? string.Empty, StringComparison.CurrentCultureIgnoreCase)); + +protected override async Task OnInitializedAsync() +{ + allCountries = _countries.AsQueryable(); +} + +private static readonly CountryModel[] _countries = +[ + new CountryModel { Code = ""AR"", Name = ""Argentina"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 2 } }, + new CountryModel { Code = ""AM"", Name = ""Armenia"", Medals = new MedalsModel { Gold = 0, Silver = 2, Bronze = 2 } }, + new CountryModel { Code = ""AU"", Name = ""Australia"", Medals = new MedalsModel { Gold = 17, Silver = 7, Bronze = 22 } }, + new CountryModel { Code = ""AT"", Name = ""Austria"", Medals = new MedalsModel { Gold = 1, Silver = 1, Bronze = 5 } }, + new CountryModel { Code = ""AZ"", Name = ""Azerbaijan"", Medals = new MedalsModel { Gold = 0, Silver = 3, Bronze = 4 } }, + new CountryModel { Code = ""BS"", Name = ""Bahamas"", Medals = new MedalsModel { Gold = 2, Silver = 0, Bronze = 0 } }, + new CountryModel { Code = ""BH"", Name = ""Bahrain"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, + new CountryModel { Code = ""BY"", Name = ""Belarus"", Medals = new MedalsModel { Gold = 1, Silver = 3, Bronze = 3 } }, + new CountryModel { Code = ""BE"", Name = ""Belgium"", Medals = new MedalsModel { Gold = 3, Silver = 1, Bronze = 3 } }, + new CountryModel { Code = ""BM"", Name = ""Bermuda"", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 0 } }, + new CountryModel { Code = ""BW"", Name = ""Botswana"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = ""BR"", Name = ""Brazil"", Medals = new MedalsModel { Gold = 7, Silver = 6, Bronze = 8 } }, + new CountryModel { Code = ""BF"", Name = ""Burkina Faso"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = ""CA"", Name = ""Canada"", Medals = new MedalsModel { Gold = 7, Silver = 6, Bronze = 11 } }, + new CountryModel { Code = ""TW"", Name = ""Chinese Taipei"", Medals = new MedalsModel { Gold = 2, Silver = 4, Bronze = 6 } }, + new CountryModel { Code = ""CO"", Name = ""Colombia"", Medals = new MedalsModel { Gold = 0, Silver = 4, Bronze = 1 } }, + new CountryModel { Code = ""CI"", Name = ""Côte d'Ivoire"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = ""HR"", Name = ""Croatia"", Medals = new MedalsModel { Gold = 3, Silver = 3, Bronze = 2 } }, + new CountryModel { Code = ""CU"", Name = ""Cuba"", Medals = new MedalsModel { Gold = 7, Silver = 3, Bronze = 5 } }, + new CountryModel { Code = ""CZ"", Name = ""Czech Republic"", Medals = new MedalsModel { Gold = 4, Silver = 4, Bronze = 3 } }, + new CountryModel { Code = ""DK"", Name = ""Denmark"", Medals = new MedalsModel { Gold = 3, Silver = 4, Bronze = 4 } }, + new CountryModel { Code = ""DO"", Name = ""Dominican Republic"", Medals = new MedalsModel { Gold = 0, Silver = 3, Bronze = 2 } }, + new CountryModel { Code = ""EC"", Name = ""Ecuador"", Medals = new MedalsModel { Gold = 2, Silver = 1, Bronze = 0 } }, + new CountryModel { Code = ""EE"", Name = ""Estonia"", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = ""ET"", Name = ""Ethiopia"", Medals = new MedalsModel { Gold = 1, Silver = 1, Bronze = 2 } }, + new CountryModel { Code = ""FJ"", Name = ""Fiji"", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = ""FI"", Name = ""Finland"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 2 } }, + new CountryModel { Code = ""FR"", Name = ""France"", Medals = new MedalsModel { Gold = 10, Silver = 12, Bronze = 11 } }, + new CountryModel { Code = ""GE"", Name = ""Georgia"", Medals = new MedalsModel { Gold = 2, Silver = 5, Bronze = 1 } }, + new CountryModel { Code = ""DE"", Name = ""Germany"", Medals = new MedalsModel { Gold = 10, Silver = 11, Bronze = 16 } }, + new CountryModel { Code = ""GH"", Name = ""Ghana"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = ""GB"", Name = ""Great Britain"", Medals = new MedalsModel { Gold = 22, Silver = 21, Bronze = 22 } }, + new CountryModel { Code = ""GR"", Name = ""Greece"", Medals = new MedalsModel { Gold = 2, Silver = 1, Bronze = 1 } }, + new CountryModel { Code = ""GD"", Name = ""Grenada"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = ""HK"", Name = ""Hong Kong, China"", Medals = new MedalsModel { Gold = 1, Silver = 2, Bronze = 3 } }, + new CountryModel { Code = ""HU"", Name = ""Hungary"", Medals = new MedalsModel { Gold = 6, Silver = 7, Bronze = 7 } }, + new CountryModel { Code = ""ID"", Name = ""Indonesia"", Medals = new MedalsModel { Gold = 1, Silver = 1, Bronze = 3 } }, + new CountryModel { Code = ""IE"", Name = ""Ireland"", Medals = new MedalsModel { Gold = 2, Silver = 0, Bronze = 2 } }, + new CountryModel { Code = ""IR"", Name = ""Iran"", Medals = new MedalsModel { Gold = 3, Silver = 2, Bronze = 2 } }, + new CountryModel { Code = ""IL"", Name = ""Israel"", Medals = new MedalsModel { Gold = 2, Silver = 0, Bronze = 2 } }, + new CountryModel { Code = ""IT"", Name = ""Italy"", Medals = new MedalsModel { Gold = 10, Silver = 10, Bronze = 20 } }, + new CountryModel { Code = ""JM"", Name = ""Jamaica"", Medals = new MedalsModel { Gold = 4, Silver = 1, Bronze = 4 } }, + new CountryModel { Code = ""JO"", Name = ""Jordan"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 1 } }, + new CountryModel { Code = ""KZ"", Name = ""Kazakhstan"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 8 } }, + new CountryModel { Code = ""KE"", Name = ""Kenya"", Medals = new MedalsModel { Gold = 4, Silver = 4, Bronze = 2 } }, + new CountryModel { Code = ""XK"", Name = ""Kosovo"", Medals = new MedalsModel { Gold = 2, Silver = 0, Bronze = 0 } }, + new CountryModel { Code = ""KW"", Name = ""Kuwait"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = ""LV"", Name = ""Latvia"", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = ""LT"", Name = ""Lithuania"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, + new CountryModel { Code = ""MY"", Name = ""Malaysia"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 1 } }, + new CountryModel { Code = ""MX"", Name = ""Mexico"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 4 } }, + new CountryModel { Code = ""MA"", Name = ""Morocco"", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 0 } }, + new CountryModel { Code = ""NA"", Name = ""Namibia"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, + new CountryModel { Code = ""NL"", Name = ""Netherlands"", Medals = new MedalsModel { Gold = 10, Silver = 12, Bronze = 14 } }, + new CountryModel { Code = ""NZ"", Name = ""New Zealand"", Medals = new MedalsModel { Gold = 7, Silver = 6, Bronze = 7 } }, + new CountryModel { Code = ""MK"", Name = ""North Macedonia"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, + new CountryModel { Code = ""NO"", Name = ""Norway"", Medals = new MedalsModel { Gold = 4, Silver = 2, Bronze = 2 } }, + new CountryModel { Code = ""PH"", Name = ""Philippines"", Medals = new MedalsModel { Gold = 1, Silver = 2, Bronze = 1 } }, + new CountryModel { Code = ""PL"", Name = ""Poland"", Medals = new MedalsModel { Gold = 4, Silver = 5, Bronze = 5 } }, + new CountryModel { Code = ""PT"", Name = ""Portugal"", Medals = new MedalsModel { Gold = 1, Silver = 1, Bronze = 2 } }, + new CountryModel { Code = ""PR"", Name = ""Puerto Rico"", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 0 } }, + new CountryModel { Code = ""QA"", Name = ""Qatar"", Medals = new MedalsModel { Gold = 2, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = ""KR"", Name = ""Republic of Korea"", Medals = new MedalsModel { Gold = 6, Silver = 4, Bronze = 10 } }, + new CountryModel { Code = ""MD"", Name = ""Republic of Moldova"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = ""RO"", Name = ""Romania"", Medals = new MedalsModel { Gold = 1, Silver = 3, Bronze = 0 } }, + new CountryModel { Code = ""SM"", Name = ""San Marino"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 2 } }, + new CountryModel { Code = ""SA"", Name = ""Saudi Arabia"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, + new CountryModel { Code = ""RS"", Name = ""Serbia"", Medals = new MedalsModel { Gold = 3, Silver = 1, Bronze = 5 } }, + new CountryModel { Code = ""SK"", Name = ""Slovakia"", Medals = new MedalsModel { Gold = 1, Silver = 2, Bronze = 1 } }, + new CountryModel { Code = ""SI"", Name = ""Slovenia"", Medals = new MedalsModel { Gold = 3, Silver = 1, Bronze = 1 } }, + new CountryModel { Code = ""ZA"", Name = ""South Africa"", Medals = new MedalsModel { Gold = 1, Silver = 2, Bronze = 0 } }, + new CountryModel { Code = ""ES"", Name = ""Spain"", Medals = new MedalsModel { Gold = 3, Silver = 8, Bronze = 6 } }, + new CountryModel { Code = ""SE"", Name = ""Sweden"", Medals = new MedalsModel { Gold = 3, Silver = 6, Bronze = 0 } }, + new CountryModel { Code = ""CH"", Name = ""Switzerland"", Medals = new MedalsModel { Gold = 3, Silver = 4, Bronze = 6 } }, + new CountryModel { Code = ""SY"", Name = ""Syrian Arab Republic"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = ""TH"", Name = ""Thailand"", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = ""TR"", Name = ""Turkey"", Medals = new MedalsModel { Gold = 2, Silver = 2, Bronze = 9 } }, + new CountryModel { Code = ""TM"", Name = ""Turkmenistan"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, + new CountryModel { Code = ""UA"", Name = ""Ukraine"", Medals = new MedalsModel { Gold = 1, Silver = 6, Bronze = 12 } }, + new CountryModel { Code = ""US"", Name = ""United States of America"", Medals = new MedalsModel { Gold = 39, Silver = 41, Bronze = 33 } }, + new CountryModel { Code = ""UZ"", Name = ""Uzbekistan"", Medals = new MedalsModel { Gold = 3, Silver = 0, Bronze = 2 } }, + new CountryModel { Code = ""VE"", Name = ""Venezuela"", Medals = new MedalsModel { Gold = 1, Silver = 3, Bronze = 0 } }, +]; + +public class CountryModel +{ + public string Code { get; set; } + public string Name { get; set; } + public MedalsModel Medals { get; set; } +} + +public class MedalsModel +{ + public int Gold { get; set; } + public int Silver { get; set; } + public int Bronze { get; set; } + public int Total => Gold + Silver + Bronze; +} +"; + + private readonly string example3RazorCode = @" +@using System.Text.Json; +@inject HttpClient HttpClient +@inject NavigationManager NavManager + + + +
+ + c.EventId)"" /> + c.State)"" /> + c.City)"" /> + c.RecallingFirm)"" Title=""Company"" /> + c.Status)"" /> + c.ReportDate)"" Title=""Report Date"" Sortable=""true"" /> + +
+
+ +
"; + private readonly string example3CsharpCode = @" +BitQuickGrid? dataGrid; +string _virtualSampleNameFilter = string.Empty; +BitQuickGridItemsProvider foodRecallProvider; + +string VirtualSampleNameFilter +{ + get => _virtualSampleNameFilter; + set + { + _virtualSampleNameFilter = value; + _ = dataGrid.RefreshDataAsync(); + } +} + +protected override async Task OnInitializedAsync() +{ + foodRecallProvider = async req => + { + try + { + var query = new Dictionary + { + { ""search"",$""recalling_firm:\""{_virtualSampleNameFilter}\"" }, + { ""skip"", req.StartIndex }, + { ""limit"", req.Count } + }; + + var sort = req.GetSortByProperties().SingleOrDefault(); + + if (sort != default) + { + var sortByColumnName = sort.PropertyName switch + { + nameof(FoodRecall.ReportDate) => ""report_date"", + _ => throw new InvalidOperationException() + }; + + query.Add(""sort"", $""{sortByColumnName}:{(sort.Direction == BitQuickGridSortDirection.Ascending ? ""asc"" : ""desc"")}""); + } + + var url = NavManager.GetUriWithQueryParameters(""https://api.fda.gov/food/enforcement.json"", query); + + var data = await HttpClient.GetFromJsonAsync(url, AppJsonContext.Default.FoodRecallQueryResult, req.CancellationToken); + + return BitQuickGridItemsProviderResult.From( + items: data!.Results, + totalItemCount: data!.Meta.Results.Total); + } + catch + { + return BitQuickGridItemsProviderResult.From(new List { }, 0); + } + }; +} + +//https://devblogs.microsoft.com/dotnet/try-the-new-system-text-json-source-generator/ +[JsonSerializable(typeof(FoodRecallQueryResult))] +[JsonSerializable(typeof(Meta))] +[JsonSerializable(typeof(FoodRecall))] +[JsonSerializable(typeof(Results))] +[JsonSerializable(typeof(Openfda))] +public partial class AppJsonContext : JsonSerializerContext +{ + +} + +public class FoodRecallQueryResult +{ + [JsonPropertyName(""meta"")] + public Meta? Meta { get; set; } + + [JsonPropertyName(""results"")] + public List? Results { get; set; } +} + +public class Meta +{ + [JsonPropertyName(""disclaimer"")] + public string? Disclaimer { get; set; } + + [JsonPropertyName(""terms"")] + public string? Terms { get; set; } + + [JsonPropertyName(""license"")] + public string? License { get; set; } + + [JsonPropertyName(""last_updated"")] + public string? LastUpdated { get; set; } + + [JsonPropertyName(""results"")] + public Results? Results { get; set; } +} + +public class FoodRecall +{ + [JsonPropertyName(""country"")] + public string? CountryModel { get; set; } + + [JsonPropertyName(""city"")] + public string? City { get; set; } + + [JsonPropertyName(""address_1"")] + public string? Address1 { get; set; } + + [JsonPropertyName(""reason_for_recall"")] + public string? ReasonForRecall { get; set; } + + [JsonPropertyName(""address_2"")] + public string? Address2 { get; set; } + + [JsonPropertyName(""product_quantity"")] + public string? ProductQuantity { get; set; } + + [JsonPropertyName(""code_info"")] + public string? CodeInfo { get; set; } + + [JsonPropertyName(""center_classification_date"")] + public string? CenterClassificationDate { get; set; } + + [JsonPropertyName(""distribution_pattern"")] + public string? DistributionPattern { get; set; } + + [JsonPropertyName(""state"")] + public string? State { get; set; } + + [JsonPropertyName(""product_description"")] + public string? ProductDescription { get; set; } + + [JsonPropertyName(""report_date"")] + public string? ReportDate { get; set; } + + [JsonPropertyName(""classification"")] + public string? Classification { get; set; } + + [JsonPropertyName(""openfda"")] + public Openfda? Openfda { get; set; } + + [JsonPropertyName(""recalling_firm"")] + public string? RecallingFirm { get; set; } + + [JsonPropertyName(""recall_number"")] + public string? RecallNumber { get; set; } + + [JsonPropertyName(""initial_firm_notification"")] + public string? InitialFirmNotification { get; set; } + + [JsonPropertyName(""product_type"")] + public string? ProductType { get; set; } + + [JsonPropertyName(""event_id"")] + public string? EventId { get; set; } + + [JsonPropertyName(""more_code_info"")] + public string? MoreCodeInfo { get; set; } + + [JsonPropertyName(""recall_initiation_date"")] + public string? RecallInitiationDate { get; set; } + + [JsonPropertyName(""postal_code"")] + public string? PostalCode { get; set; } + + [JsonPropertyName(""voluntary_mandated"")] + public string? VoluntaryMandated { get; set; } + + [JsonPropertyName(""status"")] + public string? Status { get; set; } +} + +public class Results +{ + [JsonPropertyName(""skip"")] + public int Skip { get; set; } + + [JsonPropertyName(""limit"")] + public int Limit { get; set; } + + [JsonPropertyName(""total"")] + public int Total { get; set; } +} + +public class Openfda +{ +} +"; + + private readonly string example4RazorCode = @" +@using System.Text.Json; +@inject HttpClient HttpClient +@inject NavigationManager NavManager + + + +
+ p.Id)"" TGridItem=""ProductDto"" Virtualize> + p.Id)"" Sortable=""true"" IsDefaultSort=""BitQuickGridSortDirection.Ascending"" /> + p.Name)"" Sortable=""true"" /> + p.Price)"" Sortable=""true"" /> + +
+
+ +
"; + private readonly string example4CsharpCode = @" + +// ========== Server code ========== + +// To make following aspnetcore controller work, simply change services.AddControllers(); to services.AddControllers().AddOData(options => options.EnableQueryFeatures()) +// Note that this need Microsoft.AspNetCore.OData nuget package to be installed + +[ApiController] +[Route(""[controller]/[action]"")] +public class ProductsController : ControllerBase +{ + private static readonly Random _random = new Random(); + + private static readonly ProductDto[] _products = Enumerable.Range(1, 500_000) + .Select(i => new ProductDto { Id = i, Name = Guid.NewGuid().ToString(""N""), Price = _random.Next(1, 100) }) + .ToArray(); + + [HttpGet] + public async Task> GetProducts(ODataQueryOptions odataQuery, CancellationToken cancellationToken) + { + var query = _products.AsQueryable(); + + query = (IQueryable)odataQuery.ApplyTo(query, ignoreQueryOptions: AllowedQueryOptions.Top | AllowedQueryOptions.Skip); + + var totalCount = query.Count(); + + if (odataQuery.Skip is not null) + query = query.Skip(odataQuery.Skip.Value); + + query = query.Take(odataQuery.Top?.Value ?? 50); + + return new PagedResult(query.ToArray(), totalCount); + } +} + + +// ========== Shared code ========== + + +//https://devblogs.microsoft.com/dotnet/try-the-new-system-text-json-source-generator/ +[JsonSerializable(typeof(PagedResult))] +public partial class AppJsonContext : JsonSerializerContext +{ + +} + +public class ProductDto +{ + public int Id { get; set; } + + public string Name { get; set; } + + public decimal Price { get; set; } +} + +public class PagedResult +{ + public IList? Items { get; set; } + + public int TotalCount { get; set; } + + public PagedResult(IList items, int totalCount) + { + Items = items; + TotalCount = totalCount; + } + + public PagedResult() + { + + } +} + + +// ========== Client code ========== + + +BitQuickGrid? productsDataGrid; +string _odataSampleNameFilter = string.Empty; +BitQuickGridItemsProvider productsItemsProvider; + +string ODataSampleNameFilter +{ + get => _odataSampleNameFilter; + set + { + _odataSampleNameFilter = value; + _ = productsDataGrid.RefreshDataAsync(); + } +} + +protected override async Task OnInitializedAsync() +{ + productsItemsProvider = async req => + { + try + { + // https://docs.microsoft.com/en-us/odata/concepts/queryoptions-overview + + var query = new Dictionary() + { + { ""$top"", req.Count ?? 50 }, + { ""$skip"", req.StartIndex } + }; + + if (string.IsNullOrEmpty(_odataSampleNameFilter) is false) + { + query.Add(""$filter"", $""contains(Name,'{_odataSampleNameFilter}')""); + } + + if (req.GetSortByProperties().Any()) + { + query.Add(""$orderby"", string.Join("", "", req.GetSortByProperties().Select(p => $""{p.PropertyName} {(p.Direction == BitQuickGridSortDirection.Ascending ? ""asc"" : ""desc"")}""))); + } + + var url = NavManager.GetUriWithQueryParameters(""api/Products/GetProducts"", query); + + var data = await HttpClient.GetFromJsonAsync(url, AppJsonContext.Default.PagedResultProductDto); + + return BitQuickGridItemsProviderResult.From(data!.Items, (int)data!.TotalCount); + } + catch + { + return BitQuickGridItemsProviderResult.From(new List { }, 0); + } + }; +}"; + + private readonly string example5RazorCode = @" +@using System.Text.Json; +@inject HttpClient HttpClient +@inject NavigationManager NavManager + + + +
+ p.Id)"" TGridItem=""ProductDto"" Pagination=""pagination""> + + p.Id)"" Sortable=""true"" IsDefaultSort=""BitQuickGridSortDirection.Ascending"" /> + p.Name)"" Sortable=""true"" /> + p.Price)"" Sortable=""true"" /> + + + + Loading items... + + + + +
+ $""Total: {v.TotalItemCount?.ToString(""N0"")}"")""> + @(state.CurrentPageIndex + 1) / @(state.LastPageIndex + 1) +"; + private readonly string example5CsharpCode = @" + +// ========== Server code ========== + +// To make following aspnetcore controller work, simply change services.AddControllers(); to services.AddControllers().AddOData(options => options.EnableQueryFeatures()) +// Note that this need Microsoft.AspNetCore.OData nuget package to be installed + +[ApiController] +[Route(""[controller]/[action]"")] +public class ProductsController : ControllerBase +{ + private static readonly Random _random = new Random(); + + private static readonly ProductDto[] _products = Enumerable.Range(1, 500_000) + .Select(i => new ProductDto { Id = i, Name = Guid.NewGuid().ToString(""N""), Price = _random.Next(1, 100) }) + .ToArray(); + + [HttpGet] + public async Task> GetProducts(ODataQueryOptions odataQuery, CancellationToken cancellationToken) + { + var query = _products.AsQueryable(); + + query = (IQueryable)odataQuery.ApplyTo(query, ignoreQueryOptions: AllowedQueryOptions.Top | AllowedQueryOptions.Skip); + + var totalCount = query.Count(); + + if (odataQuery.Skip is not null) + { + query = query.Skip(odataQuery.Skip.Value); + } + + query = query.Take(odataQuery.Top?.Value ?? 50); + + return new PagedResult(query.ToArray(), totalCount); + } +} + + +// ========== Shared code ========== + + +//https://devblogs.microsoft.com/dotnet/try-the-new-system-text-json-source-generator/ +[JsonSerializable(typeof(PagedResult))] +public partial class AppJsonContext : JsonSerializerContext +{ + +} + +public class ProductDto +{ + public int Id { get; set; } + + public string Name { get; set; } + + public decimal Price { get; set; } +} + +public class PagedResult +{ + public IList? Items { get; set; } + + public int TotalCount { get; set; } + + public PagedResult(IList items, int totalCount) + { + Items = items; + TotalCount = totalCount; + } + + public PagedResult() + { + + } +} + + +// ========== Client code ========== + + +BitQuickGrid? productsDataGrid; +BitQuickGridItemsProvider productsItemsProvider; +BitQuickGridPaginationState pagination = new() { ItemsPerPage = 7 }; + +protected override async Task OnInitializedAsync() +{ + productsItemsProvider = async req => + { + try + { + // https://docs.microsoft.com/en-us/odata/concepts/queryoptions-overview + + var query = new Dictionary() + { + { ""$top"", req.Count ?? 50 }, + { ""$skip"", req.StartIndex } + }; + + if (req.GetSortByProperties().Any()) + { + query.Add(""$orderby"", string.Join("", "", req.GetSortByProperties().Select(p => $""{p.PropertyName} {(p.Direction == BitQuickGridSortDirection.Ascending ? ""asc"" : ""desc"")}""))); + } + + var url = NavManager.GetUriWithQueryParameters(""api/Products/GetProducts"", query); + + var data = await HttpClient.GetFromJsonAsync(url, AppJsonContext.Default.PagedResultProductDto); + + return BitQuickGridItemsProviderResult.From(data!.Items, (int)data!.TotalCount); + } + catch + { + return BitQuickGridItemsProviderResult.From(new List { }, 0); + } + }; +}"; + + private readonly string example6RazorCode = @" + + +
+ + c.Name)"" /> + c.Medals.Gold)"" /> + c.Medals.Silver)"" /> + c.Medals.Bronze)"" /> + c.Medals.Total)"" /> + + +
"; + private readonly string example6CsharpCode = @" +private IQueryable allCountries; +private BitQuickGridPaginationState pagination = new() { ItemsPerPage = 7 }; + +protected override async Task OnInitializedAsync() +{ + allCountries = _countries.AsQueryable(); +} + +private static readonly CountryModel[] _countries = +[ + new CountryModel { Code = ""AR"", Name = ""Argentina"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 2 } }, + new CountryModel { Code = ""AM"", Name = ""Armenia"", Medals = new MedalsModel { Gold = 0, Silver = 2, Bronze = 2 } }, + new CountryModel { Code = ""AU"", Name = ""Australia"", Medals = new MedalsModel { Gold = 17, Silver = 7, Bronze = 22 } }, + new CountryModel { Code = ""AT"", Name = ""Austria"", Medals = new MedalsModel { Gold = 1, Silver = 1, Bronze = 5 } }, + new CountryModel { Code = ""AZ"", Name = ""Azerbaijan"", Medals = new MedalsModel { Gold = 0, Silver = 3, Bronze = 4 } }, + new CountryModel { Code = ""BS"", Name = ""Bahamas"", Medals = new MedalsModel { Gold = 2, Silver = 0, Bronze = 0 } }, + new CountryModel { Code = ""BH"", Name = ""Bahrain"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, + new CountryModel { Code = ""BY"", Name = ""Belarus"", Medals = new MedalsModel { Gold = 1, Silver = 3, Bronze = 3 } }, + new CountryModel { Code = ""BE"", Name = ""Belgium"", Medals = new MedalsModel { Gold = 3, Silver = 1, Bronze = 3 } }, + new CountryModel { Code = ""BM"", Name = ""Bermuda"", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 0 } }, + new CountryModel { Code = ""BW"", Name = ""Botswana"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = ""BR"", Name = ""Brazil"", Medals = new MedalsModel { Gold = 7, Silver = 6, Bronze = 8 } }, + new CountryModel { Code = ""BF"", Name = ""Burkina Faso"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = ""CA"", Name = ""Canada"", Medals = new MedalsModel { Gold = 7, Silver = 6, Bronze = 11 } }, + new CountryModel { Code = ""TW"", Name = ""Chinese Taipei"", Medals = new MedalsModel { Gold = 2, Silver = 4, Bronze = 6 } }, + new CountryModel { Code = ""CO"", Name = ""Colombia"", Medals = new MedalsModel { Gold = 0, Silver = 4, Bronze = 1 } }, + new CountryModel { Code = ""CI"", Name = ""Côte d'Ivoire"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = ""HR"", Name = ""Croatia"", Medals = new MedalsModel { Gold = 3, Silver = 3, Bronze = 2 } }, + new CountryModel { Code = ""CU"", Name = ""Cuba"", Medals = new MedalsModel { Gold = 7, Silver = 3, Bronze = 5 } }, + new CountryModel { Code = ""CZ"", Name = ""Czech Republic"", Medals = new MedalsModel { Gold = 4, Silver = 4, Bronze = 3 } }, + new CountryModel { Code = ""DK"", Name = ""Denmark"", Medals = new MedalsModel { Gold = 3, Silver = 4, Bronze = 4 } }, + new CountryModel { Code = ""DO"", Name = ""Dominican Republic"", Medals = new MedalsModel { Gold = 0, Silver = 3, Bronze = 2 } }, + new CountryModel { Code = ""EC"", Name = ""Ecuador"", Medals = new MedalsModel { Gold = 2, Silver = 1, Bronze = 0 } }, + new CountryModel { Code = ""EE"", Name = ""Estonia"", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = ""ET"", Name = ""Ethiopia"", Medals = new MedalsModel { Gold = 1, Silver = 1, Bronze = 2 } }, + new CountryModel { Code = ""FJ"", Name = ""Fiji"", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = ""FI"", Name = ""Finland"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 2 } }, + new CountryModel { Code = ""FR"", Name = ""France"", Medals = new MedalsModel { Gold = 10, Silver = 12, Bronze = 11 } }, + new CountryModel { Code = ""GE"", Name = ""Georgia"", Medals = new MedalsModel { Gold = 2, Silver = 5, Bronze = 1 } }, + new CountryModel { Code = ""DE"", Name = ""Germany"", Medals = new MedalsModel { Gold = 10, Silver = 11, Bronze = 16 } }, + new CountryModel { Code = ""GH"", Name = ""Ghana"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = ""GB"", Name = ""Great Britain"", Medals = new MedalsModel { Gold = 22, Silver = 21, Bronze = 22 } }, + new CountryModel { Code = ""GR"", Name = ""Greece"", Medals = new MedalsModel { Gold = 2, Silver = 1, Bronze = 1 } }, + new CountryModel { Code = ""GD"", Name = ""Grenada"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = ""HK"", Name = ""Hong Kong, China"", Medals = new MedalsModel { Gold = 1, Silver = 2, Bronze = 3 } }, + new CountryModel { Code = ""HU"", Name = ""Hungary"", Medals = new MedalsModel { Gold = 6, Silver = 7, Bronze = 7 } }, + new CountryModel { Code = ""ID"", Name = ""Indonesia"", Medals = new MedalsModel { Gold = 1, Silver = 1, Bronze = 3 } }, + new CountryModel { Code = ""IE"", Name = ""Ireland"", Medals = new MedalsModel { Gold = 2, Silver = 0, Bronze = 2 } }, + new CountryModel { Code = ""IR"", Name = ""Iran"", Medals = new MedalsModel { Gold = 3, Silver = 2, Bronze = 2 } }, + new CountryModel { Code = ""IL"", Name = ""Israel"", Medals = new MedalsModel { Gold = 2, Silver = 0, Bronze = 2 } }, + new CountryModel { Code = ""IT"", Name = ""Italy"", Medals = new MedalsModel { Gold = 10, Silver = 10, Bronze = 20 } }, + new CountryModel { Code = ""JM"", Name = ""Jamaica"", Medals = new MedalsModel { Gold = 4, Silver = 1, Bronze = 4 } }, + new CountryModel { Code = ""JO"", Name = ""Jordan"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 1 } }, + new CountryModel { Code = ""KZ"", Name = ""Kazakhstan"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 8 } }, + new CountryModel { Code = ""KE"", Name = ""Kenya"", Medals = new MedalsModel { Gold = 4, Silver = 4, Bronze = 2 } }, + new CountryModel { Code = ""XK"", Name = ""Kosovo"", Medals = new MedalsModel { Gold = 2, Silver = 0, Bronze = 0 } }, + new CountryModel { Code = ""KW"", Name = ""Kuwait"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = ""LV"", Name = ""Latvia"", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = ""LT"", Name = ""Lithuania"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, + new CountryModel { Code = ""MY"", Name = ""Malaysia"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 1 } }, + new CountryModel { Code = ""MX"", Name = ""Mexico"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 4 } }, + new CountryModel { Code = ""MA"", Name = ""Morocco"", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 0 } }, + new CountryModel { Code = ""NA"", Name = ""Namibia"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, + new CountryModel { Code = ""NL"", Name = ""Netherlands"", Medals = new MedalsModel { Gold = 10, Silver = 12, Bronze = 14 } }, + new CountryModel { Code = ""NZ"", Name = ""New Zealand"", Medals = new MedalsModel { Gold = 7, Silver = 6, Bronze = 7 } }, + new CountryModel { Code = ""MK"", Name = ""North Macedonia"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, + new CountryModel { Code = ""NO"", Name = ""Norway"", Medals = new MedalsModel { Gold = 4, Silver = 2, Bronze = 2 } }, + new CountryModel { Code = ""PH"", Name = ""Philippines"", Medals = new MedalsModel { Gold = 1, Silver = 2, Bronze = 1 } }, + new CountryModel { Code = ""PL"", Name = ""Poland"", Medals = new MedalsModel { Gold = 4, Silver = 5, Bronze = 5 } }, + new CountryModel { Code = ""PT"", Name = ""Portugal"", Medals = new MedalsModel { Gold = 1, Silver = 1, Bronze = 2 } }, + new CountryModel { Code = ""PR"", Name = ""Puerto Rico"", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 0 } }, + new CountryModel { Code = ""QA"", Name = ""Qatar"", Medals = new MedalsModel { Gold = 2, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = ""KR"", Name = ""Republic of Korea"", Medals = new MedalsModel { Gold = 6, Silver = 4, Bronze = 10 } }, + new CountryModel { Code = ""MD"", Name = ""Republic of Moldova"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = ""RO"", Name = ""Romania"", Medals = new MedalsModel { Gold = 1, Silver = 3, Bronze = 0 } }, + new CountryModel { Code = ""SM"", Name = ""San Marino"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 2 } }, + new CountryModel { Code = ""SA"", Name = ""Saudi Arabia"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, + new CountryModel { Code = ""RS"", Name = ""Serbia"", Medals = new MedalsModel { Gold = 3, Silver = 1, Bronze = 5 } }, + new CountryModel { Code = ""SK"", Name = ""Slovakia"", Medals = new MedalsModel { Gold = 1, Silver = 2, Bronze = 1 } }, + new CountryModel { Code = ""SI"", Name = ""Slovenia"", Medals = new MedalsModel { Gold = 3, Silver = 1, Bronze = 1 } }, + new CountryModel { Code = ""ZA"", Name = ""South Africa"", Medals = new MedalsModel { Gold = 1, Silver = 2, Bronze = 0 } }, + new CountryModel { Code = ""ES"", Name = ""Spain"", Medals = new MedalsModel { Gold = 3, Silver = 8, Bronze = 6 } }, + new CountryModel { Code = ""SE"", Name = ""Sweden"", Medals = new MedalsModel { Gold = 3, Silver = 6, Bronze = 0 } }, + new CountryModel { Code = ""CH"", Name = ""Switzerland"", Medals = new MedalsModel { Gold = 3, Silver = 4, Bronze = 6 } }, + new CountryModel { Code = ""SY"", Name = ""Syrian Arab Republic"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = ""TH"", Name = ""Thailand"", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = ""TR"", Name = ""Turkey"", Medals = new MedalsModel { Gold = 2, Silver = 2, Bronze = 9 } }, + new CountryModel { Code = ""TM"", Name = ""Turkmenistan"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, + new CountryModel { Code = ""UA"", Name = ""Ukraine"", Medals = new MedalsModel { Gold = 1, Silver = 6, Bronze = 12 } }, + new CountryModel { Code = ""US"", Name = ""United States of America"", Medals = new MedalsModel { Gold = 39, Silver = 41, Bronze = 33 } }, + new CountryModel { Code = ""UZ"", Name = ""Uzbekistan"", Medals = new MedalsModel { Gold = 3, Silver = 0, Bronze = 2 } }, + new CountryModel { Code = ""VE"", Name = ""Venezuela"", Medals = new MedalsModel { Gold = 1, Silver = 3, Bronze = 0 } }, +]; + +public class CountryModel +{ + public string Code { get; set; } + public string Name { get; set; } + public MedalsModel Medals { get; set; } +} + +public class MedalsModel +{ + public int Gold { get; set; } + public int Silver { get; set; } + public int Bronze { get; set; } + public int Total => Gold + Silver + Bronze; +}"; + + private readonly string example7RazorCode = @" + + +
+ + + + ToggleRowRendererExpand(context.Code))"" /> + + c.Name)"" /> + c.Medals.Gold)"" /> + c.Medals.Silver)"" /> + c.Medals.Bronze)"" /> + c.Medals.Total)"" /> + + + @args.OriginalRow + @if (expandedRowTemplateCodes.Contains(args.RowItem.Code)) + { + + +
+ Additional data: + Code: [@args.RowItem.Code] - + Gold: [@args.RowItem.Medals.Gold], + Silver: [@args.RowItem.Medals.Silver], + Bronze: [@args.RowItem.Medals.Bronze] + (Total: @args.RowItem.Medals.Total) +
+ + + } +
+
+ +
"; + private readonly string example7CsharpCode = @" +private IQueryable allCountries; +private BitQuickGridPaginationState pagination = new() { ItemsPerPage = 7 }; + +protected override async Task OnInitializedAsync() +{ + allCountries = _countries.AsQueryable(); +} + +private HashSet expandedRowTemplateCodes = []; + +private void ToggleRowRendererExpand(string code) +{ + if (expandedRowTemplateCodes.Remove(code)) return; + + expandedRowTemplateCodes.Add(code); +} + +private static readonly CountryModel[] _countries = +[ + new CountryModel { Code = ""AR"", Name = ""Argentina"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 2 } }, + new CountryModel { Code = ""AM"", Name = ""Armenia"", Medals = new MedalsModel { Gold = 0, Silver = 2, Bronze = 2 } }, + new CountryModel { Code = ""AU"", Name = ""Australia"", Medals = new MedalsModel { Gold = 17, Silver = 7, Bronze = 22 } }, + new CountryModel { Code = ""AT"", Name = ""Austria"", Medals = new MedalsModel { Gold = 1, Silver = 1, Bronze = 5 } }, + new CountryModel { Code = ""AZ"", Name = ""Azerbaijan"", Medals = new MedalsModel { Gold = 0, Silver = 3, Bronze = 4 } }, + new CountryModel { Code = ""BS"", Name = ""Bahamas"", Medals = new MedalsModel { Gold = 2, Silver = 0, Bronze = 0 } }, + new CountryModel { Code = ""BH"", Name = ""Bahrain"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, + new CountryModel { Code = ""BY"", Name = ""Belarus"", Medals = new MedalsModel { Gold = 1, Silver = 3, Bronze = 3 } }, + new CountryModel { Code = ""BE"", Name = ""Belgium"", Medals = new MedalsModel { Gold = 3, Silver = 1, Bronze = 3 } }, + new CountryModel { Code = ""BM"", Name = ""Bermuda"", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 0 } }, + new CountryModel { Code = ""BW"", Name = ""Botswana"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = ""BR"", Name = ""Brazil"", Medals = new MedalsModel { Gold = 7, Silver = 6, Bronze = 8 } }, + new CountryModel { Code = ""BF"", Name = ""Burkina Faso"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = ""CA"", Name = ""Canada"", Medals = new MedalsModel { Gold = 7, Silver = 6, Bronze = 11 } }, + new CountryModel { Code = ""TW"", Name = ""Chinese Taipei"", Medals = new MedalsModel { Gold = 2, Silver = 4, Bronze = 6 } }, + new CountryModel { Code = ""CO"", Name = ""Colombia"", Medals = new MedalsModel { Gold = 0, Silver = 4, Bronze = 1 } }, + new CountryModel { Code = ""CI"", Name = ""Côte d'Ivoire"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = ""HR"", Name = ""Croatia"", Medals = new MedalsModel { Gold = 3, Silver = 3, Bronze = 2 } }, + new CountryModel { Code = ""CU"", Name = ""Cuba"", Medals = new MedalsModel { Gold = 7, Silver = 3, Bronze = 5 } }, + new CountryModel { Code = ""CZ"", Name = ""Czech Republic"", Medals = new MedalsModel { Gold = 4, Silver = 4, Bronze = 3 } }, + new CountryModel { Code = ""DK"", Name = ""Denmark"", Medals = new MedalsModel { Gold = 3, Silver = 4, Bronze = 4 } }, + new CountryModel { Code = ""DO"", Name = ""Dominican Republic"", Medals = new MedalsModel { Gold = 0, Silver = 3, Bronze = 2 } }, + new CountryModel { Code = ""EC"", Name = ""Ecuador"", Medals = new MedalsModel { Gold = 2, Silver = 1, Bronze = 0 } }, + new CountryModel { Code = ""EE"", Name = ""Estonia"", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = ""ET"", Name = ""Ethiopia"", Medals = new MedalsModel { Gold = 1, Silver = 1, Bronze = 2 } }, + new CountryModel { Code = ""FJ"", Name = ""Fiji"", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = ""FI"", Name = ""Finland"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 2 } }, + new CountryModel { Code = ""FR"", Name = ""France"", Medals = new MedalsModel { Gold = 10, Silver = 12, Bronze = 11 } }, + new CountryModel { Code = ""GE"", Name = ""Georgia"", Medals = new MedalsModel { Gold = 2, Silver = 5, Bronze = 1 } }, + new CountryModel { Code = ""DE"", Name = ""Germany"", Medals = new MedalsModel { Gold = 10, Silver = 11, Bronze = 16 } }, + new CountryModel { Code = ""GH"", Name = ""Ghana"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = ""GB"", Name = ""Great Britain"", Medals = new MedalsModel { Gold = 22, Silver = 21, Bronze = 22 } }, + new CountryModel { Code = ""GR"", Name = ""Greece"", Medals = new MedalsModel { Gold = 2, Silver = 1, Bronze = 1 } }, + new CountryModel { Code = ""GD"", Name = ""Grenada"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = ""HK"", Name = ""Hong Kong, China"", Medals = new MedalsModel { Gold = 1, Silver = 2, Bronze = 3 } }, + new CountryModel { Code = ""HU"", Name = ""Hungary"", Medals = new MedalsModel { Gold = 6, Silver = 7, Bronze = 7 } }, + new CountryModel { Code = ""ID"", Name = ""Indonesia"", Medals = new MedalsModel { Gold = 1, Silver = 1, Bronze = 3 } }, + new CountryModel { Code = ""IE"", Name = ""Ireland"", Medals = new MedalsModel { Gold = 2, Silver = 0, Bronze = 2 } }, + new CountryModel { Code = ""IR"", Name = ""Iran"", Medals = new MedalsModel { Gold = 3, Silver = 2, Bronze = 2 } }, + new CountryModel { Code = ""IL"", Name = ""Israel"", Medals = new MedalsModel { Gold = 2, Silver = 0, Bronze = 2 } }, + new CountryModel { Code = ""IT"", Name = ""Italy"", Medals = new MedalsModel { Gold = 10, Silver = 10, Bronze = 20 } }, + new CountryModel { Code = ""JM"", Name = ""Jamaica"", Medals = new MedalsModel { Gold = 4, Silver = 1, Bronze = 4 } }, + new CountryModel { Code = ""JO"", Name = ""Jordan"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 1 } }, + new CountryModel { Code = ""KZ"", Name = ""Kazakhstan"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 8 } }, + new CountryModel { Code = ""KE"", Name = ""Kenya"", Medals = new MedalsModel { Gold = 4, Silver = 4, Bronze = 2 } }, + new CountryModel { Code = ""XK"", Name = ""Kosovo"", Medals = new MedalsModel { Gold = 2, Silver = 0, Bronze = 0 } }, + new CountryModel { Code = ""KW"", Name = ""Kuwait"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = ""LV"", Name = ""Latvia"", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = ""LT"", Name = ""Lithuania"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, + new CountryModel { Code = ""MY"", Name = ""Malaysia"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 1 } }, + new CountryModel { Code = ""MX"", Name = ""Mexico"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 4 } }, + new CountryModel { Code = ""MA"", Name = ""Morocco"", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 0 } }, + new CountryModel { Code = ""NA"", Name = ""Namibia"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, + new CountryModel { Code = ""NL"", Name = ""Netherlands"", Medals = new MedalsModel { Gold = 10, Silver = 12, Bronze = 14 } }, + new CountryModel { Code = ""NZ"", Name = ""New Zealand"", Medals = new MedalsModel { Gold = 7, Silver = 6, Bronze = 7 } }, + new CountryModel { Code = ""MK"", Name = ""North Macedonia"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, + new CountryModel { Code = ""NO"", Name = ""Norway"", Medals = new MedalsModel { Gold = 4, Silver = 2, Bronze = 2 } }, + new CountryModel { Code = ""PH"", Name = ""Philippines"", Medals = new MedalsModel { Gold = 1, Silver = 2, Bronze = 1 } }, + new CountryModel { Code = ""PL"", Name = ""Poland"", Medals = new MedalsModel { Gold = 4, Silver = 5, Bronze = 5 } }, + new CountryModel { Code = ""PT"", Name = ""Portugal"", Medals = new MedalsModel { Gold = 1, Silver = 1, Bronze = 2 } }, + new CountryModel { Code = ""PR"", Name = ""Puerto Rico"", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 0 } }, + new CountryModel { Code = ""QA"", Name = ""Qatar"", Medals = new MedalsModel { Gold = 2, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = ""KR"", Name = ""Republic of Korea"", Medals = new MedalsModel { Gold = 6, Silver = 4, Bronze = 10 } }, + new CountryModel { Code = ""MD"", Name = ""Republic of Moldova"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = ""RO"", Name = ""Romania"", Medals = new MedalsModel { Gold = 1, Silver = 3, Bronze = 0 } }, + new CountryModel { Code = ""SM"", Name = ""San Marino"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 2 } }, + new CountryModel { Code = ""SA"", Name = ""Saudi Arabia"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, + new CountryModel { Code = ""RS"", Name = ""Serbia"", Medals = new MedalsModel { Gold = 3, Silver = 1, Bronze = 5 } }, + new CountryModel { Code = ""SK"", Name = ""Slovakia"", Medals = new MedalsModel { Gold = 1, Silver = 2, Bronze = 1 } }, + new CountryModel { Code = ""SI"", Name = ""Slovenia"", Medals = new MedalsModel { Gold = 3, Silver = 1, Bronze = 1 } }, + new CountryModel { Code = ""ZA"", Name = ""South Africa"", Medals = new MedalsModel { Gold = 1, Silver = 2, Bronze = 0 } }, + new CountryModel { Code = ""ES"", Name = ""Spain"", Medals = new MedalsModel { Gold = 3, Silver = 8, Bronze = 6 } }, + new CountryModel { Code = ""SE"", Name = ""Sweden"", Medals = new MedalsModel { Gold = 3, Silver = 6, Bronze = 0 } }, + new CountryModel { Code = ""CH"", Name = ""Switzerland"", Medals = new MedalsModel { Gold = 3, Silver = 4, Bronze = 6 } }, + new CountryModel { Code = ""SY"", Name = ""Syrian Arab Republic"", Medals = new MedalsModel { Gold = 0, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = ""TH"", Name = ""Thailand"", Medals = new MedalsModel { Gold = 1, Silver = 0, Bronze = 1 } }, + new CountryModel { Code = ""TR"", Name = ""Turkey"", Medals = new MedalsModel { Gold = 2, Silver = 2, Bronze = 9 } }, + new CountryModel { Code = ""TM"", Name = ""Turkmenistan"", Medals = new MedalsModel { Gold = 0, Silver = 1, Bronze = 0 } }, + new CountryModel { Code = ""UA"", Name = ""Ukraine"", Medals = new MedalsModel { Gold = 1, Silver = 6, Bronze = 12 } }, + new CountryModel { Code = ""US"", Name = ""United States of America"", Medals = new MedalsModel { Gold = 39, Silver = 41, Bronze = 33 } }, + new CountryModel { Code = ""UZ"", Name = ""Uzbekistan"", Medals = new MedalsModel { Gold = 3, Silver = 0, Bronze = 2 } }, + new CountryModel { Code = ""VE"", Name = ""Venezuela"", Medals = new MedalsModel { Gold = 1, Silver = 3, Bronze = 0 } }, +]; + +public class CountryModel +{ + public string Code { get; set; } + public string Name { get; set; } + public MedalsModel Medals { get; set; } +} + +public class MedalsModel +{ + public int Gold { get; set; } + public int Silver { get; set; } + public int Bronze { get; set; } + public int Total => Gold + Silver + Bronze; +}"; +} diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.scss b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.scss new file mode 100644 index 0000000000..83e80b6df1 --- /dev/null +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.scss @@ -0,0 +1,131 @@ +.custom-grid { + border: 1px solid; + + .container { + overflow: auto; + } + + .flag { + vertical-align: middle; + } + + ::deep { + table { + border-spacing: 0; + } + + tr { + height: 42px; + + &:nth-child(even) { + background: var(--bit-clr-bg-sec); + } + + &:nth-child(odd) { + background: var(--bit-clr-bg-pri); + } + } + + th { + border-bottom: 1px solid; + background-color: var(--bit-clr-bg-sec); + border-bottom-color: var(--bit-clr-brd-sec); + + &:not(:last-child) { + border-right: 1px solid; + border-right-color: var(--bit-clr-brd-sec); + } + } + + td { + border-bottom: 1px solid var(--bit-clr-brd-sec); + + &:not(:last-child) { + border-right: 1px solid var(--bit-clr-brd-sec); + } + } + + .bit-qkg-drg::after { + border-left: unset; + } + + .wide { + width: 220px; + } + } +} + +.grid { + height: 15rem; + overflow-y: auto; + + ::deep { + thead { + top: 0; + z-index: 1; + position: sticky; + background-color: var(--bit-clr-bg-sec); + } + + tr { + height: 2rem; + } + + td { + max-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + } +} + +.search-panel { + max-width: 15rem; + margin-top: 2rem; +} + +.responsive-grid { + ::deep { + table { + border-collapse: collapse; + } + + tr { + padding: 1rem; + margin-bottom: 1rem; + border: 1px solid var(--bit-clr-brd-sec); + } + + @media (max-width: 600px) { + table { + width: 100%; + } + + table, thead, tbody, th, td, tr, td { + display: block; + } + + thead tr { + display: none; + } + + td::before { + font-weight: bold; + content: attr(data-title) " : "; + } + } + } +} + +.row-template-grid { + ::deep { + .row-template-expand-col { + width: 2rem; + } + + .row-template-detail { + padding: 0.5rem; + } + } +} diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/CountryModel.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/CountryModel.cs similarity index 70% rename from src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/CountryModel.cs rename to src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/CountryModel.cs index eb1a73a1c8..00c8a9e187 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/CountryModel.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/CountryModel.cs @@ -1,4 +1,4 @@ -namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.DataGrid; +namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.QuickGrid; public class CountryModel { diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/MedalsModel.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/MedalsModel.cs similarity index 70% rename from src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/MedalsModel.cs rename to src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/MedalsModel.cs index 8aeeba520d..df4afcb371 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/MedalsModel.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/MedalsModel.cs @@ -1,4 +1,4 @@ -namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.DataGrid; +namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.QuickGrid; public class MedalsModel { diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Shared/MainLayout.razor.NavItems.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Shared/MainLayout.razor.NavItems.cs index 9224d74fa2..3fda549bbc 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Shared/MainLayout.razor.NavItems.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Shared/MainLayout.razor.NavItems.cs @@ -159,6 +159,7 @@ public partial class MainLayout new() { Text = "AppShell", Url = "/components/appshell" }, new() { Text = "Chart", Url = "/components/chart" }, new() { Text = "DataGrid", Url = "/components/datagrid", AdditionalUrls = ["/components/data-grid"] }, + new() { Text = "QuickGrid", Url = "/components/quickgrid", AdditionalUrls = ["/components/quick-grid"] }, new() { Text = "ErrorBoundary", Url = "/components/errorboundary" }, new() { Text = "Flag", Url = "/components/flag" }, new() { Text = "InfiniteScrolling", Url = "/components/infinitescrolling" }, diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/compilerconfig.json b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/compilerconfig.json index f568b576f2..c8316868ce 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/compilerconfig.json +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/compilerconfig.json @@ -65,6 +65,12 @@ "minify": { "enabled": false }, "options": { "sourceMap": false } }, + { + "outputFile": "Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.css", + "inputFile": "Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.scss", + "minify": { "enabled": false }, + "options": { "sourceMap": false } + }, { "outputFile": "Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.css", "inputFile": "Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.scss", From 83160574786671d93d67f56d276943547a33ecb6 Mon Sep 17 00:00:00 2001 From: msynk Date: Sun, 21 Jun 2026 20:19:20 +0330 Subject: [PATCH 02/35] resolve review comments --- .../Components/DataGrid/BitDataGrid.razor.cs | 75 ++++++++++++------- .../Components/DataGrid/BitDataGridColumn.cs | 2 + .../BitDataGridDataProcessor.cs | 10 +-- .../BitDataGridPropertyAccessor.cs | 26 ++++++- .../DataGrid/Models/BitDataGridReadResult.cs | 4 + .../Components/QuickGrid/BitQuickGrid.razor | 2 +- .../QuickGrid/BitQuickGrid.razor.cs | 38 +++------- .../Components/QuickGrid/BitQuickGrid.scss | 3 +- .../Components/QuickGrid/BitQuickGrid.ts | 6 +- .../Columns/BitQuickGridColumnBase.razor | 4 +- .../QuickGrid/Columns/BitQuickGridSort.cs | 16 ++-- .../Extras/DataGrid/BitDataGridDemo.razor.cs | 25 +++++-- .../DataGrid/BitDataGridDemo.razor.samples.cs | 1 + .../Components/Extras/DataGrid/SampleData.cs | 4 +- .../Extras/QuickGrid/BitQuickGridDemo.razor | 2 +- .../QuickGrid/BitQuickGridDemo.razor.cs | 6 +- 16 files changed, 138 insertions(+), 86 deletions(-) diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs index 3b6979d17b..cf8fce289d 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs @@ -214,6 +214,9 @@ public partial class BitDataGrid : ComponentBase, IAsyncDisposable private IJSObjectReference? _infiniteHandle; private bool _infiniteObserverAttached; + // Cancels superseded in-flight OnRead/OnLoadMore requests. + private CancellationTokenSource? _loadCts; + internal IReadOnlyList> AllColumns => _columns; internal IReadOnlyList> VisibleColumns => _columns.Where(c => c.Visible).ToList(); internal IReadOnlyList Sorts => _sorts; @@ -452,26 +455,32 @@ private async Task LoadNextBatchAsync() _infiniteLoading = true; StateHasChanged(); - var batch = Math.Max(1, LoadMoreBatchSize); - var read = new BitDataGridReadRequest + try { - Skip = _infiniteItems.Count, - Take = batch, - Sorts = _sorts.Where(s => s.Direction != BitDataGridSortDirection.None).OrderBy(s => s.Priority).ToList(), - Filters = _filters.ToList() - }; - - var result = await OnLoadMore(read); - var loaded = result.Items; - _infiniteItems.AddRange(loaded); - if (loaded.Count < batch) _infiniteHasMore = false; - - _view = _infiniteItems; - _pageItems = _infiniteItems; - _footerAggregates = BitDataGridDataProcessor.Aggregate(_infiniteItems, _columns); - - _infiniteLoading = false; - StateHasChanged(); + var batch = Math.Max(1, LoadMoreBatchSize); + var read = new BitDataGridReadRequest + { + Skip = _infiniteItems.Count, + Take = batch, + Sorts = _sorts.Where(s => s.Direction != BitDataGridSortDirection.None).OrderBy(s => s.Priority).ToList(), + Filters = _filters.ToList(), + CancellationToken = ResetLoadCancellation() + }; + + var result = await OnLoadMore(read); + var loaded = result.Items; + _infiniteItems.AddRange(loaded); + if (loaded.Count < batch) _infiniteHasMore = false; + + _view = _infiniteItems; + _pageItems = _infiniteItems; + _footerAggregates = BitDataGridDataProcessor.Aggregate(_infiniteItems, _columns); + } + finally + { + _infiniteLoading = false; + StateHasChanged(); + } } /// Invoked from JavaScript when the viewport is scrolled near its end. @@ -514,9 +523,23 @@ public async ValueTask DisposeAsync() catch (JSDisconnectedException) { } catch (JSException) { } _infiniteSelfRef?.Dispose(); + _loadCts?.Cancel(); + _loadCts?.Dispose(); GC.SuppressFinalize(this); } + /// + /// Cancels any in-flight data request and returns a fresh token for the next one, + /// so superseded requests can stop early. + /// + private CancellationToken ResetLoadCancellation() + { + _loadCts?.Cancel(); + _loadCts?.Dispose(); + _loadCts = new CancellationTokenSource(); + return _loadCts.Token; + } + private async Task LoadServerDataAsync() { var request = new BitDataGridReadRequest @@ -524,7 +547,8 @@ private async Task LoadServerDataAsync() Skip = Pageable ? (_currentPage - 1) * _effectivePageSize : 0, Take = Pageable ? _effectivePageSize : null, Sorts = _sorts.Where(s => s.Direction != BitDataGridSortDirection.None).OrderBy(s => s.Priority).ToList(), - Filters = _filters.ToList() + Filters = _filters.ToList(), + CancellationToken = ResetLoadCancellation() }; var result = await OnRead!(request); _pageItems = result.Items; @@ -554,15 +578,12 @@ internal async Task ToggleSortAsync(BitDataGridColumn column, bool additi if (!ColumnSortable(column)) return; var existing = GetSort(column); - if (!additive && (!MultiSort || true)) + if (!additive && !MultiSort) { // Single sort: clear others unless additive - if (!additive) - { - var keep = existing; - _sorts.Clear(); - if (keep is not null) _sorts.Add(keep); - } + var keep = existing; + _sorts.Clear(); + if (keep is not null) _sorts.Add(keep); } if (existing is null) diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridColumn.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridColumn.cs index 971187db9c..afcaf90b6d 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridColumn.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridColumn.cs @@ -127,6 +127,8 @@ protected override void OnParametersSet() { if (HasField) Accessor = BitDataGridPropertyAccessor.For(Field!); + else + Accessor = null; } public void Dispose() => Grid?.RemoveColumn(this); diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridDataProcessor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridDataProcessor.cs index 3d28a24a1e..69fdb09337 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridDataProcessor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridDataProcessor.cs @@ -144,13 +144,13 @@ public static List Aggregate( case BitDataGridAggregateType.Sum: case BitDataGridAggregateType.Average: { - double sum = 0; int n = 0; + decimal sum = 0; int n = 0; foreach (var item in source) { - if (TryToDouble(accessor.GetValue(item), out var d)) { sum += d; n++; } + if (TryToDecimal(accessor.GetValue(item), out var d)) { sum += d; n++; } } if (column.Aggregate == BitDataGridAggregateType.Sum) return sum; - return n == 0 ? 0d : sum / n; + return n == 0 ? 0m : sum / n; } case BitDataGridAggregateType.Min: case BitDataGridAggregateType.Max: @@ -171,11 +171,11 @@ public static List Aggregate( } } - private static bool TryToDouble(object? value, out double result) + private static bool TryToDecimal(object? value, out decimal result) { result = 0; if (value is null) return false; - try { result = Convert.ToDouble(value, CultureInfo.InvariantCulture); return true; } + try { result = Convert.ToDecimal(value, CultureInfo.InvariantCulture); return true; } catch { return false; } } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridPropertyAccessor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridPropertyAccessor.cs index dfe90d733a..6d9b722959 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridPropertyAccessor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridPropertyAccessor.cs @@ -72,16 +72,29 @@ public void SetValue(TItem item, object? value) } public static BitDataGridPropertyAccessor For(string path) - => Cache.GetOrAdd(path, Build); + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("Property path must not be null, empty or whitespace.", nameof(path)); + + return Cache.GetOrAdd(path, Build); + } private static BitDataGridPropertyAccessor Build(string path) { var param = Expression.Parameter(typeof(TItem), "x"); Expression body = param; PropertyInfo? lastProp = null; + Expression? nullGuard = null; foreach (var segment in path.Split('.', StringSplitOptions.RemoveEmptyEntries)) { + // If the owner of this segment is an intermediate (nullable) value, guard against it being null. + if (!ReferenceEquals(body, param) && CanBeNull(body.Type)) + { + var isNull = Expression.Equal(body, Expression.Constant(null, body.Type)); + nullGuard = nullGuard is null ? isNull : Expression.OrElse(nullGuard, isNull); + } + var prop = body.Type.GetProperty(segment, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase) ?? throw new ArgumentException($"Property '{segment}' not found on type '{body.Type.Name}'."); @@ -91,8 +104,12 @@ private static BitDataGridPropertyAccessor Build(string path) var propertyType = body.Type; - // Getter: x => (object)x.Path (with null-safety on the object boxing) - var getterBody = Expression.Convert(body, typeof(object)); + // Getter: x => (object)x.Path, returning null early if any intermediate property is null. + Expression getterBody = Expression.Convert(body, typeof(object)); + if (nullGuard is not null) + { + getterBody = Expression.Condition(nullGuard, Expression.Constant(null, typeof(object)), getterBody); + } var getter = Expression.Lambda>(getterBody, param).Compile(); // Setter (only for a simple, writable, single-level-or-nested property) @@ -108,4 +125,7 @@ private static BitDataGridPropertyAccessor Build(string path) return new BitDataGridPropertyAccessor(path, propertyType, canWrite, getter, setter); } + + private static bool CanBeNull(Type type) + => !type.IsValueType || Nullable.GetUnderlyingType(type) is not null; } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridReadResult.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridReadResult.cs index ae9db2b23e..e0e10164d5 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridReadResult.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridReadResult.cs @@ -6,6 +6,10 @@ public sealed class BitDataGridReadResult { public BitDataGridReadResult(IReadOnlyList items, int totalCount) { + ArgumentNullException.ThrowIfNull(items); + if (totalCount < 0) + throw new ArgumentOutOfRangeException(nameof(totalCount), totalCount, "Total count must be greater than or equal to zero."); + Items = items; TotalCount = totalCount; } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor index a31d80c43a..69d84cd1e7 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor @@ -104,7 +104,7 @@ private void RenderPlaceholderRow(RenderTreeBuilder __builder, PlaceholderContext placeholderContext) { - + @foreach (var col in _columns) { diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor.cs index bc0f485587..48ffcb459e 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor.cs @@ -127,7 +127,7 @@ public BitQuickGrid() [Parameter] public float ItemSize { get; set; } = 50; /// - /// A callback that supplies data for the rid. + /// A callback that supplies data for the grid. /// /// You should supply either or , but not both. /// @@ -347,7 +347,7 @@ private async Task RefreshDataCoreAsync() { _currentNonVirtualizedViewItems = result.Items; _ariaBodyRowCount = _currentNonVirtualizedViewItems.Count; - Pagination?.SetTotalItemCountAsync(result.TotalItemCount); + await (Pagination?.SetTotalItemCountAsync(result.TotalItemCount) ?? Task.CompletedTask); _pendingDataLoadCancellationTokenSource = null; } } @@ -389,7 +389,7 @@ private async Task RefreshDataCoreAsync() // be better if we let the last page only be as big as its number of actual rows. _ariaBodyRowCount = Pagination is null ? providerResult.TotalItemCount : Pagination.ItemsPerPage; - Pagination?.SetTotalItemCountAsync(providerResult.TotalItemCount); + await (Pagination?.SetTotalItemCountAsync(providerResult.TotalItemCount) ?? Task.CompletedTask); // We're supplying the row index along with each row's data because we need it for aria-rowindex, and we have to account for // the virtualized start index. It might be more performant just to have some _latestQueryRowStartIndex field, but we'd have @@ -446,36 +446,20 @@ private void CloseColumnOptions() private string? GetRowClass(TGridItem item) { - var classes = new List(); + var selected = RowClassSelector?.Invoke(item); - if (RowClass is not null) - { - classes.Add(RowClass); - } - - if (RowClassSelector is not null) - { - classes.Add(RowClassSelector(item)); - } - - return classes.Any() ? string.Join(' ', classes) : null; + if (string.IsNullOrEmpty(RowClass)) return string.IsNullOrEmpty(selected) ? null : selected; + if (string.IsNullOrEmpty(selected)) return RowClass; + return $"{RowClass} {selected}"; } private string? GetRowStyle(TGridItem item) { - var styles = new List(); - - if (RowStyle is not null) - { - styles.Add(RowStyle); - } - - if (RowStyleSelector is not null) - { - styles.Add(RowStyleSelector(item)); - } + var selected = RowStyleSelector?.Invoke(item); - return styles.Any() ? string.Join(';', styles) : null; + if (string.IsNullOrEmpty(RowStyle)) return string.IsNullOrEmpty(selected) ? null : selected; + if (string.IsNullOrEmpty(selected)) return RowStyle; + return $"{RowStyle};{selected}"; } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.scss b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.scss index ad3d541e97..6eb05dde74 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.scss +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.scss @@ -12,8 +12,7 @@ &.loading > tbody { opacity: 0.25; - transition-delay: 25ms; - transition: opacity linear 100ms; + transition: opacity linear 100ms 25ms; } > tbody > tr > td { diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.ts b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.ts index 873dcd8d3f..f5cc59fc6d 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.ts +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.ts @@ -44,7 +44,11 @@ namespace BitBlazorUI { colOptions.style.transform = `translateX(${applyOffset}px)`; } - colOptions.scrollIntoViewIfNeeded(); + if (typeof colOptions.scrollIntoViewIfNeeded === 'function') { + colOptions.scrollIntoViewIfNeeded(); + } else { + colOptions.scrollIntoView(); + } const autoFocusElem = colOptions.querySelector('[autofocus]'); if (autoFocusElem) { diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridColumnBase.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridColumnBase.razor index ff912cf56b..372b2dcb31 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridColumnBase.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridColumnBase.razor @@ -107,7 +107,7 @@ { @if (ColumnOptions is not null && Align != BitQuickGridAlign.Right) { - + } if (Sortable.HasValue ? Sortable.Value : IsSortableByDefault()) @@ -126,7 +126,7 @@ @if (ColumnOptions is not null && Align == BitQuickGridAlign.Right) { - + } } } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridSort.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridSort.cs index 6fa34588af..36c5bfd929 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridSort.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridSort.cs @@ -28,31 +28,31 @@ internal BitQuickGridSort(Func, bool, IOrderedQueryable - /// Produces a instance that sorts according to the specified , ascending. + /// Produces a instance that sorts according to the specified , ascending. ///
/// The type of the expression's value. /// An expression defining how a set of instances are to be sorted. - /// A instance representing the specified sorting rule. + /// A instance representing the specified sorting rule. public static BitQuickGridSort ByAscending(Expression> expression) => new BitQuickGridSort((queryable, asc) => asc ? queryable.OrderBy(expression) : queryable.OrderByDescending(expression), (expression, true)); /// - /// Produces a instance that sorts according to the specified , descending. + /// Produces a instance that sorts according to the specified , descending. /// /// The type of the expression's value. /// An expression defining how a set of instances are to be sorted. - /// A instance representing the specified sorting rule. + /// A instance representing the specified sorting rule. public static BitQuickGridSort ByDescending(Expression> expression) => new BitQuickGridSort((queryable, asc) => asc ? queryable.OrderByDescending(expression) : queryable.OrderBy(expression), (expression, false)); /// - /// Updates a instance by appending a further sorting rule. + /// Updates a instance by appending a further sorting rule. /// /// The type of the expression's value. /// An expression defining how a set of instances are to be sorted. - /// A instance representing the specified sorting rule. + /// A instance representing the specified sorting rule. public BitQuickGridSort ThenAscending(Expression> expression) { _then ??= new(); @@ -65,11 +65,11 @@ public BitQuickGridSort ThenAscending(Expression - /// Updates a instance by appending a further sorting rule. + /// Updates a instance by appending a further sorting rule. ///
/// The type of the expression's value. /// An expression defining how a set of instances are to be sorted. - /// A instance representing the specified sorting rule. + /// A instance representing the specified sorting rule. public BitQuickGridSort ThenDescending(Expression> expression) { _then ??= new(); diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.cs index 43d0a760cd..ab7f516f11 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.cs @@ -145,8 +145,8 @@ private async Task> LoadServerData(BitDataGridRea }; } - var sort = request.Sorts.FirstOrDefault(); - if (sort is not null) + IOrderedEnumerable? ordered = null; + foreach (var sort in request.Sorts) { Func key = sort.ColumnId switch { @@ -157,10 +157,21 @@ private async Task> LoadServerData(BitDataGridRea nameof(Product.Stock) => p => p.Stock, _ => p => p.Id }; - query = sort.Direction == BitDataGridSortDirection.Descending - ? query.OrderByDescending(key) - : query.OrderBy(key); + + if (ordered is null) + { + ordered = sort.Direction == BitDataGridSortDirection.Descending + ? query.OrderByDescending(key) + : query.OrderBy(key); + } + else + { + ordered = sort.Direction == BitDataGridSortDirection.Descending + ? ordered.ThenByDescending(key) + : ordered.ThenBy(key); + } } + if (ordered is not null) query = ordered; var filtered = query.ToList(); var total = filtered.Count; @@ -201,7 +212,9 @@ private async Task> LoadMore(BitDataGridReadReque infiniteRequests++; var end = request.Skip + batch.Count; - infiniteLog = $"Batch #{infiniteRequests} → loaded rows {request.Skip + 1}–{end} ({batch.Count} rows)"; + infiniteLog = batch.Count == 0 + ? $"Batch #{infiniteRequests} → no additional rows loaded" + : $"Batch #{infiniteRequests} → loaded rows {request.Skip + 1}–{end} ({batch.Count} rows)"; await InvokeAsync(StateHasChanged); return new BitDataGridReadResult(batch, 0); diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.samples.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.samples.cs index 0968c0bda3..6cb09b78bb 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.samples.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.samples.cs @@ -228,6 +228,7 @@ void OnReorder(BitDataGridRowReorderEventArgs e) "; private readonly string example16CsharpCode = @" void OnCellClick(BitDataGridCellEventArgs e) { /* e.Item, e.ColumnTitle, e.Value */ } +void OnCellDoubleClick(BitDataGridCellEventArgs e) { /* e.Item, e.ColumnTitle, e.Value */ } void OnCellContextMenu(BitDataGridCellEventArgs e) { /* e.Mouse.ClientX / e.Mouse.ClientY */ }"; private readonly string example17RazorCode = @" diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/SampleData.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/SampleData.cs index 9415dc288b..524505eea5 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/SampleData.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/SampleData.cs @@ -15,6 +15,8 @@ public static List Generate(int count, int seed = 42) var rng = new Random(seed); var categories = Enum.GetValues(); var list = new List(count); + // Fixed reference date keeps the generated data deterministic regardless of when it runs. + var referenceDate = new DateTime(2024, 1, 1); for (int i = 1; i <= count; i++) { list.Add(new Product @@ -26,7 +28,7 @@ public static List Generate(int count, int seed = 42) Stock = rng.Next(0, 500), Rating = Math.Round(rng.NextDouble() * 4 + 1, 1), Discontinued = rng.Next(0, 5) == 0, - ReleaseDate = DateTime.Today.AddDays(-rng.Next(0, 2000)), + ReleaseDate = referenceDate.AddDays(-rng.Next(0, 2000)), Supplier = Suppliers[rng.Next(Suppliers.Length)] }); } diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor index 96251f979c..948807f6f6 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor @@ -113,7 +113,7 @@
- + diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.cs index c2ad9c0cb2..bc21487701 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.cs @@ -554,6 +554,7 @@ UI will be included in the header cell by default. private IQueryable allCountries = default!; private BitQuickGrid dataGrid = default!; private BitQuickGrid productsDataGrid = default!; + private BitQuickGrid loadingProductsDataGrid = default!; private BitQuickGridItemsProvider foodRecallProvider = default!; private BitQuickGridItemsProvider productsItemsProvider = default!; private BitQuickGridPaginationState pagination1 = new() { ItemsPerPage = 7 }; @@ -656,7 +657,8 @@ protected override async Task OnInitAsync() if (string.IsNullOrEmpty(_odataSampleNameFilter) is false) { - query.Add("$filter", $"contains(Name,'{_odataSampleNameFilter}')"); + var escapedFilter = _odataSampleNameFilter.Replace("'", "''"); + query.Add("$filter", $"contains(Name,'{escapedFilter}')"); } if (req.GetSortByProperties().Any()) @@ -666,7 +668,7 @@ protected override async Task OnInitAsync() var url = NavigationManager.GetUriWithQueryParameters("api/Products/GetProducts", query); - var data = await HttpClient.GetFromJsonAsync(url, AppJsonContext.Default.PagedResultProductDto); + var data = await HttpClient.GetFromJsonAsync(url, AppJsonContext.Default.PagedResultProductDto, req.CancellationToken); return BitQuickGridItemsProviderResult.From(data!.Items!, data!.TotalCount); } From 8acd2aa38484c741788a83d2dc3b8c7c3bb4976d Mon Sep 17 00:00:00 2001 From: msynk Date: Mon, 22 Jun 2026 10:36:09 +0330 Subject: [PATCH 03/35] fix some issues --- .../Components/DataGrid/BitDataGrid.razor | 2 +- .../Components/DataGrid/BitDataGrid.razor.cs | 10 +- .../Components/DataGrid/BitDataGrid.scss | 140 ++++---- .../DataGrid/Models/BitDataGridDirection.cs | 8 - .../Bit.BlazorUI/Utils/BitDirection.cs | 7 - .../Extras/DataGrid/BitDataGridDemo.razor | 48 ++- .../Extras/DataGrid/BitDataGridDemo.razor.cs | 21 +- .../DataGrid/BitDataGridDemo.razor.params.cs | 101 ++++-- .../DataGrid/BitDataGridDemo.razor.samples.cs | 308 +++++++++++++++--- .../Components/Extras/DataGrid/SampleData.cs | 32 ++ 10 files changed, 494 insertions(+), 183 deletions(-) delete mode 100644 src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridDirection.cs delete mode 100644 src/BlazorUI/Bit.BlazorUI/Utils/BitDirection.cs diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor index a390ef0173..ef6124c8c6 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor @@ -6,7 +6,7 @@ @ChildContent -
+
@* ---------------------------------------------------------- Toolbar *@ @if (ShowToolbar || ToolbarTemplate is not null || ShowColumnChooser || ShowCsvExport || (Editable && NewItemFactory is not null)) diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs index cf8fce289d..109fcffbf0 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs @@ -65,7 +65,7 @@ public partial class BitDataGrid : ComponentBase, IAsyncDisposable [Parameter] public bool Bordered { get; set; } = true; [Parameter] public bool ShowHeader { get; set; } = true; [Parameter] public bool ShowFooter { get; set; } - [Parameter] public BitDataGridDirection Direction { get; set; } = BitDataGridDirection.Ltr; + [Parameter] public BitDir Direction { get; set; } = BitDir.Ltr; // -------------------------------------------------------- Feature toggles [Parameter] public bool Sortable { get; set; } = true; @@ -834,7 +834,7 @@ internal void OnResizeMove(double clientX) { if (_resizingColumn is null) return; var delta = clientX - _resizeStartX; - if (Direction == BitDataGridDirection.Rtl) delta = -delta; + if (Direction == BitDir.Rtl) delta = -delta; var newWidth = Math.Max(_resizingColumn.MinWidth, _resizeStartWidth + delta); if (_resizingColumn.MaxWidth is { } max) newWidth = Math.Min(max, newWidth); _resizingColumn.ResizedWidth = newWidth; @@ -993,7 +993,7 @@ internal async Task HandleCellKeyDownAsync(TItem item, int colIndex, KeyboardEve if (rowIdx < 0) rowIdx = 0; int row = rowIdx, col = colIndex; - var rtl = Direction == BitDataGridDirection.Rtl; + var rtl = Direction == BitDir.Rtl; var handled = true; switch (e.Key) @@ -1183,7 +1183,7 @@ private string RootClasses() if (Bordered) c += " bit-dtg-bordered"; if (Striped) c += " bit-dtg-striped"; if (Hoverable) c += " bit-dtg-hoverable"; - if (Direction == BitDataGridDirection.Rtl) c += " bit-dtg-rtl"; + if (Direction == BitDir.Rtl) c += " bit-dtg-rtl"; if (!string.IsNullOrEmpty(Class)) c += " " + Class; return c; } @@ -1218,7 +1218,7 @@ internal double FrozenOffset(BitDataGridColumn column) internal string FrozenStyle(BitDataGridColumn column) { if (!column.Frozen) return string.Empty; - var edge = Direction == BitDataGridDirection.Rtl ? "right" : "left"; + var edge = Direction == BitDir.Rtl ? "right" : "left"; return $"{edge}:{FrozenOffset(column).ToString(CultureInfo.InvariantCulture)}px;"; } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.scss b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.scss index e49890814a..79955c6539 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.scss +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.scss @@ -1,28 +1,20 @@ /* ============================================================ BitDataGrid - styles - Uses CSS custom properties + light-dark() for theming. + Theming is driven entirely by the Bit BlazorUI theme system + (the global --bit-clr-* / --bit-tpg-* / --bit-shp-* variables), + so the grid automatically follows the active theme, including + light/dark. ============================================================ */ .bit-dtg { - /* Theme tokens (override these to customize) */ - --bit-dtg-font: 14px/1.4 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; - --bit-dtg-color: light-dark(#1f2328, #e6e6e6); - --bit-dtg-muted: light-dark(#656d76, #9aa4ad); - --bit-dtg-bg: light-dark(#ffffff, #1c1f24); - --bit-dtg-header-bg: light-dark(#f6f8fa, #22262c); - --bit-dtg-row-hover: light-dark(#f3f6fb, #262b32); - --bit-dtg-row-selected: light-dark(#dcebff, #173b5e); - --bit-dtg-border: light-dark(#d8dee4, #3a4048); - --bit-dtg-accent: light-dark(#0969da, #4493f8); - --bit-dtg-danger: light-dark(#cf222e, #f85149); - --bit-dtg-radius: 8px; --bit-dtg-cell-pad: 8px 10px; - color-scheme: light dark; - font: var(--bit-dtg-font); - color: var(--bit-dtg-color); - background: var(--bit-dtg-bg); - border-radius: var(--bit-dtg-radius); + font-family: var(--bit-tpg-font-family); + font-size: 14px; + line-height: 1.4; + color: var(--bit-clr-fg-pri); + background: var(--bit-clr-bg-pri); + border-radius: var(--bit-shp-brd-radius); display: flex; flex-direction: column; position: relative; @@ -31,7 +23,7 @@ .bit-dtg *, .bit-dtg *::before, .bit-dtg *::after { box-sizing: border-box; } -.bit-dtg.bit-dtg-bordered { border: 1px solid var(--bit-dtg-border); } +.bit-dtg.bit-dtg-bordered { border: 1px solid var(--bit-clr-brd-ter); } /* ---------------------------------------------------------- Toolbar */ .bit-dtg-toolbar { @@ -40,7 +32,7 @@ align-items: center; gap: 8px; padding: 8px; - border-bottom: 1px solid var(--bit-dtg-border); + border-bottom: 1px solid var(--bit-clr-brd-ter); flex-wrap: wrap; } .bit-dtg-toolbar-start, .bit-dtg-toolbar-end { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } @@ -50,8 +42,8 @@ flex-wrap: wrap; gap: 12px; padding: 10px; - border-bottom: 1px solid var(--bit-dtg-border); - background: var(--bit-dtg-header-bg); + border-bottom: 1px solid var(--bit-clr-brd-ter); + background: var(--bit-clr-bg-sec); } .bit-dtg-chooser-item { display: inline-flex; gap: 6px; align-items: center; cursor: pointer; } @@ -65,7 +57,7 @@ display: grid; grid-template-columns: var(--bit-dtg-template); align-items: stretch; - border-bottom: 1px solid var(--bit-dtg-border); + border-bottom: 1px solid var(--bit-clr-brd-ter); } .bit-dtg-header { @@ -74,8 +66,8 @@ z-index: 3; } -.bit-dtg-header-row, .bit-dtg-filter-row { background: var(--bit-dtg-header-bg); } -.bit-dtg-filter-row { border-bottom: 1px solid var(--bit-dtg-border); } +.bit-dtg-header-row, .bit-dtg-filter-row { background: var(--bit-clr-bg-sec); } +.bit-dtg-filter-row { border-bottom: 1px solid var(--bit-clr-brd-ter); } .bit-dtg-hcell { display: flex; @@ -84,7 +76,7 @@ padding: var(--bit-dtg-cell-pad); font-weight: 600; position: relative; - background: var(--bit-dtg-header-bg); + background: var(--bit-clr-bg-sec); border-right: 1px solid transparent; user-select: none; overflow: hidden; @@ -97,25 +89,25 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - background: var(--bit-dtg-bg); + background: var(--bit-clr-bg-pri); } -.bit-dtg-bordered .bit-dtg-cell, .bit-dtg-bordered .bit-dtg-hcell { border-right: 1px solid var(--bit-dtg-border); } +.bit-dtg-bordered .bit-dtg-cell, .bit-dtg-bordered .bit-dtg-hcell { border-right: 1px solid var(--bit-clr-brd-ter); } .bit-dtg-center { justify-content: center; text-align: center; } .bit-dtg-right { justify-content: flex-end; text-align: right; } /* Striping & hover */ -.bit-dtg-striped .bit-dtg-body .bit-dtg-row:nth-child(even) .bit-dtg-cell { background: light-dark(#fbfcfd, #20242a); } -.bit-dtg-hoverable .bit-dtg-body .bit-dtg-row:hover .bit-dtg-cell { background: var(--bit-dtg-row-hover); } +.bit-dtg-striped .bit-dtg-body .bit-dtg-row:nth-child(even) .bit-dtg-cell { background: var(--bit-clr-bg-sec); } +.bit-dtg-hoverable .bit-dtg-body .bit-dtg-row:hover .bit-dtg-cell { background: var(--bit-clr-bg-pri-hover); } /* Selection & editing must win over both striping and hover (in every row position) */ .bit-dtg-row.bit-dtg-selected .bit-dtg-cell, .bit-dtg-striped .bit-dtg-body .bit-dtg-row.bit-dtg-selected:nth-child(even) .bit-dtg-cell, -.bit-dtg-hoverable .bit-dtg-body .bit-dtg-row.bit-dtg-selected:hover .bit-dtg-cell { background: var(--bit-dtg-row-selected); } +.bit-dtg-hoverable .bit-dtg-body .bit-dtg-row.bit-dtg-selected:hover .bit-dtg-cell { background: var(--bit-clr-pri-light); } .bit-dtg-row.bit-dtg-editing .bit-dtg-cell, .bit-dtg-striped .bit-dtg-body .bit-dtg-row.bit-dtg-editing:nth-child(even) .bit-dtg-cell, -.bit-dtg-hoverable .bit-dtg-body .bit-dtg-row.bit-dtg-editing:hover .bit-dtg-cell { background: light-dark(#fff8e6, #2e2a17); } +.bit-dtg-hoverable .bit-dtg-body .bit-dtg-row.bit-dtg-editing:hover .bit-dtg-cell { background: var(--bit-clr-wrn-light); } /* Sticky / frozen columns */ .bit-dtg-sticky { position: sticky; z-index: 2; } @@ -127,11 +119,11 @@ /* ---------------------------------------------------------- Header sorting */ .bit-dtg-sortable .bit-dtg-htext { cursor: pointer; } .bit-dtg-htext { display: inline-flex; align-items: center; gap: 4px; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.bit-dtg-sort-icon { font-size: 10px; color: var(--bit-dtg-accent); } +.bit-dtg-sort-icon { font-size: 10px; color: var(--bit-clr-pri); } .bit-dtg-sort-priority { font-size: 9px; - background: var(--bit-dtg-accent); - color: #fff; + background: var(--bit-clr-pri); + color: var(--bit-clr-pri-text); border-radius: 8px; padding: 0 4px; line-height: 14px; @@ -148,7 +140,7 @@ touch-action: none; } .bit-dtg-rtl .bit-dtg-resizer { right: auto; left: 0; } -.bit-dtg-resizer:hover { background: var(--bit-dtg-accent); opacity: .5; } +.bit-dtg-resizer:hover { background: var(--bit-clr-pri); opacity: .5; } .bit-dtg-resize-overlay { position: fixed; @@ -159,28 +151,28 @@ } /* ---------------------------------------------------------- Group rows */ -.bit-dtg-group-row { border-bottom: 1px solid var(--bit-dtg-border); } +.bit-dtg-group-row { border-bottom: 1px solid var(--bit-clr-brd-ter); } .bit-dtg-group-cell { grid-column: 1 / -1; display: flex; align-items: center; gap: 10px; padding: var(--bit-dtg-cell-pad); - background: light-dark(#eef2f6, #262b32); + background: var(--bit-clr-bg-ter); } -.bit-dtg-group-count { color: var(--bit-dtg-muted); } -.bit-dtg-group-agg { color: var(--bit-dtg-muted); font-size: 12px; } +.bit-dtg-group-count { color: var(--bit-clr-fg-sec); } +.bit-dtg-group-agg { color: var(--bit-clr-fg-sec); font-size: 12px; } /* Nested groups: subtle indentation shading by level */ .bit-dtg-group-row .bit-dtg-group-cell { border-left: 3px solid transparent; } -.bit-dtg-group-row[style*="--bit-dtg-group-level:0"] .bit-dtg-group-cell { border-left-color: var(--bit-dtg-accent); } +.bit-dtg-group-row[style*="--bit-dtg-group-level:0"] .bit-dtg-group-cell { border-left-color: var(--bit-clr-pri); } /* ----------------------------------------------- Grouped column headers */ -.bit-dtg-group-header-row { background: var(--bit-dtg-header-bg); border-bottom: 1px solid var(--bit-dtg-border); } +.bit-dtg-group-header-row { background: var(--bit-clr-bg-sec); border-bottom: 1px solid var(--bit-clr-brd-ter); } .bit-dtg-group-header { font-weight: 700; - background: var(--bit-dtg-header-bg); - border-right: 1px solid var(--bit-dtg-border); + background: var(--bit-clr-bg-sec); + border-right: 1px solid var(--bit-clr-brd-ter); display: flex; align-items: center; justify-content: center; @@ -190,13 +182,13 @@ /* ---------------------------------------------------------- Row reorder */ .bit-dtg-cell-reorder { display: flex; align-items: center; justify-content: center; } -.bit-dtg-drag-handle { cursor: grab; color: var(--bit-dtg-muted); user-select: none; } +.bit-dtg-drag-handle { cursor: grab; color: var(--bit-clr-fg-sec); user-select: none; } .bit-dtg-drag-handle:active { cursor: grabbing; } .bit-dtg-row[draggable="true"] { cursor: grab; } /* ---------------------------------------------------------- Detail rows */ -.bit-dtg-detail-row { border-bottom: 1px solid var(--bit-dtg-border); } -.bit-dtg-detail-content { padding: 12px 16px; background: light-dark(#fbfcfd, #1a1d22); } +.bit-dtg-detail-row { border-bottom: 1px solid var(--bit-clr-brd-ter); } +.bit-dtg-detail-content { padding: 12px 16px; background: var(--bit-clr-bg-sec); } /* ---------------------------------------------------------- Tree view */ .bit-dtg-tree-indent { display: inline-block; flex: 0 0 auto; } @@ -206,27 +198,27 @@ /* ---------------------------------------------------------- Cell navigation */ /* The focus ring is driven solely by real DOM :focus so exactly one cell can ever be outlined (state and DOM focus are kept in sync via @key + FocusAsync). */ -.bit-dtg-cell[tabindex]:focus { outline: 2px solid var(--bit-dtg-accent); outline-offset: -2px; z-index: 1; } -.bit-dtg-cell[tabindex]:focus-visible { outline: 2px solid var(--bit-dtg-accent); outline-offset: -2px; z-index: 1; } +.bit-dtg-cell[tabindex]:focus { outline: 2px solid var(--bit-clr-pri); outline-offset: -2px; z-index: 1; } +.bit-dtg-cell[tabindex]:focus-visible { outline: 2px solid var(--bit-clr-pri); outline-offset: -2px; z-index: 1; } /* ---------------------------------------------------------- Footer */ .bit-dtg-footer { position: sticky; bottom: 0; z-index: 3; } .bit-dtg-footer-row .bit-dtg-cell { - background: var(--bit-dtg-header-bg); + background: var(--bit-clr-bg-sec); font-weight: 600; - border-top: 2px solid var(--bit-dtg-border); + border-top: 2px solid var(--bit-clr-brd-ter); } /* ---------------------------------------------------------- Empty / loading */ .bit-dtg-empty, .bit-dtg-loading { padding: 32px; text-align: center; - color: var(--bit-dtg-muted); + color: var(--bit-clr-fg-sec); } .bit-dtg-spinner { display: inline-block; width: 14px; height: 14px; - border: 2px solid var(--bit-dtg-muted); + border: 2px solid var(--bit-clr-fg-sec); border-top-color: transparent; border-radius: 50%; animation: bit-dtg-spin .7s linear infinite; @@ -242,9 +234,9 @@ height: 10px; border-radius: 6px; background: linear-gradient(90deg, - var(--bit-dtg-border) 25%, - light-dark(#eef1f4, #2a2e35) 37%, - var(--bit-dtg-border) 63%); + var(--bit-clr-brd-ter) 25%, + var(--bit-clr-bg-sec) 37%, + var(--bit-clr-brd-ter) 63%); background-size: 400% 100%; animation: bit-dtg-shimmer 1.2s ease-in-out infinite; } @@ -252,7 +244,7 @@ .bit-dtg-infinite-end { padding: 14px; text-align: center; - color: var(--bit-dtg-muted); + color: var(--bit-clr-fg-sec); font-size: 13px; } @@ -260,10 +252,10 @@ .bit-dtg-editor, .bit-dtg-filter-input { width: 100%; padding: 4px 6px; - border: 1px solid var(--bit-dtg-border); + border: 1px solid var(--bit-clr-brd-ter); border-radius: 4px; - background: var(--bit-dtg-bg); - color: var(--bit-dtg-color); + background: var(--bit-clr-bg-pri); + color: var(--bit-clr-fg-pri); font: inherit; } .bit-dtg-editor-check { width: auto; } @@ -275,32 +267,32 @@ align-items: center; gap: 4px; padding: 5px 10px; - border: 1px solid var(--bit-dtg-border); + border: 1px solid var(--bit-clr-brd-ter); border-radius: 6px; - background: var(--bit-dtg-bg); - color: var(--bit-dtg-color); + background: var(--bit-clr-bg-pri); + color: var(--bit-clr-fg-pri); font: inherit; cursor: pointer; text-decoration: none; line-height: 1.2; } -.bit-dtg-btn:hover:not(:disabled) { background: var(--bit-dtg-row-hover); } +.bit-dtg-btn:hover:not(:disabled) { background: var(--bit-clr-bg-pri-hover); } .bit-dtg-btn:disabled { opacity: .45; cursor: default; } -.bit-dtg-btn-primary { background: var(--bit-dtg-accent); border-color: var(--bit-dtg-accent); color: #fff; } -.bit-dtg-btn-danger { color: var(--bit-dtg-danger); border-color: var(--bit-dtg-danger); } +.bit-dtg-btn-primary { background: var(--bit-clr-pri); border-color: var(--bit-clr-pri); color: var(--bit-clr-pri-text); } +.bit-dtg-btn-danger { color: var(--bit-clr-err); border-color: var(--bit-clr-err); } .bit-dtg-cell-command { gap: 6px; } .bit-dtg-icon-btn { background: none; border: none; - color: var(--bit-dtg-color); + color: var(--bit-clr-fg-pri); cursor: pointer; font-size: 13px; padding: 2px 4px; border-radius: 4px; } -.bit-dtg-icon-btn:hover { background: var(--bit-dtg-row-hover); } -.bit-dtg-group-btn.bit-dtg-active { color: var(--bit-dtg-accent); } +.bit-dtg-icon-btn:hover { background: var(--bit-clr-bg-pri-hover); } +.bit-dtg-group-btn.bit-dtg-active { color: var(--bit-clr-pri); } /* ---------------------------------------------------------- Pager */ .bit-dtg-pager { @@ -309,17 +301,17 @@ align-items: center; gap: 8px; padding: 8px; - border-top: 1px solid var(--bit-dtg-border); + border-top: 1px solid var(--bit-clr-brd-ter); flex-wrap: wrap; } -.bit-dtg-pager-info { display: flex; align-items: center; gap: 10px; color: var(--bit-dtg-muted); } +.bit-dtg-pager-info { display: flex; align-items: center; gap: 10px; color: var(--bit-clr-fg-sec); } .bit-dtg-pager-buttons { display: flex; align-items: center; gap: 4px; } .bit-dtg-pager-current { padding: 0 8px; } .bit-dtg-page-size { padding: 4px 6px; - border: 1px solid var(--bit-dtg-border); + border: 1px solid var(--bit-clr-brd-ter); border-radius: 6px; - background: var(--bit-dtg-bg); - color: var(--bit-dtg-color); + background: var(--bit-clr-bg-pri); + color: var(--bit-clr-fg-pri); font: inherit; } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridDirection.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridDirection.cs deleted file mode 100644 index d36a7dbcfc..0000000000 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridDirection.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Bit.BlazorUI; - -/// Text direction for the grid. -public enum BitDataGridDirection -{ - Ltr = 0, - Rtl -} diff --git a/src/BlazorUI/Bit.BlazorUI/Utils/BitDirection.cs b/src/BlazorUI/Bit.BlazorUI/Utils/BitDirection.cs deleted file mode 100644 index 975cb80344..0000000000 --- a/src/BlazorUI/Bit.BlazorUI/Utils/BitDirection.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Bit.BlazorUI; - -public enum BitDirection -{ - LeftToRight, - RightToLeft -} diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor index e48586ad08..0d40d9a340 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor @@ -9,6 +9,7 @@ - +
- The grid is themed through CSS custom properties and the light-dark() function. - Override any --bit-dtg-* token to create your own theme, toggle borders/striping, or switch to RTL. + Toggle column/row borders and alternate-row striping using the Bordered and Striped parameters.

- Default - Emerald - Grape - @(rtl ? "LTR" : "RTL") Borders: @(bordered ? "on" : "off") Striping: @(striped ? "on" : "off")
- @@ -502,21 +497,24 @@
+ +
+ Render the grid in a right-to-left layout by setting the Direction parameter to BitDir.Rtl. +
+
+ + + + + + + + + +
+
- -@* Theme tokens must be global: Class="@theme" is applied to the grid root rendered by the Extras component. *@ - diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.cs index ab7f516f11..ae72b99a0e 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.cs @@ -75,13 +75,26 @@ public partial class BitDataGridDemo : AppComponentBase private bool emptyHasData; private List EmptyCurrent => emptyHasData ? emptyData : emptyNone; - // example 20 - theming - private readonly List themeProducts = SampleData.Generate(60); - private string theme = ""; - private bool rtl; + // example 20 - borders & striping + private readonly List borderStripeProducts = SampleData.Generate(60); private bool bordered = true; private bool striped = true; + // example 21 - RTL + private readonly List rtlProducts = SampleData.GeneratePersian(60); + + private static string CategoryFa(Category category) => category switch + { + Category.Electronics => "الکترونیک", + Category.Books => "کتاب", + Category.Clothing => "پوشاک", + Category.Home => "خانه", + Category.Toys => "اسباب‌بازی", + Category.Sports => "ورزش", + Category.Grocery => "خواربار", + _ => category.ToString() + }; + protected override Task OnInitAsync() { diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.params.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.params.cs index 5b10070974..098e606cac 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.params.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.params.cs @@ -5,15 +5,15 @@ public partial class BitDataGridDemo private readonly List componentParameters = [ new() { Name = "Items", Type = "IEnumerable?", DefaultValue = "null", Description = "The data source bound to the grid for client-side processing." }, - new() { Name = "OnRead", Type = "Func>>?", DefaultValue = "null", Description = "Server-side data callback. When set, the grid delegates sort/filter/page to the caller." }, - new() { Name = "OnLoadMore", Type = "Func>>?", DefaultValue = "null", Description = "Infinite-scrolling data callback. Loads rows in batches and appends the next batch as the user scrolls toward the end." }, + new() { Name = "OnRead", Type = "Func>>?", DefaultValue = "null", Description = "Server-side data callback. When set, the grid delegates sort/filter/page to the caller.", LinkType = LinkType.Link, Href = "#BitDataGridReadRequest" }, + new() { Name = "OnLoadMore", Type = "Func>>?", DefaultValue = "null", Description = "Infinite-scrolling data callback. Loads rows in batches and appends the next batch as the user scrolls toward the end.", LinkType = LinkType.Link, Href = "#BitDataGridReadRequest" }, new() { Name = "LoadMoreBatchSize", Type = "int", DefaultValue = "50", Description = "Number of rows fetched per batch in infinite-scrolling mode." }, new() { Name = "ChildContent", Type = "RenderFragment?", DefaultValue = "null", Description = "Column definitions and other declarative children." }, new() { Name = "Loading", Type = "bool", DefaultValue = "false", Description = "Shows a loading overlay while data is being fetched." }, new() { Name = "KeyField", Type = "Func?", DefaultValue = "null", Description = "Optional key selector used for selection/edit identity. Defaults to reference equality." }, new() { Name = "ChildrenSelector", Type = "Func?>?", DefaultValue = "null", Description = "Child selector that turns the grid into a hierarchical tree grid." }, new() { Name = "TreeInitiallyExpanded", Type = "bool", DefaultValue = "false", Description = "When tree mode is active, controls whether nodes start expanded." }, - new() { Name = "Class", Type = "string?", DefaultValue = "null", Description = "Custom CSS class for the root element. Use it to apply theme tokens." }, + new() { Name = "Class", Type = "string?", DefaultValue = "null", Description = "Custom CSS class for the root element." }, new() { Name = "Style", Type = "string?", DefaultValue = "null", Description = "Custom inline style for the root element." }, new() { Name = "Height", Type = "string?", DefaultValue = "null", Description = "Height of the scroll viewport, e.g. \"480px\". Required for virtualization and infinite scrolling." }, new() { Name = "Striped", Type = "bool", DefaultValue = "true", Description = "Renders alternate-row striping." }, @@ -21,7 +21,7 @@ public partial class BitDataGridDemo new() { Name = "Bordered", Type = "bool", DefaultValue = "true", Description = "Renders cell borders." }, new() { Name = "ShowHeader", Type = "bool", DefaultValue = "true", Description = "Renders the header row." }, new() { Name = "ShowFooter", Type = "bool", DefaultValue = "false", Description = "Renders the footer/aggregate row." }, - new() { Name = "Direction", Type = "BitDataGridDirection", DefaultValue = "BitDataGridDirection.Ltr", Description = "Text direction (LTR/RTL)." }, + new() { Name = "Direction", Type = "BitDir", DefaultValue = "BitDir.Ltr", Description = "Text direction (LTR/RTL).", LinkType = LinkType.Link, Href = "#BitDir" }, new() { Name = "Sortable", Type = "bool", DefaultValue = "true", Description = "Enables column sorting by clicking headers." }, new() { Name = "MultiSort", Type = "bool", DefaultValue = "true", Description = "Enables multi-column sorting via Ctrl/⌘+click with priority badges." }, new() { Name = "Filterable", Type = "bool", DefaultValue = "false", Description = "Renders a per-column quick-filter row." }, @@ -33,19 +33,19 @@ public partial class BitDataGridDemo new() { Name = "ShowCsvExport", Type = "bool", DefaultValue = "false", Description = "Renders a CSV export link of the current view." }, new() { Name = "CellNavigation", Type = "bool", DefaultValue = "false", Description = "Enables keyboard cell navigation with a roving tabindex." }, new() { Name = "RowReorderable", Type = "bool", DefaultValue = "false", Description = "Enables drag-and-drop row reordering." }, - new() { Name = "OnRowReorder", Type = "EventCallback>", DefaultValue = "", Description = "Raised when a row is dropped onto another row during reordering." }, - new() { Name = "SelectionMode", Type = "BitDataGridSelectionMode", DefaultValue = "BitDataGridSelectionMode.None", Description = "How rows can be selected (None/Single/Multiple)." }, + new() { Name = "OnRowReorder", Type = "EventCallback>", DefaultValue = "", Description = "Raised when a row is dropped onto another row during reordering.", LinkType = LinkType.Link, Href = "#BitDataGridRowReorderEventArgs" }, + new() { Name = "SelectionMode", Type = "BitDataGridSelectionMode", DefaultValue = "BitDataGridSelectionMode.None", Description = "How rows can be selected (None/Single/Multiple).", LinkType = LinkType.Link, Href = "#BitDataGridSelectionMode" }, new() { Name = "SelectedItems", Type = "IReadOnlyList?", DefaultValue = "null", Description = "The selected items (supports two-way binding)." }, new() { Name = "SelectedItemsChanged", Type = "EventCallback>", DefaultValue = "", Description = "Raised when the selection changes." }, new() { Name = "OnRowClick", Type = "EventCallback", DefaultValue = "", Description = "Raised when a row is clicked." }, - new() { Name = "OnCellClick", Type = "EventCallback>", DefaultValue = "", Description = "Raised when a data cell is clicked." }, - new() { Name = "OnCellDoubleClick", Type = "EventCallback>", DefaultValue = "", Description = "Raised when a data cell is double-clicked." }, - new() { Name = "OnCellContextMenu", Type = "EventCallback>", DefaultValue = "", Description = "Raised when a data cell is right-clicked." }, + new() { Name = "OnCellClick", Type = "EventCallback>", DefaultValue = "", Description = "Raised when a data cell is clicked.", LinkType = LinkType.Link, Href = "#BitDataGridCellEventArgs" }, + new() { Name = "OnCellDoubleClick", Type = "EventCallback>", DefaultValue = "", Description = "Raised when a data cell is double-clicked.", LinkType = LinkType.Link, Href = "#BitDataGridCellEventArgs" }, + new() { Name = "OnCellContextMenu", Type = "EventCallback>", DefaultValue = "", Description = "Raised when a data cell is right-clicked.", LinkType = LinkType.Link, Href = "#BitDataGridCellEventArgs" }, new() { Name = "IsRowSelectionDisabled", Type = "Func?", DefaultValue = "null", Description = "Predicate returning true when a given row may not be selected." }, new() { Name = "Pageable", Type = "bool", DefaultValue = "false", Description = "Enables paging with a pager UI." }, new() { Name = "PageSize", Type = "int", DefaultValue = "20", Description = "The number of rows per page." }, new() { Name = "PageSizeOptions", Type = "int[]", DefaultValue = "{ 10, 20, 50, 100 }", Description = "The page-size options offered in the pager dropdown." }, - new() { Name = "PagerPosition", Type = "BitDataGridPagerPosition", DefaultValue = "BitDataGridPagerPosition.Bottom", Description = "Where the pager renders relative to the grid." }, + new() { Name = "PagerPosition", Type = "BitDataGridPagerPosition", DefaultValue = "BitDataGridPagerPosition.Bottom", Description = "Where the pager renders relative to the grid.", LinkType = LinkType.Link, Href = "#BitDataGridPagerPosition" }, new() { Name = "Virtualize", Type = "bool", DefaultValue = "false", Description = "Renders only the visible rows for large datasets. Requires a fixed Height and RowHeight." }, new() { Name = "RowHeight", Type = "float", DefaultValue = "36", Description = "Uniform row height in pixels (required when virtualizing)." }, new() { Name = "RowHeightSelector", Type = "Func?", DefaultValue = "null", Description = "Optional per-row height selector (ignored while virtualizing)." }, @@ -60,6 +60,15 @@ public partial class BitDataGridDemo new() { Name = "DetailTemplate", Type = "RenderFragment?", DefaultValue = "null", Description = "Expandable master-detail content rendered under a row." }, ]; + private readonly List componentPublicMembers = + [ + new() { Name = "RefreshAsync", Type = "Task", DefaultValue = "", Description = "Recomputes the data view (filter → sort → group → page) and re-renders the grid." }, + new() { Name = "ClearFiltersAsync", Type = "Task", DefaultValue = "", Description = "Clears all active column filters and refreshes." }, + new() { Name = "ClearGroupsAsync", Type = "Task", DefaultValue = "", Description = "Removes all active groupings and refreshes." }, + new() { Name = "ExpandAllAsync", Type = "Task", DefaultValue = "", Description = "Expands every node in the tree. No-op outside tree mode." }, + new() { Name = "CollapseAllAsync", Type = "Task", DefaultValue = "", Description = "Collapses every node in the tree. No-op outside tree mode." }, + ]; + private readonly List componentSubClasses = [ new() @@ -86,15 +95,17 @@ public partial class BitDataGridDemo new() { Name = "Group", Type = "string?", DefaultValue = "null", Description = "Optional header group name. Consecutive columns sharing the same value render under a single spanning header cell." }, new() { Name = "ColSpan", Type = "Func?", DefaultValue = "null", Description = "Optional per-row column span." }, new() { Name = "Visible", Type = "bool", DefaultValue = "true", Description = "Whether the column is visible." }, - new() { Name = "Align", Type = "BitDataGridColumnAlign", DefaultValue = "BitDataGridColumnAlign.Left", Description = "Horizontal alignment of cell content." }, + new() { Name = "Align", Type = "BitDataGridColumnAlign", DefaultValue = "BitDataGridColumnAlign.Left", Description = "Horizontal alignment of cell content.", LinkType = LinkType.Link, Href = "#BitDataGridColumnAlign" }, new() { Name = "Format", Type = "string?", DefaultValue = "null", Description = "A .NET format string applied to the value (e.g. \"C2\", \"yyyy-MM-dd\")." }, - new() { Name = "DataType", Type = "BitDataGridColumnDataType", DefaultValue = "BitDataGridColumnDataType.Auto", Description = "The data type used to pick the editor/filter." }, - new() { Name = "Aggregate", Type = "BitDataGridAggregateType", DefaultValue = "BitDataGridAggregateType.None", Description = "The footer/group aggregate function." }, + new() { Name = "DataType", Type = "BitDataGridColumnDataType", DefaultValue = "BitDataGridColumnDataType.Auto", Description = "The data type used to pick the editor/filter.", LinkType = LinkType.Link, Href = "#BitDataGridColumnDataType" }, + new() { Name = "Aggregate", Type = "BitDataGridAggregateType", DefaultValue = "BitDataGridAggregateType.None", Description = "The footer/group aggregate function.", LinkType = LinkType.Link, Href = "#BitDataGridAggregateType" }, new() { Name = "AggregateFormat", Type = "string?", DefaultValue = "null", Description = "Format string for the aggregate value. Falls back to Format." }, + new() { Name = "HeaderClass", Type = "string?", DefaultValue = "null", Description = "Custom CSS class applied to the header cell." }, + new() { Name = "CellClass", Type = "string?", DefaultValue = "null", Description = "Custom CSS class applied to each data cell." }, new() { Name = "Template", Type = "RenderFragment?", DefaultValue = "null", Description = "Custom rendering for a data cell." }, new() { Name = "HeaderTemplate", Type = "RenderFragment?", DefaultValue = "null", Description = "Custom rendering for the header cell content." }, new() { Name = "EditTemplate", Type = "RenderFragment?", DefaultValue = "null", Description = "Custom editor rendered when the row/cell is in edit mode." }, - new() { Name = "FooterTemplate", Type = "RenderFragment?", DefaultValue = "null", Description = "Custom rendering for the footer/aggregate cell." }, + new() { Name = "FooterTemplate", Type = "RenderFragment?", DefaultValue = "null", Description = "Custom rendering for the footer/aggregate cell.", LinkType = LinkType.Link, Href = "#BitDataGridAggregateResult" }, ], }, new() @@ -106,8 +117,9 @@ public partial class BitDataGridDemo [ new() { Name = "Skip", Type = "int", DefaultValue = "0", Description = "Zero-based number of items to skip." }, new() { Name = "Take", Type = "int?", DefaultValue = "null", Description = "Maximum number of items to return (null means all)." }, - new() { Name = "Sorts", Type = "IReadOnlyList", DefaultValue = "[]", Description = "The active sort descriptors ordered by priority." }, - new() { Name = "Filters", Type = "IReadOnlyList", DefaultValue = "[]", Description = "The active filter descriptors." }, + new() { Name = "Sorts", Type = "IReadOnlyList", DefaultValue = "[]", Description = "The active sort descriptors ordered by priority.", LinkType = LinkType.Link, Href = "#BitDataGridSortDescriptor" }, + new() { Name = "Filters", Type = "IReadOnlyList", DefaultValue = "[]", Description = "The active filter descriptors.", LinkType = LinkType.Link, Href = "#BitDataGridFilterDescriptor" }, + new() { Name = "CancellationToken", Type = "CancellationToken", DefaultValue = "", Description = "A token that is cancelled when the request is superseded by a newer one." }, ], }, new() @@ -129,7 +141,7 @@ public partial class BitDataGridDemo Parameters = [ new() { Name = "Item", Type = "TItem", DefaultValue = "", Description = "The row item." }, - new() { Name = "Column", Type = "BitDataGridColumn", DefaultValue = "", Description = "The column the cell belongs to." }, + new() { Name = "Column", Type = "BitDataGridColumn", DefaultValue = "", Description = "The column the cell belongs to.", LinkType = LinkType.Link, Href = "#BitDataGridColumn" }, new() { Name = "ColumnId", Type = "string", DefaultValue = "", Description = "The column field/identifier." }, new() { Name = "ColumnTitle", Type = "string", DefaultValue = "", Description = "The column's display title." }, new() { Name = "Value", Type = "object?", DefaultValue = "null", Description = "The raw value of the cell." }, @@ -149,6 +161,54 @@ public partial class BitDataGridDemo new() { Name = "ToIndex", Type = "int", DefaultValue = "", Description = "The destination index." }, ], }, + new() + { + Id = "BitDataGridSortDescriptor", + Title = "BitDataGridSortDescriptor", + Description = "Describes the sort state applied to a single column (found on BitDataGridReadRequest.Sorts).", + Parameters = + [ + new() { Name = "ColumnId", Type = "string", DefaultValue = "", Description = "The identifier of the column being sorted." }, + new() { Name = "Direction", Type = "BitDataGridSortDirection", DefaultValue = "BitDataGridSortDirection.Ascending", Description = "The sort direction.", LinkType = LinkType.Link, Href = "#BitDataGridSortDirection" }, + new() { Name = "Priority", Type = "int", DefaultValue = "0", Description = "Priority for multi-column sorting (1 = primary)." }, + ], + }, + new() + { + Id = "BitDataGridFilterDescriptor", + Title = "BitDataGridFilterDescriptor", + Description = "Describes a filter applied to a single column (found on BitDataGridReadRequest.Filters).", + Parameters = + [ + new() { Name = "ColumnId", Type = "string", DefaultValue = "", Description = "The identifier of the column being filtered." }, + new() { Name = "Operator", Type = "BitDataGridFilterOperator", DefaultValue = "BitDataGridFilterOperator.Contains", Description = "The comparison operator applied to the value.", LinkType = LinkType.Link, Href = "#BitDataGridFilterOperator" }, + new() { Name = "Value", Type = "object?", DefaultValue = "null", Description = "The value compared against the column's cell value." }, + ], + }, + new() + { + Id = "BitDataGridGroupDescriptor", + Title = "BitDataGridGroupDescriptor", + Description = "Describes a grouping applied to a column.", + Parameters = + [ + new() { Name = "ColumnId", Type = "string", DefaultValue = "", Description = "The identifier of the column being grouped." }, + new() { Name = "Direction", Type = "BitDataGridSortDirection", DefaultValue = "BitDataGridSortDirection.Ascending", Description = "The sort direction applied to the group keys.", LinkType = LinkType.Link, Href = "#BitDataGridSortDirection" }, + ], + }, + new() + { + Id = "BitDataGridAggregateResult", + Title = "BitDataGridAggregateResult", + Description = "Holds the computed aggregate value for a column footer or group (passed to a column's FooterTemplate).", + Parameters = + [ + new() { Name = "ColumnId", Type = "string", DefaultValue = "", Description = "The identifier of the aggregated column." }, + new() { Name = "Type", Type = "BitDataGridAggregateType", DefaultValue = "BitDataGridAggregateType.None", Description = "The aggregate function that produced the value.", LinkType = LinkType.Link, Href = "#BitDataGridAggregateType" }, + new() { Name = "Value", Type = "object?", DefaultValue = "null", Description = "The raw aggregate value." }, + new() { Name = "FormattedValue", Type = "string", DefaultValue = "string.Empty", Description = "The aggregate value formatted using the column's AggregateFormat/Format." }, + ], + }, ]; private readonly List componentSubEnums = @@ -218,13 +278,14 @@ public partial class BitDataGridDemo }, new() { - Id = "BitDataGridDirection", - Name = "BitDataGridDirection", - Description = "Text direction for the grid.", + Id = "BitDir", + Name = "BitDir", + Description = "Determines the component's direction (Ltr/Rtl/Auto).", Items = [ new() { Name = "Ltr", Value = "0" }, new() { Name = "Rtl", Value = "1" }, + new() { Name = "Auto", Value = "2" }, ] }, new() diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.samples.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.samples.cs index 6cb09b78bb..70e5491df9 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.samples.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.samples.cs @@ -2,6 +2,161 @@ namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.DataGrid; public partial class BitDataGridDemo { + // ------------------------------------------------------------------ + // Shared supporting types used by the C# snippets below. They are + // appended to each example's CsharpCode so every snippet is complete + // and can be copied & pasted into a project as-is. + // ------------------------------------------------------------------ + + private const string ProductModelCode = @" + +public enum Category { Electronics, Books, Clothing, Home, Toys, Sports, Grocery } + +public class Product +{ + public int Id { get; set; } + public string Name { get; set; } = """"; + public Category Category { get; set; } + public decimal Price { get; set; } + public int Stock { get; set; } + public double Rating { get; set; } + public bool Discontinued { get; set; } + public DateTime ReleaseDate { get; set; } + public string Supplier { get; set; } = """"; +}"; + + private const string SampleDataCode = @" + +// Deterministic generator so the demo data is reproducible. +public static class SampleData +{ + static readonly string[] Adjectives = + { ""Ultra"", ""Premium"", ""Eco"", ""Smart"", ""Classic"", ""Pro"", ""Mini"", ""Mega"", ""Vintage"", ""Modern"", ""Deluxe"", ""Compact"" }; + static readonly string[] Nouns = + { ""Widget"", ""Gadget"", ""Speaker"", ""Notebook"", ""Jacket"", ""Lamp"", ""Blender"", ""Drone"", ""Backpack"", ""Sneaker"", ""Camera"", ""Mug"" }; + static readonly string[] Suppliers = + { ""Acme Corp"", ""Globex"", ""Initech"", ""Umbrella"", ""Soylent"", ""Stark Industries"", ""Wayne Enterprises"", ""Wonka Inc"" }; + + public static List Generate(int count, int seed = 42) + { + var rng = new Random(seed); + var categories = Enum.GetValues(); + var list = new List(count); + var referenceDate = new DateTime(2024, 1, 1); + for (int i = 1; i <= count; i++) + { + list.Add(new Product + { + Id = i, + Name = $""{Adjectives[rng.Next(Adjectives.Length)]} {Nouns[rng.Next(Nouns.Length)]} {rng.Next(100, 999)}"", + Category = categories[rng.Next(categories.Length)], + Price = Math.Round((decimal)(rng.NextDouble() * 990 + 5), 2), + Stock = rng.Next(0, 500), + Rating = Math.Round(rng.NextDouble() * 4 + 1, 1), + Discontinued = rng.Next(0, 5) == 0, + ReleaseDate = referenceDate.AddDays(-rng.Next(0, 2000)), + Supplier = Suppliers[rng.Next(Suppliers.Length)] + }); + } + return list; + } +}"; + + private const string PersianSampleDataCode = @" + +// Deterministic generator that produces Persian sample data for the RTL demo. +public static class SampleData +{ + static readonly string[] Adjectives = + { ""فوق‌العاده"", ""ممتاز"", ""اقتصادی"", ""هوشمند"", ""کلاسیک"", ""حرفه‌ای"", ""کوچک"", ""بزرگ"", ""قدیمی"", ""مدرن"", ""لوکس"", ""فشرده"" }; + static readonly string[] Nouns = + { ""ویجت"", ""گجت"", ""بلندگو"", ""دفترچه"", ""ژاکت"", ""چراغ"", ""مخلوط‌کن"", ""پهپاد"", ""کوله‌پشتی"", ""کفش"", ""دوربین"", ""لیوان"" }; + static readonly string[] Suppliers = + { ""شرکت آلفا"", ""گلوبکس"", ""اینیتک"", ""آمبرلا"", ""سویلنت"", ""صنایع استارک"", ""شرکت وین"", ""ونکا"" }; + + public static List GeneratePersian(int count, int seed = 42) + { + var rng = new Random(seed); + var categories = Enum.GetValues(); + var list = new List(count); + var referenceDate = new DateTime(2024, 1, 1); + for (int i = 1; i <= count; i++) + { + list.Add(new Product + { + Id = i, + Name = $""{Adjectives[rng.Next(Adjectives.Length)]} {Nouns[rng.Next(Nouns.Length)]} {rng.Next(100, 999)}"", + Category = categories[rng.Next(categories.Length)], + Price = Math.Round((decimal)(rng.NextDouble() * 990 + 5), 2), + Stock = rng.Next(0, 500), + Rating = Math.Round(rng.NextDouble() * 4 + 1, 1), + Discontinued = rng.Next(0, 5) == 0, + ReleaseDate = referenceDate.AddDays(-rng.Next(0, 2000)), + Supplier = Suppliers[rng.Next(Suppliers.Length)] + }); + } + return list; + } +}"; + + private const string FileSystemDataCode = @" + +public class FileNode +{ + public int Id { get; set; } + public string Name { get; set; } = """"; + public string Kind { get; set; } = ""Folder""; + public long Size { get; set; } + public DateTime Modified { get; set; } + public List Children { get; set; } = new(); +} + +public static class FileSystemData +{ + public static List Build() + { + var id = 0; + var baseDate = new DateTime(2025, 1, 1); + + FileNode Folder(string name, params FileNode[] children) + { + var node = new FileNode { Id = ++id, Name = name, Kind = ""Folder"", Modified = baseDate.AddDays(id), Children = children.ToList() }; + node.Size = node.Children.Sum(c => c.Size); + return node; + } + + FileNode File(string name, long size) => new() { Id = ++id, Name = name, Kind = ""File"", Size = size, Modified = baseDate.AddDays(id) }; + + return new List + { + Folder(""src"", + Folder(""BitDataGrid"", + File(""BitDataGrid.razor"", 24_500), + File(""BitDataGrid.razor.cs"", 41_200), + Folder(""Models"", + File(""BitDataGridColumnAlign.cs"", 320), + File(""BitDataGridSortDescriptor.cs"", 540)))), + Folder(""docs"", + File(""README.md"", 6_400), + File(""CHANGELOG.md"", 2_100)), + File(""LICENSE"", 1_070), + File("".gitignore"", 410) + }; + } +}"; + + private const string SupplierModelCode = @" + +public sealed class SupplierModel +{ + public string Name { get; set; } = """"; + public List Products { get; set; } = new(); + public int ProductCount => Products.Count; + public int TotalStock => Products.Sum(p => p.Stock); + public decimal AveragePrice => Products.Count == 0 ? 0 : Math.Round(Products.Average(p => p.Price), 2); +}"; + + private readonly string example1RazorCode = @" @@ -12,7 +167,7 @@ public partial class BitDataGridDemo "; private readonly string example1CsharpCode = @" -private List products = SampleData.Generate(50);"; +private List products = SampleData.Generate(50);" + ProductModelCode + SampleDataCode; private readonly string example2RazorCode = @" "; private readonly string example2CsharpCode = @" -private List products = SampleData.Generate(200);"; +private List products = SampleData.Generate(200);" + ProductModelCode + SampleDataCode; private readonly string example3RazorCode = @" products = SampleData.Generate(60); private BitDataGridSelectionMode selectionMode = BitDataGridSelectionMode.Multiple; -private IReadOnlyList selected = new List();"; +private IReadOnlyList selected = new List();" + ProductModelCode + SampleDataCode; private readonly string example4RazorCode = @" "; private readonly string example4CsharpCode = @" private List products = SampleData.Generate(25); +private int nextId; -private Product CreateProduct() => new() { Id = NextId(), Name = ""New product"", Category = Category.Electronics }; -private void OnCreate(Product p) { /* ... */ } +protected override void OnInitialized() => nextId = products.Max(p => p.Id) + 1; + +private Product CreateProduct() => new() +{ + Id = nextId++, + Name = ""New product"", + Category = Category.Electronics, + ReleaseDate = DateTime.Today +}; +private void OnCreate(Product p) { /* called when a new row starts being added */ } private void OnSave(Product p) { if (!products.Contains(p)) products.Insert(0, p); } -private void OnDelete(Product p) => products.Remove(p);"; +private void OnDelete(Product p) => products.Remove(p);" + ProductModelCode + SampleDataCode; private readonly string example5RazorCode = @" "; private readonly string example5CsharpCode = @" -private List products = SampleData.Generate(80);"; +private List products = SampleData.Generate(80);" + ProductModelCode + SampleDataCode; private readonly string example6RazorCode = @" @@ -95,7 +259,7 @@ private void OnCreate(Product p) { /* ... */ } "; private readonly string example6CsharpCode = @" -private List products = SampleData.Generate(30);"; +private List products = SampleData.Generate(30);" + ProductModelCode + SampleDataCode; private readonly string example7RazorCode = @" @@ -105,7 +269,7 @@ private void OnCreate(Product p) { /* ... */ } "; private readonly string example7CsharpCode = @" -private List products = SampleData.Generate(40);"; +private List products = SampleData.Generate(40);" + ProductModelCode + SampleDataCode; private readonly string example8RazorCode = @" @@ -118,7 +282,7 @@ private void OnCreate(Product p) { /* ... */ } "; private readonly string example8CsharpCode = @" -private List products = SampleData.Generate(40);"; +private List products = SampleData.Generate(40);" + ProductModelCode + SampleDataCode; private readonly string example9RazorCode = @" @@ -133,7 +297,7 @@ private void OnCreate(Product p) { /* ... */ } private List products = SampleData.Generate(40); private int? NameSpan(Product p) => p.Discontinued ? 2 : null; -private int? PriceSpan(Product p) => p.Price > 800 ? 2 : null;"; +private int? PriceSpan(Product p) => p.Price > 800 ? 2 : null;" + ProductModelCode + SampleDataCode; private readonly string example10RazorCode = @" "; private readonly string example10CsharpCode = @" -private List products = SampleData.Generate(10_000);"; +private List products = SampleData.Generate(10_000);" + ProductModelCode + SampleDataCode; private readonly string example11RazorCode = @" "; private readonly string example11CsharpCode = @" -async Task> LoadData(BitDataGridReadRequest request) +private bool loading; +private readonly List all = SampleData.Generate(523); + +private async Task> LoadData(BitDataGridReadRequest request) { - // request.Sorts, request.Filters, request.Skip, request.Take - var page = await Backend.QueryAsync(request); - return new BitDataGridReadResult(page.Items, page.TotalCount); -}"; + loading = true; + await Task.Delay(250); // simulate a backend round-trip + + IEnumerable query = all; + + // filtering + foreach (var f in request.Filters) + { + var term = f.Value?.ToString() ?? """"; + query = query.Where(p => p.Name.Contains(term, StringComparison.OrdinalIgnoreCase)); + } + + // sorting + var sort = request.Sorts.FirstOrDefault(); + if (sort is not null) + { + query = sort.Direction == BitDataGridSortDirection.Descending + ? query.OrderByDescending(p => p.Name) + : query.OrderBy(p => p.Name); + } + + // paging + var filtered = query.ToList(); + var items = filtered.Skip(request.Skip).Take(request.Take ?? filtered.Count).ToList(); + + loading = false; + return new BitDataGridReadResult(items, filtered.Count); +}" + ProductModelCode + SampleDataCode; private readonly string example12RazorCode = @" > LoadData(BitDataGridReadRequest reque "; private readonly string example12CsharpCode = @" -async Task> LoadMore(BitDataGridReadRequest request) +private readonly List all = SampleData.Generate(2_000); + +private async Task> LoadMore(BitDataGridReadRequest request) { - var batch = Query.Skip(request.Skip).Take(request.Take ?? 40).ToList(); - // Return fewer rows than requested to signal the end of the data. + await Task.Delay(350); // simulate a backend round-trip + var batch = all.Skip(request.Skip).Take(request.Take ?? 40).ToList(); + // Pass 0 as the total count to signal there is no known total (infinite scrolling). return new BitDataGridReadResult(batch, 0); -}"; +}" + ProductModelCode + SampleDataCode; private readonly string example13RazorCode = @" > LoadMore(BitDataGridReadRequest reque private BitDataGrid? grid; private async Task ExpandAll() { if (grid is not null) await grid.ExpandAllAsync(); } -private async Task CollapseAll() { if (grid is not null) await grid.CollapseAllAsync(); }"; +private async Task CollapseAll() { if (grid is not null) await grid.CollapseAllAsync(); }" + FileSystemDataCode; private readonly string example14RazorCode = @" @@ -204,7 +398,14 @@ async Task> LoadMore(BitDataGridReadRequest reque "; private readonly string example14CsharpCode = @" -private List suppliers = BuildSuppliers();"; +private List suppliers = BuildSuppliers(); + +private static List BuildSuppliers() => + SampleData.Generate(240) + .GroupBy(p => p.Supplier) + .Select(g => new SupplierModel { Name = g.Key, Products = g.OrderBy(p => p.Name).ToList() }) + .OrderBy(s => s.Name) + .ToList();" + SupplierModelCode + ProductModelCode + SampleDataCode; private readonly string example15RazorCode = @" > LoadMore(BitDataGridReadRequest reque "; private readonly string example15CsharpCode = @" -void OnReorder(BitDataGridRowReorderEventArgs e) +private List products = SampleData.Generate(12); + +private void OnReorder(BitDataGridRowReorderEventArgs e) { // e.DraggedItem, e.FromIndex, e.ToIndex -}"; +}" + ProductModelCode + SampleDataCode; private readonly string example16RazorCode = @" e) "; private readonly string example16CsharpCode = @" -void OnCellClick(BitDataGridCellEventArgs e) { /* e.Item, e.ColumnTitle, e.Value */ } -void OnCellDoubleClick(BitDataGridCellEventArgs e) { /* e.Item, e.ColumnTitle, e.Value */ } -void OnCellContextMenu(BitDataGridCellEventArgs e) { /* e.Mouse.ClientX / e.Mouse.ClientY */ }"; +private List products = SampleData.Generate(40); + +private void OnCellClick(BitDataGridCellEventArgs e) { /* e.Item, e.ColumnTitle, e.Value */ } +private void OnCellDoubleClick(BitDataGridCellEventArgs e) { /* e.Item, e.ColumnTitle, e.Value */ } +private void OnCellContextMenu(BitDataGridCellEventArgs e) { /* e.Mouse.ClientX / e.Mouse.ClientY */ }" + ProductModelCode + SampleDataCode; private readonly string example17RazorCode = @" e) { /* e.Item, e.Colum "; private readonly string example17CsharpCode = @" -private List products = SampleData.Generate(40);"; +private List products = SampleData.Generate(40);" + ProductModelCode + SampleDataCode; private readonly string example18RazorCode = @" e) { /* e.Item, e.Colum "; private readonly string example18CsharpCode = @" -private float RowHeight(Product p) => p.Price > 500 ? 64f : 36f;"; +private List products = SampleData.Generate(40); + +private float RowHeight(Product p) => p.Price > 500 ? 64f : 36f;" + ProductModelCode + SampleDataCode; private readonly string example19RazorCode = @" @@ -263,22 +470,45 @@ void OnCellDoubleClick(BitDataGridCellEventArgs e) { /* e.Item, e.Colum "; private readonly string example19CsharpCode = @" -private List items = new(); // empty"; +private List items = new(); // empty" + ProductModelCode; private readonly string example20RazorCode = @" "; private readonly string example20CsharpCode = @" -/* Override CSS tokens to create a theme: */ -.theme-emerald { - --bit-dtg-accent: #0f9d58; - --bit-dtg-header-bg: light-dark(#e7f6ee, #10241a); - --bit-dtg-row-selected: light-dark(#c9efda, #14402a); -}"; +private List products = SampleData.Generate(60); +private bool bordered = true; +private bool striped = true;" + ProductModelCode + SampleDataCode; + + private readonly string example21RazorCode = @" + + + + + + + + +"; + private readonly string example21CsharpCode = @" +private List products = SampleData.GeneratePersian(60); + +private static string CategoryFa(Category category) => category switch +{ + Category.Electronics => ""الکترونیک"", + Category.Books => ""کتاب"", + Category.Clothing => ""پوشاک"", + Category.Home => ""خانه"", + Category.Toys => ""اسباب‌بازی"", + Category.Sports => ""ورزش"", + Category.Grocery => ""خواربار"", + _ => category.ToString() +};" + ProductModelCode + PersianSampleDataCode; } diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/SampleData.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/SampleData.cs index 524505eea5..7754ac0227 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/SampleData.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/SampleData.cs @@ -34,4 +34,36 @@ public static List Generate(int count, int seed = 42) } return list; } + + private static readonly string[] PersianAdjectives = + { "فوق‌العاده", "ممتاز", "اقتصادی", "هوشمند", "کلاسیک", "حرفه‌ای", "کوچک", "بزرگ", "قدیمی", "مدرن", "لوکس", "فشرده" }; + private static readonly string[] PersianNouns = + { "ویجت", "گجت", "بلندگو", "دفترچه", "ژاکت", "چراغ", "مخلوط‌کن", "پهپاد", "کوله‌پشتی", "کفش", "دوربین", "لیوان" }; + private static readonly string[] PersianSuppliers = + { "شرکت آلفا", "گلوبکس", "اینیتک", "آمبرلا", "سویلنت", "صنایع استارک", "شرکت وین", "ونکا" }; + + /// Deterministic generator that produces Persian sample data for RTL demos. + public static List GeneratePersian(int count, int seed = 42) + { + var rng = new Random(seed); + var categories = Enum.GetValues(); + var list = new List(count); + var referenceDate = new DateTime(2024, 1, 1); + for (int i = 1; i <= count; i++) + { + list.Add(new Product + { + Id = i, + Name = $"{PersianAdjectives[rng.Next(PersianAdjectives.Length)]} {PersianNouns[rng.Next(PersianNouns.Length)]} {rng.Next(100, 999)}", + Category = categories[rng.Next(categories.Length)], + Price = Math.Round((decimal)(rng.NextDouble() * 990 + 5), 2), + Stock = rng.Next(0, 500), + Rating = Math.Round(rng.NextDouble() * 4 + 1, 1), + Discontinued = rng.Next(0, 5) == 0, + ReleaseDate = referenceDate.AddDays(-rng.Next(0, 2000)), + Supplier = PersianSuppliers[rng.Next(PersianSuppliers.Length)] + }); + } + return list; + } } From b9ba019cc89b42fb7f9bc225ec262452cd39f24b Mon Sep 17 00:00:00 2001 From: msynk Date: Mon, 22 Jun 2026 12:15:31 +0330 Subject: [PATCH 04/35] resolve review comments II --- .../Components/DataGrid/BitDataGrid.razor | 2 +- .../Components/DataGrid/BitDataGrid.scss | 12 ++++++++-- .../Components/DataGrid/BitDataGrid.ts | 5 ++-- .../BitDataGridDataProcessor.cs | 4 +++- .../BitDataGridPropertyAccessor.cs | 8 ++++++- .../Models/BitDataGridCellEventArgs.cs | 3 +++ .../Models/BitDataGridSortDescriptor.cs | 5 ++-- .../QuickGrid/BitQuickGrid.razor.cs | 23 +++++++++++++++---- .../Components/QuickGrid/BitQuickGrid.scss | 2 +- .../Components/QuickGrid/BitQuickGrid.ts | 3 +++ .../AsyncQueryExecutorSupplier.cs | 6 ++++- .../DataGrid/BitDataGridDemo.razor.samples.cs | 18 ++++++++++++--- .../Extras/QuickGrid/BitQuickGridDemo.razor | 2 ++ .../QuickGrid/BitQuickGridDemo.razor.cs | 6 ++--- .../BitQuickGridDemo.razor.samples.cs | 6 ++--- 15 files changed, 80 insertions(+), 25 deletions(-) diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor index ef6124c8c6..3f48d6424a 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor @@ -265,7 +265,7 @@ else if (UseVirtualization) { - + } else diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.scss b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.scss index 79955c6539..600b185cad 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.scss +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.scss @@ -221,9 +221,13 @@ border: 2px solid var(--bit-clr-fg-sec); border-top-color: transparent; border-radius: 50%; - animation: bit-dtg-spin .7s linear infinite; vertical-align: middle; } +@media (prefers-reduced-motion: no-preference) { + .bit-dtg-spinner { + animation: bit-dtg-spin .7s linear infinite; + } +} @keyframes bit-dtg-spin { to { transform: rotate(360deg); } } /* ------------------------------------------ Infinite-scroll placeholder */ @@ -238,7 +242,11 @@ var(--bit-clr-bg-sec) 37%, var(--bit-clr-brd-ter) 63%); background-size: 400% 100%; - animation: bit-dtg-shimmer 1.2s ease-in-out infinite; +} +@media (prefers-reduced-motion: no-preference) { + .bit-dtg-skeleton { + animation: bit-dtg-shimmer 1.2s ease-in-out infinite; + } } @keyframes bit-dtg-shimmer { 0% { background-position: 100% 0; } 100% { background-position: 0 0; } } .bit-dtg-infinite-end { diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.ts b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.ts index d3183ef246..914c07670d 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.ts +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.ts @@ -6,10 +6,11 @@ namespace BitBlazorUI { public static initInfiniteScroll(viewport: HTMLElement, dotNetRef: DotNetObject, threshold: number) { const distance = threshold ?? 200; let ticking = false; + let disposed = false; const check = () => { ticking = false; - if (!viewport) return; + if (disposed || !viewport) return; const remaining = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight; if (remaining <= distance) { dotNetRef.invokeMethodAsync('OnInfiniteScrollNearEndAsync'); @@ -30,7 +31,7 @@ namespace BitBlazorUI { return { check: () => check(), scrollToTop: () => { if (viewport) viewport.scrollTop = 0; }, - dispose: () => viewport.removeEventListener('scroll', onScroll) + dispose: () => { disposed = true; viewport.removeEventListener('scroll', onScroll); } }; } } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridDataProcessor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridDataProcessor.cs index 69fdb09337..8d79750b90 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridDataProcessor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridDataProcessor.cs @@ -86,7 +86,9 @@ private static List> BuildGroups( { var keyText = column.FormatValue(g.Key); var items = g.ToList(); - var path = $"{parentPath}/{level}:{keyText}"; + // Use the raw key (not the formatted display text) for the path identifier so that + // distinct keys producing identical display values don't collide and share collapse/expand state. + var path = $"{parentPath}/{level}:{g.Key}"; var group = new BitDataGridGroup { ColumnId = descriptor.ColumnId, diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridPropertyAccessor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridPropertyAccessor.cs index 6d9b722959..b8cf1e6c34 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridPropertyAccessor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridPropertyAccessor.cs @@ -119,7 +119,13 @@ private static BitDataGridPropertyAccessor Build(string path) { var valueParam = Expression.Parameter(typeof(object), "v"); var convertedValue = Expression.Convert(valueParam, propertyType); - var assign = Expression.Assign(body, convertedValue); + Expression assign = Expression.Assign(body, convertedValue); + // Mirror the getter's null handling: if any intermediate property in the chain is null, + // skip the assignment instead of throwing a NullReferenceException. + if (nullGuard is not null) + { + assign = Expression.IfThen(Expression.Not(nullGuard), assign); + } setter = Expression.Lambda>(assign, param, valueParam).Compile(); } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridCellEventArgs.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridCellEventArgs.cs index 5f18e7518e..8c1a850ece 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridCellEventArgs.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridCellEventArgs.cs @@ -10,6 +10,9 @@ namespace Bit.BlazorUI; public sealed class BitDataGridCellEventArgs { public required TItem Item { get; init; } + // Note: this holds a live reference to the column rather than an immutable snapshot. The grid assumes + // column instances remain stable for its lifetime, so this is safe today. If columns ever become + // dynamically mutated, capture immutable metadata (e.g. Id and DisplayTitle) here instead of the whole column. public required BitDataGridColumn Column { get; init; } /// The column field/identifier for convenience. diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridSortDescriptor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridSortDescriptor.cs index f583e686c7..b081f1a91a 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridSortDescriptor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridSortDescriptor.cs @@ -5,6 +5,7 @@ public sealed class BitDataGridSortDescriptor { public required string ColumnId { get; init; } public BitDataGridSortDirection Direction { get; set; } = BitDataGridSortDirection.Ascending; - /// Priority for multi-column sorting (1 = primary). - public int Priority { get; set; } + /// Priority for multi-column sorting (1 = primary). Defaults to so that + /// explicitly assigned priorities always take precedence over descriptors left unset. + public int Priority { get; set; } = int.MaxValue; } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor.cs index 48ffcb459e..2a341ae781 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor.cs @@ -323,8 +323,13 @@ private void FinishCollectingColumns() // because in that case there's going to be a re-render anyway. private async Task RefreshDataCoreAsync() { - // Move into a "loading" state, cancelling any earlier-but-still-pending load - _pendingDataLoadCancellationTokenSource?.Cancel(); + // Move into a "loading" state, cancelling and disposing any earlier-but-still-pending load + var previousCts = _pendingDataLoadCancellationTokenSource; + if (previousCts is not null) + { + previousCts.Cancel(); + previousCts.Dispose(); + } var thisLoadCts = _pendingDataLoadCancellationTokenSource = new CancellationTokenSource(); if (_virtualizeComponent is not null) @@ -333,7 +338,11 @@ private async Task RefreshDataCoreAsync() // (1) It won't know to update its own internal state if the provider output has changed // (2) We won't know what slice of data to query for await _virtualizeComponent.RefreshDataAsync(); - _pendingDataLoadCancellationTokenSource = null; + if (ReferenceEquals(_pendingDataLoadCancellationTokenSource, thisLoadCts)) + { + thisLoadCts.Dispose(); + _pendingDataLoadCancellationTokenSource = null; + } } else { @@ -348,7 +357,11 @@ private async Task RefreshDataCoreAsync() _currentNonVirtualizedViewItems = result.Items; _ariaBodyRowCount = _currentNonVirtualizedViewItems.Count; await (Pagination?.SetTotalItemCountAsync(result.TotalItemCount) ?? Task.CompletedTask); - _pendingDataLoadCancellationTokenSource = null; + if (ReferenceEquals(_pendingDataLoadCancellationTokenSource, thisLoadCts)) + { + thisLoadCts.Dispose(); + _pendingDataLoadCancellationTokenSource = null; + } } } } @@ -373,7 +386,7 @@ private async Task RefreshDataCoreAsync() if (Pagination is not null) { startIndex += Pagination.CurrentPageIndex * Pagination.ItemsPerPage; - count = Math.Min(request.Count, Pagination.ItemsPerPage - request.StartIndex); + count = Math.Max(0, Math.Min(request.Count, Pagination.ItemsPerPage - request.StartIndex)); } var providerRequest = new BitQuickGridItemsProviderRequest( diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.scss b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.scss index 6eb05dde74..1109021ff7 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.scss +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.scss @@ -66,7 +66,7 @@ inset-block-start: 5px; inset-inline-start: 0.5rem; border-color: var(--bit-clr-brd-pri); - border-inline-start: 1px solid black; + border-inline-start: 1px solid var(--bit-clr-brd-pri); } } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.ts b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.ts index f5cc59fc6d..de7ca8342b 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.ts +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.ts @@ -42,6 +42,9 @@ namespace BitBlazorUI { // In the unlikely event that it overhangs both sides, we'll center it const applyOffset = leftOverhang && rightOverhang ? (leftOverhang - rightOverhang) / 2 : (leftOverhang - rightOverhang); colOptions.style.transform = `translateX(${applyOffset}px)`; + } else { + // Clear any offset left over from a previous opening so the popup isn't misaligned. + colOptions.style.transform = ''; } if (typeof colOptions.scrollIntoViewIfNeeded === 'function') { diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Infrastructure/AsyncQueryExecutorSupplier.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Infrastructure/AsyncQueryExecutorSupplier.cs index 8a4a5693c6..3b5af50f4e 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Infrastructure/AsyncQueryExecutorSupplier.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Infrastructure/AsyncQueryExecutorSupplier.cs @@ -33,7 +33,11 @@ internal static class AsyncQueryExecutorSupplier var providerType = queryable.Provider?.GetType(); if (providerType is not null && IsEntityFrameworkProviderTypeCache.GetOrAdd(providerType, IsEntityFrameworkProviderType)) { - throw new InvalidOperationException($"The supplied {nameof(IQueryable)} is provided by Entity Framework. To query it efficiently, you must reference the package Microsoft.AspNetCore.Components.BitQuickGrid.EntityFrameworkAdapter and call AddBitQuickGridEntityFrameworkAdapter on your service collection."); + throw new InvalidOperationException( + $"The supplied {nameof(IQueryable)} is provided by Entity Framework. To query it efficiently without blocking threads, " + + $"register an implementation of {nameof(IAsyncQueryExecutor)} in your service collection that wraps EF Core's async query APIs " + + $"(for example ToArrayAsync/CountAsync) and reports IsSupported(queryable) == true for EF queryables. " + + $"Alternatively, supply non-EF data via the Items or ItemsProvider parameters."); } } else if (executor.IsSupported(queryable)) diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.samples.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.samples.cs index 70e5491df9..6d558d7f85 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.samples.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.samples.cs @@ -331,16 +331,28 @@ private async Task> LoadData(BitDataGridReadReque foreach (var f in request.Filters) { var term = f.Value?.ToString() ?? """"; - query = query.Where(p => p.Name.Contains(term, StringComparison.OrdinalIgnoreCase)); + query = f.ColumnId switch + { + nameof(Product.Name) => query.Where(p => p.Name.Contains(term, StringComparison.OrdinalIgnoreCase)), + nameof(Product.Price) => query.Where(p => p.Price.ToString().Contains(term)), + nameof(Product.Id) => query.Where(p => p.Id.ToString().Contains(term)), + _ => query + }; } // sorting var sort = request.Sorts.FirstOrDefault(); if (sort is not null) { + Func key = sort.ColumnId switch + { + nameof(Product.Name) => p => p.Name, + nameof(Product.Price) => p => p.Price, + _ => p => p.Id + }; query = sort.Direction == BitDataGridSortDirection.Descending - ? query.OrderByDescending(p => p.Name) - : query.OrderBy(p => p.Name); + ? query.OrderByDescending(key) + : query.OrderBy(key); } // paging diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor index 948807f6f6..fa701398c5 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor @@ -163,6 +163,8 @@ diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.cs index bc21487701..05b0fba13c 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.cs @@ -65,7 +65,7 @@ or an EntityFramework DataSet or an IQueryable derived from it. Name = "ItemsProvider", Type = "BitQuickGridItemsProvider?", DefaultValue = "null", - Description = @"A callback that supplies data for the rid. + Description = @"A callback that supplies data for the grid. You should supply either Items or ItemsProvider, but not both.", }, new() @@ -157,7 +157,7 @@ scrolling and causes the grid to fetch and render only the data around the curre { Id = "BitQuickGridColumnBase", Title = "BitQuickGridColumnBase", - Description = "BitQuickGrid has two built-in column types, BitQuickGridPropertyColumn and BitQuickGridTemplateColumn. You can also create your own column types by subclassing ColumnBase he BitQuickGridColumnBase type, which all column must derive from, offers some common parameters", + Description = "BitQuickGrid has two built-in column types, BitQuickGridPropertyColumn and BitQuickGridTemplateColumn. You can also create your own column types by subclassing the BitQuickGridColumnBase type, which all columns must derive from. It offers some common parameters.", Parameters= [ new() @@ -251,7 +251,7 @@ UI will be included in the header cell by default. { Id = "BitQuickGridTemplateColumn", Title = "BitQuickGridTemplateColumn", - Description = @"It uses arbitrary Razor fragments to supply contents for its cells. It can't infer the column's title or sort order automatically. also it's possible to add arbitrary Blazor components to your table cells. Remember that rendering many components, or many event handlers, can impact the performance of your grid. One way to mitigate this issue is by paginating or virtualizing your grid", + Description = @"It uses arbitrary Razor fragments to supply contents for its cells. It can't infer the column's title or sort order automatically. Also, it's possible to add arbitrary Blazor components to your table cells. Remember that rendering many components, or many event handlers, can impact the performance of your grid. One way to mitigate this issue is by paginating or virtualizing your grid.", Parameters = [ new() diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.samples.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.samples.cs index 6a25848a2b..ebf7778408 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.samples.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.samples.cs @@ -725,7 +725,7 @@ protected override async Task OnInitializedAsync() if (string.IsNullOrEmpty(_odataSampleNameFilter) is false) { - query.Add(""$filter"", $""contains(Name,'{_odataSampleNameFilter}')""); + query.Add(""$filter"", $""contains(Name,'{_odataSampleNameFilter.Replace(""'"", ""''"")}')""); } if (req.GetSortByProperties().Any()) @@ -735,7 +735,7 @@ protected override async Task OnInitializedAsync() var url = NavManager.GetUriWithQueryParameters(""api/Products/GetProducts"", query); - var data = await HttpClient.GetFromJsonAsync(url, AppJsonContext.Default.PagedResultProductDto); + var data = await HttpClient.GetFromJsonAsync(url, AppJsonContext.Default.PagedResultProductDto, req.CancellationToken); return BitQuickGridItemsProviderResult.From(data!.Items, (int)data!.TotalCount); } @@ -903,7 +903,7 @@ protected override async Task OnInitializedAsync() var url = NavManager.GetUriWithQueryParameters(""api/Products/GetProducts"", query); - var data = await HttpClient.GetFromJsonAsync(url, AppJsonContext.Default.PagedResultProductDto); + var data = await HttpClient.GetFromJsonAsync(url, AppJsonContext.Default.PagedResultProductDto, req.CancellationToken); return BitQuickGridItemsProviderResult.From(data!.Items, (int)data!.TotalCount); } From a44c2231f3eedd6a6ea2eb349fcc596de3959ac6 Mon Sep 17 00:00:00 2001 From: msynk Date: Mon, 22 Jun 2026 13:47:12 +0330 Subject: [PATCH 05/35] resovle review comments III --- .../Components/DataGrid/BitDataGrid.razor.cs | 118 +++++- .../Components/DataGrid/BitDataGrid.scss | 360 ++++++++++++++---- .../Components/DataGrid/BitDataGridRow.razor | 2 +- .../BitDataGridDataProcessor.cs | 13 +- .../Models/BitDataGridAggregateResult.cs | 2 +- .../Models/BitDataGridCellEventArgs.cs | 4 +- .../QuickGrid/BitQuickGrid.razor.cs | 31 +- .../Components/QuickGrid/BitQuickGrid.ts | 4 +- .../Extras/DataGrid/BitDataGridDemo.razor.cs | 95 ++--- .../BitQuickGridDemo.razor.samples.cs | 4 +- 10 files changed, 489 insertions(+), 144 deletions(-) diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs index 109fcffbf0..b2beb59fc5 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs @@ -158,7 +158,10 @@ public partial class BitDataGrid : ComponentBase, IAsyncDisposable private readonly List _sorts = new(); private readonly List _filters = new(); private readonly List _groups = new(); - private readonly HashSet _selected = new(); + // Tracks the selected rows by their key (via GetKey) rather than by object reference, so a + // selection survives data refreshes that produce new TItem instances with the same key. + private HashSet? _selectedSet; + private HashSet _selected => _selectedSet ??= new HashSet(new KeySelectionComparer(GetKey)); private readonly HashSet _expandedDetails = new(); private readonly HashSet _collapsedGroups = new(); @@ -216,6 +219,8 @@ public partial class BitDataGrid : ComponentBase, IAsyncDisposable // Cancels superseded in-flight OnRead/OnLoadMore requests. private CancellationTokenSource? _loadCts; + // Monotonic load version; bumped on every (re)load so a superseded response can detect it is stale. + private int _loadVersion; internal IReadOnlyList> AllColumns => _columns; internal IReadOnlyList> VisibleColumns => _columns.Where(c => c.Visible).ToList(); @@ -455,19 +460,23 @@ private async Task LoadNextBatchAsync() _infiniteLoading = true; StateHasChanged(); - try + var batch = Math.Max(1, LoadMoreBatchSize); + var read = new BitDataGridReadRequest { - var batch = Math.Max(1, LoadMoreBatchSize); - var read = new BitDataGridReadRequest - { - Skip = _infiniteItems.Count, - Take = batch, - Sorts = _sorts.Where(s => s.Direction != BitDataGridSortDirection.None).OrderBy(s => s.Priority).ToList(), - Filters = _filters.ToList(), - CancellationToken = ResetLoadCancellation() - }; + Skip = _infiniteItems.Count, + Take = batch, + Sorts = _sorts.Where(s => s.Direction != BitDataGridSortDirection.None).OrderBy(s => s.Priority).ToList(), + Filters = _filters.ToList(), + CancellationToken = ResetLoadCancellation() + }; + var version = _loadVersion; + try + { var result = await OnLoadMore(read); + // A newer request superseded this one (e.g. sort/filter changed mid-flight); drop the stale response. + if (version != _loadVersion) return; + var loaded = result.Items; _infiniteItems.AddRange(loaded); if (loaded.Count < batch) _infiniteHasMore = false; @@ -478,8 +487,13 @@ private async Task LoadNextBatchAsync() } finally { - _infiniteLoading = false; - StateHasChanged(); + // Only clear the loading flag if we are still the current request; a superseding request + // owns the loading state otherwise. + if (version == _loadVersion) + { + _infiniteLoading = false; + StateHasChanged(); + } } } @@ -537,6 +551,7 @@ private CancellationToken ResetLoadCancellation() _loadCts?.Cancel(); _loadCts?.Dispose(); _loadCts = new CancellationTokenSource(); + _loadVersion++; return _loadCts.Token; } @@ -550,7 +565,11 @@ private async Task LoadServerDataAsync() Filters = _filters.ToList(), CancellationToken = ResetLoadCancellation() }; + // Capture this request's version right after ResetLoadCancellation; bail out below if a newer + // request has since superseded it so a stale response can't overwrite fresher state. + var version = _loadVersion; var result = await OnRead!(request); + if (version != _loadVersion) return; _pageItems = result.Items; _view = result.Items; _totalCount = result.TotalCount; @@ -578,9 +597,10 @@ internal async Task ToggleSortAsync(BitDataGridColumn column, bool additi if (!ColumnSortable(column)) return; var existing = GetSort(column); - if (!additive && !MultiSort) + if (!additive) { - // Single sort: clear others unless additive + // Non-additive action (plain click): clear all prior sorts regardless of MultiSort, + // keeping only this column. Additive (Ctrl/⌘+click) preserves existing sorts. var keep = existing; _sorts.Clear(); if (keep is not null) _sorts.Add(keep); @@ -995,15 +1015,17 @@ internal async Task HandleCellKeyDownAsync(TItem item, int colIndex, KeyboardEve int row = rowIdx, col = colIndex; var rtl = Direction == BitDir.Rtl; var handled = true; + // Horizontal travel direction in column-index space (used to skip over spanned-away columns). + int colDir = 0; switch (e.Key) { - case "ArrowRight": col += rtl ? -1 : 1; break; - case "ArrowLeft": col += rtl ? 1 : -1; break; + case "ArrowRight": col += rtl ? -1 : 1; colDir = rtl ? -1 : 1; break; + case "ArrowLeft": col += rtl ? 1 : -1; colDir = rtl ? 1 : -1; break; case "ArrowDown": row += 1; break; case "ArrowUp": row -= 1; break; case "Home": if (e.CtrlKey) { row = 0; col = 0; } else col = 0; break; - case "End": if (e.CtrlKey) { row = rows.Count - 1; col = colCount - 1; } else col = colCount - 1; break; + case "End": if (e.CtrlKey) { row = rows.Count - 1; col = colCount - 1; } else col = colCount - 1; colDir = -1; break; case "PageDown": row += 10; break; case "PageUp": row -= 10; break; case "Enter": @@ -1020,6 +1042,9 @@ internal async Task HandleCellKeyDownAsync(TItem item, int colIndex, KeyboardEve row = Math.Clamp(row, 0, rows.Count - 1); col = Math.Clamp(col, 0, colCount - 1); + // The target row may span columns; snap focus to the actually-rendered cell so it always + // lands on a real BitDataGridCell with tabindex=0 instead of a spanned-away column index. + col = SnapToRenderedColumn(rows[row], col, colDir); _focusedRow = rows[row]; _focusedCol = col; _focusPending = true; @@ -1046,6 +1071,43 @@ internal int ResolveColSpan(BitDataGridColumn column, TItem item) return Math.Min(span, cols.Count - idx); } + /// + /// Maps a desired visible-column index to the index of the cell actually rendered in the given + /// row, accounting for column spanning (columns covered by a preceding span are not rendered). + /// When travelling horizontally ( ≠ 0) the focus advances past the span in + /// the travel direction so keyboard navigation never stalls inside a spanned-over column. + /// + private int SnapToRenderedColumn(TItem item, int target, int dir) + { + var cols = VisibleColumns; + var count = cols.Count; + if (count == 0) return 0; + target = Math.Clamp(target, 0, count - 1); + + // For every column position compute the index of the rendered (span-start) cell that covers it + // and the last column the span covers. + var starts = new int[count]; + var ends = new int[count]; + int i = 0; + while (i < count) + { + var span = Math.Max(1, ResolveColSpan(cols[i], item)); + var end = Math.Min(count - 1, i + span - 1); + for (int j = i; j <= end; j++) { starts[j] = i; ends[j] = end; } + i = end + 1; + } + + var start = starts[target]; + // Moving right but the target fell inside a span that starts earlier: jump to the next span start. + if (dir > 0 && start < target) + { + var next = ends[target] + 1; + return next <= count - 1 ? starts[next] : start; + } + // Moving left (or stationary): the span start is the rendered cell to focus. + return start; + } + // ------------------------------------------------- Column header groups internal bool HasColumnGroups => VisibleColumns.Any(c => !string.IsNullOrEmpty(c.Group)); @@ -1103,6 +1165,17 @@ internal async Task SetColumnVisibilityAsync(BitDataGridColumn column, bo private bool KeyEquals(TItem a, TItem b) => KeyField is not null ? Equals(KeyField(a), KeyField(b)) : EqualityComparer.Default.Equals(a, b); + /// Compares rows by their key (via ) so selection tracks key identity + /// rather than object reference, surviving refreshes that yield new instances with the same key. + private sealed class KeySelectionComparer : IEqualityComparer + { + private readonly Func _keyOf; + public KeySelectionComparer(Func keyOf) => _keyOf = keyOf; + public bool Equals(TItem? x, TItem? y) + => (x is null || y is null) ? ReferenceEquals(x, y) : object.Equals(_keyOf(x), _keyOf(y)); + public int GetHashCode(TItem obj) => _keyOf(obj)?.GetHashCode() ?? 0; + } + // ----------------------------------------------------------- CSV export /// Builds a CSV string of the current (filtered/sorted) data. public string ToCsv() @@ -1134,9 +1207,12 @@ static string Escape(string v) private double DetailOffset => HasReorderColumn ? ReorderColWidth : 0; private double SelectOffset => DetailOffset + (HasDetailColumn ? DetailColWidth : 0); - internal string ReorderStickyStyle => "left:0;"; - internal string DetailStickyStyle => $"left:{DetailOffset.ToString(CultureInfo.InvariantCulture)}px;"; - internal string SelectStickyStyle => $"left:{SelectOffset.ToString(CultureInfo.InvariantCulture)}px;"; + /// The inline-start CSS edge for sticky special columns, flipped to "right" in RTL. + private string StickyEdge => Direction == BitDir.Rtl ? "right" : "left"; + + internal string ReorderStickyStyle => $"{StickyEdge}:0;"; + internal string DetailStickyStyle => $"{StickyEdge}:{DetailOffset.ToString(CultureInfo.InvariantCulture)}px;"; + internal string SelectStickyStyle => $"{StickyEdge}:{SelectOffset.ToString(CultureInfo.InvariantCulture)}px;"; private string ColumnWidthToken(BitDataGridColumn column) { diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.scss b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.scss index 600b185cad..cf0b50b288 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.scss +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.scss @@ -21,9 +21,15 @@ box-sizing: border-box; } -.bit-dtg *, .bit-dtg *::before, .bit-dtg *::after { box-sizing: border-box; } +.bit-dtg *, +.bit-dtg *::before, +.bit-dtg *::after { + box-sizing: border-box; +} -.bit-dtg.bit-dtg-bordered { border: 1px solid var(--bit-clr-brd-ter); } +.bit-dtg.bit-dtg-bordered { + border: 1px solid var(--bit-clr-brd-ter); +} /* ---------------------------------------------------------- Toolbar */ .bit-dtg-toolbar { @@ -35,7 +41,14 @@ border-bottom: 1px solid var(--bit-clr-brd-ter); flex-wrap: wrap; } -.bit-dtg-toolbar-start, .bit-dtg-toolbar-end { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } + +.bit-dtg-toolbar-start, +.bit-dtg-toolbar-end { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} .bit-dtg-column-chooser { display: flex; @@ -45,12 +58,24 @@ border-bottom: 1px solid var(--bit-clr-brd-ter); background: var(--bit-clr-bg-sec); } -.bit-dtg-chooser-item { display: inline-flex; gap: 6px; align-items: center; cursor: pointer; } + +.bit-dtg-chooser-item { + display: inline-flex; + gap: 6px; + align-items: center; + cursor: pointer; +} /* ---------------------------------------------------------- Viewport */ -.bit-dtg-viewport { overflow: auto; position: relative; } +.bit-dtg-viewport { + overflow: auto; + position: relative; +} -.bit-dtg-table { display: block; min-width: 100%; } +.bit-dtg-table { + display: block; + min-width: 100%; +} /* ---------------------------------------------------------- Rows */ .bit-dtg-row { @@ -66,8 +91,14 @@ z-index: 3; } -.bit-dtg-header-row, .bit-dtg-filter-row { background: var(--bit-clr-bg-sec); } -.bit-dtg-filter-row { border-bottom: 1px solid var(--bit-clr-brd-ter); } +.bit-dtg-header-row, +.bit-dtg-filter-row { + background: var(--bit-clr-bg-sec); +} + +.bit-dtg-filter-row { + border-bottom: 1px solid var(--bit-clr-brd-ter); +} .bit-dtg-hcell { display: flex; @@ -92,34 +123,83 @@ background: var(--bit-clr-bg-pri); } -.bit-dtg-bordered .bit-dtg-cell, .bit-dtg-bordered .bit-dtg-hcell { border-right: 1px solid var(--bit-clr-brd-ter); } +.bit-dtg-bordered .bit-dtg-cell, +.bit-dtg-bordered .bit-dtg-hcell { + border-right: 1px solid var(--bit-clr-brd-ter); +} -.bit-dtg-center { justify-content: center; text-align: center; } -.bit-dtg-right { justify-content: flex-end; text-align: right; } +.bit-dtg-center { + justify-content: center; + text-align: center; +} + +.bit-dtg-right { + justify-content: flex-end; + text-align: right; +} /* Striping & hover */ -.bit-dtg-striped .bit-dtg-body .bit-dtg-row:nth-child(even) .bit-dtg-cell { background: var(--bit-clr-bg-sec); } -.bit-dtg-hoverable .bit-dtg-body .bit-dtg-row:hover .bit-dtg-cell { background: var(--bit-clr-bg-pri-hover); } +.bit-dtg-striped .bit-dtg-body .bit-dtg-row:nth-child(even) .bit-dtg-cell { + background: var(--bit-clr-bg-sec); +} + +.bit-dtg-hoverable .bit-dtg-body .bit-dtg-row:hover .bit-dtg-cell { + background: var(--bit-clr-bg-pri-hover); +} /* Selection & editing must win over both striping and hover (in every row position) */ .bit-dtg-row.bit-dtg-selected .bit-dtg-cell, .bit-dtg-striped .bit-dtg-body .bit-dtg-row.bit-dtg-selected:nth-child(even) .bit-dtg-cell, -.bit-dtg-hoverable .bit-dtg-body .bit-dtg-row.bit-dtg-selected:hover .bit-dtg-cell { background: var(--bit-clr-pri-light); } +.bit-dtg-hoverable .bit-dtg-body .bit-dtg-row.bit-dtg-selected:hover .bit-dtg-cell { + color: var(--bit-clr-pri-text); + background: var(--bit-clr-pri); +} + .bit-dtg-row.bit-dtg-editing .bit-dtg-cell, .bit-dtg-striped .bit-dtg-body .bit-dtg-row.bit-dtg-editing:nth-child(even) .bit-dtg-cell, -.bit-dtg-hoverable .bit-dtg-body .bit-dtg-row.bit-dtg-editing:hover .bit-dtg-cell { background: var(--bit-clr-wrn-light); } +.bit-dtg-hoverable .bit-dtg-body .bit-dtg-row.bit-dtg-editing:hover .bit-dtg-cell { + background: var(--bit-clr-wrn-light); +} /* Sticky / frozen columns */ -.bit-dtg-sticky { position: sticky; z-index: 2; } -.bit-dtg-header .bit-dtg-sticky { z-index: 4; } -.bit-dtg-rtl .bit-dtg-sticky { right: auto; } +.bit-dtg-sticky { + position: sticky; + z-index: 2; +} -.bit-dtg-cell-detail, .bit-dtg-cell-select { justify-content: center; } +.bit-dtg-header .bit-dtg-sticky { + z-index: 4; +} + +.bit-dtg-rtl .bit-dtg-sticky { + right: auto; +} + +.bit-dtg-cell-detail, +.bit-dtg-cell-select { + justify-content: center; +} /* ---------------------------------------------------------- Header sorting */ -.bit-dtg-sortable .bit-dtg-htext { cursor: pointer; } -.bit-dtg-htext { display: inline-flex; align-items: center; gap: 4px; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.bit-dtg-sort-icon { font-size: 10px; color: var(--bit-clr-pri); } +.bit-dtg-sortable .bit-dtg-htext { + cursor: pointer; +} + +.bit-dtg-htext { + display: inline-flex; + align-items: center; + gap: 4px; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.bit-dtg-sort-icon { + font-size: 10px; + color: var(--bit-clr-pri); +} + .bit-dtg-sort-priority { font-size: 9px; background: var(--bit-clr-pri); @@ -139,8 +219,16 @@ cursor: col-resize; touch-action: none; } -.bit-dtg-rtl .bit-dtg-resizer { right: auto; left: 0; } -.bit-dtg-resizer:hover { background: var(--bit-clr-pri); opacity: .5; } + +.bit-dtg-rtl .bit-dtg-resizer { + right: auto; + left: 0; +} + +.bit-dtg-resizer:hover { + background: var(--bit-clr-pri); + opacity: .5; +} .bit-dtg-resize-overlay { position: fixed; @@ -151,7 +239,10 @@ } /* ---------------------------------------------------------- Group rows */ -.bit-dtg-group-row { border-bottom: 1px solid var(--bit-clr-brd-ter); } +.bit-dtg-group-row { + border-bottom: 1px solid var(--bit-clr-brd-ter); +} + .bit-dtg-group-cell { grid-column: 1 / -1; display: flex; @@ -160,15 +251,31 @@ padding: var(--bit-dtg-cell-pad); background: var(--bit-clr-bg-ter); } -.bit-dtg-group-count { color: var(--bit-clr-fg-sec); } -.bit-dtg-group-agg { color: var(--bit-clr-fg-sec); font-size: 12px; } + +.bit-dtg-group-count { + color: var(--bit-clr-fg-sec); +} + +.bit-dtg-group-agg { + color: var(--bit-clr-fg-sec); + font-size: 12px; +} /* Nested groups: subtle indentation shading by level */ -.bit-dtg-group-row .bit-dtg-group-cell { border-left: 3px solid transparent; } -.bit-dtg-group-row[style*="--bit-dtg-group-level:0"] .bit-dtg-group-cell { border-left-color: var(--bit-clr-pri); } +.bit-dtg-group-row .bit-dtg-group-cell { + border-left: 3px solid transparent; +} + +.bit-dtg-group-row[style*="--bit-dtg-group-level:0"] .bit-dtg-group-cell { + border-left-color: var(--bit-clr-pri); +} /* ----------------------------------------------- Grouped column headers */ -.bit-dtg-group-header-row { background: var(--bit-clr-bg-sec); border-bottom: 1px solid var(--bit-clr-brd-ter); } +.bit-dtg-group-header-row { + background: var(--bit-clr-bg-sec); + border-bottom: 1px solid var(--bit-clr-brd-ter); +} + .bit-dtg-group-header { font-weight: 700; background: var(--bit-clr-bg-sec); @@ -178,31 +285,83 @@ justify-content: center; padding: var(--bit-dtg-cell-pad); } -.bit-dtg-group-header-empty { background: transparent; border-right: none; } + +.bit-dtg-group-header-empty { + background: transparent; + border-right: none; +} /* ---------------------------------------------------------- Row reorder */ -.bit-dtg-cell-reorder { display: flex; align-items: center; justify-content: center; } -.bit-dtg-drag-handle { cursor: grab; color: var(--bit-clr-fg-sec); user-select: none; } -.bit-dtg-drag-handle:active { cursor: grabbing; } -.bit-dtg-row[draggable="true"] { cursor: grab; } +.bit-dtg-cell-reorder { + display: flex; + align-items: center; + justify-content: center; +} + +.bit-dtg-drag-handle { + cursor: grab; + color: var(--bit-clr-fg-sec); + user-select: none; +} + +.bit-dtg-drag-handle:active { + cursor: grabbing; +} + +.bit-dtg-row[draggable="true"] { + cursor: grab; +} /* ---------------------------------------------------------- Detail rows */ -.bit-dtg-detail-row { border-bottom: 1px solid var(--bit-clr-brd-ter); } -.bit-dtg-detail-content { padding: 12px 16px; background: var(--bit-clr-bg-sec); } +.bit-dtg-detail-row { + border-bottom: 1px solid var(--bit-clr-brd-ter); +} + +.bit-dtg-detail-content { + padding: 12px 16px; + background: var(--bit-clr-bg-sec); +} /* ---------------------------------------------------------- Tree view */ -.bit-dtg-tree-indent { display: inline-block; flex: 0 0 auto; } -.bit-dtg-tree-toggle { flex: 0 0 auto; width: 20px; text-align: center; } -.bit-dtg-tree-leaf { display: inline-block; width: 20px; flex: 0 0 auto; } +.bit-dtg-tree-indent { + display: inline-block; + flex: 0 0 auto; +} + +.bit-dtg-tree-toggle { + flex: 0 0 auto; + width: 20px; + text-align: center; +} + +.bit-dtg-tree-leaf { + display: inline-block; + width: 20px; + flex: 0 0 auto; +} /* ---------------------------------------------------------- Cell navigation */ /* The focus ring is driven solely by real DOM :focus so exactly one cell can ever be outlined (state and DOM focus are kept in sync via @key + FocusAsync). */ -.bit-dtg-cell[tabindex]:focus { outline: 2px solid var(--bit-clr-pri); outline-offset: -2px; z-index: 1; } -.bit-dtg-cell[tabindex]:focus-visible { outline: 2px solid var(--bit-clr-pri); outline-offset: -2px; z-index: 1; } +.bit-dtg-cell[tabindex]:focus { + outline: 2px solid var(--bit-clr-pri); + outline-offset: -2px; + z-index: 1; +} + +.bit-dtg-cell[tabindex]:focus-visible { + outline: 2px solid var(--bit-clr-pri); + outline-offset: -2px; + z-index: 1; +} /* ---------------------------------------------------------- Footer */ -.bit-dtg-footer { position: sticky; bottom: 0; z-index: 3; } +.bit-dtg-footer { + position: sticky; + bottom: 0; + z-index: 3; +} + .bit-dtg-footer-row .bit-dtg-cell { background: var(--bit-clr-bg-sec); font-weight: 600; @@ -210,45 +369,69 @@ } /* ---------------------------------------------------------- Empty / loading */ -.bit-dtg-empty, .bit-dtg-loading { +.bit-dtg-empty, +.bit-dtg-loading { padding: 32px; text-align: center; color: var(--bit-clr-fg-sec); } + .bit-dtg-spinner { display: inline-block; - width: 14px; height: 14px; + width: 14px; + height: 14px; border: 2px solid var(--bit-clr-fg-sec); border-top-color: transparent; border-radius: 50%; vertical-align: middle; } + @media (prefers-reduced-motion: no-preference) { .bit-dtg-spinner { animation: bit-dtg-spin .7s linear infinite; } } -@keyframes bit-dtg-spin { to { transform: rotate(360deg); } } + +@keyframes bit-dtg-spin { + to { + transform: rotate(360deg); + } +} /* ------------------------------------------ Infinite-scroll placeholder */ -.bit-dtg-placeholder-cell { display: flex; align-items: center; } +.bit-dtg-placeholder-cell { + display: flex; + align-items: center; +} + .bit-dtg-skeleton { display: block; width: 60%; height: 10px; border-radius: 6px; background: linear-gradient(90deg, - var(--bit-clr-brd-ter) 25%, - var(--bit-clr-bg-sec) 37%, - var(--bit-clr-brd-ter) 63%); + var(--bit-clr-brd-ter) 25%, + var(--bit-clr-bg-sec) 37%, + var(--bit-clr-brd-ter) 63%); background-size: 400% 100%; } + @media (prefers-reduced-motion: no-preference) { .bit-dtg-skeleton { animation: bit-dtg-shimmer 1.2s ease-in-out infinite; } } -@keyframes bit-dtg-shimmer { 0% { background-position: 100% 0; } 100% { background-position: 0 0; } } + +@keyframes bit-dtg-shimmer { + 0% { + background-position: 100% 0; + } + + 100% { + background-position: 0 0; + } +} + .bit-dtg-infinite-end { padding: 14px; text-align: center; @@ -257,7 +440,8 @@ } /* ---------------------------------------------------------- Editors */ -.bit-dtg-editor, .bit-dtg-filter-input { +.bit-dtg-editor, +.bit-dtg-filter-input { width: 100%; padding: 4px 6px; border: 1px solid var(--bit-clr-brd-ter); @@ -266,8 +450,14 @@ color: var(--bit-clr-fg-pri); font: inherit; } -.bit-dtg-editor-check { width: auto; } -.bit-dtg-filter-input { font-weight: 400; } + +.bit-dtg-editor-check { + width: auto; +} + +.bit-dtg-filter-input { + font-weight: 400; +} /* ---------------------------------------------------------- Buttons */ .bit-dtg-btn { @@ -284,11 +474,30 @@ text-decoration: none; line-height: 1.2; } -.bit-dtg-btn:hover:not(:disabled) { background: var(--bit-clr-bg-pri-hover); } -.bit-dtg-btn:disabled { opacity: .45; cursor: default; } -.bit-dtg-btn-primary { background: var(--bit-clr-pri); border-color: var(--bit-clr-pri); color: var(--bit-clr-pri-text); } -.bit-dtg-btn-danger { color: var(--bit-clr-err); border-color: var(--bit-clr-err); } -.bit-dtg-cell-command { gap: 6px; } + +.bit-dtg-btn:hover:not(:disabled) { + background: var(--bit-clr-bg-pri-hover); +} + +.bit-dtg-btn:disabled { + opacity: .45; + cursor: default; +} + +.bit-dtg-btn-primary { + background: var(--bit-clr-pri); + border-color: var(--bit-clr-pri); + color: var(--bit-clr-pri-text); +} + +.bit-dtg-btn-danger { + color: var(--bit-clr-err); + border-color: var(--bit-clr-err); +} + +.bit-dtg-cell-command { + gap: 6px; +} .bit-dtg-icon-btn { background: none; @@ -299,8 +508,14 @@ padding: 2px 4px; border-radius: 4px; } -.bit-dtg-icon-btn:hover { background: var(--bit-clr-bg-pri-hover); } -.bit-dtg-group-btn.bit-dtg-active { color: var(--bit-clr-pri); } + +.bit-dtg-icon-btn:hover { + background: var(--bit-clr-bg-pri-hover); +} + +.bit-dtg-group-btn.bit-dtg-active { + color: var(--bit-clr-pri); +} /* ---------------------------------------------------------- Pager */ .bit-dtg-pager { @@ -312,9 +527,24 @@ border-top: 1px solid var(--bit-clr-brd-ter); flex-wrap: wrap; } -.bit-dtg-pager-info { display: flex; align-items: center; gap: 10px; color: var(--bit-clr-fg-sec); } -.bit-dtg-pager-buttons { display: flex; align-items: center; gap: 4px; } -.bit-dtg-pager-current { padding: 0 8px; } + +.bit-dtg-pager-info { + display: flex; + align-items: center; + gap: 10px; + color: var(--bit-clr-fg-sec); +} + +.bit-dtg-pager-buttons { + display: flex; + align-items: center; + gap: 4px; +} + +.bit-dtg-pager-current { + padding: 0 8px; +} + .bit-dtg-page-size { padding: 4px 6px; border: 1px solid var(--bit-clr-brd-ter); @@ -322,4 +552,4 @@ background: var(--bit-clr-bg-pri); color: var(--bit-clr-fg-pri); font: inherit; -} +} \ No newline at end of file diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridRow.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridRow.razor index 08dd2678e5..775c815792 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridRow.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridRow.razor @@ -9,7 +9,7 @@ @if (Grid.RowReorderable) { -
+
} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridDataProcessor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridDataProcessor.cs index 8d79750b90..297c8a3942 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridDataProcessor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridDataProcessor.cs @@ -86,9 +86,16 @@ private static List> BuildGroups( { var keyText = column.FormatValue(g.Key); var items = g.ToList(); - // Use the raw key (not the formatted display text) for the path identifier so that - // distinct keys producing identical display values don't collide and share collapse/expand state. - var path = $"{parentPath}/{level}:{g.Key}"; + // Use a culture-invariant, type-qualified identifier for the path so that distinct keys + // never collide in collapse/expand state regardless of the current culture's formatting + // (e.g. "1,5" vs "1.5") or display text shared across different key types. + var keyId = g.Key switch + { + null => "∅", + IFormattable f => $"{g.Key.GetType().Name}:{f.ToString(null, CultureInfo.InvariantCulture)}", + _ => $"{g.Key.GetType().Name}:{g.Key}" + }; + var path = $"{parentPath}/{level}:{keyId}"; var group = new BitDataGridGroup { ColumnId = descriptor.ColumnId, diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridAggregateResult.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridAggregateResult.cs index b068190d7a..6cdb83f518 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridAggregateResult.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridAggregateResult.cs @@ -6,5 +6,5 @@ public sealed class BitDataGridAggregateResult public required string ColumnId { get; init; } public BitDataGridAggregateType Type { get; init; } public object? Value { get; init; } - public string FormattedValue { get; init; } = string.Empty; + public required string FormattedValue { get; init; } } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridCellEventArgs.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridCellEventArgs.cs index 8c1a850ece..1793837ca5 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridCellEventArgs.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridCellEventArgs.cs @@ -22,8 +22,8 @@ public sealed class BitDataGridCellEventArgs public string ColumnTitle => Column.DisplayTitle; /// The raw value of the cell. - public object? Value { get; init; } + public required object? Value { get; init; } /// The underlying browser mouse event. - public MouseEventArgs Mouse { get; init; } = new(); + public required MouseEventArgs Mouse { get; init; } } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor.cs index 2a341ae781..63c4585c40 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor.cs @@ -351,12 +351,23 @@ private async Task RefreshDataCoreAsync() var startIndex = Pagination is null ? 0 : (Pagination.CurrentPageIndex * Pagination.ItemsPerPage); var request = new BitQuickGridItemsProviderRequest( startIndex, Pagination?.ItemsPerPage, _sortByColumn, _sortByAscending, thisLoadCts.Token); - var result = await ResolveItemsRequestAsync(request); - if (!thisLoadCts.IsCancellationRequested) + try + { + var result = await ResolveItemsRequestAsync(request); + if (!thisLoadCts.IsCancellationRequested) + { + _currentNonVirtualizedViewItems = result.Items; + _ariaBodyRowCount = _currentNonVirtualizedViewItems.Count; + await (Pagination?.SetTotalItemCountAsync(result.TotalItemCount) ?? Task.CompletedTask); + } + } + catch (OperationCanceledException) + { + // This load was superseded by a newer request; swallow the cancellation and fall through + // to the cleanup below so the load-state remains consistent. + } + finally { - _currentNonVirtualizedViewItems = result.Items; - _ariaBodyRowCount = _currentNonVirtualizedViewItems.Count; - await (Pagination?.SetTotalItemCountAsync(result.TotalItemCount) ?? Task.CompletedTask); if (ReferenceEquals(_pendingDataLoadCancellationTokenSource, thisLoadCts)) { thisLoadCts.Dispose(); @@ -374,7 +385,15 @@ private async Task RefreshDataCoreAsync() // Debounce the requests. This eliminates a lot of redundant queries at the cost of slight lag after interactions. // TODO: Consider making this configurable, or smarter (e.g., doesn't delay on first call in a batch, then the amount // of delay increases if you rapidly issue repeated requests, such as when scrolling a long way) - await Task.Delay(100); + try + { + await Task.Delay(100, request.CancellationToken); + } + catch (OperationCanceledException) + { + // The request was superseded/cancelled during the debounce window; abandon it early. + return default; + } if (request.CancellationToken.IsCancellationRequested) { return default; diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.ts b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.ts index de7ca8342b..708ad82274 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.ts +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.ts @@ -80,7 +80,9 @@ namespace BitBlazorUI { function handleMouseMove(evt: any) { evt.stopPropagation(); const newPageX = evt.touches ? evt.touches[0].pageX : evt.pageX; - const nextWidth = originalColumnWidth + (newPageX - startPageX) * rtlMultiplier; + // Clamp to a minimum width so a column can't collapse to (or below) zero while dragging. + const minColumnWidth = 20; + const nextWidth = Math.max(minColumnWidth, originalColumnWidth + (newPageX - startPageX) * rtlMultiplier); if (Math.abs(nextWidth - updatedColumnWidth) > 0) { updatedColumnWidth = nextWidth; th.style.width = `${updatedColumnWidth}px`; diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.cs index ae72b99a0e..553f3a1453 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.cs @@ -142,57 +142,66 @@ private async Task> LoadServerData(BitDataGridRea await InvokeAsync(StateHasChanged); await Task.Delay(250); - IEnumerable query = serverAll; - - foreach (var f in request.Filters) - { - var term = f.Value?.ToString() ?? ""; - query = f.ColumnId switch - { - nameof(Product.Name) => query.Where(p => p.Name.Contains(term, StringComparison.OrdinalIgnoreCase)), - nameof(Product.Category) => query.Where(p => p.Category.ToString().Contains(term, StringComparison.OrdinalIgnoreCase)), - nameof(Product.Supplier) => query.Where(p => p.Supplier.Contains(term, StringComparison.OrdinalIgnoreCase)), - nameof(Product.Price) => query.Where(p => p.Price.ToString().Contains(term)), - nameof(Product.Stock) => query.Where(p => p.Stock.ToString().Contains(term)), - _ => query - }; - } - - IOrderedEnumerable? ordered = null; - foreach (var sort in request.Sorts) + int total = 0; + try { - Func key = sort.ColumnId switch - { - nameof(Product.Name) => p => p.Name, - nameof(Product.Category) => p => p.Category, - nameof(Product.Supplier) => p => p.Supplier, - nameof(Product.Price) => p => p.Price, - nameof(Product.Stock) => p => p.Stock, - _ => p => p.Id - }; + IEnumerable query = serverAll; - if (ordered is null) + foreach (var f in request.Filters) { - ordered = sort.Direction == BitDataGridSortDirection.Descending - ? query.OrderByDescending(key) - : query.OrderBy(key); + var term = f.Value?.ToString() ?? ""; + query = f.ColumnId switch + { + nameof(Product.Name) => query.Where(p => p.Name.Contains(term, StringComparison.OrdinalIgnoreCase)), + nameof(Product.Category) => query.Where(p => p.Category.ToString().Contains(term, StringComparison.OrdinalIgnoreCase)), + nameof(Product.Supplier) => query.Where(p => p.Supplier.Contains(term, StringComparison.OrdinalIgnoreCase)), + nameof(Product.Price) => query.Where(p => p.Price.ToString().Contains(term)), + nameof(Product.Stock) => query.Where(p => p.Stock.ToString().Contains(term)), + _ => query + }; } - else + + IOrderedEnumerable? ordered = null; + foreach (var sort in request.Sorts) { - ordered = sort.Direction == BitDataGridSortDirection.Descending - ? ordered.ThenByDescending(key) - : ordered.ThenBy(key); + Func key = sort.ColumnId switch + { + nameof(Product.Name) => p => p.Name, + nameof(Product.Category) => p => p.Category, + nameof(Product.Supplier) => p => p.Supplier, + nameof(Product.Price) => p => p.Price, + nameof(Product.Stock) => p => p.Stock, + _ => p => p.Id + }; + + if (ordered is null) + { + ordered = sort.Direction == BitDataGridSortDirection.Descending + ? query.OrderByDescending(key) + : query.OrderBy(key); + } + else + { + ordered = sort.Direction == BitDataGridSortDirection.Descending + ? ordered.ThenByDescending(key) + : ordered.ThenBy(key); + } } - } - if (ordered is not null) query = ordered; + if (ordered is not null) query = ordered; - var filtered = query.ToList(); - var total = filtered.Count; - var items = filtered.Skip(request.Skip).Take(request.Take ?? total).ToList(); + var filtered = query.ToList(); + total = filtered.Count; + var items = filtered.Skip(request.Skip).Take(request.Take ?? total).ToList(); - serverLastRequest = $"Last request → skip {request.Skip}, take {request.Take}, sorts: {request.Sorts.Count}, filters: {request.Filters.Count}, total: {total}"; - serverLoading = false; - return new BitDataGridReadResult(items, total); + return new BitDataGridReadResult(items, total); + } + finally + { + serverLastRequest = $"Last request → skip {request.Skip}, take {request.Take}, sorts: {request.Sorts.Count}, filters: {request.Filters.Count}, total: {total}"; + serverLoading = false; + // Ensure the parent re-renders after the load completes, since this runs as a callback. + await InvokeAsync(StateHasChanged); + } } diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.samples.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.samples.cs index ebf7778408..c8f1f30dbe 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.samples.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.samples.cs @@ -381,7 +381,7 @@ .grid td {
-
"; @@ -1089,6 +1089,8 @@ public class MedalsModel ToggleRowRendererExpand(context.Code))"" /> c.Name)"" /> From 8a11d7ce76ab39f52a679ab4455d41d8a2607546 Mon Sep 17 00:00:00 2001 From: msynk Date: Tue, 23 Jun 2026 07:14:56 +0330 Subject: [PATCH 06/35] resolve review comments IV --- .../Components/DataGrid/BitDataGrid.razor | 2 +- .../Components/DataGrid/BitDataGrid.razor.cs | 24 ++++++- .../Components/DataGrid/BitDataGrid.ts | 4 +- .../DataGrid/BitDataGridCellEditor.razor | 2 +- .../BitDataGridDataProcessor.cs | 30 +++++++- .../BitDataGridPropertyAccessor.cs | 5 +- .../QuickGrid/BitQuickGrid.razor.cs | 29 ++++++-- .../Extras/DataGrid/BitDataGridDemo.razor.cs | 21 ++++-- .../DataGrid/BitDataGridDemo.razor.samples.cs | 68 ++++++++++--------- 9 files changed, 134 insertions(+), 51 deletions(-) diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor index 3f48d6424a..543fded493 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor @@ -400,7 +400,7 @@ var to = Math.Min(CurrentPage * _effectivePageSize, TotalCount); } @from–@to of @TotalCount - @foreach (var size in PageSizeOptions) { diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs index b2beb59fc5..1d2acb6afa 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs @@ -731,8 +731,22 @@ internal async Task ToggleRowSelectionAsync(TItem item, bool? value = null) await NotifySelectionAsync(); } - internal bool AllPageSelected => _pageItems.Where(CanSelectRow).Any() && _pageItems.Where(CanSelectRow).All(_selected.Contains); - internal bool SomePageSelected => _pageItems.Where(CanSelectRow).Any(_selected.Contains) && !AllPageSelected; + internal bool AllPageSelected + { + get + { + var selectable = _pageItems.Where(CanSelectRow).ToList(); + return selectable.Count > 0 && selectable.All(_selected.Contains); + } + } + internal bool SomePageSelected + { + get + { + var selectable = _pageItems.Where(CanSelectRow).ToList(); + return selectable.Any(_selected.Contains) && !(selectable.Count > 0 && selectable.All(_selected.Contains)); + } + } internal async Task ToggleSelectAllAsync(bool value) { @@ -1066,7 +1080,11 @@ internal int ResolveColSpan(BitDataGridColumn column, TItem item) var span = column.ColSpan(item) ?? 1; if (span < 1) span = 1; var cols = VisibleColumns; - var idx = cols.ToList().IndexOf(column); + var idx = -1; + for (int i = 0; i < cols.Count; i++) + { + if (cols[i] == column) { idx = i; break; } + } if (idx < 0) return 1; return Math.Min(span, cols.Count - idx); } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.ts b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.ts index 914c07670d..bf643f3894 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.ts +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.ts @@ -13,7 +13,9 @@ namespace BitBlazorUI { if (disposed || !viewport) return; const remaining = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight; if (remaining <= distance) { - dotNetRef.invokeMethodAsync('OnInfiniteScrollNearEndAsync'); + // The circuit may disconnect (navigation, refresh) between the disposed check and + // this async call, so swallow the resulting rejection to avoid unhandled console errors. + dotNetRef.invokeMethodAsync('OnInfiniteScrollNearEndAsync').catch(() => { }); } }; diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridCellEditor.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridCellEditor.razor index 8721c04a84..640c31bec8 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridCellEditor.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridCellEditor.razor @@ -25,7 +25,7 @@ break; diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridDataProcessor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridDataProcessor.cs index 297c8a3942..a829788930 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridDataProcessor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridDataProcessor.cs @@ -198,14 +198,35 @@ private static bool Matches(object? value, BitDataGridFilterDescriptor filter) return value is not null && !string.IsNullOrEmpty(value.ToString()); } - if (filter.Value is null) - return true; - // Numeric / comparable operators if (filter.Operator is BitDataGridFilterOperator.GreaterThan or BitDataGridFilterOperator.GreaterThanOrEqual or BitDataGridFilterOperator.LessThan or BitDataGridFilterOperator.LessThanOrEqual or BitDataGridFilterOperator.Equals or BitDataGridFilterOperator.NotEquals) { + // Handle nulls explicitly rather than letting the comparer order nulls-first, which would + // otherwise make a null row value spuriously match LessThan/LessThanOrEqual filters. + if (value is null) + { + return filter.Operator switch + { + BitDataGridFilterOperator.Equals => filter.Value is null, + BitDataGridFilterOperator.NotEquals => filter.Value is not null, + _ => false + }; + } + + if (filter.Value is null) + { + // Row value is non-null here, so equality against a null filter value is deterministic; + // ordering operators have no meaningful null operand, so they don't match. + return filter.Operator switch + { + BitDataGridFilterOperator.Equals => false, + BitDataGridFilterOperator.NotEquals => true, + _ => false + }; + } + var cmp = BitDataGridValueComparer.Instance.Compare(value, CoerceToValueType(value, filter.Value)); return filter.Operator switch { @@ -219,6 +240,9 @@ or BitDataGridFilterOperator.LessThan or BitDataGridFilterOperator.LessThanOrEqu }; } + if (filter.Value is null) + return true; + // String operators var text = value?.ToString() ?? string.Empty; var term = filter.Value.ToString() ?? string.Empty; diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridPropertyAccessor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridPropertyAccessor.cs index b8cf1e6c34..f28de92dde 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridPropertyAccessor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridPropertyAccessor.cs @@ -86,8 +86,11 @@ private static BitDataGridPropertyAccessor Build(string path) PropertyInfo? lastProp = null; Expression? nullGuard = null; - foreach (var segment in path.Split('.', StringSplitOptions.RemoveEmptyEntries)) + foreach (var segment in path.Split('.')) { + if (string.IsNullOrWhiteSpace(segment)) + throw new ArgumentException($"Property path '{path}' contains an empty or whitespace segment.", nameof(path)); + // If the owner of this segment is an intermediate (nullable) value, guard against it being null. if (!ReferenceEquals(body, param) && CanBeNull(body.Type)) { diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor.cs index 63c4585c40..8dc080231d 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor.cs @@ -337,11 +337,19 @@ private async Task RefreshDataCoreAsync() // If we're using Virtualize, we have to go through its RefreshDataAsync API otherwise: // (1) It won't know to update its own internal state if the provider output has changed // (2) We won't know what slice of data to query for - await _virtualizeComponent.RefreshDataAsync(); - if (ReferenceEquals(_pendingDataLoadCancellationTokenSource, thisLoadCts)) + try + { + await _virtualizeComponent.RefreshDataAsync(); + } + finally { - thisLoadCts.Dispose(); - _pendingDataLoadCancellationTokenSource = null; + // Always reconcile the load-state, even if RefreshDataAsync threw, so we don't leak the + // CTS or leave _pendingDataLoadCancellationTokenSource pointing at a disposed instance. + if (ReferenceEquals(_pendingDataLoadCancellationTokenSource, thisLoadCts)) + { + thisLoadCts.Dispose(); + _pendingDataLoadCancellationTokenSource = null; + } } } else @@ -410,7 +418,18 @@ private async Task RefreshDataCoreAsync() var providerRequest = new BitQuickGridItemsProviderRequest( startIndex, count, _sortByColumn, _sortByAscending, request.CancellationToken); - var providerResult = await ResolveItemsRequestAsync(providerRequest); + BitQuickGridItemsProviderResult providerResult; + try + { + providerResult = await ResolveItemsRequestAsync(providerRequest); + } + catch (OperationCanceledException) + { + // The request was superseded by a newer one after the debounce window; the items provider + // observed the cancellation token and bailed out. Return an empty result the virtualization + // system can handle rather than letting the cancellation propagate out of here. + return default; + } if (!request.CancellationToken.IsCancellationRequested) { diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.cs index 553f3a1453..951c60139b 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.cs @@ -212,8 +212,8 @@ private async Task> LoadMore(BitDataGridReadReque IEnumerable query = infiniteAll; - var sort = request.Sorts.FirstOrDefault(); - if (sort is not null) + IOrderedEnumerable? ordered = null; + foreach (var sort in request.Sorts) { Func key = sort.ColumnId switch { @@ -225,10 +225,21 @@ private async Task> LoadMore(BitDataGridReadReque nameof(Product.Rating) => p => p.Rating, _ => p => p.Id }; - query = sort.Direction == BitDataGridSortDirection.Descending - ? query.OrderByDescending(key) - : query.OrderBy(key); + + if (ordered is null) + { + ordered = sort.Direction == BitDataGridSortDirection.Descending + ? query.OrderByDescending(key) + : query.OrderBy(key); + } + else + { + ordered = sort.Direction == BitDataGridSortDirection.Descending + ? ordered.ThenByDescending(key) + : ordered.ThenBy(key); + } } + if (ordered is not null) query = ordered; var batch = query.Skip(request.Skip).Take(request.Take ?? 40).ToList(); diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.samples.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.samples.cs index 6d558d7f85..d2e536ebe6 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.samples.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.samples.cs @@ -323,44 +323,50 @@ public sealed class SupplierModel private async Task> LoadData(BitDataGridReadRequest request) { loading = true; - await Task.Delay(250); // simulate a backend round-trip + try + { + await Task.Delay(250, request.CancellationToken); // simulate a backend round-trip - IEnumerable query = all; + IEnumerable query = all; - // filtering - foreach (var f in request.Filters) - { - var term = f.Value?.ToString() ?? """"; - query = f.ColumnId switch + // filtering + foreach (var f in request.Filters) { - nameof(Product.Name) => query.Where(p => p.Name.Contains(term, StringComparison.OrdinalIgnoreCase)), - nameof(Product.Price) => query.Where(p => p.Price.ToString().Contains(term)), - nameof(Product.Id) => query.Where(p => p.Id.ToString().Contains(term)), - _ => query - }; - } + var term = f.Value?.ToString() ?? """"; + query = f.ColumnId switch + { + nameof(Product.Name) => query.Where(p => p.Name.Contains(term, StringComparison.OrdinalIgnoreCase)), + nameof(Product.Price) => query.Where(p => p.Price.ToString().Contains(term)), + nameof(Product.Id) => query.Where(p => p.Id.ToString().Contains(term)), + _ => query + }; + } - // sorting - var sort = request.Sorts.FirstOrDefault(); - if (sort is not null) - { - Func key = sort.ColumnId switch + // sorting + var sort = request.Sorts.FirstOrDefault(); + if (sort is not null) { - nameof(Product.Name) => p => p.Name, - nameof(Product.Price) => p => p.Price, - _ => p => p.Id - }; - query = sort.Direction == BitDataGridSortDirection.Descending - ? query.OrderByDescending(key) - : query.OrderBy(key); - } + Func key = sort.ColumnId switch + { + nameof(Product.Name) => p => p.Name, + nameof(Product.Price) => p => p.Price, + _ => p => p.Id + }; + query = sort.Direction == BitDataGridSortDirection.Descending + ? query.OrderByDescending(key) + : query.OrderBy(key); + } - // paging - var filtered = query.ToList(); - var items = filtered.Skip(request.Skip).Take(request.Take ?? filtered.Count).ToList(); + // paging + var filtered = query.ToList(); + var items = filtered.Skip(request.Skip).Take(request.Take ?? filtered.Count).ToList(); - loading = false; - return new BitDataGridReadResult(items, filtered.Count); + return new BitDataGridReadResult(items, filtered.Count); + } + finally + { + loading = false; + } }" + ProductModelCode + SampleDataCode; private readonly string example12RazorCode = @" From 08a958f9aca2a3ce59bb8c8642b6d05589f469ff Mon Sep 17 00:00:00 2001 From: msynk Date: Tue, 23 Jun 2026 08:07:12 +0330 Subject: [PATCH 07/35] resolve review comments V --- .../Components/DataGrid/BitDataGrid.razor.cs | 28 +++++++++- .../Components/DataGrid/BitDataGridCell.razor | 8 ++- .../DataGrid/BitDataGridCellEditor.razor | 4 +- .../BitDataGridPropertyAccessor.cs | 55 +++++++++++++------ .../QuickGrid/BitQuickGrid.razor.cs | 2 +- .../Extras/DataGrid/BitDataGridDemo.razor | 2 +- .../Extras/DataGrid/BitDataGridDemo.razor.cs | 20 +++++-- .../DataGrid/BitDataGridDemo.razor.samples.cs | 44 ++++++++++++--- .../Components/Extras/DataGrid/SampleData.cs | 35 ++++-------- .../BitQuickGridDemo.razor.samples.cs | 4 +- 10 files changed, 138 insertions(+), 64 deletions(-) diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs index 1d2acb6afa..3e9c81e72b 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs @@ -485,6 +485,13 @@ private async Task LoadNextBatchAsync() _pageItems = _infiniteItems; _footerAggregates = BitDataGridDataProcessor.Aggregate(_infiniteItems, _columns); } + catch (OperationCanceledException) + { + // The in-flight batch was superseded by a newer load (sort/filter change or reset) whose + // cancellation token fired. Cancellation is expected here, so drop this batch and let the + // newer load own the loading state. + return; + } finally { // Only clear the loading flag if we are still the current request; a superseding request @@ -568,7 +575,17 @@ private async Task LoadServerDataAsync() // Capture this request's version right after ResetLoadCancellation; bail out below if a newer // request has since superseded it so a stale response can't overwrite fresher state. var version = _loadVersion; - var result = await OnRead!(request); + BitDataGridReadResult result; + try + { + result = await OnRead!(request); + } + catch (OperationCanceledException) + { + // Superseded by a newer request whose cancellation token fired; cancellation is expected, + // so keep the existing state and let the newer load complete. + return; + } if (version != _loadVersion) return; _pageItems = result.Items; _view = result.Items; @@ -1207,9 +1224,16 @@ public string ToCsv() return sb.ToString(); static string Escape(string v) - => v.Contains(',') || v.Contains('"') || v.Contains('\n') + { + // Neutralise CSV formula injection: spreadsheet apps may execute a cell whose text begins + // with =, +, - or @ as a formula. Prefixing with a single quote forces it to be read as text. + if (v.Length > 0 && (v[0] is '=' or '+' or '-' or '@')) + v = "'" + v; + + return v.Contains(',') || v.Contains('"') || v.Contains('\n') ? "\"" + v.Replace("\"", "\"\"") + "\"" : v; + } } // ----------------------------------------------------- Layout helpers diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridCell.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridCell.razor index b1bb273890..6422d1f479 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridCell.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridCell.razor @@ -10,7 +10,8 @@ @oncontextmenu="HandleContextMenu" @oncontextmenu:preventDefault="Grid.OnCellContextMenu.HasDelegate" @onfocusin="HandleFocusIn" - @onkeydown="HandleKeyDown"> + @onkeydown="HandleKeyDown" + @onkeydown:preventDefault="PreventKeyDefault"> @ChildContent @@ -28,6 +29,11 @@ private int? TabIndex => Grid.CellNavigation ? Grid.CellTabIndex(Item, ColIndex) : (int?)null; private bool Editing => Grid.IsEditing(Item); + // Suppress native browser key handling (e.g. scrolling on arrows/Page keys) only while cell + // navigation is active and the cell is not being inline-edited, so editor inputs keep their + // default typing/caret behavior. + private bool PreventKeyDefault => Grid.CellNavigation && !Editing; + protected override async Task OnAfterRenderAsync(bool firstRender) { if (Grid.CellNavigation && Grid.ShouldFocusCell(Item, ColIndex)) diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridCellEditor.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridCellEditor.razor index 640c31bec8..cefffeccd8 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridCellEditor.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridCellEditor.razor @@ -21,9 +21,9 @@ @onchange="e => Set(e.Value)" /> break; - case BitDataGridColumnDataType.Enum: + case BitDataGridColumnDataType.Enum when Column.Accessor is not null && Column.Accessor.UnderlyingType.IsEnum: break; @@ -44,6 +44,15 @@ private object? Value => Column.GetValue(Item); private string GetString() => Value?.ToString() ?? string.Empty; + + // HTML always expects a period as the decimal separator regardless of the + // browser locale, so format numeric values with the invariant culture to avoid emitting a comma + // (which the input would reject) in comma-decimal cultures. + private string GetNumberString() + => Value is IFormattable f + ? f.ToString(null, System.Globalization.CultureInfo.InvariantCulture) + : (Value?.ToString() ?? string.Empty); + private bool GetBool() => Value is bool b && b; private string GetDate() diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridColumn.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridColumn.cs index afcaf90b6d..feb95a82f8 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridColumn.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridColumn.cs @@ -110,6 +110,7 @@ internal BitDataGridColumnDataType EffectiveDataType if (t.IsEnum) return BitDataGridColumnDataType.Enum; if (t == typeof(DateTime) || t == typeof(DateOnly) || t == typeof(DateTimeOffset)) return BitDataGridColumnDataType.Date; if (t == typeof(int) || t == typeof(long) || t == typeof(short) || t == typeof(byte) + || t == typeof(sbyte) || t == typeof(ushort) || t == typeof(uint) || t == typeof(ulong) || t == typeof(double) || t == typeof(float) || t == typeof(decimal)) return BitDataGridColumnDataType.Number; return BitDataGridColumnDataType.Text; diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.samples.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.samples.cs index 8ed9b4ef25..cda998fb92 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.samples.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.samples.cs @@ -323,6 +323,7 @@ public sealed class SupplierModel private async Task> LoadData(BitDataGridReadRequest request) { loading = true; + await InvokeAsync(StateHasChanged); // re-render so the loading indicator shows try { await Task.Delay(250, request.CancellationToken); // simulate a backend round-trip @@ -372,6 +373,7 @@ private async Task> LoadData(BitDataGridReadReque finally { loading = false; + await InvokeAsync(StateHasChanged); // re-render after the load completes (runs as a callback) } }" + ProductModelCode + SampleDataCode; From 2bc4693011ebdfd9b50b502cc2b793b5c1e3f41a Mon Sep 17 00:00:00 2001 From: msynk Date: Fri, 26 Jun 2026 10:46:35 +0330 Subject: [PATCH 11/35] resolve review comments IX --- .../Components/DataGrid/BitDataGrid.razor | 4 +- .../Components/DataGrid/BitDataGrid.razor.cs | 19 +++++++-- .../DataGrid/BitDataGridCellEditor.razor | 9 +++-- .../BitDataGridPropertyAccessor.cs | 4 ++ .../Components/QuickGrid/BitQuickGrid.razor | 4 +- .../QuickGrid/BitQuickGrid.razor.cs | 40 +++++++++++++++++++ .../BitQuickGridJsRuntimeExtensions.cs | 4 +- .../Columns/BitQuickGridColumnBase.razor | 5 ++- .../Extras/DataGrid/BitDataGridDemo.razor.cs | 4 +- .../DataGrid/BitDataGridDemo.razor.samples.cs | 11 +++-- .../Components/Extras/DataGrid/SampleData.cs | 3 ++ .../QuickGrid/BitQuickGridDemo.razor.cs | 12 ++++++ .../BitQuickGridDemo.razor.samples.cs | 12 ++++++ 13 files changed, 115 insertions(+), 16 deletions(-) diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor index 1a06a76cd6..82676ce88c 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor @@ -337,7 +337,9 @@ @code { private bool UseVirtualization => Virtualize && _viewGroups is null && !IsServerMode; - private ICollection VirtualRows => _view as ICollection ?? _view.ToList(); + // Virtualize the current page slice so paging is honored when Pageable and Virtualize are both on. + // When paging is off, _pageItems is the full view, so this still virtualizes everything. + private ICollection VirtualRows => _pageItems as ICollection ?? _pageItems.ToList(); private RenderFragment RenderGroupList(IReadOnlyList> groups) => __builder => { diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs index 821eed2060..a5c19231fa 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs @@ -248,10 +248,21 @@ internal void AddColumn(BitDataGridColumn column) if (_columns.Contains(column)) return; _columns.Add(column); _columnsById[column.Id] = column; - // Recompute the data view (not just re-render) so footer/aggregate columns registered after the - // initial data load have their values calculated. RefreshAsync is cheap in client mode and any - // superseded server-mode load is cancelled by the load-cancellation token. - InvokeAsync(RefreshAsync); + + // A column registering itself must not trigger a fresh data fetch in server/infinite modes — + // doing so once per column re-queries the backend (or resets the infinite list) repeatedly. + // Instead recompute footer/aggregate values from the rows already loaded and just re-render so + // late-registered footer columns still get their values. In client mode RefreshAsync only + // reprocesses the in-memory view (and recomputes aggregates), so it is cheap and used as-is. + if (IsServerMode || IsInfiniteMode) + { + _footerAggregates = BitDataGridDataProcessor.Aggregate(_pageItems, _columns); + InvokeAsync(StateHasChanged); + } + else + { + InvokeAsync(RefreshAsync); + } } internal void RemoveColumn(BitDataGridColumn column) diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridCellEditor.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridCellEditor.razor index ab536e182a..8e202bc887 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridCellEditor.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridCellEditor.razor @@ -57,11 +57,14 @@ private string GetDate() { + // requires a strict ISO 8601 yyyy-MM-dd value; format with the invariant + // culture so non-Gregorian thread cultures don't emit an invalid (e.g. localized) date string. + var inv = System.Globalization.CultureInfo.InvariantCulture; return Value switch { - DateTime dt => dt.ToString("yyyy-MM-dd"), - DateOnly d => d.ToString("yyyy-MM-dd"), - DateTimeOffset dto => dto.ToString("yyyy-MM-dd"), + DateTime dt => dt.ToString("yyyy-MM-dd", inv), + DateOnly d => d.ToString("yyyy-MM-dd", inv), + DateTimeOffset dto => dto.ToString("yyyy-MM-dd", inv), _ => string.Empty }; } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridPropertyAccessor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridPropertyAccessor.cs index eec82e1e10..7b51c66ef6 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridPropertyAccessor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridPropertyAccessor.cs @@ -76,6 +76,10 @@ public bool TryConvertValue(object? value, out object? result) result = value is DateOnly d ? d : DateOnly.Parse(value.ToString()!); else if (target == typeof(TimeOnly)) result = value is TimeOnly t ? t : TimeOnly.Parse(value.ToString()!); + else if (target == typeof(DateTimeOffset)) + // DateTimeOffset is not IConvertible, so Convert.ChangeType below would throw for it; + // handle it explicitly like the other date/time types above. + result = value is DateTimeOffset dto ? dto : DateTimeOffset.Parse(value.ToString()!); else result = Convert.ChangeType(value, target); return true; diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor index 69d84cd1e7..a19957d7d1 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor @@ -65,7 +65,9 @@ { while (rowIndex++ < initialRowIndex + Pagination.ItemsPerPage) { - + + + } } } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor.cs index beee38b6a9..034feed7f6 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor.cs @@ -46,6 +46,9 @@ public partial class BitQuickGrid : IAsyncDisposable private int? _lastRefreshedPaginationStateHash; private object? _lastAssignedItemsOrProvider; private CancellationTokenSource? _pendingDataLoadCancellationTokenSource; + // Hash of the collected column set the resize handles were last bound against, so we only rebind + // when the columns actually change rather than on every render. + private int? _lastInitColumnsHash; // If the PaginationState mutates, it raises this event. We use it to trigger a re-render. private readonly EventCallbackSubscriber _currentPageItemsChanged; @@ -297,6 +300,21 @@ protected override async Task OnAfterRenderAsync(bool firstRender) if (firstRender) { _jsEventDisposable = await _js.BitQuickGridInit(_tableReference); + _lastInitColumnsHash = ComputeColumnsHash(); + } + else if (ResizableColumns) + { + // The resize handles (.bit-qkg-drg) are bound per-element by init. When the column set + // changes, the header re-renders with fresh handles that have no listeners, so rebind them. + // Re-running init also re-adds the document-level listeners, so stop the previous + // registration first to avoid leaking duplicate handlers. Unchanged renders are skipped. + var hash = ComputeColumnsHash(); + if (hash != _lastInitColumnsHash) + { + _lastInitColumnsHash = hash; + await StopJsEventsAsync(); + _jsEventDisposable = await _js.BitQuickGridInit(_tableReference); + } } if (_checkColumnOptionsPosition && _displayOptionsForColumn is not null) @@ -306,6 +324,28 @@ protected override async Task OnAfterRenderAsync(bool firstRender) } } + private int ComputeColumnsHash() + { + var hash = new HashCode(); + foreach (var col in _columns) hash.Add(col); + return hash.ToHashCode(); + } + + private async Task StopJsEventsAsync() + { + try + { + if (_jsEventDisposable is not null) + { + await _jsEventDisposable.InvokeVoidAsync("stop"); + await _jsEventDisposable.DisposeAsync(); + _jsEventDisposable = null; + } + } + catch (JSDisconnectedException) { } + catch (JSException) { } + } + private void StartCollectingColumns() diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGridJsRuntimeExtensions.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGridJsRuntimeExtensions.cs index dca4e28a30..74e83b985a 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGridJsRuntimeExtensions.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGridJsRuntimeExtensions.cs @@ -4,11 +4,11 @@ internal static class BitQuickGridJsRuntimeExtensions { public static async ValueTask BitQuickGridInit(this IJSRuntime jsRuntime, ElementReference tableElement) { - return await jsRuntime.Invoke("BitBlazorUI.QuickGrid.init", tableElement); + return await jsRuntime.InvokeAsync("BitBlazorUI.QuickGrid.init", tableElement); } public static async ValueTask BitQuickGridCheckColumnOptionsPosition(this IJSRuntime jsRuntime, ElementReference tableElement) { - await jsRuntime.InvokeVoid("BitBlazorUI.QuickGrid.checkColumnOptionsPosition", tableElement); + await jsRuntime.InvokeVoidAsync("BitBlazorUI.QuickGrid.checkColumnOptionsPosition", tableElement); } } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridColumnBase.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridColumnBase.razor index 372b2dcb31..5e537915b3 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridColumnBase.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridColumnBase.razor @@ -97,6 +97,9 @@ HeaderContent = RenderDefaultHeaderContent; } + private string SortButtonLabel() + => string.IsNullOrEmpty(Title) ? "Sort" : $"Sort by {Title}"; + private void RenderDefaultHeaderContent(RenderTreeBuilder __builder) { @if (HeaderTemplate is not null) @@ -112,7 +115,7 @@ if (Sortable.HasValue ? Sortable.Value : IsSortableByDefault()) { - diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.cs index 5609b4a81a..f951aa0a61 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.cs @@ -44,7 +44,9 @@ public partial class BitDataGridDemo : AppComponentBase private string serverLastRequest = ""; // example 12 - infinite scrolling - private readonly List infiniteAll = SampleData.Generate(2_000); + // Use a count that is not a multiple of the 40-row batch size so the final batch is short, + // letting the grid detect the end without an extra empty fetch. + private readonly List infiniteAll = SampleData.Generate(2_017); private string infiniteLog = "Scroll down to load more…"; private int infiniteRequests; diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.samples.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.samples.cs index cda998fb92..d3c535b20e 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.samples.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.samples.cs @@ -372,8 +372,13 @@ private async Task> LoadData(BitDataGridReadReque } finally { - loading = false; - await InvokeAsync(StateHasChanged); // re-render after the load completes (runs as a callback) + // Only the active request should clear the loading state; a superseded request observes a + // cancelled token, so skip the reset and let the newer in-flight load own the indicator. + if (!request.CancellationToken.IsCancellationRequested) + { + loading = false; + await InvokeAsync(StateHasChanged); // re-render after the load completes (runs as a callback) + } } }" + ProductModelCode + SampleDataCode; @@ -385,7 +390,7 @@ private async Task> LoadData(BitDataGridReadReque "; private readonly string example12CsharpCode = @" -private readonly List all = SampleData.Generate(2_000); +private readonly List all = SampleData.Generate(2_017); private async Task> LoadMore(BitDataGridReadRequest request) { diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/SampleData.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/SampleData.cs index a5e95754e1..3f9e5d89f7 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/SampleData.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/SampleData.cs @@ -30,6 +30,9 @@ public static List GeneratePersian(int count, int seed = 42) /// private static List GenerateCore(int count, int seed, string[] adjectives, string[] nouns, string[] suppliers) { + if (count < 0) + throw new ArgumentOutOfRangeException(nameof(count), count, "Count must not be negative."); + var rng = new Random(seed); var categories = Enum.GetValues(); var list = new List(count); diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.cs index 05b0fba13c..13068fb1ed 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.cs @@ -637,6 +637,12 @@ protected override async Task OnInitAsync() return BitQuickGridItemsProviderResult.From(data!.Results!, data!.Meta!.Results!.Total); } + catch (OperationCanceledException) when (req.CancellationToken.IsCancellationRequested) + { + // A rapid refresh superseded this request; let the cancellation propagate so the grid + // treats it as a cancelled load rather than a genuine zero-item result. + throw; + } catch { return BitQuickGridItemsProviderResult.From([], 0); @@ -672,6 +678,12 @@ protected override async Task OnInitAsync() return BitQuickGridItemsProviderResult.From(data!.Items!, data!.TotalCount); } + catch (OperationCanceledException) when (req.CancellationToken.IsCancellationRequested) + { + // A rapid refresh superseded this request; let the cancellation propagate so the grid + // treats it as a cancelled load rather than a genuine zero-item result. + throw; + } catch { return BitQuickGridItemsProviderResult.From(new List { }, 0); diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.samples.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.samples.cs index 5b1fbf0da8..3807179a6c 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.samples.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.samples.cs @@ -434,6 +434,10 @@ protected override async Task OnInitializedAsync() items: data!.Results, totalItemCount: data!.Meta.Results.Total); } + catch (OperationCanceledException) when (req.CancellationToken.IsCancellationRequested) + { + throw; // a rapid refresh superseded this request; let cancellation flow through + } catch { return BitQuickGridItemsProviderResult.From(new List { }, 0); @@ -739,6 +743,10 @@ protected override async Task OnInitializedAsync() return BitQuickGridItemsProviderResult.From(data!.Items, (int)data!.TotalCount); } + catch (OperationCanceledException) when (req.CancellationToken.IsCancellationRequested) + { + throw; // a rapid refresh superseded this request; let cancellation flow through + } catch { return BitQuickGridItemsProviderResult.From(new List { }, 0); @@ -907,6 +915,10 @@ protected override async Task OnInitializedAsync() return BitQuickGridItemsProviderResult.From(data!.Items, (int)data!.TotalCount); } + catch (OperationCanceledException) when (req.CancellationToken.IsCancellationRequested) + { + throw; // a rapid refresh superseded this request; let cancellation flow through + } catch { return BitQuickGridItemsProviderResult.From(new List { }, 0); From 58170be27ca883c39cbf2654d57c819856e3b788 Mon Sep 17 00:00:00 2001 From: msynk Date: Fri, 26 Jun 2026 14:06:51 +0330 Subject: [PATCH 12/35] resolve review comments X --- .../Components/DataGrid/BitDataGrid.razor.cs | 51 ++++++++++++++----- .../DataGrid/BitDataGridCellEditor.razor | 33 +++++++++++- .../Components/DataGrid/BitDataGridColumn.cs | 12 ++++- .../Components/DataGrid/BitDataGridRow.razor | 13 +++++ .../BitDataGridDataProcessor.cs | 3 ++ .../BitDataGridPropertyAccessor.cs | 8 ++- .../Models/BitDataGridColumnDataType.cs | 1 + .../Models/BitDataGridFilterOperator.cs | 4 +- .../Models/BitDataGridRowReorderEventArgs.cs | 14 ++++- .../QuickGrid/BitQuickGrid.razor.cs | 20 ++++++-- .../Components/QuickGrid/BitQuickGrid.ts | 22 +++++++- .../Columns/BitQuickGridPropertyColumn.cs | 15 ++++-- .../BitQuickGridItemsProviderRequest.cs | 9 +++- .../Styles/extra-components.scss | 4 +- .../Extras/DataGrid/BitDataGridDemo.razor | 2 +- .../DataGrid/BitDataGridDemo.razor.params.cs | 32 ++++++------ .../Extras/QuickGrid/BitQuickGridDemo.razor | 2 +- .../QuickGrid/BitQuickGridDemo.razor.cs | 5 +- .../BitQuickGridDemo.razor.samples.cs | 5 +- 19 files changed, 202 insertions(+), 53 deletions(-) diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs index a5c19231fa..90072af3fa 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs @@ -277,6 +277,17 @@ internal void RemoveColumn(BitDataGridColumn column) // ------------------------------------------------------- Lifecycle protected override async Task OnParametersSetAsync() { + // Server mode (OnRead) and infinite-scrolling mode (OnLoadMore) drive paging, total count and + // ARIA state in mutually exclusive ways. Allowing both would let RefreshAsync behave like + // infinite loading while TotalCount/TotalPages still report server paging, so reject the + // ambiguous configuration up-front and force callers to pick a single data mode. + if (OnRead is not null && OnLoadMore is not null) + { + throw new InvalidOperationException( + $"{nameof(BitDataGrid)} cannot use both {nameof(OnRead)} (server mode) and " + + $"{nameof(OnLoadMore)} (infinite-scrolling mode) at the same time. Provide only one data callback."); + } + _effectivePageSize = Pageable ? Math.Max(1, PageSize) : int.MaxValue; // Reset the current selection whenever the selection mode changes @@ -284,25 +295,23 @@ protected override async Task OnParametersSetAsync() // selection no longer makes sense under the new semantics. if (_lastSelectionMode is not null && _lastSelectionMode != SelectionMode) { - if (_selected.Count > 0) - { - _selected.Clear(); - await NotifySelectionAsync(); - } - // A parent may change SelectionMode and provide SelectedItems in the same render cycle. - // Apply the incoming selection here too so the internal state reflects the new values - // instead of being left empty after the mode-change reset. + // When selection is controlled, apply the (mode-normalized) incoming selection directly + // without first emitting a transient cleared selection back to the parent. if (SelectedItems is not null) { + ApplyControlledSelection(); + } + else if (_selected.Count > 0) + { + // Uncontrolled: the previous selection no longer fits the new mode, so clear and notify. _selected.Clear(); - foreach (var i in SelectedItems) _selected.Add(i); + await NotifySelectionAsync(); } } else if (SelectedItems is not null) { - _selected.Clear(); - foreach (var i in SelectedItems) _selected.Add(i); + ApplyControlledSelection(); } _lastSelectionMode = SelectionMode; @@ -837,6 +846,22 @@ private async Task NotifySelectionAsync() StateHasChanged(); } + /// + /// Applies the parent-controlled into the internal selection set, + /// normalized to the current (None selects nothing, Single keeps at + /// most one item). Does not notify the parent, since the selection is incoming rather than changed here. + /// + private void ApplyControlledSelection() + { + _selected.Clear(); + if (SelectedItems is null || SelectionMode == BitDataGridSelectionMode.None) return; + foreach (var i in SelectedItems) + { + _selected.Add(i); + if (SelectionMode == BitDataGridSelectionMode.Single) break; + } + } + internal async Task HandleRowClickAsync(TItem item) { if (OnRowClick.HasDelegate) await OnRowClick.InvokeAsync(item); @@ -1022,8 +1047,8 @@ await OnRowReorder.InvokeAsync(new BitDataGridRowReorderEventArgs { DraggedItem = dragged, TargetItem = target, - FromIndex = -1, - ToIndex = -1 + FromIndex = null, + ToIndex = null }); } } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridCellEditor.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridCellEditor.razor index 8e202bc887..608de18521 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridCellEditor.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridCellEditor.razor @@ -21,8 +21,19 @@ @onchange="e => Set(e.Value)" /> break; + case BitDataGridColumnDataType.DateTime: + + break; + case BitDataGridColumnDataType.Enum when Column.Accessor is not null && Column.Accessor.UnderlyingType.IsEnum: - + @if (IsNullableEnum || Value is null) + { + + } @foreach (var name in Enum.GetNames(Column.Accessor.UnderlyingType)) { @@ -69,5 +80,25 @@ }; } + private string GetDateTime() + { + // requires a strict ISO 8601 yyyy-MM-ddTHH:mm value; format with + // the invariant culture and preserve the time (and the offset's local time) component. + var inv = System.Globalization.CultureInfo.InvariantCulture; + return Value switch + { + DateTime dt => dt.ToString("yyyy-MM-ddTHH:mm", inv), + DateTimeOffset dto => dto.ToString("yyyy-MM-ddTHH:mm", inv), + _ => string.Empty + }; + } + + // True when the bound member is a nullable enum (e.g. MyEnum?), so the editor can offer an empty + // option that maps back to null rather than forcing a concrete enum member. + private bool IsNullableEnum + => Column.Accessor is not null + && Nullable.GetUnderlyingType(Column.Accessor.PropertyType) is not null + && Column.Accessor.UnderlyingType.IsEnum; + private void Set(object? raw) => Grid.SetEditValue(Column, raw); } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridColumn.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridColumn.cs index feb95a82f8..277cd69e5f 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridColumn.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridColumn.cs @@ -108,7 +108,10 @@ internal BitDataGridColumnDataType EffectiveDataType var t = Accessor.UnderlyingType; if (t == typeof(bool)) return BitDataGridColumnDataType.Boolean; if (t.IsEnum) return BitDataGridColumnDataType.Enum; - if (t == typeof(DateTime) || t == typeof(DateOnly) || t == typeof(DateTimeOffset)) return BitDataGridColumnDataType.Date; + if (t == typeof(DateOnly)) return BitDataGridColumnDataType.Date; + // DateTime/DateTimeOffset carry a time (and offset) component, so keep them on a distinct + // type with a time-aware editor rather than the date-only control DateOnly uses. + if (t == typeof(DateTime) || t == typeof(DateTimeOffset)) return BitDataGridColumnDataType.DateTime; if (t == typeof(int) || t == typeof(long) || t == typeof(short) || t == typeof(byte) || t == typeof(sbyte) || t == typeof(ushort) || t == typeof(uint) || t == typeof(ulong) || t == typeof(double) || t == typeof(float) || t == typeof(decimal)) @@ -121,6 +124,13 @@ protected override void OnInitialized() { if (Grid is null) throw new InvalidOperationException($"{nameof(BitDataGridColumn)} must be used inside a {nameof(BitDataGrid)}."); + + // Resolve the accessor before registering with the grid. AddColumn recomputes footer/aggregate + // values immediately, so a field-bound aggregate column whose Accessor was still null at that + // point would be skipped on first registration. + if (HasField) + Accessor = BitDataGridPropertyAccessor.For(Field!); + Grid.AddColumn(this); } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridRow.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridRow.razor index 96f1d929e3..5f3950a64e 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridRow.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridRow.razor @@ -27,6 +27,7 @@ {
} @@ -83,6 +84,18 @@ private bool Selected => Grid.IsRowSelected(Item); private bool Editing => Grid.IsEditing(Item); + // Gives the row-selection checkbox an accessible name so screen readers can identify which row is + // being selected. Uses the first visible column's value as row-specific context when available. + private string SelectRowLabel + { + get + { + var cols = Grid.VisibleColumns; + var context = cols.Count > 0 ? cols[0].GetFormattedValue(Item) : null; + return string.IsNullOrWhiteSpace(context) ? "Select row" : $"Select row: {context}"; + } + } + private RenderFragment CellContent(BitDataGridColumn column, int colIndex) => @ @if (Grid.IsTreeMode && colIndex == 0) { diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridDataProcessor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridDataProcessor.cs index a829788930..76b1a444ab 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridDataProcessor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridDataProcessor.cs @@ -192,6 +192,9 @@ private static bool Matches(object? value, BitDataGridFilterDescriptor filter) { switch (filter.Operator) { + case BitDataGridFilterOperator.Unspecified: + // No operator selected: treat the filter as omitted so it doesn't exclude any rows. + return true; case BitDataGridFilterOperator.IsEmpty: return value is null || string.IsNullOrEmpty(value.ToString()); case BitDataGridFilterOperator.IsNotEmpty: diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridPropertyAccessor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridPropertyAccessor.cs index 7b51c66ef6..ebec270764 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridPropertyAccessor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridPropertyAccessor.cs @@ -43,8 +43,12 @@ public void SetValue(TItem item, object? value) _setter(item, converted); } - /// Coerces an arbitrary value into the property's type, falling back to the type's default on failure. - public object? ConvertValue(object? value) + /// + /// Coerces an arbitrary value into the property's type, falling back to the type's default on failure. + /// The name makes the silent-default behavior explicit; prefer when a + /// conversion failure must be detected rather than masked. + /// + public object? ConvertOrDefault(object? value) => TryConvertValue(value, out var result) ? result : DefaultValue(); /// diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridColumnDataType.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridColumnDataType.cs index 1d0efe3f07..842af65b6a 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridColumnDataType.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridColumnDataType.cs @@ -8,5 +8,6 @@ public enum BitDataGridColumnDataType Number, Boolean, Date, + DateTime, Enum } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridFilterOperator.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridFilterOperator.cs index ff0633186a..5af3f89191 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridFilterOperator.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridFilterOperator.cs @@ -3,7 +3,9 @@ namespace Bit.BlazorUI; /// Comparison operators available for column filtering. public enum BitDataGridFilterOperator { - Contains = 0, + /// No operator selected. The default value; such a filter is treated as omitted/invalid. + Unspecified = 0, + Contains, DoesNotContain, StartsWith, EndsWith, diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridRowReorderEventArgs.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridRowReorderEventArgs.cs index a9c314e6d5..7576094c9a 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridRowReorderEventArgs.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridRowReorderEventArgs.cs @@ -9,6 +9,16 @@ public sealed class BitDataGridRowReorderEventArgs { public required TItem DraggedItem { get; init; } public required TItem TargetItem { get; init; } - public required int FromIndex { get; init; } - public required int ToIndex { get; init; } + + /// + /// The original index of the dragged item within the bound source, or null when the index + /// is unavailable (for example when the bound Items is not an indexable ). + /// + public int? FromIndex { get; init; } + + /// + /// The destination index within the bound source, or null when the index is unavailable + /// (for example when the bound Items is not an indexable ). + /// + public int? ToIndex { get; init; } } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor.cs index 034feed7f6..5da6f92cd5 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor.cs @@ -49,6 +49,9 @@ public partial class BitQuickGrid : IAsyncDisposable // Hash of the collected column set the resize handles were last bound against, so we only rebind // when the columns actually change rather than on every render. private int? _lastInitColumnsHash; + // Tracks the ResizableColumns value the resize handles were last bound against, so toggling the + // feature on/off (without otherwise changing the columns) still rebinds the new/removed handles. + private bool _lastResizableColumns; // If the PaginationState mutates, it raises this event. We use it to trigger a re-render. private readonly EventCallbackSubscriber _currentPageItemsChanged; @@ -301,21 +304,32 @@ protected override async Task OnAfterRenderAsync(bool firstRender) { _jsEventDisposable = await _js.BitQuickGridInit(_tableReference); _lastInitColumnsHash = ComputeColumnsHash(); + _lastResizableColumns = ResizableColumns; } else if (ResizableColumns) { // The resize handles (.bit-qkg-drg) are bound per-element by init. When the column set // changes, the header re-renders with fresh handles that have no listeners, so rebind them. - // Re-running init also re-adds the document-level listeners, so stop the previous - // registration first to avoid leaking duplicate handlers. Unchanged renders are skipped. + // The handles also appear/disappear when ResizableColumns itself is toggled, so rebind on + // that transition too. Re-running init re-adds the document-level listeners, so stop the + // previous registration first to avoid leaking duplicate handlers. Unchanged renders are skipped. var hash = ComputeColumnsHash(); - if (hash != _lastInitColumnsHash) + if (hash != _lastInitColumnsHash || !_lastResizableColumns) { _lastInitColumnsHash = hash; + _lastResizableColumns = true; await StopJsEventsAsync(); _jsEventDisposable = await _js.BitQuickGridInit(_tableReference); } } + else if (_lastResizableColumns) + { + // ResizableColumns was just turned off; the drag handles are gone. Rebind so the + // document-level listeners are refreshed and the stale handle registration is dropped. + _lastResizableColumns = false; + await StopJsEventsAsync(); + _jsEventDisposable = await _js.BitQuickGridInit(_tableReference); + } if (_checkColumnOptionsPosition && _displayOptionsForColumn is not null) { diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.ts b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.ts index 708ad82274..054fba239e 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.ts +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.ts @@ -1,7 +1,10 @@ namespace BitBlazorUI { export class QuickGrid { public static init(tableElement: any) { - QuickGrid.enableColumnResizing(tableElement); + // Tracks the drag handles this init() bound so stop() can remove their listeners too, + // preventing handlers from accumulating across repeated init()/stop() cycles. + const boundDragHandles: { handle: any, listener: any }[] = []; + QuickGrid.enableColumnResizing(tableElement, boundDragHandles); const bodyClickHandler = (event: any) => { const columnOptionsElement = tableElement.tHead.querySelector('.bit-qkg-cop'); @@ -25,6 +28,15 @@ namespace BitBlazorUI { document.body.removeEventListener('click', bodyClickHandler); document.body.removeEventListener('mousedown', bodyClickHandler); document.body.removeEventListener('keydown', keyDownHandler); + + // Remove the per-handle drag listeners and clear the bound marker so a later + // init() can rebind the same surviving elements without duplicating handlers. + boundDragHandles.forEach(({ handle, listener }) => { + handle.removeEventListener('mousedown', listener); + handle.removeEventListener('touchstart', listener); + delete handle.__bitQkgResizeBound; + }); + boundDragHandles.length = 0; } }; } @@ -60,12 +72,18 @@ namespace BitBlazorUI { } } - private static enableColumnResizing(tableElement: any) { + private static enableColumnResizing(tableElement: any, boundDragHandles: { handle: any, listener: any }[]) { tableElement.tHead.querySelectorAll('.bit-qkg-drg').forEach((handle: any) => { + // Bind each handle only once. A surviving handle (reused by Blazor's diffing across + // re-renders) would otherwise accumulate a fresh listener on every init() call. + if (handle.__bitQkgResizeBound) return; + handle.__bitQkgResizeBound = true; + handle.addEventListener('mousedown', handleMouseDown); if ('ontouchstart' in window) { handle.addEventListener('touchstart', handleMouseDown); } + boundDragHandles.push({ handle, listener: handleMouseDown }); function handleMouseDown(evt: any) { evt.preventDefault(); diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridPropertyColumn.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridPropertyColumn.cs index 25759d1bb3..4d701c0e95 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridPropertyColumn.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridPropertyColumn.cs @@ -36,16 +36,14 @@ protected override void OnParametersSet() // or the Format has changed, so a Format-only change still rebuilds the cell formatter. if (_lastAssignedProperty != Property || _lastAssignedFormat != Format) { - _lastAssignedProperty = Property; - _lastAssignedFormat = Format; var compiledPropertyExpression = Property.Compile(); + Func cellTextFunc; if (Format.HasValue()) { if (typeof(IFormattable).IsAssignableFrom(typeof(TProp))) { - _cellTextFunc = item => ((IFormattable?)compiledPropertyExpression!(item))?.ToString(Format, null); - + cellTextFunc = item => ((IFormattable?)compiledPropertyExpression!(item))?.ToString(Format, null); } else { @@ -54,10 +52,17 @@ protected override void OnParametersSet() } else { - _cellTextFunc = item => compiledPropertyExpression!(item)?.ToString(); + cellTextFunc = item => compiledPropertyExpression!(item)?.ToString(); } + _cellTextFunc = cellTextFunc; _sortBuilder = BitQuickGridSort.ByAscending(Property); + + // Only record the assignments after the formatter has been built and validated, so a failed + // Format/TProp validation above doesn't suppress a retry on the next parameters set (which + // would leave _cellTextFunc in a stale or null state). + _lastAssignedProperty = Property; + _lastAssignedFormat = Format; } if (Title is null && Property.Body is MemberExpression memberExpression) diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/ItemsProvider/BitQuickGridItemsProviderRequest.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/ItemsProvider/BitQuickGridItemsProviderRequest.cs index c6a307167c..b1d4d402e1 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/ItemsProvider/BitQuickGridItemsProviderRequest.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/ItemsProvider/BitQuickGridItemsProviderRequest.cs @@ -58,7 +58,10 @@ internal BitQuickGridItemsProviderRequest( /// A new representing the with sorting rules applied. public IQueryable ApplySorting(IQueryable source) => SortByColumn switch { - IBitQuickGridSortBuilderColumn sbc => sbc.SortBuilder?.Apply(source, SortByAscending) ?? source, + // A sort-builder column with a null SortBuilder cannot apply its sort; treat it as unsupported + // (like a non-sort-builder column) rather than silently returning the unsorted source, which + // would hide an active sort on e.g. BitQuickGridTemplateColumn. + IBitQuickGridSortBuilderColumn { SortBuilder: { } sortBuilder } => sortBuilder.Apply(source, SortByAscending), null => source, _ => throw new NotSupportedException(ColumnNotSortableMessage(SortByColumn)), }; @@ -72,7 +75,9 @@ internal BitQuickGridItemsProviderRequest( /// A collection of (property name, direction) pairs representing the sorting rules public IReadOnlyCollection<(string PropertyName, BitQuickGridSortDirection Direction)> GetSortByProperties() => SortByColumn switch { - IBitQuickGridSortBuilderColumn sbc => sbc.SortBuilder?.ToPropertyList(SortByAscending) ?? Array.Empty<(string, BitQuickGridSortDirection)>(), + // Mirror ApplySorting: a null SortBuilder on a sort-builder column is unsupported rather than + // an empty (no-op) sort, so the caller isn't misled into thinking there is no active sort. + IBitQuickGridSortBuilderColumn { SortBuilder: { } sortBuilder } => sortBuilder.ToPropertyList(SortByAscending), null => Array.Empty<(string, BitQuickGridSortDirection)>(), _ => throw new NotSupportedException(ColumnNotSortableMessage(SortByColumn)), }; diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Styles/extra-components.scss b/src/BlazorUI/Bit.BlazorUI.Extras/Styles/extra-components.scss index 5b99550080..0fe3673ad8 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Styles/extra-components.scss +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Styles/extra-components.scss @@ -1,11 +1,11 @@ @import "../Components/AccordionList/BitAccordionList.scss"; @import "../Components/AppShell/BitAppShell.scss"; @import "../Components/DataGrid/BitDataGrid.scss"; -@import "../Components/QuickGrid/BitQuickGrid.scss"; -@import "../Components/QuickGrid/Pagination/BitQuickGridPaginator.scss"; @import "../Components/ErrorBoundary/BitErrorBoundary.scss"; @import "../Components/Flag/BitFlag.scss"; @import "../Components/InfiniteScrolling/BitInfiniteScrolling.scss"; +@import "../Components/QuickGrid/BitQuickGrid.scss"; +@import "../Components/QuickGrid/Pagination/BitQuickGridPaginator.scss"; @import "../Components/Map/BitMap.scss"; @import "../Components/MarkdownEditor/BitMarkdownEditor.scss"; @import "../Components/MarkdownViewer/BitMarkdownViewer.scss"; diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor index dd924ff727..2bc2a6d204 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor @@ -4,7 +4,7 @@ + Description="BitDataGrid is a feature-rich, native Blazor data grid: sorting, filtering, paging, virtualization, selection, inline editing, grouping, aggregates, tree-view, master-detail and theming." /> { - { "search", $"recalling_firm:\"{_virtualSampleNameFilter}\"" }, + { "search", $"recalling_firm:\"{firmFilter}\"" }, { "skip", req.StartIndex }, { "limit", req.Count } }; diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.samples.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.samples.cs index 3807179a6c..e3cddc5695 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.samples.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.samples.cs @@ -406,9 +406,12 @@ protected override async Task OnInitializedAsync() { try { + // Strip characters that would break the openFDA Lucene query syntax (the firm name is + // wrapped in quotes), namely double quotes and backslashes, before interpolating it. + var firmFilter = _virtualSampleNameFilter?.Replace(""\\"", string.Empty).Replace(""\"""""", string.Empty) ?? string.Empty; var query = new Dictionary { - { ""search"",$""recalling_firm:\""{_virtualSampleNameFilter}\"" }, + { ""search"",$""recalling_firm:\""{firmFilter}\"" }, { ""skip"", req.StartIndex }, { ""limit"", req.Count } }; From ad74781659340626dff103256d16571f98a6c1f7 Mon Sep 17 00:00:00 2001 From: msynk Date: Fri, 26 Jun 2026 19:39:19 +0330 Subject: [PATCH 13/35] resolve review comments XI --- .../Components/DataGrid/BitDataGrid.razor | 6 ++--- .../Components/DataGrid/BitDataGrid.razor.cs | 24 +++++++++++++----- .../DataGrid/BitDataGridCellEditor.razor | 25 +++++++++++++++++++ .../Components/DataGrid/BitDataGridColumn.cs | 8 +++--- .../BitDataGridPropertyAccessor.cs | 10 +++++--- .../Models/BitDataGridColumnDataType.cs | 1 + .../Components/QuickGrid/BitQuickGrid.razor | 1 + .../DataGrid/BitDataGridDemo.razor.samples.cs | 1 - 8 files changed, 59 insertions(+), 17 deletions(-) diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor index 82676ce88c..35f59fc2f8 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor @@ -50,14 +50,14 @@ } - @if (Pageable && (PagerPosition is BitDataGridPagerPosition.Top or BitDataGridPagerPosition.TopAndBottom)) + @if (PagingActive && (PagerPosition is BitDataGridPagerPosition.Top or BitDataGridPagerPosition.TopAndBottom)) { @RenderPager } @* ------------------------------------------------------- Grid viewport *@
-
+
@if (ShowHeader) { @@ -320,7 +320,7 @@
- @if (Pageable && (PagerPosition is BitDataGridPagerPosition.Bottom or BitDataGridPagerPosition.TopAndBottom)) + @if (PagingActive && (PagerPosition is BitDataGridPagerPosition.Bottom or BitDataGridPagerPosition.TopAndBottom)) { @RenderPager } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs index 90072af3fa..cf91341409 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs @@ -237,7 +237,11 @@ public partial class BitDataGrid : ComponentBase, IAsyncDisposable internal bool IsEditing(TItem item) => _editItem is not null && KeyEquals(_editItem, item); internal bool IsRowSelected(TItem item) => _selected.Contains(item); internal int TotalCount => IsServerMode ? _totalCount : _view.Count; - internal int TotalPages => _effectivePageSize <= 0 ? 1 : Math.Max(1, (int)Math.Ceiling(TotalCount / (double)_effectivePageSize)); + // Paging is suppressed while grouping is active: the grouped view renders every row, so a pager + // would misrepresent the data and leave page math out of sync with what is displayed. Treat paging + // as off in that case so the pager UI, TotalPages and GoToPageAsync all agree with the rendered rows. + internal bool PagingActive => Pageable && _groups.Count == 0; + internal int TotalPages => (!PagingActive || _effectivePageSize <= 0) ? 1 : Math.Max(1, (int)Math.Ceiling(TotalCount / (double)_effectivePageSize)); internal int CurrentPage => _currentPage; internal IReadOnlyList FooterAggregates => _footerAggregates; internal TItem? PendingNewItem => _pendingNew; @@ -413,8 +417,11 @@ void Walk(IEnumerable siblings, int level) : siblings.ToList(); foreach (var item in sorted) { - var children = ChildrenSelector!(item); - var hasChildren = children is not null && children.Any(); + // Materialize the children once: ChildrenSelector may return a lazy or single-pass + // sequence, and enumerating it twice (for the has-children check and the recursive + // walk) could yield different results or throw. Cache the snapshot and reuse it. + var children = ChildrenSelector!(item) is { } c ? c as IReadOnlyList ?? c.ToList() : null; + var hasChildren = children is not null && children.Count > 0; _treeMeta[GetKey(item)] = (level, hasChildren); flat.Add(item); if (hasChildren && IsTreeExpanded(item)) @@ -427,8 +434,9 @@ private void ExpandTreeRecursive(IEnumerable siblings) { foreach (var item in siblings) { - var children = ChildrenSelector!(item); - if (children is not null && children.Any()) + // Snapshot the children once to avoid re-enumerating a lazy/single-pass sequence. + var children = ChildrenSelector!(item) is { } c ? c as IReadOnlyList ?? c.ToList() : null; + if (children is not null && children.Count > 0) { _expandedTree.Add(GetKey(item)); ExpandTreeRecursive(children); @@ -749,7 +757,11 @@ public async Task ClearFiltersAsync() // ----------------------------------------------------------- Grouping internal bool ColumnGroupable(BitDataGridColumn column) - => column.HasField && (column.Groupable ?? Groupable); + // Grouping is a client-side operation: it reshapes the locally-held _view into _viewGroups. + // Server mode (OnRead) and infinite-scrolling mode (OnLoadMore) only forward sorts and filters + // to the data callback and render the returned rows flat, so exposing a group toggle there would + // appear active without affecting the list. Disable it until the remote flow carries grouping. + => column.HasField && !IsServerMode && !IsInfiniteMode && (column.Groupable ?? Groupable); internal bool IsGrouped(BitDataGridColumn column) => _groups.Any(g => g.ColumnId == column.Id); diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridCellEditor.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridCellEditor.razor index 608de18521..9136b4af2c 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridCellEditor.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridCellEditor.razor @@ -27,6 +27,12 @@ @onchange="e => Set(e.Value)" /> break; + case BitDataGridColumnDataType.DateTimeOffset: + + break; + case BitDataGridColumnDataType.Enum when Column.Accessor is not null && Column.Accessor.UnderlyingType.IsEnum: has no UTC offset, so a naive parse back through the property + // accessor would discard the original offset. Combine the edited local date/time with the offset + // of the current value (falling back to the system local offset for a new/empty value) and pass a + // real DateTimeOffset so the accessor stores it without losing the offset. + private void SetDateTimeOffset(object? raw) + { + var inv = System.Globalization.CultureInfo.InvariantCulture; + if (raw is string s && !string.IsNullOrEmpty(s) + && DateTime.TryParse(s, inv, System.Globalization.DateTimeStyles.None, out var local)) + { + var offset = Value is DateTimeOffset current ? current.Offset : DateTimeOffset.Now.Offset; + Grid.SetEditValue(Column, new DateTimeOffset(local, offset)); + } + else + { + Grid.SetEditValue(Column, raw); + } + } } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridColumn.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridColumn.cs index 277cd69e5f..05b6cb76d2 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridColumn.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridColumn.cs @@ -109,9 +109,11 @@ internal BitDataGridColumnDataType EffectiveDataType if (t == typeof(bool)) return BitDataGridColumnDataType.Boolean; if (t.IsEnum) return BitDataGridColumnDataType.Enum; if (t == typeof(DateOnly)) return BitDataGridColumnDataType.Date; - // DateTime/DateTimeOffset carry a time (and offset) component, so keep them on a distinct - // type with a time-aware editor rather than the date-only control DateOnly uses. - if (t == typeof(DateTime) || t == typeof(DateTimeOffset)) return BitDataGridColumnDataType.DateTime; + // DateTime carries a time component, so use a time-aware editor rather than the date-only + // control DateOnly uses. DateTimeOffset additionally carries a UTC offset that a plain + // datetime-local control cannot represent, so it gets its own offset-preserving path. + if (t == typeof(DateTime)) return BitDataGridColumnDataType.DateTime; + if (t == typeof(DateTimeOffset)) return BitDataGridColumnDataType.DateTimeOffset; if (t == typeof(int) || t == typeof(long) || t == typeof(short) || t == typeof(byte) || t == typeof(sbyte) || t == typeof(ushort) || t == typeof(uint) || t == typeof(ulong) || t == typeof(double) || t == typeof(float) || t == typeof(decimal)) diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridPropertyAccessor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridPropertyAccessor.cs index ebec270764..d5f60b7c42 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridPropertyAccessor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridPropertyAccessor.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using System.Globalization; using System.Linq.Expressions; using System.Reflection; @@ -77,13 +78,14 @@ public bool TryConvertValue(object? value, out object? result) else if (target == typeof(Guid)) result = value is Guid g ? g : Guid.Parse(value.ToString()!); else if (target == typeof(DateOnly)) - result = value is DateOnly d ? d : DateOnly.Parse(value.ToString()!); + result = value is DateOnly d ? d : DateOnly.Parse(value.ToString()!, CultureInfo.InvariantCulture); else if (target == typeof(TimeOnly)) - result = value is TimeOnly t ? t : TimeOnly.Parse(value.ToString()!); + result = value is TimeOnly t ? t : TimeOnly.Parse(value.ToString()!, CultureInfo.InvariantCulture); else if (target == typeof(DateTimeOffset)) // DateTimeOffset is not IConvertible, so Convert.ChangeType below would throw for it; - // handle it explicitly like the other date/time types above. - result = value is DateTimeOffset dto ? dto : DateTimeOffset.Parse(value.ToString()!); + // handle it explicitly like the other date/time types above. Parse with the invariant + // culture to match the ISO 8601 string the editors emit, so conversion is locale-stable. + result = value is DateTimeOffset dto ? dto : DateTimeOffset.Parse(value.ToString()!, CultureInfo.InvariantCulture); else result = Convert.ChangeType(value, target); return true; diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridColumnDataType.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridColumnDataType.cs index 842af65b6a..5a2e808945 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridColumnDataType.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridColumnDataType.cs @@ -9,5 +9,6 @@ public enum BitDataGridColumnDataType Boolean, Date, DateTime, + DateTimeOffset, Enum } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor index a19957d7d1..62096e04df 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor @@ -24,6 +24,7 @@ diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.samples.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.samples.cs index d3c535b20e..d650b41faa 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.samples.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.samples.cs @@ -195,7 +195,6 @@ public sealed class SupplierModel "; private readonly string example3CsharpCode = @" private List products = SampleData.Generate(60); -private BitDataGridSelectionMode selectionMode = BitDataGridSelectionMode.Multiple; private IReadOnlyList selected = new List();" + ProductModelCode + SampleDataCode; private readonly string example4RazorCode = @" From 1803009c37d5a5f9d4109e5359dde2a36e409b93 Mon Sep 17 00:00:00 2001 From: msynk Date: Fri, 26 Jun 2026 22:00:08 +0330 Subject: [PATCH 14/35] resolve review comments XII --- .../Components/DataGrid/BitDataGrid.razor | 54 ++++++++------- .../Components/DataGrid/BitDataGrid.razor.cs | 66 +++++++++++++++++-- .../Components/DataGrid/BitDataGridColumn.cs | 15 +++++ .../BitDataGridDataProcessor.cs | 5 +- .../BitDataGridPropertyAccessor.cs | 11 +++- .../QuickGrid/BitQuickGrid.razor.cs | 22 +++++++ .../Components/QuickGrid/BitQuickGrid.ts | 4 ++ .../Columns/BitQuickGridPropertyColumn.cs | 6 +- .../AsyncQueryExecutorSupplier.cs | 31 +++++---- .../BitQuickGridItemsProvider.cs | 2 +- .../BitQuickGridItemsProviderRequest.cs | 14 ++-- .../QuickGrid/BitQuickGridDemo.razor.cs | 6 +- 12 files changed, 184 insertions(+), 52 deletions(-) diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor index 35f59fc2f8..926b40445e 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor @@ -166,7 +166,7 @@ }
- @if (Filterable || VisibleColumns.Any(ColumnFilterable)) + @if (!IsTreeMode && (Filterable || VisibleColumns.Any(ColumnFilterable))) {
@if (HasReorderColumn) @@ -208,19 +208,23 @@ } @if (Loading) { -
Loading…
+
+
Loading…
+
} else if (TotalCount == 0 && PendingNewItem is null && !IsInfiniteMode) { -
- @if (EmptyTemplate is not null) - { - @EmptyTemplate - } - else - { - No records to display. - } +
+
+ @if (EmptyTemplate is not null) + { + @EmptyTemplate + } + else + { + No records to display. + } +
} else if (_viewGroups is not null) @@ -231,15 +235,17 @@ { @if (InfiniteItems.Count == 0 && !InfiniteLoading) { -
- @if (EmptyTemplate is not null) - { - @EmptyTemplate - } - else - { - No records to display. - } +
+
+ @if (EmptyTemplate is not null) + { + @EmptyTemplate + } + else + { + No records to display. + } +
} else @@ -259,8 +265,10 @@ } else if (!InfiniteHasMore) { -
- — End of results — +
+
+ — End of results — +
} } @@ -354,7 +362,7 @@ var groupCol = _columnsById.GetValueOrDefault(group.ColumnId); var collapsed = IsGroupCollapsed(group);
-
+
diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs index cf91341409..dfb6738315 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs @@ -240,7 +240,9 @@ public partial class BitDataGrid : ComponentBase, IAsyncDisposable // Paging is suppressed while grouping is active: the grouped view renders every row, so a pager // would misrepresent the data and leave page math out of sync with what is displayed. Treat paging // as off in that case so the pager UI, TotalPages and GoToPageAsync all agree with the rendered rows. - internal bool PagingActive => Pageable && _groups.Count == 0; + // Tree mode also flattens every visible node without paging (ProcessTreeData ignores paging), so the + // pager is suppressed there too to stay consistent with the rendered rows. + internal bool PagingActive => Pageable && _groups.Count == 0 && !IsTreeMode; internal int TotalPages => (!PagingActive || _effectivePageSize <= 0) ? 1 : Math.Max(1, (int)Math.Ceiling(TotalCount / (double)_effectivePageSize)); internal int CurrentPage => _currentPage; internal IReadOnlyList FooterAggregates => _footerAggregates; @@ -271,13 +273,63 @@ internal void AddColumn(BitDataGridColumn column) internal void RemoveColumn(BitDataGridColumn column) { + // Remove by the key the column is actually registered under: a column whose ColumnId/Field + // changed after registration is re-keyed via UpdateColumnRegistration, but guard against any + // stale key by also matching on the column instance. + var key = column.Id; + if (!(_columnsById.TryGetValue(key, out var byId) && ReferenceEquals(byId, column))) + { + var match = _columnsById.FirstOrDefault(kvp => ReferenceEquals(kvp.Value, column)); + if (match.Key is not null) key = match.Key; + } + if (_columns.Remove(column)) { - _columnsById.Remove(column.Id); + _columnsById.Remove(key); + // Drop any sort/filter/group descriptors that referenced the removed column so later + // refreshes and remote reads no longer carry descriptors for a column that is gone. + _sorts.RemoveAll(s => s.ColumnId == key); + _filters.RemoveAll(f => f.ColumnId == key); + _groups.RemoveAll(g => g.ColumnId == key); InvokeAsync(StateHasChanged); } } + /// + /// Re-keys a column in the registry after its changes + /// (its ColumnId/Field parameters were mutated after the initial registration). + /// Without this the registry keeps the stale key and sort/filter/group lookups — which resolve + /// columns by id — would no longer find the column. Active descriptors are migrated to the new id. + /// + internal void UpdateColumnRegistration(BitDataGridColumn column, string oldId) + { + if (oldId == column.Id) return; + + if (_columnsById.TryGetValue(oldId, out var existing) && ReferenceEquals(existing, column)) + _columnsById.Remove(oldId); + _columnsById[column.Id] = column; + + // Descriptors are immutable on ColumnId, so rebuild the affected entries with the new id to + // preserve the column's active sort/filter/group state across the rename. + for (int i = 0; i < _sorts.Count; i++) + { + if (_sorts[i].ColumnId == oldId) + _sorts[i] = new BitDataGridSortDescriptor { ColumnId = column.Id, Direction = _sorts[i].Direction, Priority = _sorts[i].Priority }; + } + for (int i = 0; i < _filters.Count; i++) + { + if (_filters[i].ColumnId == oldId) + _filters[i] = new BitDataGridFilterDescriptor { ColumnId = column.Id, Operator = _filters[i].Operator, Value = _filters[i].Value }; + } + for (int i = 0; i < _groups.Count; i++) + { + if (_groups[i].ColumnId == oldId) + _groups[i] = new BitDataGridGroupDescriptor { ColumnId = column.Id, Direction = _groups[i].Direction }; + } + + InvokeAsync(RefreshAsync); + } + // ------------------------------------------------------- Lifecycle protected override async Task OnParametersSetAsync() { @@ -723,7 +775,10 @@ private void Reprioritize() // ----------------------------------------------------------- Filtering internal bool ColumnFilterable(BitDataGridColumn column) - => column.HasField && (column.Filterable ?? Filterable); + // Filtering is not applied in tree mode: ProcessTreeData flattens the hierarchy using only the + // sibling sort and never runs the filter pipeline, so a filter input there would appear active + // without affecting the rendered rows. Disable it until tree mode carries filtering. + => column.HasField && !IsTreeMode && (column.Filterable ?? Filterable); internal BitDataGridFilterDescriptor? GetFilter(BitDataGridColumn column) => _filters.FirstOrDefault(f => f.ColumnId == column.Id); @@ -760,8 +815,9 @@ internal bool ColumnGroupable(BitDataGridColumn column) // Grouping is a client-side operation: it reshapes the locally-held _view into _viewGroups. // Server mode (OnRead) and infinite-scrolling mode (OnLoadMore) only forward sorts and filters // to the data callback and render the returned rows flat, so exposing a group toggle there would - // appear active without affecting the list. Disable it until the remote flow carries grouping. - => column.HasField && !IsServerMode && !IsInfiniteMode && (column.Groupable ?? Groupable); + // appear active without affecting the list. Tree mode likewise flattens the hierarchy without + // grouping. Disable it until those flows carry grouping. + => column.HasField && !IsServerMode && !IsInfiniteMode && !IsTreeMode && (column.Groupable ?? Groupable); internal bool IsGrouped(BitDataGridColumn column) => _groups.Any(g => g.ColumnId == column.Id); diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridColumn.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridColumn.cs index 05b6cb76d2..b0b1428132 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridColumn.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridColumn.cs @@ -95,6 +95,11 @@ public class BitDataGridColumn : ComponentBase, IDisposable internal string Id => ColumnId ?? Field ?? $"col-{GetHashCode():x}"; + // The id this column is currently registered under in the grid. Tracked separately from Id so a + // later change to ColumnId/Field can re-register the column under its new id (and the old entry + // can be removed) rather than leaving a stale registry key behind. + private string? _registeredId; + internal string DisplayTitle => Title ?? Humanize(Field) ?? Id; internal bool HasField => !string.IsNullOrEmpty(Field); @@ -134,6 +139,7 @@ protected override void OnInitialized() Accessor = BitDataGridPropertyAccessor.For(Field!); Grid.AddColumn(this); + _registeredId = Id; } protected override void OnParametersSet() @@ -142,6 +148,15 @@ protected override void OnParametersSet() Accessor = BitDataGridPropertyAccessor.For(Field!); else Accessor = null; + + // ColumnId/Field are mutable parameters, so the resolved Id may have changed since the column + // was registered. Re-register under the new id (migrating any active descriptors) so grid + // lookups by id keep finding this column. + if (_registeredId is not null && _registeredId != Id) + { + Grid?.UpdateColumnRegistration(this, _registeredId); + _registeredId = Id; + } } public void Dispose() => Grid?.RemoveColumn(this); diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridDataProcessor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridDataProcessor.cs index 76b1a444ab..60e2d629d5 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridDataProcessor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridDataProcessor.cs @@ -95,7 +95,10 @@ private static List> BuildGroups( IFormattable f => $"{g.Key.GetType().Name}:{f.ToString(null, CultureInfo.InvariantCulture)}", _ => $"{g.Key.GetType().Name}:{g.Key}" }; - var path = $"{parentPath}/{level}:{keyId}"; + // Include the grouping column id in the path so the collapse/expand state is scoped to + // the column that produced the group. Without it, changing the grouped column would let + // a same-valued key at the same level reuse another column's stale expansion state. + var path = $"{parentPath}/{level}:{descriptor.ColumnId}:{keyId}"; var group = new BitDataGridGroup { ColumnId = descriptor.ColumnId, diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridPropertyAccessor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridPropertyAccessor.cs index d5f60b7c42..f62185ea35 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridPropertyAccessor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridPropertyAccessor.cs @@ -60,7 +60,16 @@ public bool TryConvertValue(object? value, out object? result) { if (value is null) { - result = DefaultValue(); + // A cleared edit (null) must not silently become the type's default (e.g. 0 / MinValue for + // a non-nullable value type), which would discard the user's intent. Only let null through + // for nullable value types and reference types; reject it for non-nullable value targets. + if (PropertyType.IsValueType && Nullable.GetUnderlyingType(PropertyType) is null) + { + result = null; + return false; + } + + result = null; return true; } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor.cs index 5da6f92cd5..baa506501d 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor.cs @@ -30,6 +30,9 @@ public partial class BitQuickGrid : IAsyncDisposable private BitQuickGridColumnBase? _sortByColumn; private bool _sortByAscending; private bool _checkColumnOptionsPosition; + // Set when column recollection drops the active sort column; triggers a data refresh after render + // so the grid query stays in sync with the (now changed) header sort state. + private bool _queueSortReconciliationRefresh; // The associated ES6 module, which uses document-level event listeners //private IJSObjectReference? _jsModule; @@ -336,6 +339,14 @@ protected override async Task OnAfterRenderAsync(bool firstRender) _checkColumnOptionsPosition = false; await _js.BitQuickGridCheckColumnOptionsPosition(_tableReference); } + + if (_queueSortReconciliationRefresh) + { + // Column recollection dropped the active sort column; re-query so the grid data matches + // the header state that no longer shows that sort. + _queueSortReconciliationRefresh = false; + await RefreshDataAsync(); + } } private int ComputeColumnsHash() @@ -371,6 +382,17 @@ private void StartCollectingColumns() private void FinishCollectingColumns() { _collectingColumns = false; + + // The column that drove the last data load may no longer be among the freshly collected + // columns (it was removed or replaced). Leaving _sortByColumn pointing at a dropped column + // desyncs the data query from the header, so drop it and queue a refresh so the grid + // re-queries without it. The refresh is run from OnAfterRenderAsync because this runs mid-render. + if (_sortByColumn is not null && _columns.Contains(_sortByColumn) is false) + { + _sortByColumn = null; + _sortByAscending = false; + _queueSortReconciliationRefresh = true; + } } // Same as RefreshDataAsync, except without forcing a re-render. We use this from OnParametersSetAsync diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.ts b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.ts index 054fba239e..6d5ea575f3 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.ts +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.ts @@ -112,11 +112,15 @@ namespace BitBlazorUI { document.body.removeEventListener('mouseup', handleMouseUp); document.body.removeEventListener('touchmove', handleMouseMove); document.body.removeEventListener('touchend', handleMouseUp); + document.body.removeEventListener('touchcancel', handleMouseUp); } if (window.TouchEvent && evt instanceof TouchEvent) { document.body.addEventListener('touchmove', handleMouseMove, { passive: true }); document.body.addEventListener('touchend', handleMouseUp, { passive: true }); + // A touch gesture can be interrupted (e.g. by the system) without firing touchend, + // which would leave the move/end listeners attached. Tear down on touchcancel too. + document.body.addEventListener('touchcancel', handleMouseUp, { passive: true }); } else { document.body.addEventListener('mousemove', handleMouseMove, { passive: true }); document.body.addEventListener('mouseup', handleMouseUp, { passive: true }); diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridPropertyColumn.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridPropertyColumn.cs index 4d701c0e95..55af55dd3c 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridPropertyColumn.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridPropertyColumn.cs @@ -41,7 +41,11 @@ protected override void OnParametersSet() if (Format.HasValue()) { - if (typeof(IFormattable).IsAssignableFrom(typeof(TProp))) + // For a nullable value type (e.g. int?, DateTime?) Nullable itself does not implement + // IFormattable, but its underlying type does and a boxed non-null value formats correctly. + // Check the underlying type so Format is allowed on nullable columns too. + var formattableType = Nullable.GetUnderlyingType(typeof(TProp)) ?? typeof(TProp); + if (typeof(IFormattable).IsAssignableFrom(formattableType)) { cellTextFunc = item => ((IFormattable?)compiledPropertyExpression!(item))?.ToString(Format, null); } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Infrastructure/AsyncQueryExecutorSupplier.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Infrastructure/AsyncQueryExecutorSupplier.cs index 3b5af50f4e..54f90b55f1 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Infrastructure/AsyncQueryExecutorSupplier.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Infrastructure/AsyncQueryExecutorSupplier.cs @@ -24,25 +24,28 @@ internal static class AsyncQueryExecutorSupplier { if (queryable is not null) { - var executor = services.GetService(); - - if (executor is null) + // Inspect every registered executor, not just the first one resolved: a registered executor + // that does not support this queryable must not shadow another that does, nor suppress the + // EF misconfiguration warning below. Use the first executor that reports support. + foreach (var executor in services.GetServices()) { - // It's useful to detect if the developer is unaware that they should be using the EF adapter, otherwise - // they will likely never notice and simply deploy an inefficient app that blocks threads on each query. - var providerType = queryable.Provider?.GetType(); - if (providerType is not null && IsEntityFrameworkProviderTypeCache.GetOrAdd(providerType, IsEntityFrameworkProviderType)) + if (executor.IsSupported(queryable)) { - throw new InvalidOperationException( - $"The supplied {nameof(IQueryable)} is provided by Entity Framework. To query it efficiently without blocking threads, " + - $"register an implementation of {nameof(IAsyncQueryExecutor)} in your service collection that wraps EF Core's async query APIs " + - $"(for example ToArrayAsync/CountAsync) and reports IsSupported(queryable) == true for EF queryables. " + - $"Alternatively, supply non-EF data via the Items or ItemsProvider parameters."); + return executor; } } - else if (executor.IsSupported(queryable)) + + // No registered executor supports this queryable. It's useful to detect if the developer is + // unaware that they should be using the EF adapter, otherwise they will likely never notice + // and simply deploy an inefficient app that blocks threads on each query. + var providerType = queryable.Provider?.GetType(); + if (providerType is not null && IsEntityFrameworkProviderTypeCache.GetOrAdd(providerType, IsEntityFrameworkProviderType)) { - return executor; + throw new InvalidOperationException( + $"The supplied {nameof(IQueryable)} is provided by Entity Framework. To query it efficiently without blocking threads, " + + $"register an implementation of {nameof(IAsyncQueryExecutor)} in your service collection that wraps EF Core's async query APIs " + + $"(for example ToArrayAsync/CountAsync) and reports IsSupported(queryable) == true for EF queryables. " + + $"Alternatively, supply non-EF data via the Items or ItemsProvider parameters."); } } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/ItemsProvider/BitQuickGridItemsProvider.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/ItemsProvider/BitQuickGridItemsProvider.cs index 15365486e7..726d2d7ab3 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/ItemsProvider/BitQuickGridItemsProvider.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/ItemsProvider/BitQuickGridItemsProvider.cs @@ -5,5 +5,5 @@ namespace Bit.BlazorUI; ///
/// The type of data represented by each row in the grid. /// Parameters describing the data being requested. -/// A that gives the data to be displayed. +/// A whose result is a that gives the data to be displayed. public delegate ValueTask> BitQuickGridItemsProvider(BitQuickGridItemsProviderRequest request); diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/ItemsProvider/BitQuickGridItemsProviderRequest.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/ItemsProvider/BitQuickGridItemsProviderRequest.cs index b1d4d402e1..471b66baf5 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/ItemsProvider/BitQuickGridItemsProviderRequest.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/ItemsProvider/BitQuickGridItemsProviderRequest.cs @@ -51,8 +51,9 @@ internal BitQuickGridItemsProviderRequest( /// /// Applies the request's sorting rules to the supplied . /// - /// Note that this only works if the current implements , - /// otherwise it will throw. + /// Note that this only works if the current implements + /// and exposes a non-null sort builder. If the column does not implement that interface, or implements it + /// but its sort builder is null (as with ), it will throw. /// /// An . /// A new representing the with sorting rules applied. @@ -69,8 +70,9 @@ internal BitQuickGridItemsProviderRequest( /// /// Produces a collection of (property name, direction) pairs representing the sorting rules. /// - /// Note that this only works if the current implements , - /// otherwise it will throw. + /// Note that this only works if the current implements + /// and exposes a non-null sort builder. If the column does not implement that interface, or implements it + /// but its sort builder is null (as with ), it will throw. /// /// A collection of (property name, direction) pairs representing the sorting rules public IReadOnlyCollection<(string PropertyName, BitQuickGridSortDirection Direction)> GetSortByProperties() => SortByColumn switch @@ -83,5 +85,7 @@ internal BitQuickGridItemsProviderRequest( }; private static string ColumnNotSortableMessage(BitQuickGridColumnBase col) - => $"The current sort column is of type '{col.GetType().FullName}', which does not implement {nameof(IBitQuickGridSortBuilderColumn)}, so its sorting rules cannot be applied automatically."; + => col is IBitQuickGridSortBuilderColumn + ? $"The current sort column '{col.GetType().FullName}' implements {nameof(IBitQuickGridSortBuilderColumn)} but its {nameof(IBitQuickGridSortBuilderColumn.SortBuilder)} is null, so its sorting rules cannot be applied automatically." + : $"The current sort column is of type '{col.GetType().FullName}', which does not implement {nameof(IBitQuickGridSortBuilderColumn)}, so its sorting rules cannot be applied automatically."; } diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.cs index 8a60d2c698..d3fa5b70f4 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.cs @@ -597,7 +597,11 @@ string ODataSampleNameFilter set { _odataSampleNameFilter = value; - _ = productsDataGrid.RefreshDataAsync(); + // The OData and LoadingTemplate demos share this filter and the same productsItemsProvider, + // so refresh both grids; otherwise the LoadingTemplate grid keeps showing data for the + // previous filter until it is independently re-queried. + _ = productsDataGrid?.RefreshDataAsync(); + _ = loadingProductsDataGrid?.RefreshDataAsync(); } } From c3a5f870e971d83dabf471b775c5e37d01f210cb Mon Sep 17 00:00:00 2001 From: msynk Date: Sat, 27 Jun 2026 06:13:17 +0330 Subject: [PATCH 15/35] resolve review comments XIII --- .../Components/DataGrid/BitDataGrid.razor | 1 + .../Components/DataGrid/BitDataGrid.razor.cs | 49 +++++++++++++++++++ .../DataGrid/BitDataGridCellEditor.razor | 27 ++++++++-- .../Components/DataGrid/BitDataGridColumn.cs | 34 +++++++++++++ .../Components/DataGrid/BitDataGridRow.razor | 13 ++++- .../BitDataGridPropertyAccessor.cs | 5 +- .../BitDataGridValueComparer.cs | 11 ++++- .../DataGrid/BitDataGridDemo.razor.samples.cs | 2 +- .../QuickGrid/BitQuickGridDemo.razor.cs | 11 ++++- .../BitQuickGridDemo.razor.samples.cs | 8 ++- 10 files changed, 149 insertions(+), 12 deletions(-) diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor index 926b40445e..e4a39927b7 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor @@ -114,6 +114,7 @@ var draggable = ColumnReorderable(column) ? "true" : "false";
: ComponentBase, IAsyncDisposable internal void AddColumn(BitDataGridColumn column) { if (_columns.Contains(column)) return; + + // Reject a second column registering under an id that is already taken. Overwriting the + // registry entry while both columns remain in _columns would desync the two collections, so + // sort/filter/group/footer lookups (which resolve a column by id) could resolve to the wrong + // instance. Skip the duplicate instead of silently shadowing the existing column. + if (_columnsById.ContainsKey(column.Id)) return; + _columns.Add(column); _columnsById[column.Id] = column; @@ -271,6 +278,24 @@ internal void AddColumn(BitDataGridColumn column) } } + /// + /// Recomputes the grid's view and aggregates after a registered column's semantic parameters + /// (Field/Aggregate/Format/AggregateFormat) change without its + /// changing. Mirrors 's mode-aware refresh so the active view never goes stale. + /// + internal void NotifyColumnChanged() + { + if (IsServerMode || IsInfiniteMode) + { + _footerAggregates = BitDataGridDataProcessor.Aggregate(_pageItems, _columns); + InvokeAsync(StateHasChanged); + } + else + { + InvokeAsync(RefreshAsync); + } + } + internal void RemoveColumn(BitDataGridColumn column) { // Remove by the key the column is actually registered under: a column whose ColumnId/Field @@ -1078,6 +1103,30 @@ internal void DropColumn(BitDataGridColumn target) // ----------------------------------------------------- Row reordering internal void StartRowDrag(TItem row) => _dragRow = row; + /// + /// Moves a row one position toward the start ( = -1) or end (+1) of the + /// current view, reusing the same reorder pipeline as drag-and-drop. Backs the keyboard-accessible + /// reorder handle so row reordering is not pointer-only. + /// + internal async Task MoveRowAsync(TItem row, int delta) + { + if (!RowReorderable) return; + + var view = _view; + int from = -1; + for (int i = 0; i < view.Count; i++) + { + if (EqualityComparer.Default.Equals(view[i], row)) { from = i; break; } + } + if (from < 0) return; + + var to = from + delta; + if (to < 0 || to >= view.Count) return; + + _dragRow = row; + await DropRowAsync(view[to]); + } + internal async Task DropRowAsync(TItem target) { if (_dragRow is null || EqualityComparer.Default.Equals(_dragRow, target)) { _dragRow = default; return; } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridCellEditor.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridCellEditor.razor index 9136b4af2c..373d704f23 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridCellEditor.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridCellEditor.razor @@ -12,19 +12,19 @@ case BitDataGridColumnDataType.Number: + @oninput="e => SetNullable(e.Value)" /> break; case BitDataGridColumnDataType.Date: + @onchange="e => SetNullable(e.Value)" /> break; case BitDataGridColumnDataType.DateTime: + @onchange="e => SetNullable(e.Value)" /> break; case BitDataGridColumnDataType.DateTimeOffset: @@ -108,6 +108,25 @@ private void Set(object? raw) => Grid.SetEditValue(Column, raw); + // True when the bound member is a nullable value type (e.g. int?, DateTime?). A cleared editor + // (empty string) should then map back to null rather than being rejected by the accessor's type + // conversion, which would otherwise leave the previous value in place. + private bool IsNullableValue + => Column.Accessor is not null + && Nullable.GetUnderlyingType(Column.Accessor.PropertyType) is not null; + + // Normalizes a cleared number/date input: an empty string maps to null when the bound property is + // nullable so the cell actually clears, otherwise the raw value flows through the normal Set path. + private void SetNullable(object? raw) + { + if (raw is string s && string.IsNullOrEmpty(s) && IsNullableValue) + { + Grid.SetEditValue(Column, null); + return; + } + Set(raw); + } + // has no UTC offset, so a naive parse back through the property // accessor would discard the original offset. Combine the edited local date/time with the offset // of the current value (falling back to the system local offset for a new/empty value) and pass a @@ -123,7 +142,7 @@ } else { - Grid.SetEditValue(Column, raw); + SetNullable(raw); } } } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridColumn.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridColumn.cs index b0b1428132..202b5e8990 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridColumn.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridColumn.cs @@ -100,6 +100,14 @@ public class BitDataGridColumn : ComponentBase, IDisposable // can be removed) rather than leaving a stale registry key behind. private string? _registeredId; + // Snapshot of the parameters that affect the grid's computed view/aggregates. When any of these + // change after registration the grid must recompute even if the resolved Id is unchanged (e.g. a + // fixed ColumnId with a mutated Field, or a changed Aggregate/Format). + private string? _lastField; + private BitDataGridAggregateType _lastAggregate; + private string? _lastFormat; + private string? _lastAggregateFormat; + internal string DisplayTitle => Title ?? Humanize(Field) ?? Id; internal bool HasField => !string.IsNullOrEmpty(Field); @@ -140,6 +148,7 @@ protected override void OnInitialized() Grid.AddColumn(this); _registeredId = Id; + SnapshotSemanticParameters(); } protected override void OnParametersSet() @@ -156,9 +165,34 @@ protected override void OnParametersSet() { Grid?.UpdateColumnRegistration(this, _registeredId); _registeredId = Id; + // UpdateColumnRegistration already refreshes the grid; resync the snapshot so the change + // detection below doesn't trigger a second redundant refresh in the same parameter set. + SnapshotSemanticParameters(); + } + else if (_registeredId is not null && SemanticParametersChanged()) + { + // Field/Aggregate/Format/AggregateFormat changed while the Id stayed the same (typically a + // fixed ColumnId with a mutated Field). The grid resolves accessors/aggregates by column, + // so ask it to recompute its view so the active state doesn't go stale. + Grid?.NotifyColumnChanged(); + SnapshotSemanticParameters(); } } + private bool SemanticParametersChanged() + => _lastField != Field + || _lastAggregate != Aggregate + || _lastFormat != Format + || _lastAggregateFormat != AggregateFormat; + + private void SnapshotSemanticParameters() + { + _lastField = Field; + _lastAggregate = Aggregate; + _lastFormat = Format; + _lastAggregateFormat = AggregateFormat; + } + public void Dispose() => Grid?.RemoveColumn(this); internal object? GetValue(TItem item) => Accessor?.GetValue(item); diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridRow.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridRow.razor index 5f3950a64e..a9394a7e9a 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridRow.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridRow.razor @@ -10,7 +10,9 @@ @if (Grid.RowReorderable) {
- +
} @@ -157,4 +159,13 @@ } private Task OnRowClick() => Grid.HandleRowClickAsync(Item); + + // Lets keyboard users reorder rows without drag-and-drop: ArrowUp/ArrowDown move the focused row + // one position toward the start/end, reusing the same reorder pipeline as the pointer drag path. + private async Task HandleReorderKeyDown(KeyboardEventArgs e) + { + if (!Grid.RowReorderable) return; + if (e.Key == "ArrowUp") await Grid.MoveRowAsync(Item, -1); + else if (e.Key == "ArrowDown") await Grid.MoveRowAsync(Item, 1); + } } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridPropertyAccessor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridPropertyAccessor.cs index f62185ea35..8cf9980978 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridPropertyAccessor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridPropertyAccessor.cs @@ -96,7 +96,10 @@ public bool TryConvertValue(object? value, out object? result) // culture to match the ISO 8601 string the editors emit, so conversion is locale-stable. result = value is DateTimeOffset dto ? dto : DateTimeOffset.Parse(value.ToString()!, CultureInfo.InvariantCulture); else - result = Convert.ChangeType(value, target); + // Parse with the invariant culture so editor values are coerced consistently + // regardless of the current thread culture (e.g. "1.5" must not be misread in a + // comma-decimal locale where the editor still emits an invariant numeric string). + result = Convert.ChangeType(value, target, CultureInfo.InvariantCulture); return true; } catch diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridValueComparer.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridValueComparer.cs index c486f81b6a..b9ce9e2a1d 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridValueComparer.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridValueComparer.cs @@ -14,12 +14,19 @@ public int Compare(object? x, object? y) if (x is IComparable cx && x.GetType() == y.GetType()) return cx.CompareTo(y); + // Mixed types: compare by string representation first so the result is symmetric regardless of + // argument order. A one-sided Convert.ChangeType (coercing y to x's type) could order the same + // pair differently when the operands are swapped, breaking the IComparer contract. Only when + // the strings are equal do we attempt a type-specific tie-break. + var byString = string.Compare(x.ToString(), y.ToString(), StringComparison.OrdinalIgnoreCase); + if (byString != 0) return byString; + if (x is IComparable cx2) { - try { return cx2.CompareTo(Convert.ChangeType(y, x.GetType())); } + try { return cx2.CompareTo(Convert.ChangeType(y, x.GetType(), System.Globalization.CultureInfo.InvariantCulture)); } catch { /* fall through */ } } - return string.Compare(x.ToString(), y.ToString(), StringComparison.OrdinalIgnoreCase); + return 0; } } diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.samples.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.samples.cs index d650b41faa..302d82b1cd 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.samples.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.samples.cs @@ -492,7 +492,7 @@ private void OnCellDoubleClick(BitDataGridCellEventArgs e) { /* e.Item, private readonly string example17RazorCode = @" + CellNavigation=""true"" Sortable=""true"" Editable=""true"" OnRowSave=""_ => {}""> diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.cs index d3fa5b70f4..3794167a3a 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.cs @@ -356,7 +356,7 @@ UI will be included in the header cell by default. { Id = "pagination-state", Title = "BitQuickGridPaginationState", - Description = "A component that provides a user interface for pagination.", + Description = "Holds state to represent the pagination of a BitQuickGrid. Tracks the current page index, the number of items per page, and the total item count so a paginator UI and the grid stay in sync.", Parameters= [ new() @@ -620,11 +620,18 @@ protected override async Task OnInitAsync() var firmFilter = _virtualSampleNameFilter?.Replace("\\", string.Empty).Replace("\"", string.Empty) ?? string.Empty; var query = new Dictionary { - { "search", $"recalling_firm:\"{firmFilter}\"" }, { "skip", req.StartIndex }, { "limit", req.Count } }; + // Only constrain by firm when the user actually typed something: sending an empty + // recalling_firm clause is an empty Lucene query that suppresses the default results + // on the initial (unfiltered) load. + if (!string.IsNullOrWhiteSpace(firmFilter)) + { + query.Add("search", $"recalling_firm:\"{firmFilter}\""); + } + var sort = req.GetSortByProperties().SingleOrDefault(); if (sort != default) diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.samples.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.samples.cs index e3cddc5695..dd3916471d 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.samples.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.samples.cs @@ -411,11 +411,17 @@ protected override async Task OnInitializedAsync() var firmFilter = _virtualSampleNameFilter?.Replace(""\\"", string.Empty).Replace(""\"""""", string.Empty) ?? string.Empty; var query = new Dictionary { - { ""search"",$""recalling_firm:\""{firmFilter}\"" }, { ""skip"", req.StartIndex }, { ""limit"", req.Count } }; + // Only add the firm filter when the user typed something; an empty Lucene clause + // would suppress the default (unfiltered) results. + if (!string.IsNullOrWhiteSpace(firmFilter)) + { + query.Add(""search"", $""recalling_firm:\""{firmFilter}\""""); + } + var sort = req.GetSortByProperties().SingleOrDefault(); if (sort != default) From f1e27e093b23e220f83bdebf124059faed36819c Mon Sep 17 00:00:00 2001 From: msynk Date: Sat, 27 Jun 2026 08:04:31 +0330 Subject: [PATCH 16/35] resolve review comments XIV --- .../Components/DataGrid/BitDataGrid.razor | 22 ++++++------ .../Components/DataGrid/BitDataGrid.razor.cs | 36 +++++++++++++++---- .../QuickGrid/BitQuickGrid.razor.cs | 15 ++++++-- 3 files changed, 54 insertions(+), 19 deletions(-) diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor index e4a39927b7..b0fc87f875 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor @@ -172,19 +172,19 @@
@if (HasReorderColumn) { -
+
} @if (HasDetailColumn) { -
+
} @if (HasSelectColumn) { -
+
} @foreach (var column in VisibleColumns) { -
+
@if (ColumnFilterable(column)) {
+
}
} @@ -259,7 +259,7 @@ {
-
+
@@ -295,20 +295,20 @@ @@ -38,7 +41,7 @@ @if (_showColumnChooserPanel) { -
+
@foreach (var col in AllColumns) {
} -
+ @* Virtualize injects spacer
s as direct children of its host. An ARIA rowgroup must + contain only row elements, so when virtualizing we drop the rowgroup role here (the rows + stay valid grid descendants) to keep the Virtualize host free of non-row ARIA children. *@ +
@if (PendingNewItem is not null) { diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs index 73e3d3fbd0..55237d1aea 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs @@ -185,6 +185,9 @@ public partial class BitDataGrid : ComponentBase, IAsyncDisposable private int _currentPage = 1; private int _effectivePageSize; private bool _showColumnChooserPanel; + // Stable per-instance id tying the column-chooser toggle button (aria-controls/aria-expanded) to + // the chooser panel it shows, so assistive tech can announce whether the chooser is open. + private readonly string _columnChooserPanelId = $"bit-dtg-cc-{Guid.NewGuid():n}"; // Tracks external data inputs so we only (re)load when they actually change, // rather than on every parent re-render (which would loop in server mode). @@ -1184,7 +1187,24 @@ internal void DropColumn(BitDataGridColumn target) internal bool ColumnReorderable(BitDataGridColumn column) => column.Reorderable ?? Reorderable; // ----------------------------------------------------- Row reordering - internal void StartRowDrag(TItem row) => _dragRow = row; + // Row reordering moves items by index within the bound source list (see DropRowAsync). That is only + // coherent when the rendered order maps 1:1 to that source, so it is disabled whenever the view is + // transformed (sorting, filtering, grouping, tree mode) or driven remotely (server/infinite), where + // _view no longer matches the underlying Items list. Centralizing the gate here keeps the drag handle, + // drag start, keyboard move and drop all consistent. + internal bool RowReorderEnabled => RowReorderable + && _sorts.Count == 0 + && _filters.Count == 0 + && _groups.Count == 0 + && !IsTreeMode + && !IsServerMode + && !IsInfiniteMode; + + internal void StartRowDrag(TItem row) + { + if (!RowReorderEnabled) return; + _dragRow = row; + } /// /// Moves a row one position toward the start ( = -1) or end (+1) of the @@ -1193,7 +1213,7 @@ internal void DropColumn(BitDataGridColumn target) /// internal async Task MoveRowAsync(TItem row, int delta) { - if (!RowReorderable) return; + if (!RowReorderEnabled) return; var view = _view; int from = -1; @@ -1212,6 +1232,7 @@ internal async Task MoveRowAsync(TItem row, int delta) internal async Task DropRowAsync(TItem target) { + if (!RowReorderEnabled) { _dragRow = default; return; } if (_dragRow is null || KeyEquals(_dragRow, target)) { _dragRow = default; return; } var dragged = _dragRow; @@ -1564,7 +1585,7 @@ static string Escape(string v) internal bool HasSelectColumn => SelectionMode == BitDataGridSelectionMode.Multiple; internal bool HasDetailColumn => DetailTemplate is not null; internal bool HasCommandColumn => Editable; - internal bool HasReorderColumn => RowReorderable; + internal bool HasReorderColumn => RowReorderEnabled; private const double ReorderColWidth = 36; private const double DetailColWidth = 44; diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.scss b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.scss index 8efc72b8be..26609f7c6a 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.scss +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.scss @@ -500,6 +500,13 @@ button.bit-dtg-htext { color: var(--bit-clr-pri-text); } +/* Win over the generic .bit-dtg-btn:hover rule so primary buttons keep their primary treatment on hover */ +.bit-dtg-btn-primary:hover:not(:disabled) { + background: var(--bit-clr-pri-hover); + border-color: var(--bit-clr-pri-hover); + color: var(--bit-clr-pri-text); +} + .bit-dtg-btn-danger { color: var(--bit-clr-err); border-color: var(--bit-clr-err); diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridRow.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridRow.razor index 25ed56e637..e47002710c 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridRow.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridRow.razor @@ -2,12 +2,12 @@ @namespace Bit.BlazorUI
- @if (Grid.RowReorderable) + @if (Grid.RowReorderEnabled) {
+ } if (Sortable.HasValue ? Sortable.Value : IsSortableByDefault()) @@ -129,7 +132,7 @@ @if (ColumnOptions is not null && Align == BitQuickGridAlign.Right) { - + } } } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Pagination/BitQuickGridPaginator.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Pagination/BitQuickGridPaginator.razor.cs index eaa26867cd..1deb4b2c45 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Pagination/BitQuickGridPaginator.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Pagination/BitQuickGridPaginator.razor.cs @@ -57,8 +57,10 @@ public partial class BitQuickGridPaginator : IDisposable /// public BitQuickGridPaginator() { - // The "total item count" handler doesn't need to do anything except cause this component to re-render - _totalItemCountChanged = new(new EventCallback(this, null)); + // The "total item count" handler doesn't need to do anything except cause this component to + // re-render, so the paginator UI (page summary and navigation buttons) refreshes when the + // grid reports a new total item count. + _totalItemCountChanged = new(EventCallback.Factory.Create(this, () => StateHasChanged())); } private Task GoFirstAsync() => GoToPageAsync(0); diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.cs index 4b02338fee..90cbf3d441 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.cs @@ -252,8 +252,8 @@ private static bool MatchText(string value, BitDataGridFilterDescriptor f) // requested equality/range operator is honored instead of a substring match on ToString(). private static bool MatchComparable(T value, BitDataGridFilterDescriptor f) where T : IComparable { - if (f.Operator is BitDataGridFilterOperator.IsEmpty) return f.Value is null; - if (f.Operator is BitDataGridFilterOperator.IsNotEmpty) return f.Value is not null; + if (f.Operator is BitDataGridFilterOperator.IsEmpty) return value is null; + if (f.Operator is BitDataGridFilterOperator.IsNotEmpty) return value is not null; if (f.Value is null) return true; // The grid hands back a value already of the column's type; guard against an unexpected type. diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor index 6c2ccf89ec..58ad1eecea 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor @@ -61,7 +61,7 @@ - @(context.Code) + @(context.Code) diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.samples.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.samples.cs index dd3916471d..bac33ab983 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.samples.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.samples.cs @@ -206,7 +206,7 @@ public class MedalsModel - + c.Medals.Gold)"" Sortable=""true"" /> c.Medals.Silver)"" Sortable=""true"" /> @@ -396,7 +396,7 @@ string VirtualSampleNameFilter set { _virtualSampleNameFilter = value; - _ = dataGrid.RefreshDataAsync(); + _ = dataGrid?.RefreshDataAsync(); } } @@ -718,7 +718,7 @@ string ODataSampleNameFilter set { _odataSampleNameFilter = value; - _ = productsDataGrid.RefreshDataAsync(); + _ = productsDataGrid?.RefreshDataAsync(); } } From 0306768b3736bbbe521ebda77daf0ca67e0e6e5a Mon Sep 17 00:00:00 2001 From: msynk Date: Sun, 28 Jun 2026 08:14:31 +0330 Subject: [PATCH 23/35] resolve review comments XX --- .../Components/DataGrid/BitDataGrid.razor | 35 +++++++++--- .../Components/DataGrid/BitDataGrid.razor.cs | 33 +++++++++-- .../Components/DataGrid/BitDataGridRow.razor | 1 + .../Columns/BitQuickGridPropertyColumn.cs | 13 ++++- .../DataGrid/BitDataGridDemo.razor.params.cs | 1 + .../DataGrid/BitDataGridDemo.razor.samples.cs | 57 ++++++++++++++++--- .../BitQuickGridDemo.razor.samples.cs | 5 +- 7 files changed, 122 insertions(+), 23 deletions(-) diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor index cdef13fd48..8e1b80b5f6 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor @@ -463,9 +463,10 @@ }; // Translates the raw editor input into a typed value + operator for the column's data type, then - // applies it. Text columns keep the "contains" behavior; the rest use Equals against a typed value. - // Date/DateTime/DateTimeOffset values are parsed to a calendar day (midnight) and matched on the day - // (see BitDataGridDataProcessor), so a row's time-of-day no longer prevents an equality match. + // applies it. Text columns keep the "contains" behavior; numbers/booleans/enums use Equals against a + // typed value. DateTime/DateTimeOffset use a boundary-safe half-open same-day range ([start, nextDay)) + // built from standard comparison operators, so a row's time-of-day never prevents a match and remote + // OnRead consumers can apply the filter with ordinary comparisons (see SetDateRangeFilterAsync). private Task SetTypedFilterAsync(BitDataGridColumn column, string? raw) { if (string.IsNullOrWhiteSpace(raw)) @@ -492,20 +493,36 @@ case BitDataGridColumnDataType.DateTime: case BitDataGridColumnDataType.DateTimeOffset: { - object? value = null; + // DateOnly has no time component, so an exact equality filter is already boundary-safe. if (underlying == typeof(DateOnly)) { - if (DateOnly.TryParse(raw, inv, System.Globalization.DateTimeStyles.None, out var d)) value = d; + return DateOnly.TryParse(raw, inv, System.Globalization.DateTimeStyles.None, out var d) + ? SetFilterAsync(column, BitDataGridFilterOperator.Equals, d) + : SetFilterAsync(column, BitDataGridFilterOperator.Equals, null); } - else if (underlying == typeof(DateTimeOffset)) + + // DateTime/DateTimeOffset row values carry a time-of-day, so a single midnight Equals + // descriptor would never match an exact-comparison consumer of OnRead. Emit a half-open + // same-day range [start, nextDay) so the whole day is selected in a boundary-safe way that + // both the client pipeline and remote consumers can apply with standard comparisons. + if (underlying == typeof(DateTimeOffset)) { - if (DateTimeOffset.TryParse(raw, inv, System.Globalization.DateTimeStyles.None, out var dto)) value = dto; + if (DateTimeOffset.TryParse(raw, inv, System.Globalization.DateTimeStyles.None, out var dto)) + { + var start = new DateTimeOffset(dto.Year, dto.Month, dto.Day, 0, 0, 0, dto.Offset); + return SetDateRangeFilterAsync(column, start, start.AddDays(1)); + } + return SetDateRangeFilterAsync(column, null, null); } else { - if (DateTime.TryParse(raw, inv, System.Globalization.DateTimeStyles.None, out var dt)) value = dt; + if (DateTime.TryParse(raw, inv, System.Globalization.DateTimeStyles.None, out var dt)) + { + var start = dt.Date; + return SetDateRangeFilterAsync(column, start, start.AddDays(1)); + } + return SetDateRangeFilterAsync(column, null, null); } - return SetFilterAsync(column, BitDataGridFilterOperator.Equals, value); } case BitDataGridColumnDataType.Enum: diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs index 55237d1aea..038aa9f8bd 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs @@ -896,7 +896,9 @@ internal async Task SetFilterAsync(BitDataGridColumn column, BitDataGridF var isEmpty = value is null || (value is string s && string.IsNullOrWhiteSpace(s)); if (isEmpty && op is not (BitDataGridFilterOperator.IsEmpty or BitDataGridFilterOperator.IsNotEmpty)) { - if (existing is not null) _filters.Remove(existing); + // Remove every descriptor for the column, not just the first match, so clearing also drops + // the paired descriptors emitted by a range filter (e.g. the half-open same-day date range). + _filters.RemoveAll(f => f.ColumnId == column.Id); } else if (existing is null) { @@ -911,6 +913,23 @@ internal async Task SetFilterAsync(BitDataGridColumn column, BitDataGridF await RefreshAsync(); } + // Applies a half-open [start, endExclusive) range for a date/time column as two standard comparison + // descriptors (>= start AND < endExclusive), which the data processor and OnRead consumers AND together. + // This keeps day-level date filtering boundary-safe for server-side consumers that compare against the + // raw values, instead of a single midnight Equals descriptor an exact match would never satisfy. A null + // start clears the column's filter. + internal async Task SetDateRangeFilterAsync(BitDataGridColumn column, object? start, object? endExclusive) + { + _filters.RemoveAll(f => f.ColumnId == column.Id); + if (start is not null && endExclusive is not null) + { + _filters.Add(new BitDataGridFilterDescriptor { ColumnId = column.Id, Operator = BitDataGridFilterOperator.GreaterThanOrEqual, Value = start }); + _filters.Add(new BitDataGridFilterDescriptor { ColumnId = column.Id, Operator = BitDataGridFilterOperator.LessThan, Value = endExclusive }); + } + _currentPage = 1; + await RefreshAsync(); + } + public async Task ClearFiltersAsync() { _filters.Clear(); @@ -1215,7 +1234,10 @@ internal async Task MoveRowAsync(TItem row, int delta) { if (!RowReorderEnabled) return; - var view = _view; + // Confine neighbor selection to the current page slice so keyboard reordering never jumps across + // pages. With no sort/filter/group active (RowReorderEnabled requires that), _pageItems is either + // the visible page (when paging) or the full view, so it is always the correct lookup set. + var view = _pageItems; int from = -1; for (int i = 0; i < view.Count; i++) { @@ -1504,10 +1526,13 @@ internal async Task SetPageSizeAsync(int size) // ------------------------------------------------------- Column chooser internal void ToggleColumnChooser() { _showColumnChooserPanel = !_showColumnChooserPanel; StateHasChanged(); } - internal async Task SetColumnVisibilityAsync(BitDataGridColumn column, bool visible) + internal void SetColumnVisibilityAsync(BitDataGridColumn column, bool visible) { + // Column visibility is a layout-only change, so just re-render. Calling RefreshAsync here would + // needlessly re-run OnRead/ResetInfiniteAsync (requerying or clearing loaded data) for what is + // purely a column-chooser toggle. column.Visible = visible; - await RefreshAsync(); + StateHasChanged(); } // ----------------------------------------------------------- Identity diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridRow.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridRow.razor index e47002710c..04b768a688 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridRow.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridRow.razor @@ -106,6 +106,7 @@ { diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridPropertyColumn.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridPropertyColumn.cs index 55af55dd3c..dc7e9b8e1c 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridPropertyColumn.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridPropertyColumn.cs @@ -11,6 +11,7 @@ public class BitQuickGridPropertyColumn : BitQuickGridColumnBa { private Expression>? _lastAssignedProperty; private string? _lastAssignedFormat; + private string? _autoTitle; private Func? _cellTextFunc; private BitQuickGridSort? _sortBuilder; @@ -69,9 +70,17 @@ protected override void OnParametersSet() _lastAssignedFormat = Format; } - if (Title is null && Property.Body is MemberExpression memberExpression) + if (Property.Body is MemberExpression memberExpression) { - Title = memberExpression.Member.Name; + // Auto-derive the header from the member name unless the consumer set Title explicitly. A Title + // still equal to the previously derived name is treated as auto-managed, so a changed Property + // replaces the old member name instead of leaving a stale header. + var derived = memberExpression.Member.Name; + if (Title is null || Title == _autoTitle) + { + Title = derived; + } + _autoTitle = derived; } } diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.params.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.params.cs index be6066a1c1..0ac123bfeb 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.params.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.params.cs @@ -119,6 +119,7 @@ public partial class BitDataGridDemo new() { Name = "Take", Type = "int?", DefaultValue = "null", Description = "Maximum number of items to return (null means all)." }, new() { Name = "Sorts", Type = "IReadOnlyList", DefaultValue = "[]", Description = "The active sort descriptors ordered by priority.", LinkType = LinkType.Link, Href = "#BitDataGridSortDescriptor" }, new() { Name = "Filters", Type = "IReadOnlyList", DefaultValue = "[]", Description = "The active filter descriptors.", LinkType = LinkType.Link, Href = "#BitDataGridFilterDescriptor" }, + new() { Name = "Groups", Type = "IReadOnlyList", DefaultValue = "[]", Description = "The active group descriptors in nesting order, letting a server-side handler reconstruct the grouping. Empty when no grouping is active." }, new() { Name = "CancellationToken", Type = "CancellationToken", DefaultValue = "", Description = "A token that is cancelled when the request is superseded by a newer one." }, ], }, diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.samples.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.samples.cs index 696300a120..7a93494a95 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.samples.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.samples.cs @@ -329,17 +329,17 @@ private async Task> LoadData(BitDataGridReadReque IEnumerable query = all; - // filtering + // filtering — honor the operator the grid emits, not just contains/equals foreach (var f in request.Filters) { query = f.ColumnId switch { - // text column: case-insensitive contains on the string value - nameof(Product.Name) => query.Where(p => p.Name.Contains(f.Value?.ToString() ?? """", StringComparison.OrdinalIgnoreCase)), - // numeric columns: BitDataGrid emits a typed value with an Equals operator, so compare - // against the typed value instead of a substring of ToString() - nameof(Product.Price) => query.Where(p => f.Value is decimal price && p.Price == price), - nameof(Product.Id) => query.Where(p => f.Value is int id && p.Id == id), + // text column uses the string operators from the grid's text filter editor + nameof(Product.Name) => query.Where(p => MatchText(p.Name, f)), + // numeric columns receive a typed value with a comparison operator, so honor the + // requested equality/range operator instead of a hard-coded equals + nameof(Product.Price) => query.Where(p => MatchComparable(p.Price, f)), + nameof(Product.Id) => query.Where(p => MatchComparable(p.Id, f)), _ => query }; } @@ -381,6 +381,49 @@ private async Task> LoadData(BitDataGridReadReque await InvokeAsync(StateHasChanged); // re-render after the load completes (runs as a callback) } } +} + +// Applies a text-column filter the way the grid's text editor emits it. +private static bool MatchText(string value, BitDataGridFilterDescriptor f) +{ + if (f.Operator is BitDataGridFilterOperator.IsEmpty) return string.IsNullOrEmpty(value); + if (f.Operator is BitDataGridFilterOperator.IsNotEmpty) return !string.IsNullOrEmpty(value); + + var term = f.Value?.ToString(); + if (string.IsNullOrWhiteSpace(term)) return true; + + return f.Operator switch + { + BitDataGridFilterOperator.Contains => value.Contains(term, StringComparison.OrdinalIgnoreCase), + BitDataGridFilterOperator.DoesNotContain => !value.Contains(term, StringComparison.OrdinalIgnoreCase), + BitDataGridFilterOperator.StartsWith => value.StartsWith(term, StringComparison.OrdinalIgnoreCase), + BitDataGridFilterOperator.EndsWith => value.EndsWith(term, StringComparison.OrdinalIgnoreCase), + BitDataGridFilterOperator.Equals => string.Equals(value, term, StringComparison.OrdinalIgnoreCase), + BitDataGridFilterOperator.NotEquals => !string.Equals(value, term, StringComparison.OrdinalIgnoreCase), + _ => true + }; +} + +// Applies a non-text-column filter against the typed value the grid emits so the requested +// equality/range operator is honored instead of a substring match on ToString(). +private static bool MatchComparable(T value, BitDataGridFilterDescriptor f) where T : IComparable +{ + if (f.Operator is BitDataGridFilterOperator.IsEmpty) return value is null; + if (f.Operator is BitDataGridFilterOperator.IsNotEmpty) return value is not null; + if (f.Value is null) return true; + if (f.Value is not T typed) return true; + + var cmp = value.CompareTo(typed); + return f.Operator switch + { + BitDataGridFilterOperator.Equals => cmp == 0, + BitDataGridFilterOperator.NotEquals => cmp != 0, + BitDataGridFilterOperator.GreaterThan => cmp > 0, + BitDataGridFilterOperator.GreaterThanOrEqual => cmp >= 0, + BitDataGridFilterOperator.LessThan => cmp < 0, + BitDataGridFilterOperator.LessThanOrEqual => cmp <= 0, + _ => true + }; }" + ProductModelCode + SampleDataCode; private readonly string example12RazorCode = @" diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.samples.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.samples.cs index bac33ab983..9d12ee58b2 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.samples.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.samples.cs @@ -337,6 +337,7 @@ public class MedalsModel private readonly string example3RazorCode = @" @using System.Text.Json; +@using System.Text.Json.Serialization; @inject HttpClient HttpClient @inject NavigationManager NavManager @@ -371,7 +372,7 @@ .grid td {
- + c.EventId)"" /> c.State)"" /> c.City)"" /> @@ -586,6 +587,7 @@ public class Openfda private readonly string example4RazorCode = @" @using System.Text.Json; +@using System.Text.Json.Serialization; @inject HttpClient HttpClient @inject NavigationManager NavManager @@ -765,6 +767,7 @@ protected override async Task OnInitializedAsync() private readonly string example5RazorCode = @" @using System.Text.Json; +@using System.Text.Json.Serialization; @inject HttpClient HttpClient @inject NavigationManager NavManager From 30ce88d9023da478b4c72413b328167a5bc92475 Mon Sep 17 00:00:00 2001 From: msynk Date: Sun, 28 Jun 2026 10:13:47 +0330 Subject: [PATCH 24/35] resolve review comments XXI --- .../Components/DataGrid/BitDataGrid.razor.cs | 8 +++++-- .../Components/DataGrid/BitDataGrid.ts | 6 ++++- .../BitDataGridValueComparer.cs | 7 ++++++ .../Components/QuickGrid/BitQuickGrid.razor | 2 +- .../QuickGrid/BitQuickGrid.razor.cs | 12 ++++++++-- .../Columns/BitQuickGridPropertyColumn.cs | 22 ++++++++++++------- .../DataGrid/BitDataGridDemo.razor.params.cs | 4 ++-- .../Extras/QuickGrid/BitQuickGridDemo.razor | 4 ++-- .../BitQuickGridDemo.razor.samples.cs | 2 +- 9 files changed, 48 insertions(+), 19 deletions(-) diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs index 038aa9f8bd..dd3ff633d4 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs @@ -1638,8 +1638,12 @@ private string ColumnWidthToken(BitDataGridColumn column) : $"minmax({Math.Max(120, column.MinWidth)}px, 1fr)"; } - /// Resolves the height (in px) for a given row, honouring . - internal float ResolveRowHeight(TItem item) => RowHeightSelector?.Invoke(item) ?? RowHeight; + /// + /// Resolves the height (in px) for a given row, honouring . + /// While is enabled the selector is ignored and the uniform + /// is always returned, because virtualization requires a constant row height. + /// + internal float ResolveRowHeight(TItem item) => Virtualize ? RowHeight : (RowHeightSelector?.Invoke(item) ?? RowHeight); /// Builds the CSS grid template-columns value for the whole row layout. private string BuildGridTemplate() diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.ts b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.ts index c05c3f8457..42f9d3acd5 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.ts +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.ts @@ -22,9 +22,13 @@ namespace BitBlazorUI { // Only re-check once the load settles if the .NET callback reports more data was // appended and remains; otherwise stop, so end-of-data (a no-op load) doesn't spin // this check()->invoke->check() loop forever. + // Defer the follow-up near-end check with requestAnimationFrame so it runs only + // after Blazor has rendered the freshly appended rows; reading scrollHeight in the + // synchronous continuation would otherwise observe stale layout. The disposed guard + // is preserved so a circuit teardown between callback and frame stops the loop. dotNetRef.invokeMethodAsync('OnInfiniteScrollNearEndAsync') .then( - (more) => { pending = false; if (!disposed && more) check(); }, + (more) => { pending = false; if (!disposed && more) requestAnimationFrame(check); }, () => { pending = false; } ); } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridValueComparer.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridValueComparer.cs index d8882c2802..d28f6d97db 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridValueComparer.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridValueComparer.cs @@ -11,6 +11,13 @@ public int Compare(object? x, object? y) if (x is null) return -1; if (y is null) return 1; + // Strings are ordered with the same case-insensitive ordinal rule as the mixed-type fallback + // below, so the comparer applies one consistent ordering rule for every code path and stays + // transitive (a culture-sensitive CompareTo here could disagree with the fallback and break + // the IComparer contract when string and non-string values are mixed in the same column). + if (x is string sx && y is string sy) + return string.Compare(sx, sy, StringComparison.OrdinalIgnoreCase); + if (x is IComparable cx && x.GetType() == y.GetType()) return cx.CompareTo(y); diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor index 62096e04df..dfecbc2aad 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor @@ -107,7 +107,7 @@ private void RenderPlaceholderRow(RenderTreeBuilder __builder, PlaceholderContext placeholderContext) { - + @foreach (var col in _columns) { diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor.cs index e21298540b..f3188304ba 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor.cs @@ -254,8 +254,16 @@ public void ShowColumnOptions(BitQuickGridColumnBase column) /// A that represents the completion of the operation. public async Task RefreshDataAsync() { - await RefreshDataCoreAsync(); - StateHasChanged(); + try + { + await RefreshDataCoreAsync(); + } + finally + { + // Always rerender after the core refresh settles, even when it throws, so the grid + // doesn't get stuck showing the loading state if the caller handles the exception. + StateHasChanged(); + } } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridPropertyColumn.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridPropertyColumn.cs index dc7e9b8e1c..75bd4cb428 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridPropertyColumn.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridPropertyColumn.cs @@ -11,7 +11,7 @@ public class BitQuickGridPropertyColumn : BitQuickGridColumnBa { private Expression>? _lastAssignedProperty; private string? _lastAssignedFormat; - private string? _autoTitle; + private bool _titleIsAutoGenerated; private Func? _cellTextFunc; private BitQuickGridSort? _sortBuilder; @@ -72,15 +72,21 @@ protected override void OnParametersSet() if (Property.Body is MemberExpression memberExpression) { - // Auto-derive the header from the member name unless the consumer set Title explicitly. A Title - // still equal to the previously derived name is treated as auto-managed, so a changed Property - // replaces the old member name instead of leaving a stale header. - var derived = memberExpression.Member.Name; - if (Title is null || Title == _autoTitle) + // Auto-derive the header from the member name unless the consumer set Title explicitly. An + // explicit flag tracks whether the current Title was auto-generated, so a user-set Title is + // preserved even when it happens to match a member name, and a changed Property only replaces + // a previously auto-generated header. + if (Title is null || _titleIsAutoGenerated) { - Title = derived; + Title = memberExpression.Member.Name; + _titleIsAutoGenerated = true; } - _autoTitle = derived; + } + else if (_titleIsAutoGenerated) + { + // Property no longer maps to a member; drop the stale auto-derived header so it doesn't persist. + Title = null; + _titleIsAutoGenerated = false; } } diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.params.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.params.cs index 0ac123bfeb..0a67cb7edf 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.params.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.params.cs @@ -171,7 +171,7 @@ public partial class BitDataGridDemo [ new() { Name = "ColumnId", Type = "string", DefaultValue = "", Description = "The identifier of the column being sorted." }, new() { Name = "Direction", Type = "BitDataGridSortDirection", DefaultValue = "BitDataGridSortDirection.Ascending", Description = "The sort direction.", LinkType = LinkType.Link, Href = "#BitDataGridSortDirection" }, - new() { Name = "Priority", Type = "int", DefaultValue = "0", Description = "Priority for multi-column sorting (1 = primary)." }, + new() { Name = "Priority", Type = "int", DefaultValue = "int.MaxValue", Description = "Priority for multi-column sorting (1 = primary)." }, ], }, new() @@ -182,7 +182,7 @@ public partial class BitDataGridDemo Parameters = [ new() { Name = "ColumnId", Type = "string", DefaultValue = "", Description = "The identifier of the column being filtered." }, - new() { Name = "Operator", Type = "BitDataGridFilterOperator", DefaultValue = "BitDataGridFilterOperator.Unspecified", Description = "The comparison operator applied to the value.", LinkType = LinkType.Link, Href = "#BitDataGridFilterOperator" }, + new() { Name = "Operator", Type = "BitDataGridFilterOperator", DefaultValue = "BitDataGridFilterOperator.Contains", Description = "The comparison operator applied to the value.", LinkType = LinkType.Link, Href = "#BitDataGridFilterOperator" }, new() { Name = "Value", Type = "object?", DefaultValue = "null", Description = "The value compared against the column's cell value." }, ], }, diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor index 58ad1eecea..8dbc7dded4 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor @@ -98,7 +98,7 @@
- + @@ -113,7 +113,7 @@
- + diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.samples.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.samples.cs index 9d12ee58b2..4132890505 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.samples.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.samples.cs @@ -413,7 +413,7 @@ protected override async Task OnInitializedAsync() var query = new Dictionary { { ""skip"", req.StartIndex }, - { ""limit"", req.Count } + { ""limit"", req.Count ?? 50 } }; // Only add the firm filter when the user typed something; an empty Lucene clause From 6261e51a5b47226178e78beed4194c49b81bbf49 Mon Sep 17 00:00:00 2001 From: msynk Date: Sun, 28 Jun 2026 12:08:07 +0330 Subject: [PATCH 25/35] resolve review comments XXII --- .../Components/DataGrid/BitDataGrid.razor | 15 ++++++++----- .../Components/DataGrid/BitDataGrid.razor.cs | 15 ++++++++++++- .../Components/DataGrid/BitDataGrid.scss | 3 +++ .../DataGrid/BitDataGridCellEditor.razor | 22 ++++++++++++++++++- .../Components/DataGrid/BitDataGridRow.razor | 4 ++-- .../BitDataGridDataProcessor.cs | 13 ++++++----- .../BitDataGridValueComparer.cs | 16 +++++++++----- .../Components/QuickGrid/BitQuickGrid.ts | 5 ++++- .../Columns/BitQuickGridPropertyColumn.cs | 17 +++++++++----- .../Pagination/BitQuickGridPaginator.razor.cs | 8 ++++--- .../DataGrid/BitDataGridDemo.razor.params.cs | 2 +- .../DataGrid/BitDataGridDemo.razor.samples.cs | 3 ++- .../Extras/QuickGrid/BitQuickGridDemo.razor | 2 +- .../BitQuickGridDemo.razor.samples.cs | 2 +- 14 files changed, 93 insertions(+), 34 deletions(-) diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor index 8e1b80b5f6..a8f1b883d8 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor @@ -421,22 +421,25 @@ { var current = GetFilter(column)?.Value; var type = column.EffectiveDataType; + // Every filter control needs an accessible name so screen readers can identify which column + // it targets (placeholders and bare selects are not exposed as accessible names). + var filterLabel = $"Filter by {column.DisplayTitle}"; if (type is BitDataGridColumnDataType.Number) { - } else if (type is BitDataGridColumnDataType.Date or BitDataGridColumnDataType.DateTime or BitDataGridColumnDataType.DateTimeOffset) { - } else if (type is BitDataGridColumnDataType.Boolean) { - @foreach (var name in EnumNames(column)) @@ -456,7 +459,7 @@ } else { - } @@ -575,7 +578,7 @@ var to = Math.Min(CurrentPage * _effectivePageSize, TotalCount); } @from–@to of @TotalCount - @foreach (var size in PageSizeOptions) { diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs index dd3ff633d4..6d7cdec559 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs @@ -400,6 +400,17 @@ protected override async Task OnParametersSetAsync() $"{nameof(OnLoadMore)} (infinite-scrolling mode) at the same time. Provide only one data callback."); } + // Tree mode flattens the bound Items via ProcessTreeData and never calls the remote data + // callbacks, so combining it with OnRead/OnLoadMore would let RefreshAsync bypass tree + // processing and present a flat remote list under a tree UI. Reject the ambiguous config. + if (ChildrenSelector is not null && (OnRead is not null || OnLoadMore is not null)) + { + throw new InvalidOperationException( + $"{nameof(BitDataGrid)} cannot combine tree mode ({nameof(ChildrenSelector)}) with " + + $"{nameof(OnRead)} (server mode) or {nameof(OnLoadMore)} (infinite-scrolling mode). " + + $"Tree data must be provided through {nameof(Items)}."); + } + _effectivePageSize = Pageable ? Math.Max(1, PageSize) : int.MaxValue; // Reset the current selection whenever the selection mode changes @@ -755,8 +766,10 @@ public async ValueTask DisposeAsync() catch (JSDisconnectedException) { } catch (JSException) { } _infiniteSelfRef?.Dispose(); + // Only signal cancellation here; deterministic disposal of _loadCts belongs to the request + // lifecycle (ResetLoadCancellation). Disposing it during teardown could surface an + // ObjectDisposedException for an OnRead/OnLoadMore call still holding the token. _loadCts?.Cancel(); - _loadCts?.Dispose(); GC.SuppressFinalize(this); } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.scss b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.scss index 26609f7c6a..0c4f8cc128 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.scss +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.scss @@ -309,6 +309,9 @@ button.bit-dtg-htext { } .bit-dtg-drag-handle { + background: none; + border: none; + padding: 0; cursor: grab; color: var(--bit-clr-fg-sec); user-select: none; diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridCellEditor.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridCellEditor.razor index 180efbfd1c..58ab4bb6e0 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridCellEditor.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridCellEditor.razor @@ -24,7 +24,7 @@ case BitDataGridColumnDataType.DateTime: + @onchange="e => SetDateTime(e.Value)" /> break; case BitDataGridColumnDataType.DateTimeOffset: @@ -106,6 +106,26 @@ && Nullable.GetUnderlyingType(Column.Accessor.PropertyType) is not null && Column.Accessor.UnderlyingType.IsEnum; + // has no time-zone/Kind information, so a naive parse back through + // the property accessor yields DateTimeKind.Unspecified and would drop the original Utc/Local + // semantics (changing the represented instant on round-trip). Re-stamp the parsed value with the + // current value's Kind so edited DateTimes preserve Utc/Local consistently — mirroring how + // SetDateTimeOffset reconstructs the offset. + private void SetDateTime(object? raw) + { + var inv = System.Globalization.CultureInfo.InvariantCulture; + if (raw is string s && !string.IsNullOrEmpty(s) + && DateTime.TryParse(s, inv, System.Globalization.DateTimeStyles.None, out var local)) + { + var kind = Value is DateTime current ? current.Kind : DateTimeKind.Unspecified; + Grid.SetEditValue(Column, DateTime.SpecifyKind(local, kind)); + } + else + { + SetNullable(raw); + } + } + private void Set(object? raw) => Grid.SetEditValue(Column, raw); // True when the bound member is a nullable value type (e.g. int?, DateTime?). A cleared editor diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridRow.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridRow.razor index 04b768a688..a3a1468666 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridRow.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridRow.razor @@ -2,8 +2,6 @@ @namespace Bit.BlazorUI
@@ -11,6 +9,8 @@ {
diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridDataProcessor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridDataProcessor.cs index d7d7a9de56..c76f606b95 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridDataProcessor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridDataProcessor.cs @@ -92,8 +92,8 @@ private static List> BuildGroups( var keyId = g.Key switch { null => "∅", - IFormattable f => $"{g.Key.GetType().Name}:{f.ToString(null, CultureInfo.InvariantCulture)}", - _ => $"{g.Key.GetType().Name}:{g.Key}" + IFormattable f => $"{g.Key.GetType().FullName ?? g.Key.GetType().Name}:{f.ToString(null, CultureInfo.InvariantCulture)}", + _ => $"{g.Key.GetType().FullName ?? g.Key.GetType().Name}:{g.Key}" }; // Include the grouping column id in the path so the collapse/expand state is scoped to // the column that produced the group. Without it, changing the grouped column would let @@ -208,10 +208,11 @@ private static bool Matches(object? value, BitDataGridFilterDescriptor filter) return value is not null && !string.IsNullOrEmpty(value.ToString()); } - // A blank (null, empty or whitespace-only) filter value carries no criteria, so treat it like - // an omitted filter and match every row. This runs before the comparison and string-operator - // branches so operators like DoesNotContain or the numeric comparisons never evaluate against "". - if (filter.Value is null || (filter.Value is string blank && string.IsNullOrWhiteSpace(blank))) + // An empty or whitespace-only string filter value carries no criteria, so treat it like an + // omitted filter and match every row. A *null* filter value, however, is a meaningful operand: + // it must flow into the numeric/comparable branch below so Equals/NotEquals can distinguish null + // rows from non-null rows (and string operators handle null via their own null guard). + if (filter.Value is string blank && string.IsNullOrWhiteSpace(blank)) return true; // Numeric / comparable operators diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridValueComparer.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridValueComparer.cs index d28f6d97db..1cccf31b3e 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridValueComparer.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridValueComparer.cs @@ -21,11 +21,17 @@ public int Compare(object? x, object? y) if (x is IComparable cx && x.GetType() == y.GetType()) return cx.CompareTo(y); - // Mixed types: compare by string representation so the result is symmetric regardless of - // argument order. A one-sided Convert.ChangeType (coercing y to x's type) could order the same - // pair differently when the operands are swapped, breaking the IComparer contract. When the - // strings are equal we treat the values as equivalent for ordering (return 0) rather than - // attempting an asymmetric type-specific tie-break. + // Mixed types: order first by a stable type discriminator (the full type name) so the ordering + // is a total order and stays transitive across the whole column. Without this, same-type values + // ordered via CompareTo and cross-type values ordered via string could disagree (e.g. ints 2 and + // 10 sort numerically, but 2 vs the string "100" sorting by text would place 2 after it, breaking + // transitivity and the IComparer contract). Within the same type name we then fall back to a + // symmetric, case-insensitive string comparison. + var tx = x.GetType().FullName ?? x.GetType().Name; + var ty = y.GetType().FullName ?? y.GetType().Name; + var typeOrder = string.Compare(tx, ty, StringComparison.Ordinal); + if (typeOrder != 0) return typeOrder; + return string.Compare(x.ToString(), y.ToString(), StringComparison.OrdinalIgnoreCase); } } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.ts b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.ts index dc54653e43..03a2585c3a 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.ts +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.ts @@ -73,7 +73,10 @@ namespace BitBlazorUI { if (typeof colOptions.scrollIntoViewIfNeeded === 'function') { colOptions.scrollIntoViewIfNeeded(); } else { - colOptions.scrollIntoView(); + // Fall back to a nearest-edge scroll so browsers without scrollIntoViewIfNeeded + // don't scroll more aggressively than needed (the default scrollIntoView() can + // jump the popup fully into view and shift the grid). + colOptions.scrollIntoView({ block: 'nearest', inline: 'nearest' }); } const autoFocusElem = colOptions.querySelector('[autofocus]'); diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridPropertyColumn.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridPropertyColumn.cs index 75bd4cb428..e6c1a070d2 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridPropertyColumn.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridPropertyColumn.cs @@ -12,6 +12,7 @@ public class BitQuickGridPropertyColumn : BitQuickGridColumnBa private Expression>? _lastAssignedProperty; private string? _lastAssignedFormat; private bool _titleIsAutoGenerated; + private string? _autoGeneratedTitle; private Func? _cellTextFunc; private BitQuickGridSort? _sortBuilder; @@ -72,21 +73,27 @@ protected override void OnParametersSet() if (Property.Body is MemberExpression memberExpression) { - // Auto-derive the header from the member name unless the consumer set Title explicitly. An - // explicit flag tracks whether the current Title was auto-generated, so a user-set Title is - // preserved even when it happens to match a member name, and a changed Property only replaces - // a previously auto-generated header. - if (Title is null || _titleIsAutoGenerated) + // Auto-derive the header from the member name unless the consumer set Title explicitly. We + // remember the last value we generated: if the incoming Title still equals it, it is still + // ours to (re)generate; if it differs, the consumer supplied a Title (even one matching a + // member name) so we stop auto-generating and preserve their value across Property changes. + if (Title is null || (_titleIsAutoGenerated && Title == _autoGeneratedTitle)) { Title = memberExpression.Member.Name; + _autoGeneratedTitle = Title; _titleIsAutoGenerated = true; } + else + { + _titleIsAutoGenerated = false; + } } else if (_titleIsAutoGenerated) { // Property no longer maps to a member; drop the stale auto-derived header so it doesn't persist. Title = null; _titleIsAutoGenerated = false; + _autoGeneratedTitle = null; } } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Pagination/BitQuickGridPaginator.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Pagination/BitQuickGridPaginator.razor.cs index 1deb4b2c45..092332de08 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Pagination/BitQuickGridPaginator.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Pagination/BitQuickGridPaginator.razor.cs @@ -58,9 +58,11 @@ public partial class BitQuickGridPaginator : IDisposable public BitQuickGridPaginator() { // The "total item count" handler doesn't need to do anything except cause this component to - // re-render, so the paginator UI (page summary and navigation buttons) refreshes when the - // grid reports a new total item count. - _totalItemCountChanged = new(EventCallback.Factory.Create(this, () => StateHasChanged())); + // re-render. Invoking this EventCallback already routes through the paginator's + // IHandleEvent.HandleEventAsync (the receiver is `this`), which re-renders the component on its + // own, so the callback body is intentionally empty — calling StateHasChanged() here as well + // would queue a second, redundant render. + _totalItemCountChanged = new(EventCallback.Factory.Create(this, () => { })); } private Task GoFirstAsync() => GoToPageAsync(0); diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.params.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.params.cs index 0a67cb7edf..d7493aaa7f 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.params.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.params.cs @@ -30,7 +30,7 @@ public partial class BitDataGridDemo new() { Name = "Groupable", Type = "bool", DefaultValue = "false", Description = "Enables grouping via a header button on groupable columns." }, new() { Name = "ShowToolbar", Type = "bool", DefaultValue = "false", Description = "Renders the toolbar area." }, new() { Name = "ShowColumnChooser", Type = "bool", DefaultValue = "false", Description = "Renders a column show/hide chooser in the toolbar." }, - new() { Name = "ShowCsvExport", Type = "bool", DefaultValue = "false", Description = "Renders a CSV export link of the current view." }, + new() { Name = "ShowCsvExport", Type = "bool", DefaultValue = "false", Description = "Renders a CSV export button for the current view." }, new() { Name = "CellNavigation", Type = "bool", DefaultValue = "false", Description = "Enables keyboard cell navigation with a roving tabindex." }, new() { Name = "RowReorderable", Type = "bool", DefaultValue = "false", Description = "Enables drag-and-drop row reordering." }, new() { Name = "OnRowReorder", Type = "EventCallback>", DefaultValue = "", Description = "Raised when a row is dropped onto another row during reordering.", LinkType = LinkType.Link, Href = "#BitDataGridRowReorderEventArgs" }, diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.samples.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.samples.cs index 7a93494a95..de6968f1a6 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.samples.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.samples.cs @@ -517,7 +517,8 @@ private static List BuildSuppliers() => private void OnReorder(BitDataGridRowReorderEventArgs e) { - // e.DraggedItem, e.FromIndex, e.ToIndex + // e.DraggedItem, e.TargetItem, e.FromIndex, e.ToIndex + // FromIndex/ToIndex are int? and may be null when Items is not an indexable IList. }" + ProductModelCode + SampleDataCode; private readonly string example16RazorCode = @" diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor index 8dbc7dded4..8866afdb93 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor @@ -98,7 +98,7 @@
- + diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.samples.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.samples.cs index 4132890505..c060bdbbb8 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.samples.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.samples.cs @@ -622,7 +622,7 @@ .grid td {
- p.Id)"" TGridItem=""ProductDto"" Virtualize> + p.Id)"" TGridItem=""ProductDto"" Virtualize ItemSize=""32""> p.Id)"" Sortable=""true"" IsDefaultSort=""BitQuickGridSortDirection.Ascending"" /> p.Name)"" Sortable=""true"" /> p.Price)"" Sortable=""true"" /> From e04eac977ab78ddf440715bfe702e278ba756a9c Mon Sep 17 00:00:00 2001 From: msynk Date: Sun, 28 Jun 2026 14:42:59 +0330 Subject: [PATCH 26/35] resolve review comments XXIII --- .../Components/DataGrid/BitDataGrid.razor.cs | 15 ++++++++++++--- .../DataGrid/BitDataGridCellEditor.razor | 16 ++++++++++++++++ .../Infrastructure/BitDataGridDataProcessor.cs | 8 ++++++-- .../DataGrid/Infrastructure/BitDataGridGroup.cs | 11 ++++++++--- .../BitDataGridPropertyAccessor.cs | 6 ++++++ .../QuickGrid/BitQuickGridDemo.razor.samples.cs | 4 ++-- 6 files changed, 50 insertions(+), 10 deletions(-) diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs index 6d7cdec559..93489190ed 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs @@ -356,10 +356,14 @@ internal void UpdateColumnRegistration(BitDataGridColumn column, string o if (!_columns.Contains(column)) return; // Reject the rename if the new id already belongs to a different live column. Overwriting the - // registry entry would shadow that column and desync _columnsById from _columns (mirrors the - // duplicate-id rejection in AddColumn). Keep the renamed column under its old key in that case. + // registry entry would shadow that column and desync _columnsById from _columns. Unlike AddColumn + // (where a duplicate column is simply never registered), the column here is already registered and + // its Id has already changed, so silently returning would leave it partially updated (registered + // under its old key while reporting the new id). Surface the collision as an error instead. if (_columnsById.TryGetValue(column.Id, out var clash) && !ReferenceEquals(clash, column)) - return; + throw new InvalidOperationException( + $"Cannot change a {nameof(BitDataGridColumn)}'s id to '{column.Id}' because another " + + $"column is already registered under that id. Column ids (ColumnId/Field) must be unique."); if (_columnsById.TryGetValue(oldId, out var existing) && ReferenceEquals(existing, column)) _columnsById.Remove(oldId); @@ -615,6 +619,11 @@ private async Task ResetInfiniteAsync() _view = _infiniteItems; _pageItems = _infiniteItems; + // Recompute footer aggregates against the now-empty list so ShowFooter doesn't keep displaying + // totals from the pre-reset data while the first batch is still loading. LoadNextBatchAsync will + // recompute them again once rows arrive. + _footerAggregates = BitDataGridDataProcessor.Aggregate(_infiniteItems, _columns); + // Bump the load version up-front so any batch still in flight from before this reset is // recognised as stale (by the version check in LoadNextBatchAsync) and won't append rows to // the freshly cleared list while we await scrollToTop below. diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridCellEditor.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridCellEditor.razor index 58ab4bb6e0..e859daa131 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridCellEditor.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridCellEditor.razor @@ -3,6 +3,18 @@ @switch (Column.EffectiveDataType) { + case BitDataGridColumnDataType.Boolean when IsNullableValue: + @* A nullable bool has three states (true/false/null); a plain checkbox only has two and would + silently collapse null into false. Use a select whose empty option round-trips back to null + (via SetNullable), while still parsing "true"/"false" through the accessor like Set does. *@ + + break; + case BitDataGridColumnDataType.Boolean: Value is bool b && b; + // Renders the current bool? as the tri-state select's value: "true"/"false" for a concrete value, + // empty string for null so the empty option is selected and a cleared cell stays null. + private string GetBoolString() => Value is bool b ? (b ? "true" : "false") : string.Empty; + private string GetDate() { // requires a strict ISO 8601 yyyy-MM-dd value; format with the invariant diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridDataProcessor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridDataProcessor.cs index c76f606b95..8f36aaa4c4 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridDataProcessor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridDataProcessor.cs @@ -99,6 +99,7 @@ private static List> BuildGroups( // the column that produced the group. Without it, changing the grouped column would let // a same-valued key at the same level reuse another column's stale expansion state. var path = $"{parentPath}/{level}:{descriptor.ColumnId}:{keyId}"; + var isLeaf = level + 1 >= groups.Count; var group = new BitDataGridGroup { ColumnId = descriptor.ColumnId, @@ -106,9 +107,12 @@ private static List> BuildGroups( KeyText = keyText, Level = level, Path = path, - Items = items + Count = items.Count, + // Only leaf groups retain the row list; parent groups rely on Count/Aggregates/SubGroups + // so a row isn't referenced again on every ancestor level. + Items = isLeaf ? items : new List() }; - if (level + 1 < groups.Count) + if (!isLeaf) group.SubGroups.AddRange(BuildGroups(items, groups, columns, level + 1, path)); group.Aggregates.AddRange(Aggregate(items, columns.Values)); return group; diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridGroup.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridGroup.cs index 3c46382fe8..ebdf3328be 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridGroup.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridGroup.cs @@ -20,7 +20,12 @@ public sealed class BitDataGridGroup /// Stable, unique path identifying this group across the whole tree (used for collapse state). public required string Path { get; init; } - /// All rows that fall under this group (across nested subgroups). + /// + /// The rows held directly by this group. Only populated for leaf groups (those without + /// ); parent groups leave this empty and expose their rows through their + /// nested subgroups, so a row is referenced once per tree rather than on every ancestor level. + /// Use and for parent-group summary data. + /// public List Items { get; init; } = new(); /// Child groups when this group is further grouped; empty for leaf groups. @@ -31,6 +36,6 @@ public sealed class BitDataGridGroup public bool HasSubGroups => SubGroups.Count > 0; - /// Total number of leaf rows in this group. - public int Count => Items.Count; + /// Total number of leaf rows in this group (including all nested subgroups). + public required int Count { get; init; } } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridPropertyAccessor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridPropertyAccessor.cs index 8cf9980978..ef66b5156b 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridPropertyAccessor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridPropertyAccessor.cs @@ -58,6 +58,12 @@ public void SetValue(TItem item, object? value) /// public bool TryConvertValue(object? value, out object? result) { + // A cleared edit can arrive as an empty string (e.g. a select/text editor reset to "") rather + // than null. Normalize it to null up front so the nullable-target handling below clears the + // value, while non-nullable value targets still reject the clear and keep the previous value. + if (value is string es && es.Length == 0) + value = null; + if (value is null) { // A cleared edit (null) must not silently become the type's default (e.g. 0 / MinValue for diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.samples.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.samples.cs index c060bdbbb8..9ffc0514b2 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.samples.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.samples.cs @@ -622,7 +622,7 @@ .grid td {
- p.Id)"" TGridItem=""ProductDto"" Virtualize ItemSize=""32""> + p.Id)"" TGridItem=""ProductDto"" Virtualize ItemSize=""32""> p.Id)"" Sortable=""true"" IsDefaultSort=""BitQuickGridSortDirection.Ascending"" /> p.Name)"" Sortable=""true"" /> p.Price)"" Sortable=""true"" /> @@ -802,7 +802,7 @@ .grid td {
- p.Id)"" TGridItem=""ProductDto"" Pagination=""pagination""> + p.Id)"" TGridItem=""ProductDto"" Pagination=""pagination""> p.Id)"" Sortable=""true"" IsDefaultSort=""BitQuickGridSortDirection.Ascending"" /> p.Name)"" Sortable=""true"" /> From 54822f6f4bac943f976177cc31dc87efc091006e Mon Sep 17 00:00:00 2001 From: msynk Date: Sun, 28 Jun 2026 15:34:23 +0330 Subject: [PATCH 27/35] resolve review comments XXIV --- .../Components/DataGrid/BitDataGrid.razor | 8 ++++++-- .../Components/DataGrid/BitDataGrid.scss | 12 ++++++++++++ .../Infrastructure/BitDataGridPropertyAccessor.cs | 5 +++-- .../DataGrid/Models/BitDataGridAggregateResult.cs | 4 ++-- .../DataGrid/Models/BitDataGridFilterDescriptor.cs | 8 ++++++-- .../ItemsProvider/BitQuickGridItemsProvider.cs | 2 +- .../Extras/QuickGrid/BitQuickGridDemo.razor | 2 +- 7 files changed, 31 insertions(+), 10 deletions(-) diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor index a8f1b883d8..bd7d57aecd 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor @@ -510,9 +510,13 @@ // both the client pipeline and remote consumers can apply with standard comparisons. if (underlying == typeof(DateTimeOffset)) { - if (DateTimeOffset.TryParse(raw, inv, System.Globalization.DateTimeStyles.None, out var dto)) + // The date editor emits a date-only string (no time/offset), so parse it as a + // DateOnly and build the day bounds with a fixed UTC offset. Relying on the offset + // that DateTimeOffset.TryParse infers would bind the range to the host's ambient + // time zone, making the same filter select a different instant per environment. + if (DateOnly.TryParse(raw, inv, System.Globalization.DateTimeStyles.None, out var d)) { - var start = new DateTimeOffset(dto.Year, dto.Month, dto.Day, 0, 0, 0, dto.Offset); + var start = new DateTimeOffset(d.Year, d.Month, d.Day, 0, 0, 0, TimeSpan.Zero); return SetDateRangeFilterAsync(column, start, start.AddDays(1)); } return SetDateRangeFilterAsync(column, null, null); diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.scss b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.scss index 0c4f8cc128..c4b75da8be 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.scss +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.scss @@ -464,8 +464,20 @@ button.bit-dtg-htext { font: inherit; } +/* The boolean editor reuses .bit-dtg-editor for class consistency, so fully strip the text-input + chrome (padding/border/background/sizing) here and restore the platform's native checkbox look + instead of leaving it boxed like a text field. Non-boolean editors keep the .bit-dtg-editor styles. */ .bit-dtg-editor-check { width: auto; + padding: 0; + margin: 0; + border: none; + border-radius: 0; + background: none; + appearance: auto; + -webkit-appearance: checkbox; + accent-color: var(--bit-clr-pri); + cursor: pointer; } .bit-dtg-filter-input { diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridPropertyAccessor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridPropertyAccessor.cs index ef66b5156b..548c5a194f 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridPropertyAccessor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridPropertyAccessor.cs @@ -60,8 +60,9 @@ public bool TryConvertValue(object? value, out object? result) { // A cleared edit can arrive as an empty string (e.g. a select/text editor reset to "") rather // than null. Normalize it to null up front so the nullable-target handling below clears the - // value, while non-nullable value targets still reject the clear and keep the previous value. - if (value is string es && es.Length == 0) + // value — but only for non-string targets. For a string-typed property, "" is a legitimate + // user edit (an intentionally emptied text cell) and must be preserved rather than nulled. + if (value is string es && es.Length == 0 && PropertyType != typeof(string)) value = null; if (value is null) diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridAggregateResult.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridAggregateResult.cs index 7dce2bb065..a1003d9e9e 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridAggregateResult.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridAggregateResult.cs @@ -6,8 +6,8 @@ public sealed class BitDataGridAggregateResult /// The identifier of the column this aggregate was computed for. public required string ColumnId { get; init; } - /// The kind of aggregation performed (e.g. Sum, Average, Count). - public BitDataGridAggregateType Type { get; init; } + /// The kind of aggregation performed (e.g. Sum, Average, Count). Required so an aggregate result cannot be created without declaring its aggregation kind. + public required BitDataGridAggregateType Type { get; init; } /// The raw computed aggregate value, or null when no value applies. public object? Value { get; init; } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridFilterDescriptor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridFilterDescriptor.cs index 8e7496bc5a..0003dee5d9 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridFilterDescriptor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridFilterDescriptor.cs @@ -6,8 +6,12 @@ public sealed class BitDataGridFilterDescriptor /// The identifier of the column being filtered. Immutable once the descriptor is created. public required string ColumnId { get; init; } - /// The filter operation to apply. Defaults to . - public BitDataGridFilterOperator Operator { get; set; } = BitDataGridFilterOperator.Contains; + /// + /// The filter operation to apply. Has no default: an omitted value stays + /// so a descriptor created without an explicit + /// operator is treated as invalid/omitted rather than silently filtering as "contains". + /// + public BitDataGridFilterOperator Operator { get; set; } /// /// The value to filter by. Its meaning depends on the selected and it is diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/ItemsProvider/BitQuickGridItemsProvider.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/ItemsProvider/BitQuickGridItemsProvider.cs index 726d2d7ab3..6985a54e8a 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/ItemsProvider/BitQuickGridItemsProvider.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/ItemsProvider/BitQuickGridItemsProvider.cs @@ -5,5 +5,5 @@ namespace Bit.BlazorUI; /// /// The type of data represented by each row in the grid. /// Parameters describing the data being requested. -/// A whose result is a that gives the data to be displayed. +/// A (specifically ValueTask<BitQuickGridItemsProviderResult<TGridItem>>) whose result is a that gives the data to be displayed. public delegate ValueTask> BitQuickGridItemsProvider(BitQuickGridItemsProviderRequest request); diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor index 8866afdb93..01a11bb529 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor @@ -80,7 +80,7 @@
- + From 70b17045f33f1ef133b2bdf38952b07434d1b8a7 Mon Sep 17 00:00:00 2001 From: msynk Date: Sun, 28 Jun 2026 16:09:52 +0330 Subject: [PATCH 28/35] resolve review comments XXV --- .../Components/DataGrid/BitDataGrid.scss | 3 +++ .../Components/DataGrid/BitDataGridCellEditor.razor | 13 +++++++++++++ .../Extras/DataGrid/BitDataGridDemo.razor | 2 +- .../Extras/DataGrid/BitDataGridDemo.razor.cs | 10 +++++++++- .../DataGrid/BitDataGridDemo.razor.samples.cs | 2 +- 5 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.scss b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.scss index c4b75da8be..2137bc60c7 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.scss +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.scss @@ -190,6 +190,9 @@ align-items: center; gap: 4px; flex: 1; + /* Flex items default to min-width:auto, which refuses to shrink below their content size and + breaks the ellipsis on long labels in narrow columns; allow shrinking so the text can clip. */ + min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridCellEditor.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridCellEditor.razor index e859daa131..259d4f906c 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridCellEditor.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridCellEditor.razor @@ -134,6 +134,13 @@ && DateTime.TryParse(s, inv, System.Globalization.DateTimeStyles.None, out var local)) { var kind = Value is DateTime current ? current.Kind : DateTimeKind.Unspecified; + // datetime-local only carries minute precision, so a round-trip would zero out the original + // seconds/ticks even when the user only edited the date or minute. Carry the original + // sub-minute component forward so editing the higher-order parts preserves it. + if (Value is DateTime original) + { + local = local.AddTicks(original.Ticks % TimeSpan.TicksPerMinute); + } Grid.SetEditValue(Column, DateTime.SpecifyKind(local, kind)); } else @@ -175,6 +182,12 @@ && DateTime.TryParse(s, inv, System.Globalization.DateTimeStyles.None, out var local)) { var offset = Value is DateTimeOffset current ? current.Offset : TimeZoneInfo.Local.GetUtcOffset(local); + // datetime-local only carries minute precision; carry the original sub-minute component + // forward so editing the date/minute doesn't reset the existing seconds/ticks to zero. + if (Value is DateTimeOffset original) + { + local = local.AddTicks(original.DateTime.Ticks % TimeSpan.TicksPerMinute); + } Grid.SetEditValue(Column, new DateTimeOffset(local, offset)); } else diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor index 23ec66c5fe..b577fa6f4f 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor @@ -464,7 +464,7 @@
📭
Nothing here yet - Try loading the sample data or adjusting your filters. + Try loading the sample data to populate the grid. Load sample data
diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.cs index 90cbf3d441..7ec6dab550 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.cs @@ -210,6 +210,10 @@ private async Task> LoadServerData(BitDataGridRea total = filtered.Count; var items = filtered.Skip(request.Skip).Take(request.Take ?? total).ToList(); + // A superseded request can finish filtering/sorting/paging after a newer one started; bail + // out before returning so the grid never receives stale rows for a cancelled load. + request.CancellationToken.ThrowIfCancellationRequested(); + return new BitDataGridReadResult(items, total); } finally @@ -347,7 +351,11 @@ private static List BuildSuppliers() => // ---- row reordering ---- private void OnReorder(BitDataGridRowReorderEventArgs e) { - reorderLog = $"{e.DraggedItem.Name} moved from #{e.FromIndex + 1} to #{e.ToIndex + 1}"; + // FromIndex/ToIndex are null when the bound Items isn't an indexable IList; fall back to "?" + // so the log stays readable instead of rendering an empty position. + var from = e.FromIndex is int fi ? (fi + 1).ToString() : "?"; + var to = e.ToIndex is int ti ? (ti + 1).ToString() : "?"; + reorderLog = $"{e.DraggedItem.Name} moved from #{from} to #{to}"; } diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.samples.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.samples.cs index de6968f1a6..858e1424f6 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.samples.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.samples.cs @@ -562,7 +562,7 @@ private void OnCellDoubleClick(BitDataGridCellEventArgs e) { /* e.Item, private readonly string example19RazorCode = @" -
Nothing here yet. Try loading the sample data or adjusting your filters.
+
Nothing here yet. Try loading the sample data to populate the grid.
From 32c2f34f5c503236b196adde0c160cc78a9a9aed Mon Sep 17 00:00:00 2001 From: msynk Date: Sun, 28 Jun 2026 16:42:56 +0330 Subject: [PATCH 29/35] resovle review comments XXVI --- .../Components/DataGrid/BitDataGridRow.razor | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridRow.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridRow.razor index a3a1468666..62508b99da 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridRow.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridRow.razor @@ -12,7 +12,8 @@ draggable="@(!Editing ? "true" : null)" @ondragstart="() => Grid.StartRowDrag(Item)" title="Drag to reorder, or focus and use the arrow keys to move this row" - aria-label="Reorder row. Press Arrow Up or Arrow Down to move this row." @onkeydown="HandleReorderKeyDown">⠿ + aria-label="Reorder row. Press Arrow Up or Arrow Down to move this row." + @onkeydown="HandleReorderKeyDown" @onkeydown:preventDefault="_preventReorderKeyDefault">⠿
} @@ -163,9 +164,15 @@ // Lets keyboard users reorder rows without drag-and-drop: ArrowUp/ArrowDown move the focused row // one position toward the start/end, reusing the same reorder pipeline as the pointer drag path. + // Blazor evaluates @onkeydown:preventDefault at render time, so we toggle this field per keystroke + // to scope the suppression to the arrow keys (keeping Tab/Enter/Space working on the handle) and + // stop the browser from scrolling the page/grid when moving rows. + private bool _preventReorderKeyDefault; + private async Task HandleReorderKeyDown(KeyboardEventArgs e) { if (!Grid.RowReorderEnabled) return; + _preventReorderKeyDefault = e.Key is "ArrowUp" or "ArrowDown"; if (e.Key == "ArrowUp") await Grid.MoveRowAsync(Item, -1); else if (e.Key == "ArrowDown") await Grid.MoveRowAsync(Item, 1); } From 334d539635bea44ba75473df224ec1030a1c3717 Mon Sep 17 00:00:00 2001 From: msynk Date: Sun, 28 Jun 2026 18:15:39 +0330 Subject: [PATCH 30/35] resolve review comments XXVII --- .../Components/DataGrid/BitDataGrid.razor | 23 +++++++------ .../Components/DataGrid/BitDataGrid.ts | 21 ++++++++++++ .../Components/DataGrid/BitDataGridRow.razor | 13 ++++--- .../QuickGrid/BitQuickGrid.razor.cs | 8 +++-- .../Columns/BitQuickGridPropertyColumn.cs | 34 ++++++++++++------- .../AsyncQueryExecutorSupplier.cs | 12 +++++-- 6 files changed, 77 insertions(+), 34 deletions(-) diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor index bd7d57aecd..e3e17c9e50 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor @@ -467,9 +467,10 @@ // Translates the raw editor input into a typed value + operator for the column's data type, then // applies it. Text columns keep the "contains" behavior; numbers/booleans/enums use Equals against a - // typed value. DateTime/DateTimeOffset use a boundary-safe half-open same-day range ([start, nextDay)) - // built from standard comparison operators, so a row's time-of-day never prevents a match and remote - // OnRead consumers can apply the filter with ordinary comparisons (see SetDateRangeFilterAsync). + // typed value. DateTime uses a boundary-safe half-open same-day range ([start, nextDay)) built from + // standard comparison operators, so a row's time-of-day never prevents a match and remote OnRead + // consumers can apply the filter with ordinary comparisons (see SetDateRangeFilterAsync). DateOnly and + // DateTimeOffset use a date-only Equals so day matching follows each row's own calendar date. private Task SetTypedFilterAsync(BitDataGridColumn column, string? raw) { if (string.IsNullOrWhiteSpace(raw)) @@ -510,16 +511,18 @@ // both the client pipeline and remote consumers can apply with standard comparisons. if (underlying == typeof(DateTimeOffset)) { - // The date editor emits a date-only string (no time/offset), so parse it as a - // DateOnly and build the day bounds with a fixed UTC offset. Relying on the offset - // that DateTimeOffset.TryParse infers would bind the range to the host's ambient - // time zone, making the same filter select a different instant per environment. + // The date editor emits a date-only string (no time/offset). A fixed UTC + // [start, nextDay) range compares instants, so a row whose own offset places it on + // the selected calendar day - but whose UTC instant falls outside that UTC day - + // would be wrongly excluded. Emit a date-only Equals filter instead: the data + // processor matches it against each row's own calendar date (DateTimeOffset.Date), + // so filtering follows the displayed date regardless of the value's offset. if (DateOnly.TryParse(raw, inv, System.Globalization.DateTimeStyles.None, out var d)) { - var start = new DateTimeOffset(d.Year, d.Month, d.Day, 0, 0, 0, TimeSpan.Zero); - return SetDateRangeFilterAsync(column, start, start.AddDays(1)); + var day = new DateTimeOffset(d.Year, d.Month, d.Day, 0, 0, 0, TimeSpan.Zero); + return SetFilterAsync(column, BitDataGridFilterOperator.Equals, day); } - return SetDateRangeFilterAsync(column, null, null); + return SetFilterAsync(column, BitDataGridFilterOperator.Equals, null); } else { diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.ts b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.ts index 42f9d3acd5..d25bf120c7 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.ts +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.ts @@ -68,4 +68,25 @@ namespace BitBlazorUI { setTimeout(() => URL.revokeObjectURL(url), 0); } } + + // Reorder drag handles move rows with ArrowUp/ArrowDown. The browser's default for those keys is to + // scroll the page/grid, which must be cancelled *before* the event reaches Blazor's .NET handler. + // Blazor evaluates @onkeydown:preventDefault at render time, so it can't decide based on the upcoming + // key and lags a keystroke behind. A single capture-phase listener decides per-key up front and only + // cancels the arrow keys on a focused drag handle, so Tab/Enter/Space keep working and the .NET + // keydown handler still runs to actually move the row. + let reorderKeyGuardInstalled = false; + function installReorderKeyGuard() { + if (reorderKeyGuardInstalled || typeof document === 'undefined') return; + reorderKeyGuardInstalled = true; + document.addEventListener('keydown', (e: KeyboardEvent) => { + if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return; + const target = e.target as HTMLElement | null; + if (target?.classList?.contains('bit-dtg-drag-handle')) { + e.preventDefault(); + } + }, { capture: true }); + } + + installReorderKeyGuard(); } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridRow.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridRow.razor index 62508b99da..ed48cb8e30 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridRow.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridRow.razor @@ -13,7 +13,7 @@ @ondragstart="() => Grid.StartRowDrag(Item)" title="Drag to reorder, or focus and use the arrow keys to move this row" aria-label="Reorder row. Press Arrow Up or Arrow Down to move this row." - @onkeydown="HandleReorderKeyDown" @onkeydown:preventDefault="_preventReorderKeyDefault">⠿ + @onkeydown="HandleReorderKeyDown">⠿
} @@ -164,15 +164,14 @@ // Lets keyboard users reorder rows without drag-and-drop: ArrowUp/ArrowDown move the focused row // one position toward the start/end, reusing the same reorder pipeline as the pointer drag path. - // Blazor evaluates @onkeydown:preventDefault at render time, so we toggle this field per keystroke - // to scope the suppression to the arrow keys (keeping Tab/Enter/Space working on the handle) and - // stop the browser from scrolling the page/grid when moving rows. - private bool _preventReorderKeyDefault; - + // The browser's default for the arrow keys (scrolling the page/grid) is cancelled by a capture-phase + // keydown guard installed in BitDataGrid.ts, which decides per-key *before* the event reaches this + // .NET callback. We can't do that with @onkeydown:preventDefault because Blazor evaluates it at + // render time, which can't know the upcoming key and lags a keystroke behind (so the first arrow + // press still scrolled and a stale "true" could swallow the next Tab). private async Task HandleReorderKeyDown(KeyboardEventArgs e) { if (!Grid.RowReorderEnabled) return; - _preventReorderKeyDefault = e.Key is "ArrowUp" or "ArrowDown"; if (e.Key == "ArrowUp") await Grid.MoveRowAsync(Item, -1); else if (e.Key == "ArrowDown") await Grid.MoveRowAsync(Item, 1); } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor.cs index f3188304ba..e6de6d6313 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor.cs @@ -552,9 +552,11 @@ private async Task RefreshDataCoreAsync() // ARIA's rowcount is part of the UI, so it should reflect what the human user regards as the number of rows in the table, // not the number of physical elements. For virtualization this means what's in the entire scrollable range, not just // the current viewport. In the case where you're also paginating then it means what's conceptually on the current page. - // TODO: This currently assumes we always want to expand the last page to have ItemsPerPage rows, but the experience might - // be better if we let the last page only be as big as its number of actual rows. - _ariaBodyRowCount = Pagination is null ? providerResult.TotalItemCount : Pagination.ItemsPerPage; + // The last page can hold fewer than ItemsPerPage rows, so clamp the paginated count to the items remaining on the current + // page; otherwise assistive tech would announce non-existent trailing rows on a short final page. + _ariaBodyRowCount = Pagination is null + ? providerResult.TotalItemCount + : Math.Clamp(providerResult.TotalItemCount - Pagination.CurrentPageIndex * Pagination.ItemsPerPage, 0, Pagination.ItemsPerPage); await (Pagination?.SetTotalItemCountAsync(providerResult.TotalItemCount) ?? Task.CompletedTask); diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridPropertyColumn.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridPropertyColumn.cs index e6c1a070d2..71cc827e62 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridPropertyColumn.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridPropertyColumn.cs @@ -12,7 +12,7 @@ public class BitQuickGridPropertyColumn : BitQuickGridColumnBa private Expression>? _lastAssignedProperty; private string? _lastAssignedFormat; private bool _titleIsAutoGenerated; - private string? _autoGeneratedTitle; + private bool _titleWasExplicitlySet; private Func? _cellTextFunc; private BitQuickGridSort? _sortBuilder; @@ -31,6 +31,22 @@ public class BitQuickGridPropertyColumn : BitQuickGridColumnBa BitQuickGridSort? IBitQuickGridSortBuilderColumn.SortBuilder => _sortBuilder; + /// + public override Task SetParametersAsync(ParameterView parameters) + { + // Track whether Title was supplied explicitly by the consumer (present in this render's + // ParameterView) rather than inferring intent from value equality, which can't tell an + // auto-derived header apart from an explicit one that happens to match the member name. + // Latch it: once a Title is set explicitly we never auto-generate over it again. + if (parameters.TryGetValue(nameof(Title), out _)) + { + _titleWasExplicitlySet = true; + } + + return base.SetParametersAsync(parameters); + } + + /// protected override void OnParametersSet() { @@ -73,27 +89,21 @@ protected override void OnParametersSet() if (Property.Body is MemberExpression memberExpression) { - // Auto-derive the header from the member name unless the consumer set Title explicitly. We - // remember the last value we generated: if the incoming Title still equals it, it is still - // ours to (re)generate; if it differs, the consumer supplied a Title (even one matching a - // member name) so we stop auto-generating and preserve their value across Property changes. - if (Title is null || (_titleIsAutoGenerated && Title == _autoGeneratedTitle)) + // Auto-derive the header from the member name only when the consumer never supplied Title + // explicitly (tracked via ParameterView in SetParametersAsync). This avoids overwriting an + // explicit Title that happens to equal the member name, and lets a truly auto-generated + // header follow Property changes. + if (!_titleWasExplicitlySet) { Title = memberExpression.Member.Name; - _autoGeneratedTitle = Title; _titleIsAutoGenerated = true; } - else - { - _titleIsAutoGenerated = false; - } } else if (_titleIsAutoGenerated) { // Property no longer maps to a member; drop the stale auto-derived header so it doesn't persist. Title = null; _titleIsAutoGenerated = false; - _autoGeneratedTitle = null; } } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Infrastructure/AsyncQueryExecutorSupplier.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Infrastructure/AsyncQueryExecutorSupplier.cs index 54f90b55f1..0ce7853d4b 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Infrastructure/AsyncQueryExecutorSupplier.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Infrastructure/AsyncQueryExecutorSupplier.cs @@ -26,15 +26,23 @@ internal static class AsyncQueryExecutorSupplier { // Inspect every registered executor, not just the first one resolved: a registered executor // that does not support this queryable must not shadow another that does, nor suppress the - // EF misconfiguration warning below. Use the first executor that reports support. + // EF misconfiguration warning below. Keep scanning and return the *last* supported executor + // so later, more specific registrations override earlier generic ones - mirroring how DI + // resolves a single service (last registration wins). + IAsyncQueryExecutor? selected = null; foreach (var executor in services.GetServices()) { if (executor.IsSupported(queryable)) { - return executor; + selected = executor; } } + if (selected is not null) + { + return selected; + } + // No registered executor supports this queryable. It's useful to detect if the developer is // unaware that they should be using the EF adapter, otherwise they will likely never notice // and simply deploy an inefficient app that blocks threads on each query. From f3a3b26ed46f1016a241fd253ff19d9ee521f93e Mon Sep 17 00:00:00 2001 From: msynk Date: Sun, 28 Jun 2026 19:06:45 +0330 Subject: [PATCH 31/35] resolve review comments XXVIII --- .../Components/DataGrid/BitDataGrid.ts | 4 +++ .../Components/DataGrid/BitDataGridRow.razor | 3 ++ .../BitDataGridPropertyAccessor.cs | 16 ++++++++- .../Columns/BitQuickGridPropertyColumn.cs | 36 +++++++++---------- .../QuickGrid/BitQuickGridDemo.razor.cs | 6 ++-- 5 files changed, 44 insertions(+), 21 deletions(-) diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.ts b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.ts index d25bf120c7..e5ce29662d 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.ts +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.ts @@ -83,6 +83,10 @@ namespace BitBlazorUI { if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return; const target = e.target as HTMLElement | null; if (target?.classList?.contains('bit-dtg-drag-handle')) { + // Don't cancel the default while the row is being edited: keyboard reordering is + // short-circuited in that state (matching the .NET handler and the draggable guard), + // so swallowing the arrow keys here would needlessly block scrolling during an edit. + if (target.closest('.bit-dtg-row')?.classList?.contains('bit-dtg-editing')) return; e.preventDefault(); } }, { capture: true }); diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridRow.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridRow.razor index ed48cb8e30..6ffa8b17c8 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridRow.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridRow.razor @@ -172,6 +172,9 @@ private async Task HandleReorderKeyDown(KeyboardEventArgs e) { if (!Grid.RowReorderEnabled) return; + // Mirror the draggable guard above: keyboard reordering must be inert while the row is being + // edited, otherwise ArrowUp/ArrowDown would move the row out from under an in-progress edit. + if (Editing) return; if (e.Key == "ArrowUp") await Grid.MoveRowAsync(Item, -1); else if (e.Key == "ArrowDown") await Grid.MoveRowAsync(Item, 1); } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridPropertyAccessor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridPropertyAccessor.cs index 548c5a194f..406807bb6d 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridPropertyAccessor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridPropertyAccessor.cs @@ -135,12 +135,24 @@ private static BitDataGridPropertyAccessor Build(string path) Expression body = param; PropertyInfo? lastProp = null; Expression? nullGuard = null; + // Tracks whether the path is written through a value-type (struct) intermediate. A property + // getter returns a *copy* of a struct, so Expression.Assign on e.g. "Address.City" (where + // Address is a struct) would mutate that throwaway copy and never write back to the item. + // We detect this and keep such paths read-only rather than compile a silently broken setter. + var crossesValueTypeIntermediate = false; foreach (var segment in path.Split('.')) { if (string.IsNullOrWhiteSpace(segment)) throw new ArgumentException($"Property path '{path}' contains an empty or whitespace segment.", nameof(path)); + // The owner of this segment is the previous body. If that owner is a value type (and not the + // root parameter), assigning to a member off it cannot write back through the parent. + if (!ReferenceEquals(body, param) && body.Type.IsValueType) + { + crossesValueTypeIntermediate = true; + } + // If the owner of this segment is an intermediate (nullable) value, guard against it being null. if (!ReferenceEquals(body, param) && CanBeNull(body.Type)) { @@ -167,7 +179,9 @@ private static BitDataGridPropertyAccessor Build(string path) // Setter (only for a simple, writable, single-level-or-nested property) Action? setter = null; - var canWrite = lastProp is { CanWrite: true }; + // A path crossing a struct intermediate cannot be written back through the value-type copy, so + // leave it read-only rather than emit a setter that compiles but silently drops writes. + var canWrite = lastProp is { CanWrite: true } && !crossesValueTypeIntermediate; if (canWrite) { var valueParam = Expression.Parameter(typeof(object), "v"); diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridPropertyColumn.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridPropertyColumn.cs index 71cc827e62..ca7fda8259 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridPropertyColumn.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridPropertyColumn.cs @@ -34,14 +34,12 @@ public class BitQuickGridPropertyColumn : BitQuickGridColumnBa /// public override Task SetParametersAsync(ParameterView parameters) { - // Track whether Title was supplied explicitly by the consumer (present in this render's - // ParameterView) rather than inferring intent from value equality, which can't tell an - // auto-derived header apart from an explicit one that happens to match the member name. - // Latch it: once a Title is set explicitly we never auto-generate over it again. - if (parameters.TryGetValue(nameof(Title), out _)) - { - _titleWasExplicitlySet = true; - } + // Track whether Title was supplied explicitly by the consumer in *this* render's ParameterView + // rather than inferring intent from value equality (which can't tell an auto-derived header apart + // from an explicit one matching the member name). Recompute it every render instead of latching: + // if a consumer later removes Title, explicitness must drop back to false so the auto-generated + // header can return. + _titleWasExplicitlySet = parameters.TryGetValue(nameof(Title), out _); return base.SetParametersAsync(parameters); } @@ -87,17 +85,19 @@ protected override void OnParametersSet() _lastAssignedFormat = Format; } - if (Property.Body is MemberExpression memberExpression) + if (_titleWasExplicitlySet) { - // Auto-derive the header from the member name only when the consumer never supplied Title - // explicitly (tracked via ParameterView in SetParametersAsync). This avoids overwriting an - // explicit Title that happens to equal the member name, and lets a truly auto-generated - // header follow Property changes. - if (!_titleWasExplicitlySet) - { - Title = memberExpression.Member.Name; - _titleIsAutoGenerated = true; - } + // The consumer supplied Title this render; respect it and forget any prior auto-generation so + // a later Property change to a non-member expression doesn't wrongly clear the explicit value. + _titleIsAutoGenerated = false; + } + else if (Property.Body is MemberExpression memberExpression) + { + // No explicit Title this render, so derive the header from the member name. Recomputed every + // render, this also lets the auto-generated header follow Property changes and reappear after + // a previously explicit Title is removed. + Title = memberExpression.Member.Name; + _titleIsAutoGenerated = true; } else if (_titleIsAutoGenerated) { diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.cs index d6dc781ee4..ba7f0dd14b 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.cs @@ -675,9 +675,11 @@ protected override async Task OnInitAsync() { "$skip", req.StartIndex } }; - if (string.IsNullOrEmpty(_odataSampleNameFilter) is false) + if (string.IsNullOrWhiteSpace(_odataSampleNameFilter) is false) { - var escapedFilter = _odataSampleNameFilter.Replace("'", "''"); + // Use the trimmed value so a whitespace-only entry isn't treated as a real search + // term, while still escaping apostrophes to keep the OData string literal valid. + var escapedFilter = _odataSampleNameFilter.Trim().Replace("'", "''"); query.Add("$filter", $"contains(Name,'{escapedFilter}')"); } From a1456d77094b1ee3adc134f9df4722cdfed3c6cb Mon Sep 17 00:00:00 2001 From: msynk Date: Sun, 28 Jun 2026 20:14:05 +0330 Subject: [PATCH 32/35] resolve review comments XXIX --- .../Extras/QuickGrid/BitQuickGridDemo.razor.samples.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.samples.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.samples.cs index 9ffc0514b2..ecd68ab1dc 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.samples.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.samples.cs @@ -802,7 +802,7 @@ .grid td {
- p.Id)"" TGridItem=""ProductDto"" Pagination=""pagination""> + p.Id)"" TGridItem=""ProductDto"" Pagination=""@pagination""> p.Id)"" Sortable=""true"" IsDefaultSort=""BitQuickGridSortDirection.Ascending"" /> p.Name)"" Sortable=""true"" /> From cfc76eebb906b8346125c309095e9ed4aa6ba0c5 Mon Sep 17 00:00:00 2001 From: Saleh Yusefnejad Date: Mon, 29 Jun 2026 06:44:41 +0330 Subject: [PATCH 33/35] resolve review comments XXX --- .../Components/DataGrid/BitDataGrid.razor.cs | 8 +++--- .../Components/DataGrid/BitDataGrid.scss | 9 +++++++ .../DataGrid/BitDataGridCellEditor.razor | 13 ++++++++++ .../Components/DataGrid/BitDataGridColumn.cs | 20 ++++++++++++-- .../BitDataGridDataProcessor.cs | 26 ++++++++++++++++--- .../BitDataGridValueComparer.cs | 22 ++++++++++++++++ .../DataGrid/Models/BitDataGridReadResult.cs | 6 +++++ .../QuickGrid/BitQuickGrid.razor.cs | 15 ++++++++++- .../Columns/BitQuickGridPropertyColumn.cs | 25 ++++++++++-------- .../DataGrid/BitDataGridDemo.razor.samples.cs | 15 ++++++++++- .../BitQuickGridDemo.razor.samples.cs | 7 +++-- 11 files changed, 142 insertions(+), 24 deletions(-) diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs index 93489190ed..9d831c1bca 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor.cs @@ -247,15 +247,15 @@ public partial class BitDataGrid : ComponentBase, IAsyncDisposable internal TItem? PendingNewItem => _pendingNew; // ------------------------------------------------- Column registration - internal void AddColumn(BitDataGridColumn column) + internal bool AddColumn(BitDataGridColumn column) { - if (_columns.Contains(column)) return; + if (_columns.Contains(column)) return true; // Reject a second column registering under an id that is already taken. Overwriting the // registry entry while both columns remain in _columns would desync the two collections, so // sort/filter/group/footer lookups (which resolve a column by id) could resolve to the wrong // instance. Skip the duplicate instead of silently shadowing the existing column. - if (_columnsById.ContainsKey(column.Id)) return; + if (_columnsById.ContainsKey(column.Id)) return false; _columns.Add(column); _columnsById[column.Id] = column; @@ -274,6 +274,8 @@ internal void AddColumn(BitDataGridColumn column) { InvokeAsync(RefreshAsync); } + + return true; } /// diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.scss b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.scss index 2137bc60c7..1c7dedb075 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.scss +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.scss @@ -371,6 +371,15 @@ button.bit-dtg-htext { z-index: 1; } +/* A selected cell/row paints its background with the primary color, so a primary-colored focus + ring would blend in and make keyboard focus invisible. Switch the ring to the selected + foreground color (which is guaranteed to contrast with the selected background) so the focused + cell stays clearly visible even while selected. */ +.bit-dtg-row.bit-dtg-selected .bit-dtg-cell[tabindex]:focus, +.bit-dtg-row.bit-dtg-selected .bit-dtg-cell[tabindex]:focus-visible { + outline-color: var(--bit-clr-pri-text); +} + /* ---------------------------------------------------------- Footer */ .bit-dtg-footer { position: sticky; diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridCellEditor.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridCellEditor.razor index 259d4f906c..3101dd25e7 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridCellEditor.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGridCellEditor.razor @@ -8,6 +8,7 @@ silently collapse null into false. Use a select whose empty option round-trips back to null (via SetNullable), while still parsing "true"/"false" through the accessor like Set does. *@ break; case BitDataGridColumnDataType.Number: break; case BitDataGridColumnDataType.Date: break; case BitDataGridColumnDataType.DateTime: break; case BitDataGridColumnDataType.DateTimeOffset: break; case BitDataGridColumnDataType.Enum when Column.Accessor is not null && Column.Accessor.UnderlyingType.IsEnum: break; } @@ -74,6 +82,11 @@ private object? Value => Column.GetValue(Item); private string GetString() => Value?.ToString() ?? string.Empty; + // Each built-in editor renders a bare control with no visible
@@ -383,6 +385,7 @@ .grid td {
"; @@ -630,6 +633,7 @@ .grid td {
";