diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor index a3e59460a4..e3e17c9e50 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor @@ -1,138 +1,603 @@ +@typeparam TItem @namespace Bit.BlazorUI -@typeparam TGridItem - - @{ - StartCollectingColumns(); +@* Collect column definitions (these render no visible markup) *@ + + @ChildContent + + +
"auto", _ => "ltr" })"> + + @* ---------------------------------------------------------- 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) + { + + } + @if (ShowColumnChooser) + { + + } +
+
} - @(Columns ?? ChildContent) - - @{ - FinishCollectingColumns(); - } - - - - - - @_renderColumnHeaders - - - - @if (Virtualize) - { - + + @if (_showColumnChooserPanel) + { +
+ @foreach (var col in AllColumns) + { + + } +
+ } + + @if (PagingActive && (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"; +
+ + @{ + RenderFragment headerInner =@ + @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 (ColumnSortable(column)) + { + @* A real button gives native keyboard activation (Enter/Space) and an + implicit button role, so no custom keydown handling is needed and + Space won't scroll the page. *@ + + } + else + { + + @headerInner + + } + + @if (ColumnGroupable(column)) + { + var grouped = IsGrouped(column); + var groupClass = grouped ? "bit-dtg-icon-btn bit-dtg-group-btn bit-dtg-active" : "bit-dtg-icon-btn bit-dtg-group-btn"; + var groupLabel = grouped ? $"Ungroup by {column.DisplayTitle}" : $"Group by {column.DisplayTitle}"; + + } + + @if (ColumnResizable(column)) + { + + } +
+ } + + @if (HasCommandColumn) + { +
Actions
+ } +
+ + @if (!IsTreeMode && (Filterable || VisibleColumns.Any(ColumnFilterable))) + { +
+ @if (HasReorderColumn) + { +
+ } + @if (HasDetailColumn) + { +
+ } + @if (HasSelectColumn) + { +
+ } + @foreach (var column in VisibleColumns) + { +
+ @if (ColumnFilterable(column)) + { + @RenderFilterEditor(column) + } +
+ } + @if (HasCommandColumn) + { +
+ } +
+ } +
+ } + + @* 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) + { + } - else + @if (Loading) + { +
+
Loading…
+
+ } + else if (TotalCount == 0 && PendingNewItem is null && !IsInfiniteMode) + { +
+
+ @if (EmptyTemplate is not null) + { + @EmptyTemplate + } + else + { + No records to display. + } +
+
+ } + else if (_viewGroups is not null) + { + @RenderGroupList(_viewGroups) + } + else if (IsInfiniteMode) { - if (IsLoading && LoadingTemplate is not null) + @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 (PagingActive && (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; + // 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 => { - 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) + // Renders a column filter editor whose input kind, operator and value type match the column's + // data type, so Number/Date/Boolean/Enum columns no longer filter as plain "contains" text. + private RenderFragment RenderFilterEditor(BitDataGridColumn column) => __builder => { - if (RowTemplate is null) + 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) { - RenderOriginalRow(__builder, rowIndex, item); + + } + else if (type is BitDataGridColumnDataType.Date or BitDataGridColumnDataType.DateTime or BitDataGridColumnDataType.DateTimeOffset) + { + + } + else if (type is BitDataGridColumnDataType.Boolean) + { + + } + else if (type is BitDataGridColumnDataType.Enum) + { + } else { - var args = new BitDataGridRowTemplateArgs - { - RowIndex = rowIndex, - RowItem = item, - OriginalRow = (builder) => RenderOriginalRow(builder, rowIndex, item) - }; - __builder.AddContent(0, RowTemplate(args)); + } - } + }; - private void RenderOriginalRow(RenderTreeBuilder __builder, int rowIndex, TGridItem item) + // 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 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) { - - @foreach (var col in _columns) + if (string.IsNullOrWhiteSpace(raw)) + return SetFilterAsync(column, BitDataGridFilterOperator.Equals, null); + + var underlying = column.Accessor?.UnderlyingType; + var inv = System.Globalization.CultureInfo.InvariantCulture; + + switch (column.EffectiveDataType) + { + case BitDataGridColumnDataType.Number: + try + { + var parsed = Convert.ChangeType(raw, underlying ?? typeof(double), inv); + return SetFilterAsync(column, BitDataGridFilterOperator.Equals, parsed); + } + catch { return SetFilterAsync(column, BitDataGridFilterOperator.Equals, null); } + + case BitDataGridColumnDataType.Boolean: + return SetFilterAsync(column, BitDataGridFilterOperator.Equals, + bool.TryParse(raw, out var b) ? b : (object?)null); + + case BitDataGridColumnDataType.Date: + case BitDataGridColumnDataType.DateTime: + case BitDataGridColumnDataType.DateTimeOffset: { - - @{ - col.CellContent(__builder, item); + // DateOnly has no time component, so an exact equality filter is already boundary-safe. + if (underlying == typeof(DateOnly)) + { + return DateOnly.TryParse(raw, inv, System.Globalization.DateTimeStyles.None, out var d) + ? SetFilterAsync(column, BitDataGridFilterOperator.Equals, d) + : SetFilterAsync(column, BitDataGridFilterOperator.Equals, null); + } + + // 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)) + { + // 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 day = new DateTimeOffset(d.Year, d.Month, d.Day, 0, 0, 0, TimeSpan.Zero); + return SetFilterAsync(column, BitDataGridFilterOperator.Equals, day); } - + return SetFilterAsync(column, BitDataGridFilterOperator.Equals, null); + } + else + { + 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); + } } - + + case BitDataGridColumnDataType.Enum: + if (underlying is { IsEnum: true } et && Enum.TryParse(et, raw, true, out var ev)) + return SetFilterAsync(column, BitDataGridFilterOperator.Equals, ev); + return SetFilterAsync(column, BitDataGridFilterOperator.Equals, null); + + default: + return SetFilterAsync(column, BitDataGridFilterOperator.Contains, raw); + } } - private void RenderPlaceholderRow(RenderTreeBuilder __builder, PlaceholderContext placeholderContext) + private static IEnumerable EnumNames(BitDataGridColumn column) { - - @foreach (var col in _columns) - { - - @{ - col.RenderPlaceholderContent(__builder, placeholderContext); - } - - } - + var t = column.Accessor?.UnderlyingType; + return t is { IsEnum: true } ? Enum.GetNames(t) : Enumerable.Empty(); } - private void RenderColumnHeaders(RenderTreeBuilder __builder) + private static string? FormatFilterNumber(object? value) + => value is IFormattable f ? f.ToString(null, System.Globalization.CultureInfo.InvariantCulture) : value?.ToString(); + + private static string? FormatFilterDate(object? value) => value switch { - foreach (var col in _columns) + DateOnly d => d.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture), + DateTime dt => dt.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture), + DateTimeOffset dto => dto.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture), + _ => null + }; + + private string ViewportStyle + { + get { - -
@col.HeaderContent
+ var s = ""; + if (!string.IsNullOrEmpty(Height)) s += $"height:{Height};"; + return s; + } + } - @if (col == _displayOptionsForColumn) - { -
@col.ColumnOptions
- } + private async Task OnHeaderClick(BitDataGridColumn column, MouseEventArgs e) + { + if (!ColumnSortable(column)) return; + await ToggleSortAsync(column, MultiSort && (e.CtrlKey || e.MetaKey)); + } - @if (ResizableColumns) + private RenderFragment RenderPager => @
+
+ @{ + var from = TotalCount == 0 ? 0 : (CurrentPage - 1) * _effectivePageSize + 1; + var to = Math.Min(CurrentPage * _effectivePageSize, TotalCount); + } + @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..9d831c1bca 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,1751 @@ -// 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(); - - // 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 BitDataGridColumnBase? _displayOptionsForColumn; - private BitDataGridColumnBase? _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!; + [Inject] private IJSRuntime JS { get; set; } = default!; + // ---------------------------------------------------------------- Data + [Parameter] public IEnumerable? Items { get; set; } + /// Server-side data callback. When set, the grid delegates sort/filter/page to the caller. + [Parameter] public Func>>? OnRead { get; set; } /// - /// Constructs an instance of . + /// 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. /// - public BitDataGrid() - { - _columns = new(); - _internalGridContext = new(this); - _currentPageItemsChanged = new(EventCallback.Factory.Create(this, RefreshDataCoreAsync)); - _renderColumnHeaders = RenderColumnHeaders; - _renderNonVirtualizedRows = RenderNonVirtualizedRows; + [Parameter] public Func>>? OnLoadMore { 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); - } + /// Number of rows fetched per batch in infinite-scrolling mode. Default: 50. + [Parameter] public int LoadMoreBatchSize { get; set; } = 50; - private bool IsLoading => _pendingDataLoadCancellationTokenSource is not null; + /// Column definitions and other declarative children. + [Parameter] public RenderFragment? ChildContent { get; set; } + [Parameter] public bool Loading { get; set; } + /// Optional key selector used for selection/edit identity. Defaults to reference equality. + [Parameter] public Func? KeyField { get; set; } /// - /// Defines the child components of this instance. For example, you may define columns by adding - /// components derived from the base class. + /// 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 RenderFragment? ChildContent { get; set; } + [Parameter] public Func?>? ChildrenSelector { get; set; } - /// - /// An optional CSS class name. If given, this will be included in the class attribute of the rendered table. - /// + /// When tree mode is active, controls whether nodes start expanded. Default: collapsed. + [Parameter] public bool TreeInitiallyExpanded { get; set; } + + // ------------------------------------------------------------ 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 BitDir Direction { get; set; } = BitDir.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; } /// - /// Alias of the ChildContent parameter. + /// 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 RenderFragment? Columns { get; set; } + [Parameter] public bool CellNavigation { 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. + /// Enables drag-and-drop row reordering using native HTML drag-and-drop (no JS interop). + /// Provide to persist the new order. /// - [Parameter] public Func ItemKey { get; set; } = x => x!; + [Parameter] public bool RowReorderable { get; set; } /// - /// 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. + /// 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. /// - [Parameter] public IQueryable? Items { get; set; } + [Parameter] public EventCallback> OnRowReorder { 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; + // ------------------------------------------------------------- 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; } - /// - /// A callback that supplies data for the rid. - /// - /// You should supply either or , but not both. - /// - [Parameter] public BitDataGridItemsProvider? ItemsProvider { get; set; } + /// Raised when a data cell is clicked. + [Parameter] public EventCallback> OnCellClick { get; set; } - /// - /// The custom template to render while loading the new items. - /// - [Parameter] public RenderFragment? LoadingTemplate { get; set; } + /// Raised when a data cell is double-clicked. + [Parameter] public EventCallback> OnCellDoubleClick { 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 BitDataGridPaginationState? Pagination { get; set; } + /// Raised when a data cell is right-clicked. Useful for custom context menus. + [Parameter] public EventCallback> OnCellContextMenu { get; set; } /// - /// If true, renders draggable handles around the column headers, allowing the user to resize the columns - /// manually. Size changes are not persisted. + /// 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 bool ResizableColumns { get; set; } + [Parameter] public Func? IsRowSelectionDisabled { get; set; } - /// - /// The CSS class of all rows of the data grid. - /// - [Parameter] public string? RowClass { 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; - /// - /// The function to generate the CSS class of each row of the data grid. - /// - [Parameter] public Func? RowClassSelector { get; set; } + // --------------------------------------------------------- Virtualization + [Parameter] public bool Virtualize { get; set; } + [Parameter] public float RowHeight { get; set; } = 36f; /// - /// The CSS style of all row of the data grid. + /// Optional per-row height selector (in pixels). Mirrors react-data-grid's functional + /// rowHeight. Ignored while is enabled, which requires a + /// uniform . /// - [Parameter] public string? RowStyle { get; set; } + [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(); + // 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(); + + // 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; + // 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). + 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; + + // 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(); + 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; + // 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. + // 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. Infinite-scrolling mode + // streams batches with no paging UI and no known total, so paging is suppressed there as well. + internal bool PagingActive => Pageable && _groups.Count == 0 && !IsTreeMode && !IsInfiniteMode; + 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; + + // ------------------------------------------------- Column registration + internal bool AddColumn(BitDataGridColumn column) + { + 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 false; + + _columns.Add(column); + _columnsById[column.Id] = column; + + // 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); + } - /// - /// The function to generate the CSS style of each row of the data grid. - /// - [Parameter] public Func? RowStyleSelector { get; set; } + return true; + } /// - /// 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. + /// 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. /// - [Parameter] public RenderFragment>? RowTemplate { get; set; } + internal void NotifyColumnChanged() + { + if (IsServerMode || IsInfiniteMode) + { + _footerAggregates = BitDataGridDataProcessor.Aggregate(_pageItems, _columns); + InvokeAsync(StateHasChanged); + } + else + { + InvokeAsync(RefreshAsync); + } + } - /// - /// A theme name, with default value "default". This affects which styling rules match the table. - /// - [Parameter] public string? Theme { get; set; } = "default"; + 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(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. + var removedDescriptors = _sorts.RemoveAll(s => s.ColumnId == key) + + _filters.RemoveAll(f => f.ColumnId == key) + + _groups.RemoveAll(g => g.ColumnId == key); + + // Rebuild the view/aggregates the same mode-aware way AddColumn does so dropping a column + // (and any of its sort/filter/group descriptors) immediately updates the rendered rows + // instead of leaving a stale _view/_pageItems. In server/infinite modes a removed + // descriptor changes the active query (a filter/sort no longer applies), so re-load the + // remote data to reflect it; when no descriptor was dropped only the aggregates and render + // need refreshing, matching AddColumn's no-requery behavior during column teardown. + if (IsServerMode || IsInfiniteMode) + { + if (removedDescriptors > 0) + { + InvokeAsync(RefreshAsync); + } + else + { + _footerAggregates = BitDataGridDataProcessor.Aggregate(_pageItems, _columns); + InvokeAsync(StateHasChanged); + } + } + else + { + InvokeAsync(RefreshAsync); + } + } + } /// - /// 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. + /// 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. /// - [Parameter] public bool Virtualize { get; set; } - + internal void UpdateColumnRegistration(BitDataGridColumn column, string oldId) + { + if (oldId == column.Id) return; + + // Only re-key a column that is actually registered. AddColumn skips a duplicate-id registration + // without adding the column to _columns; if such a skipped column later changes its id, re-keying + // here would insert an unregistered column into _columnsById and desync it from _columns. + 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. 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)) + 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); + _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); + } - /// - /// 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) + // ------------------------------------------------------- Lifecycle + protected override async Task OnParametersSetAsync() { - _sortByAscending = direction switch + // 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) { - BitDataGridSortDirection.Ascending => true, - BitDataGridSortDirection.Descending => false, - BitDataGridSortDirection.Auto => _sortByColumn == column ? !_sortByAscending : true, - _ => throw new NotSupportedException($"Unknown sort direction {direction}"), - }; + 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."); + } - _sortByColumn = column; + // 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; - StateHasChanged(); // We want to see the updated sort order in the header, even before the data query is completed - return RefreshDataAsync(); + // 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) + { + // A parent may change SelectionMode and provide SelectedItems in the same render cycle. + // 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(); + await NotifySelectionAsync(); + } + } + else if (SelectedItems is not null) + { + ApplyControlledSelection(); + } + _lastSelectionMode = SelectionMode; + + // 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... + // In client mode there is no such loop, and the parent may mutate the same Items instance + // in place (so the reference is unchanged); force a refresh there so the view never goes stale. + var inputsChanged = !ReferenceEquals(Items, _lastItems) + || PageSize != _lastPageSize + || (!IsServerMode && !IsInfiniteMode); + if (!_dataInitialized || inputsChanged) + { + // A genuinely new Items reference means a new tree hierarchy: clear the tree bootstrap + // state so ProcessTreeData re-applies TreeInitiallyExpanded to the new roots instead of + // carrying over the previous source's expand/collapse state. + if (IsTreeMode && !ReferenceEquals(Items, _lastItems)) + { + _treeInitialized = false; + _expandedTree.Clear(); + } + _lastItems = Items; + _lastPageSize = PageSize; + _dataInitialized = true; + 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) + /// Recomputes the data view (filter → sort → group → page). + public async Task RefreshAsync() { - _displayOptionsForColumn = column; - _checkColumnOptionsPosition = true; // Triggers a call to JS to position the options element, apply autofocus, and any other setup + 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 (IsServerMode) + { + await LoadServerDataAsync(); + } + else + { + ProcessClientData(); + } StateHasChanged(); } + private void ProcessClientData() + { + var source = Items ?? Enumerable.Empty(); + + if (IsTreeMode) + { + ProcessTreeData(source); + return; + } + + var filtered = BitDataGridDataProcessor.Filter(source, _filters, _columnsById); + _view = BitDataGridDataProcessor.Sort(filtered, _sorts, _columnsById); + _footerAggregates = BitDataGridDataProcessor.Aggregate(_view, _columns); + + ClampPage(); + + 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; + } + } + /// - /// Instructs the grid to re-fetch and render the current data from the supplied data source - /// (either or ). + /// 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. /// - /// A that represents the completion of the operation. - public async Task RefreshDataAsync() + private void ProcessTreeData(IEnumerable roots) { - await RefreshDataCoreAsync(); - StateHasChanged(); - } + if (!_treeInitialized && TreeInitiallyExpanded) + { + ExpandTreeRecursive(roots); + _treeInitialized = true; + } + var flat = new List(); + _treeMeta.Clear(); + Walk(roots, 0); + _treeRows = flat; + _view = flat; + _pageItems = flat; + _viewGroups = null; + _footerAggregates = BitDataGridDataProcessor.Aggregate(flat, _columns); - // Invoked by descendant columns at a special time during rendering - internal void AddColumn(BitDataGridColumnBase column, BitDataGridSortDirection? isDefaultSortDirection) - { - if (_collectingColumns) + void Walk(IEnumerable siblings, int level) { - _columns.Add(column); + var sorted = _sorts.Count > 0 + ? BitDataGridDataProcessor.Sort(siblings.ToList(), _sorts, _columnsById) + : siblings.ToList(); + foreach (var item in sorted) + { + // 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)) + Walk(children!, level + 1); + } + } + } - if (_sortByColumn is null && isDefaultSortDirection.HasValue) + private void ExpandTreeRecursive(IEnumerable siblings) + { + foreach (var item in siblings) + { + // 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) { - _sortByColumn = column; - _sortByAscending = isDefaultSortDirection.Value != BitDataGridSortDirection.Descending; + _expandedTree.Add(GetKey(item)); + ExpandTreeRecursive(children); } } } + // ------------------------------------------------------------- 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)); + internal async Task ToggleTreeNodeAsync(TItem item) + { + var key = GetKey(item); + if (!_expandedTree.Add(key)) _expandedTree.Remove(key); + await RefreshAsync(); + } - /// - protected override Task OnParametersSetAsync() + /// Expands every node in the tree. No-op outside tree mode. + public async Task ExpandAllAsync() { - // The associated pagination state may have been added/removed/replaced - _currentPageItemsChanged.SubscribeOrMove(Pagination?.CurrentPageItemsChanged); + if (!IsTreeMode) return; + _expandedTree.Clear(); + ExpandTreeRecursive(Items ?? Enumerable.Empty()); + await RefreshAsync(); + } - if (Items is not null && ItemsProvider is not null) + /// 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; + + // 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. + _loadVersion++; + + if (_infiniteHandle is not null) { - throw new InvalidOperationException($"BitDataGrid requires one of {nameof(Items)} or {nameof(ItemsProvider)}, but both were specified."); + try { await _infiniteHandle.InvokeVoidAsync("scrollToTop"); } + catch (JSException) { } + catch (JSDisconnectedException) { } } - // 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) + await LoadNextBatchAsync(); + } + + /// + /// 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. Returns true only when a + /// batch was actually appended (so callers can decide whether a viewport re-check is warranted); + /// a no-op call (load already in flight, no more data, or a superseded/cancelled request) returns + /// false. + /// + private async Task LoadNextBatchAsync() + { + if (OnLoadMore is null || _infiniteLoading || !_infiniteHasMore) return false; + + _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(), + Groups = _groups.ToList(), + CancellationToken = ResetLoadCancellation() + }; + var version = _loadVersion; + var appended = false; + + 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 false; + + var loaded = result.Items; + _infiniteItems.AddRange(loaded); + if (loaded.Count < batch) _infiniteHasMore = false; + + _view = _infiniteItems; + _pageItems = _infiniteItems; + _footerAggregates = BitDataGridDataProcessor.Aggregate(_infiniteItems, _columns); + appended = true; + } + catch (OperationCanceledException) when (read.CancellationToken.IsCancellationRequested) + { + // The in-flight batch was superseded by a newer load (sort/filter change or reset) whose + // cancellation token fired. Cancellation from our own token is expected here, so drop this + // batch and let the newer load own the loading state. Any other cancellation (e.g. a + // provider-side timeout) is a real error and propagates. + return false; + } + finally { - _lastAssignedItemsOrProvider = _newItemsOrItemsProvider; - _asyncQueryExecutor = AsyncQueryExecutorSupplier.GetAsyncQueryExecutor(_services, Items); + // 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(); + } } - var mustRefreshData = dataSourceHasChanged - || (Pagination?.GetHashCode() != _lastRefreshedPaginationStateHash); + // Only after a batch was genuinely appended do we ask JS whether the viewport still isn't + // filled (e.g. a short first batch) and therefore needs another load. Gating the re-check on a + // real append is essential: re-checking after a no-op load would spin a tight JS<->.NET loop — + // while the initial batch is still in flight the viewport is empty (so it always looks "near the + // end"), every check() would re-enter here, hit the _infiniteLoading guard, return immediately + // and re-check again, starving the in-flight batch's continuation and freezing the UI thread. + if (appended && _infiniteHasMore && _infiniteHandle is not null) + { + try { await _infiniteHandle.InvokeVoidAsync("check"); } + catch (JSException) { } + catch (JSDisconnectedException) { } + } - // 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; + return appended; + } + + /// Invoked from JavaScript when the viewport is scrolled near its end. + [JSInvokable] + public async Task OnInfiniteScrollNearEndAsync() + { + if (!IsInfiniteMode) return false; + // Returns whether the JS watcher should re-check the viewport: only when this load actually + // appended a batch and more data may remain. At end-of-data (or a no-op load) this returns + // false so the watcher stops re-invoking, instead of spinning a tight JS<->.NET loop. + var appended = await LoadNextBatchAsync(); + return appended && _infiniteHasMore; } protected override async Task OnAfterRenderAsync(bool firstRender) { - if (firstRender) + if (IsInfiniteMode && !_infiniteObserverAttached) { - _jsEventDisposable = await _js.BitDataGridInit(_tableReference); + _infiniteObserverAttached = true; + _infiniteSelfRef ??= DotNetObjectReference.Create(this); + _infiniteHandle = await JS.InvokeAsync( + "BitBlazorUI.DataGrid.initInfiniteScroll", _infiniteViewport, _infiniteSelfRef, 200); } - - if (_checkColumnOptionsPosition && _displayOptionsForColumn is not null) + else if (!IsInfiniteMode && _infiniteObserverAttached) { - _checkColumnOptionsPosition = false; - _ = _js.BitDataGridCheckColumnOptionsPosition(_tableReference); + // Infinite mode was turned off (OnLoadMore became null) after the observer was attached. + // Tear the JS observer down so it can't keep firing OnInfiniteScrollNearEndAsync against a + // grid that no longer streams batches, and reset the state so a later re-enable re-attaches. + _infiniteObserverAttached = false; + var handle = _infiniteHandle; + _infiniteHandle = null; + if (handle is not null) + { + try + { + await handle.InvokeVoidAsync("dispose"); + await handle.DisposeAsync(); + } + catch (JSDisconnectedException) { } + catch (JSException) { } + } + // Drop the callback reference too; a later re-enable recreates it via the ??= above. + _infiniteSelfRef?.Dispose(); + _infiniteSelfRef = null; } } + public async ValueTask DisposeAsync() + { + try + { + if (_infiniteHandle is not null) + { + await _infiniteHandle.InvokeVoidAsync("dispose"); + await _infiniteHandle.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(); + 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() + { + // Cancel but do NOT dispose the previous source: an OnRead/OnLoadMore call may still be holding + // its token and disposing it now would surface an ObjectDisposedException (e.g. on token + // registration) instead of the expected OperationCanceledException. The orphaned source has no + // timer or registrations of its own, so it is cheap to let the GC reclaim it once the in-flight + // operation observes cancellation and completes. + _loadCts?.Cancel(); + _loadCts = new CancellationTokenSource(); + _loadVersion++; + return _loadCts.Token; + } - private void StartCollectingColumns() + private async Task LoadServerDataAsync() { - _columns.Clear(); - _collectingColumns = true; + 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(), + Groups = _groups.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; + BitDataGridReadResult result; + try + { + result = await OnRead!(request); + } + catch (OperationCanceledException) when (request.CancellationToken.IsCancellationRequested) + { + // Superseded by a newer request whose cancellation token fired; cancellation from our own + // token is expected, so keep the existing state and let the newer load complete. Any other + // cancellation (e.g. a provider-side timeout) is a real error and propagates. + return; + } + if (version != _loadVersion) return; + _pageItems = result.Items; + _view = result.Items; + _totalCount = result.TotalCount; + _footerAggregates = BitDataGridDataProcessor.Aggregate(_pageItems, _columns); + _viewGroups = null; + + // If the server reported fewer rows than the requested page range implies, the page we just + // fetched is out of range and produced an empty/short slice. Clamp to the last valid page and, + // when that actually moved us, re-fetch so the UI shows the clamped page's data instead of the + // stale out-of-range result. The clamped page is valid for the new TotalCount, so this recurses + // at most once. + var pageBeforeClamp = _currentPage; + ClampPage(); + if (Pageable && _currentPage != pageBeforeClamp) + { + await LoadServerDataAsync(); + } } - private void FinishCollectingColumns() + private void ClampPage() { - _collectingColumns = false; + var pages = TotalPages; + if (_currentPage > pages) _currentPage = pages; + if (_currentPage < 1) _currentPage = 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() + // ------------------------------------------------------------- Sorting + internal bool ColumnSortable(BitDataGridColumn column) + => column.HasField && (column.Sortable ?? Sortable); + + internal BitDataGridSortDescriptor? GetSort(BitDataGridColumn column) + => _sorts.FirstOrDefault(s => s.ColumnId == column.Id); + + internal async Task ToggleSortAsync(BitDataGridColumn column, bool additive) { - // Move into a "loading" state, cancelling any earlier-but-still-pending load - _pendingDataLoadCancellationTokenSource?.Cancel(); - var thisLoadCts = _pendingDataLoadCancellationTokenSource = new CancellationTokenSource(); + if (!ColumnSortable(column)) return; + var existing = GetSort(column); + + if (!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); + } - if (_virtualizeComponent is not null) + if (existing is null) + { + 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)) { - // 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; + existing.Direction = column.SortDescendingFirst + ? BitDataGridSortDirection.Ascending + : BitDataGridSortDirection.Descending; } 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; - } + _sorts.Remove(existing); } + Reprioritize(); + await RefreshAsync(); } - // Gets called both by RefreshDataCoreAsync and directly by the Virtualize child component during scrolling - private async ValueTask> ProvideVirtualizedItems(ItemsProviderRequest request) + private void Reprioritize() { - _lastRefreshedPaginationStateHash = Pagination?.GetHashCode(); + for (int i = 0; i < _sorts.Count; i++) _sorts[i].Priority = i + 1; + } - // 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) + // ----------------------------------------------------------- Filtering + internal bool ColumnFilterable(BitDataGridColumn column) + // 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); + + internal async Task SetFilterAsync(BitDataGridColumn column, BitDataGridFilterOperator op, object? value) + { + var existing = GetFilter(column); + // Treat a null, empty or whitespace-only value as "no filter" so a box cleared to spaces clears + // the filter rather than being stored as a criterion. This matches the data processor, which + // also ignores whitespace-only filter values, keeping remote and client modes consistent. + var isEmpty = value is null || (value is string s && string.IsNullOrWhiteSpace(s)); + if (isEmpty && op is not (BitDataGridFilterOperator.IsEmpty or BitDataGridFilterOperator.IsNotEmpty)) { - return default; + // 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) + { + _filters.Add(new BitDataGridFilterDescriptor { ColumnId = column.Id, Operator = op, Value = value }); + } + else + { + existing.Operator = op; + existing.Value = value; + } + _currentPage = 1; + await RefreshAsync(); + } - // Combine the query parameters from Virtualize with the ones from PaginationState - var startIndex = request.StartIndex; - var count = request.Count; - if (Pagination is not null) + // 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) { - startIndex += Pagination.CurrentPageIndex * Pagination.ItemsPerPage; - count = Math.Min(request.Count, Pagination.ItemsPerPage - request.StartIndex); + _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(); + } - var providerRequest = new BitDataGridItemsProviderRequest( - startIndex, count, _sortByColumn, _sortByAscending, request.CancellationToken); - var providerResult = await ResolveItemsRequestAsync(providerRequest); + public async Task ClearFiltersAsync() + { + _filters.Clear(); + await RefreshAsync(); + } - 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; + // ----------------------------------------------------------- Grouping + 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. Tree mode likewise flattens the hierarchy without + // grouping. Disable it until those flows carry grouping. + => column.HasField && !IsServerMode && !IsInfiniteMode && !IsTreeMode && (column.Groupable ?? Groupable); - Pagination?.SetTotalItemCountAsync(providerResult.TotalItemCount); + internal bool IsGrouped(BitDataGridColumn column) => _groups.Any(g => g.ColumnId == column.Id); - // 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 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(); + } - return default; + /// Removes all active groupings. + public async Task ClearGroupsAsync() + { + if (_groups.Count == 0) return; + _groups.Clear(); + await RefreshAsync(); } - // Normalizes all the different ways of configuring a data source so they have common GridItemsProvider-shaped API - private async ValueTask> ResolveItemsRequestAsync(BitDataGridItemsProviderRequest request) + internal int GroupLevel(BitDataGridColumn column) { - if (ItemsProvider is not null) + var idx = _groups.FindIndex(g => g.ColumnId == column.Id); + return idx < 0 ? -1 : idx + 1; + } + + internal bool IsGroupCollapsed(BitDataGridGroup group) => _collapsedGroups.Contains(group.Path); + internal void ToggleGroup(BitDataGridGroup group) + { + if (!_collapsedGroups.Add(group.Path)) _collapsedGroups.Remove(group.Path); + StateHasChanged(); + } + + // ---------------------------------------------------------- Selection + internal bool SelectionEnabled => SelectionMode != BitDataGridSelectionMode.None; + + /// 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) { - return await ItemsProvider(request); + _selected.Clear(); + if (selected) _selected.Add(item); } - else if (Items is not null) + else { - 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 BitDataGridItemsProviderResult.From(resultArray, totalItemCount); + if (selected) _selected.Add(item); else _selected.Remove(item); } - else + await NotifySelectionAsync(); + } + + internal bool AllPageSelected + { + get { - return BitDataGridItemsProviderResult.From(Array.Empty(), 0); + 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)); } } - private string AriaSortValue(BitDataGridColumnBase column) - => _sortByColumn == column - ? (_sortByAscending ? "ascending" : "descending") - : "none"; + internal async Task ToggleSelectAllAsync(bool value) + { + foreach (var item in _pageItems) + { + if (!CanSelectRow(item)) continue; + if (value) _selected.Add(item); else _selected.Remove(item); + } + await NotifySelectionAsync(); + } - private string? ColumnHeaderClass(BitDataGridColumnBase column) - => _sortByColumn == column - ? $"{ColumnClass(column)} {(_sortByAscending ? "bit-dtg-csa" : "bit-dtg-csd")}" - : ColumnClass(column); + private async Task NotifySelectionAsync() + { + if (SelectedItemsChanged.HasDelegate) + await SelectedItemsChanged.InvokeAsync(_selected.ToList()); + StateHasChanged(); + } - private string GridClass() - => $"bit-dtg {Class} {((IsLoading && LoadingTemplate is null) ? "loading" : null)}".Trim(); + /// + /// 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); + if (SelectionMode == BitDataGridSelectionMode.Single && _editItem is null) + await ToggleRowSelectionAsync(item, true); + } + + // ------------------------------------------------------ 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(); + } + + // ---------------------------------------------------------- Editing + internal bool ColumnEditable(BitDataGridColumn column) + // Value-type rows are excluded from inline editing: TItem is held (and passed to the property + // setter) by value, so edits would mutate a throwaway copy and never persist back to the bound + // data. Disallowing the editor here avoids silently discarding the user's input. + => !typeof(TItem).IsValueType + && column.HasField && column.Accessor?.CanWrite == true && (column.Editable ?? Editable); - private void CloseColumnOptions() + internal void BeginEdit(TItem item) { - _displayOptionsForColumn = null; + _editItem = item; + _isNewItem = false; + SnapshotEdit(item); + StateHasChanged(); } - private string? GetRowClass(TGridItem item) + internal async Task AddNewRowAsync() { - var classes = new List(); + if (NewItemFactory is null) return; + var item = NewItemFactory(); + _pendingNew = item; + _editItem = item; + _isNewItem = true; + _editSnapshot = null; + if (OnRowCreate.HasDelegate) await OnRowCreate.InvokeAsync(item); + StateHasChanged(); + } - if (RowClass is not null) + private void SnapshotEdit(TItem item) + { + _editSnapshot = new Dictionary(); + foreach (var col in _columns.Where(ColumnEditable)) + _editSnapshot[col.Id] = col.GetValue(item); + } + + internal async Task CommitEditAsync() + { + 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) { - classes.Add(RowClass); + foreach (var (colId, value) in _editSnapshot) + if (_columnsById.TryGetValue(colId, out var col)) + col.Accessor?.SetValue(item, value); } + _editItem = default; + _pendingNew = default; + _editSnapshot = null; + _isNewItem = false; + if (OnRowCancel.HasDelegate) await OnRowCancel.InvokeAsync(item); + StateHasChanged(); + } - if (RowClassSelector is not null) + 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 == 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; + 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; } + if (!ColumnReorderable(_dragColumn) || !ColumnReorderable(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 + // 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 + /// 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 (!RowReorderEnabled) return; + + // 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++) { - classes.Add(RowClassSelector(item)); + if (KeyEquals(view[i], row)) { from = i; break; } } + if (from < 0) return; + + var to = from + delta; + if (to < 0 || to >= view.Count) return; - return classes.Any() ? string.Join(' ', classes) : null; + _dragRow = row; + await DropRowAsync(view[to]); } - private string? GetRowStyle(TGridItem item) + internal async Task DropRowAsync(TItem target) { - var styles = new List(); + if (!RowReorderEnabled) { _dragRow = default; return; } + if (_dragRow is null || KeyEquals(_dragRow, target)) { _dragRow = default; return; } + + var dragged = _dragRow; + _dragRow = default; + + // Determine indices within the bound source. + if (Items is IList list) + { + var from = IndexOfByKey(list, dragged); + var to = IndexOfByKey(list, target); + if (from < 0 || to < 0) return; + + if (!list.IsReadOnly) + { + list.RemoveAt(from); + list.Insert(to, dragged); + } - if (RowStyle is not null) + if (OnRowReorder.HasDelegate) + await OnRowReorder.InvokeAsync(new BitDataGridRowReorderEventArgs + { + DraggedItem = dragged, + TargetItem = target, + FromIndex = from, + ToIndex = to + }); + + await RefreshAsync(); + } + else if (OnRowReorder.HasDelegate) { - styles.Add(RowStyle); + await OnRowReorder.InvokeAsync(new BitDataGridRowReorderEventArgs + { + DraggedItem = dragged, + TargetItem = target, + FromIndex = null, + ToIndex = null + }); } + } + + // -------------------------------------------------------- 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)); + } - if (RowStyleSelector is not null) + 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 }; + + // ------------------------------------------------- Keyboard cell navigation + /// The flat, ordered list of rows the keyboard navigation moves across. + internal IReadOnlyList NavigableRows => _pageItems; + + internal bool IsCellFocused(TItem item, int colIndex) + => _focusedRow is not null && KeyEquals(_focusedRow, item) && _focusedCol == colIndex; + + /// Roving tabindex: only one cell is in the tab order at a time. + internal int CellTabIndex(TItem item, int colIndex) + { + var rows = NavigableRows; + + // If a focused row is set and still present in the current view, keep its cell tabbable. + if (_focusedRow is not null) { - styles.Add(RowStyleSelector(item)); + bool focusedRowVisible = false; + for (int i = 0; i < rows.Count; i++) + { + if (KeyEquals(rows[i], _focusedRow)) { focusedRowVisible = true; break; } + } + + if (focusedRowVisible) + return IsCellFocused(item, colIndex) ? 0 : -1; + // Otherwise the focused row was paged/filtered/sorted away: fall back to the first cell below. } - return styles.Any() ? string.Join(';', styles) : null; + return rows.Count > 0 && KeyEquals(rows[0], item) && colIndex == 0 ? 0 : -1; } + /// Records the focused cell when the user clicks/tabs into it (no re-focus needed). + internal void SetFocusedCell(TItem item, int colIndex) + { + if (IsCellFocused(item, colIndex)) return; + _focusedRow = item; + _focusedCol = colIndex; + StateHasChanged(); + } + internal bool ShouldFocusCell(TItem item, int colIndex) => _focusPending && IsCellFocused(item, colIndex); + internal void ClearFocusPending() => _focusPending = false; - private static string? ColumnClass(BitDataGridColumnBase column) => column.Align switch + /// 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() { - BitDataGridAlign.Center => $"bit-dtg-cjc {column.Class}", - BitDataGridAlign.Right => $"bit-dtg-cje {column.Class}", - _ => column.Class, - }; + 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 == BitDir.Rtl; + var handled = true; + // Horizontal travel direction in column-index space (used to skip over spanned-away columns). + int colDir = 0; - /// - public async ValueTask DisposeAsync() + switch (e.Key) + { + 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; colDir = -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; + + 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; + StateHasChanged(); + } + + private int IndexOfRow(IReadOnlyList rows, TItem item) { - await DisposeAsync(true); - GC.SuppressFinalize(this); + for (int i = 0; i < rows.Count; i++) + if (KeyEquals(rows[i], item)) return i; + return -1; } - protected virtual async ValueTask DisposeAsync(bool disposing) + // ----------------------------------------------------- Column spanning + /// Resolves the effective column span for a data cell (clamped to remaining columns). + internal int ResolveColSpan(BitDataGridColumn column, TItem item) { - if (_disposed || disposing is false) return; + if (column.ColSpan is null) return 1; + var span = column.ColSpan(item) ?? 1; + if (span < 1) span = 1; + var cols = VisibleColumns; + 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); + } - _pendingDataLoadCancellationTokenSource?.Cancel(); - _pendingDataLoadCancellationTokenSource?.Dispose(); + /// + /// 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; + } - _currentPageItemsChanged.Dispose(); + 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; + } - try + // ------------------------------------------------- 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) { - if (_jsEventDisposable is not null) + var name = string.IsNullOrEmpty(col.Group) ? null : col.Group; + if (started && name == current) { - await _jsEventDisposable.InvokeVoidAsync("stop"); - await _jsEventDisposable.DisposeAsync(); + count++; } + else + { + if (started) spans.Add((current, count)); + current = name; + count = 1; + started = true; + } + } + if (started) spans.Add((current, count)); + return spans; + } + + // ------------------------------------------------------------- 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(); + } - //if (_jsModule is not null) - //{ - // await _jsModule.DisposeAsync(); - //} + // ------------------------------------------------------- Column chooser + internal void ToggleColumnChooser() { _showColumnChooserPanel = !_showColumnChooserPanel; StateHasChanged(); } + 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; + StateHasChanged(); + } + + // ----------------------------------------------------------- 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); + + /// Finds the index of in using the grid's + /// key-based identity () so re-materialized rows still resolve when KeyField is set. + private int IndexOfByKey(IList list, TItem item) + { + for (int i = 0; i < list.Count; i++) + { + if (KeyEquals(list[i], item)) return i; } - catch (JSDisconnectedException) + return -1; + } + + /// 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 + /// + /// Generates the current (filtered/sorted) data as CSV and triggers a client-side download. + /// Invoked on demand from the toolbar button so the CSV is built only when the user asks for it, + /// rather than being regenerated into a DOM attribute on every render. + /// + public async Task ExportCsvAsync() + { + var csv = ToCsv(); + try + { + await JS.InvokeVoidAsync("BitBlazorUI.DataGrid.download", "export.csv", csv, "text/csv;charset=utf-8"); + } + catch (JSDisconnectedException) { } + catch (JSException) { } + } + + /// Builds a CSV string of the current (filtered/sorted) data. + public string ToCsv() + { + 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) { - // The JS side may routinely be gone already if the reason we're disposing is that - // the client disconnected. This is not an error. + // Neutralise CSV formula injection: spreadsheet apps may execute a cell whose text begins + // with =, +, - or @ as a formula. Leading whitespace can be used to bypass a naive first-char + // check (the app trims it before evaluating), so test the trimmed value but keep the original + // (whitespace included) when prefixing with a single quote to force it to be read as text. + var trimmed = v.TrimStart(' ', '\t', '\n', '\r'); + if (trimmed.Length > 0 && (trimmed[0] is '=' or '+' or '-' or '@')) + v = "'" + v; + + return v.Contains(',') || v.Contains('"') || v.Contains('\n') || v.Contains('\r') + ? "\"" + v.Replace("\"", "\"\"") + "\"" + : v; } - catch (JSException ex) + } + + // ----------------------------------------------------- Layout helpers + internal bool HasSelectColumn => SelectionMode == BitDataGridSelectionMode.Multiple; + internal bool HasDetailColumn => DetailTemplate is not null; + internal bool HasCommandColumn => Editable; + internal bool HasReorderColumn => RowReorderEnabled; + + 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); + + /// 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) + { + 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)"; + } + + /// + /// 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() + { + 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); + } + + private int TotalColumnSpan => + VisibleColumns.Count + (HasReorderColumn ? 1 : 0) + (HasDetailColumn ? 1 : 0) + (HasSelectColumn ? 1 : 0) + (HasCommandColumn ? 1 : 0); + + 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; + } + + 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 == BitDir.Rtl) c += " bit-dtg-rtl"; + if (!string.IsNullOrEmpty(Class)) c += " " + Class; + return c; + } + + 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 == BitDir.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..1c7dedb075 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.scss +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.scss @@ -1,139 +1,599 @@ +/* ============================================================ + BitDataGrid - styles + 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 { - width: 100%; - --bit-dtg-col-gap: 1rem; + --bit-dtg-cell-pad: 8px 10px; - th { - position: relative; - } + 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; + box-sizing: border-box; +} - > thead > tr > th { - font-weight: normal; - } +.bit-dtg *, +.bit-dtg *::before, +.bit-dtg *::after { + box-sizing: border-box; +} - &.loading > tbody { - opacity: 0.25; - transition-delay: 25ms; - transition: opacity linear 100ms; - } +.bit-dtg.bit-dtg-bordered { + border: 1px solid var(--bit-clr-brd-ter); +} - > tbody > tr > td { - padding: 0.1rem calc(0.4rem + var(--bit-dtg-col-gap)) 0.1rem 0.4rem; - } +/* ---------------------------------------------------------- Toolbar */ +.bit-dtg-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + padding: 8px; + 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-hct { +.bit-dtg-column-chooser { display: flex; - position: relative; + flex-wrap: wrap; + gap: 12px; + padding: 10px; + 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; - padding-inline-end: var(--bit-dtg-col-gap); + cursor: pointer; } -.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); +/* ---------------------------------------------------------- Viewport */ +.bit-dtg-viewport { + overflow: auto; + position: relative; } -.bit-dtg-cob { - width: 1.5rem; - background: unset; +.bit-dtg-table { + display: block; + min-width: 100%; +} - &::before { - content: "\E712"; - font-style: normal; - font-weight: normal; - display: inline-block; - font-family: 'Fabric MDL2 bit BlazorUI Extras'; - } +/* ---------------------------------------------------------- Rows */ +.bit-dtg-row { + display: grid; + grid-template-columns: var(--bit-dtg-template); + align-items: stretch; + border-bottom: 1px solid var(--bit-clr-brd-ter); } -.bit-dtg-drg { - width: 1rem; - cursor: ew-resize; - 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-header { + position: sticky; + top: 0; + z-index: 3; } -.bit-dtg-srt { - width: 1rem; - height: 1rem; - opacity: 0.5; - align-self: center; - text-align: center; +.bit-dtg-header-row, +.bit-dtg-filter-row { + background: var(--bit-clr-bg-sec); } -.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-filter-row { + border-bottom: 1px solid var(--bit-clr-brd-ter); } -.bit-dtg-csd .bit-dtg-srt { - transform: scaleY(-1) translateY(-2px); +.bit-dtg-hcell { + display: flex; + align-items: center; + gap: 4px; + padding: var(--bit-dtg-cell-pad); + font-weight: 600; + position: relative; + background: var(--bit-clr-bg-sec); + border-inline-end: 1px solid transparent; + user-select: none; + overflow: hidden; } -.bit-dtg-ctl { - gap: 0.4rem; - flex-grow: 1; +.bit-dtg-cell { + padding: var(--bit-dtg-cell-pad); display: flex; - min-width: 0px; - font-size: 1rem; - font-weight: bold; - padding: 0.1rem 0.4rem; + align-items: center; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + background: var(--bit-clr-bg-pri); +} + +.bit-dtg-bordered .bit-dtg-cell, +.bit-dtg-bordered .bit-dtg-hcell { + border-inline-end: 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; } -button.bit-dtg-ctl { +/* 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); +} + +/* 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 { + 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); +} + +/* 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; + /* 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; +} + +/* When the header is sortable it renders as a real + + } + + @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); + + // 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) + { + + @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); + + // 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. + // 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; + // 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/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/BitDataGridPropertyColumn.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Columns/BitDataGridPropertyColumn.cs deleted file mode 100644 index 3fe85ed0bc..0000000000 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Columns/BitDataGridPropertyColumn.cs +++ /dev/null @@ -1,69 +0,0 @@ -using System.Linq.Expressions; - -namespace Bit.BlazorUI; - -/// -/// 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 -{ - private Expression>? _lastAssignedProperty; - private Func? _cellTextFunc; - private BitDataGridSort? _sortBuilder; - - /// - /// Defines the value to be displayed in this column's cells. - /// - [Parameter, EditorRequired] public Expression> Property { get; set; } = default!; - - /// - /// Optionally specifies a format string for the value. - /// - /// Using this requires the type to implement . - /// - [Parameter] public string? Format { get; set; } - - BitDataGridSort? IBitDataGridSortBuilderColumn.SortBuilder => _sortBuilder; - - - /// - protected override void OnParametersSet() - { - // We have to do a bit of pre-processing on the lambda expression. Only do that if it's new or changed. - if (_lastAssignedProperty != Property) - { - _lastAssignedProperty = Property; - var compiledPropertyExpression = Property.Compile(); - - if (Format.HasValue()) - { - if (typeof(IFormattable).IsAssignableFrom(typeof(TProp))) - { - _cellTextFunc = item => ((IFormattable?)compiledPropertyExpression!(item))?.ToString(Format, null); - - } - else - { - throw new InvalidOperationException($"A '{nameof(Format)}' parameter was supplied, but the type '{typeof(TProp)}' does not implement '{typeof(IFormattable)}'."); - } - } - else - { - _cellTextFunc = item => compiledPropertyExpression!(item)?.ToString(); - } - - _sortBuilder = BitDataGridSort.ByAscending(Property); - } - - if (Title is null && Property.Body is MemberExpression memberExpression) - { - Title = memberExpression.Member.Name; - } - } - - /// - protected internal override void CellContent(RenderTreeBuilder builder, TGridItem item) - => builder.AddContent(0, _cellTextFunc!(item)); -} 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..b512a6090d --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridDataProcessor.cs @@ -0,0 +1,341 @@ +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), BitDataGridValueEqualityComparer.Instance) + .Select(g => + { + var keyText = column.FormatValue(g.Key); + var items = g.ToList(); + // 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().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 + // 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, + Key = g.Key, + KeyText = keyText, + Level = level, + Path = path, + 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 (!isLeaf) + group.SubGroups.AddRange(BuildGroups(items, groups, columns, level + 1, path)); + group.Aggregates.AddRange(Aggregate(items, columns.Values)); + return group; + }); + + grouped = descriptor.Direction switch + { + // None: preserve the original group encounter order rather than implicitly sorting ascending. + BitDataGridSortDirection.None => grouped, + 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: + { + decimal sum = 0; int n = 0; + foreach (var item in source) + { + if (TryToDecimal(accessor.GetValue(item), out var d)) { sum += d; n++; } + } + if (column.Aggregate == BitDataGridAggregateType.Sum) return sum; + return n == 0 ? 0m : 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 TryToDecimal(object? value, out decimal result) + { + result = 0; + if (value is null) return false; + try { result = Convert.ToDecimal(value, CultureInfo.InvariantCulture); return true; } + catch { return false; } + } + + 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: + return value is not null && !string.IsNullOrEmpty(value.ToString()); + } + + // 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 + 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 + }; + } + + // The date filter editor emits a calendar day at midnight (no time component). Comparing it + // with strict equality against a DateTime/DateTimeOffset row value that carries a time-of-day + // would never match, so equality filters on those types are evaluated on the calendar day + // only (day-range semantics). DateOnly has no time component and keeps its existing behavior. + if (filter.Operator is BitDataGridFilterOperator.Equals or BitDataGridFilterOperator.NotEquals + && TryDateOnlyEquals(value, CoerceToValueType(value, filter.Value), out var sameDay)) + { + return filter.Operator is BitDataGridFilterOperator.Equals ? sameDay : !sameDay; + } + + 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 + }; + } + + if (filter.Value is null) + return 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 + }; + } + + // Returns true (via ) when a DateTime/DateTimeOffset row value falls on the + // same calendar day as a date-only filter value (one whose time component is midnight). Used so an + // equality filter coming from the date editor matches the whole day instead of an exact timestamp. + // Returns false when the operands aren't a date/time pair carrying a midnight filter value, leaving + // the caller to fall back to the normal exact comparison. + private static bool TryDateOnlyEquals(object? value, object? filterValue, out bool equal) + { + equal = false; + if (value is DateTime vdt && filterValue is DateTime fdt && fdt.TimeOfDay == TimeSpan.Zero) + { + equal = vdt.Date == fdt.Date; + return true; + } + if (value is DateTimeOffset vdto && filterValue is DateTimeOffset fdto && fdto.TimeOfDay == TimeSpan.Zero) + { + equal = vdto.Date == fdto.Date; + return true; + } + return false; + } + + // Coerces a filter operand to the row value's runtime type before comparison. This mirrors the + // type-specific parsing in BitDataGridPropertyAccessor.TryConvertValue (Guid/DateOnly/TimeOnly/ + // DateTimeOffset and enums are not handled by Convert.ChangeType), so a filter value entered as a + // string is converted to the property's real type the same way edits are — keeping filtering, + // sorting and editing consistent. Parsing uses the invariant culture to match the ISO/invariant + // strings the editors emit. On failure the original value is returned so the comparer can still + // fall back to its string-based ordering. + 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 es ? Enum.Parse(target, es, true) : Enum.ToObject(target, filterValue); + if (target == typeof(Guid)) + return filterValue is Guid g ? g : Guid.Parse(filterValue.ToString()!); + if (target == typeof(DateOnly)) + return filterValue is DateOnly d ? d : DateOnly.Parse(filterValue.ToString()!, CultureInfo.InvariantCulture); + if (target == typeof(TimeOnly)) + return filterValue is TimeOnly t ? t : TimeOnly.Parse(filterValue.ToString()!, CultureInfo.InvariantCulture); + if (target == typeof(DateTimeOffset)) + // DateTimeOffset is not IConvertible, so Convert.ChangeType would throw for it; parse it + // explicitly like the property accessor does. + return filterValue is DateTimeOffset dto ? dto : DateTimeOffset.Parse(filterValue.ToString()!, CultureInfo.InvariantCulture); + return Convert.ChangeType(filterValue, target, CultureInfo.InvariantCulture); + } + 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..ebdf3328be --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridGroup.cs @@ -0,0 +1,41 @@ +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 +{ + /// The identifier of the column whose values define this group. + public required string ColumnId { get; init; } + public required object? Key { get; init; } + + /// The display text for this group's key, shown in the group header row. + 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; } + + /// + /// 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. + public List> SubGroups { get; init; } = new(); + + /// Aggregate values computed for this group (e.g. column sums or averages). + public List Aggregates { get; init; } = new(); + + public bool HasSubGroups => SubGroups.Count > 0; + + /// 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 new file mode 100644 index 0000000000..406807bb6d --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridPropertyAccessor.cs @@ -0,0 +1,204 @@ +using System.Collections.Concurrent; +using System.Globalization; +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; + // Only write when the value can actually be coerced to the property's type. Silently + // substituting the type's default (e.g. 0) for unparseable input would discard the user's + // entry without any feedback, so reject the conversion failure instead. + if (TryConvertValue(value, out var converted)) + _setter(item, converted); + } + + /// + /// 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(); + + /// + /// Attempts to coerce an arbitrary value into the property's type. Returns false when the + /// value cannot be converted, letting callers reject invalid input rather than overwrite it. + /// + 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 — 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) + { + // 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; + } + + if (PropertyType.IsInstanceOfType(value)) + { + result = value; + return true; + } + + var target = UnderlyingType; + try + { + if (target.IsEnum) + result = value is string s ? Enum.Parse(target, s, true) : Enum.ToObject(target, value); + 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()!, CultureInfo.InvariantCulture); + else if (target == typeof(TimeOnly)) + 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. 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 + // 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 + { + result = null; + return false; + } + } + + private object? DefaultValue() + => PropertyType.IsValueType && Nullable.GetUnderlyingType(PropertyType) is null + ? Activator.CreateInstance(PropertyType) + : null; + + public static BitDataGridPropertyAccessor For(string path) + { + 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; + // 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)) + { + 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}'."); + body = Expression.Property(body, prop); + lastProp = prop; + } + + var propertyType = body.Type; + + // 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) + Action? setter = null; + // 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"); + var convertedValue = Expression.Convert(valueParam, propertyType); + 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(); + } + + 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/Infrastructure/BitDataGridValueComparer.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridValueComparer.cs new file mode 100644 index 0000000000..8cb223684b --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridValueComparer.cs @@ -0,0 +1,64 @@ +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; + + // 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); + + // 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); + } +} + +/// +/// Equality comparer that mirrors 's ordering semantics +/// (two values are equal when the comparer ranks them as equal), so grouping keys collapse the same +/// way sorting and Equals-based filtering treat them — e.g. strings group case-insensitively. +/// +internal sealed class BitDataGridValueEqualityComparer : IEqualityComparer +{ + public static readonly BitDataGridValueEqualityComparer Instance = new(); + + public new bool Equals(object? x, object? y) => BitDataGridValueComparer.Instance.Compare(x, y) == 0; + + // Must stay consistent with Equals: values the comparer treats as equal have to hash alike. + // Strings compare case-insensitively, so hash them that way. IComparable values fall back to their + // own hash code (where CompareTo == 0 implies an equal hash for well-behaved types). Any other + // (non-IComparable) value is ranked equal by Compare only when its ToString() matches - the + // comparer's final fallback - so hash it on that same canonical string rather than obj.GetHashCode(), + // otherwise two values the comparer calls equal could hash differently and break grouping/lookups. + // Null hashes to 0. + public int GetHashCode(object? obj) => obj switch + { + null => 0, + string s => StringComparer.OrdinalIgnoreCase.GetHashCode(s), + IComparable => obj.GetHashCode(), + _ => StringComparer.OrdinalIgnoreCase.GetHashCode(obj.ToString() ?? string.Empty) + }; +} 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..a1003d9e9e --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridAggregateResult.cs @@ -0,0 +1,17 @@ +namespace Bit.BlazorUI; + +/// Holds the computed aggregate value for a column footer or group. +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). 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; } + + /// The display-ready string produced by formatting . + public required string FormattedValue { get; init; } +} 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..1793837ca5 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridCellEventArgs.cs @@ -0,0 +1,29 @@ +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; } + // 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. + public string ColumnId => Column.Id; + + /// The column's display title (header text). + public string ColumnTitle => Column.DisplayTitle; + + /// The raw value of the cell. + public required object? Value { get; init; } + + /// The underlying browser mouse event. + public required MouseEventArgs Mouse { get; init; } +} 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..5a2e808945 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridColumnDataType.cs @@ -0,0 +1,14 @@ +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, + DateTime, + DateTimeOffset, + Enum +} 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..0003dee5d9 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridFilterDescriptor.cs @@ -0,0 +1,22 @@ +namespace Bit.BlazorUI; + +/// Describes a filter applied to a single column. +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. 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 + /// unused for value-less operators such as and + /// . + /// + 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..5af3f89191 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridFilterOperator.cs @@ -0,0 +1,20 @@ +namespace Bit.BlazorUI; + +/// Comparison operators available for column filtering. +public enum BitDataGridFilterOperator +{ + /// No operator selected. The default value; such a filter is treated as omitted/invalid. + Unspecified = 0, + Contains, + 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..eb5e33f713 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridReadRequest.cs @@ -0,0 +1,27 @@ +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(); + + /// + /// The active group descriptors, in nesting order. Lets a server-side OnRead handler + /// reconstruct the grouping the grid is displaying. Empty when no grouping is active. + /// + public IReadOnlyList Groups { 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..61f8d61394 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridReadResult.cs @@ -0,0 +1,28 @@ +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) + { + ArgumentNullException.ThrowIfNull(items); + if (totalCount < 0) + throw new ArgumentOutOfRangeException(nameof(totalCount), totalCount, "Total count must be greater than or equal to zero."); + // A single page can never hold more items than the reported grand total. Rejecting it here keeps + // an inconsistent OnRead provider from feeding BitDataGrid a _pageItems/_totalCount pair where the + // page is larger than the total (which would break paging math and the displayed counts). + if (items.Count > totalCount) + throw new ArgumentOutOfRangeException(nameof(totalCount), totalCount, + $"Total count ({totalCount}) must be greater than or equal to the number of items in the result ({items.Count})."); + + 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..7576094c9a --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridRowReorderEventArgs.cs @@ -0,0 +1,24 @@ +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; } + + /// + /// 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/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..b081f1a91a --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridSortDescriptor.cs @@ -0,0 +1,11 @@ +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). 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/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..dfecbc2aad --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor @@ -0,0 +1,141 @@ +@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..a18c020f3b --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.razor.cs @@ -0,0 +1,708 @@ +// 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; + // 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; + // Tracks whether columns have been collected at least once, and the sort column captured at the + // start of a collection pass, so a *new* default sort applied during a later recollection can also + // queue a refresh (the very first collection already loads data via ColumnsFirstCollected). + private bool _columnsCollectedOnce; + private BitQuickGridColumnBase? _sortByColumnBeforeCollect; + + // 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; + // Tracks the Virtualize value the data was last refreshed under, so a flip between virtualized and + // non-virtualized rendering forces a re-query (otherwise the stale non-virtualized view would linger). + private bool? _lastRefreshedVirtualize; + 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; + // 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; + + + + [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 grid. + /// + /// 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() + { + 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(); + } + } + + + + // 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 + || (_lastRefreshedVirtualize != Virtualize) + || (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 + if (_columns.Count > 0 && mustRefreshData) + { + return RefreshDataCoreAsync(); + } + + return Task.CompletedTask; + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (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. + // 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 || !_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) + { + _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() + { + 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() + { + _sortByColumnBeforeCollect = _sortByColumn; + _columns.Clear(); + _collectingColumns = true; + } + + 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; + } + else if (_columnsCollectedOnce && _sortByColumnBeforeCollect is null && _sortByColumn is not null) + { + // A recollection assigned a brand-new default sort (none was active before). The initial + // collection already loads data via ColumnsFirstCollected, but later recollections do not, + // so queue a refresh to re-query in the newly defaulted sort order and keep header/data in sync. + _queueSortReconciliationRefresh = true; + } + + _columnsCollectedOnce = true; + } + + // 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() + { + // Record the Virtualize mode this refresh runs under so every refresh path keeps the marker + // current: the initial column-driven load (via ColumnsFirstCollected), RefreshDataAsync, and the + // parameter-change trigger in OnParametersSetAsync all funnel through here. Updating it only in + // OnParametersSetAsync would leave it stale after those other paths, so a later parameter set + // could wrongly (or never) detect a virtualized/non-virtualized flip. + _lastRefreshedVirtualize = Virtualize; + + // Move into a "loading" state, cancelling any earlier-but-still-pending load. Do NOT dispose + // the previous source here: the load that owns it may still be in flight and holding its token + // (e.g. registered on it), so disposing now could surface an ObjectDisposedException instead of + // the expected OperationCanceledException. Each load disposes its own source in its finally block + // once it has finished using it (whether or not it is still the current one), so a superseded + // source is disposed by its owning load rather than leaked to the GC. + _pendingDataLoadCancellationTokenSource?.Cancel(); + var thisLoadCts = _pendingDataLoadCancellationTokenSource = new CancellationTokenSource(); + + // Render now so the loading state (IsLoading / LoadingTemplate) becomes visible as soon as the + // refresh starts, instead of only after the async load below completes. + StateHasChanged(); + + if (Virtualize) + { + // 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 + // The reference can still be null before it's captured (first render) or right after toggling + // virtualization on; in that case Virtualize will request its own items once it renders, so we + // just reconcile the load-state here. The non-virtualized provider request must never run for a + // virtualized grid. + try + { + if (_virtualizeComponent is not null) + { + await _virtualizeComponent.RefreshDataAsync(); + } + } + finally + { + // Always reconcile the load-state, even if RefreshDataAsync threw, so we don't leak the + // CTS or leave _pendingDataLoadCancellationTokenSource pointing at a disposed instance. + // This load is done with its own source, so dispose it unconditionally; only clear the + // field when it still points at this source (a newer load may already own it). + thisLoadCts.Dispose(); + if (ReferenceEquals(_pendingDataLoadCancellationTokenSource, thisLoadCts)) + { + _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); + try + { + var result = await ResolveItemsRequestAsync(request); + if (!thisLoadCts.IsCancellationRequested) + { + _currentNonVirtualizedViewItems = result.Items; + _ariaBodyRowCount = _currentNonVirtualizedViewItems.Count; + await (Pagination?.SetTotalItemCountAsync(result.TotalItemCount) ?? Task.CompletedTask); + } + } + catch (OperationCanceledException) when (thisLoadCts.IsCancellationRequested) + { + // This load was superseded by a newer request (our own cancellation token fired); swallow + // the cancellation and fall through to the cleanup below so the load-state remains + // consistent. Cancellations from any other source (e.g. a provider-side timeout) propagate. + } + finally + { + // This load is done with its own source, so dispose it unconditionally to avoid leaking + // a superseded source; only clear the field when it still points at this source. + thisLoadCts.Dispose(); + if (ReferenceEquals(_pendingDataLoadCancellationTokenSource, thisLoadCts)) + { + _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) + 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; + } + + // 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.Max(0, Math.Min(request.Count, Pagination.ItemsPerPage - request.StartIndex)); + } + + var providerRequest = new BitQuickGridItemsProviderRequest( + startIndex, count, _sortByColumn, _sortByAscending, request.CancellationToken); + BitQuickGridItemsProviderResult providerResult; + try + { + providerResult = await ResolveItemsRequestAsync(providerRequest); + } + catch (OperationCanceledException) when (request.CancellationToken.IsCancellationRequested) + { + // The request was superseded by a newer one after the debounce window (our own cancellation + // token fired); 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. Cancellations from any other source propagate as real errors. + return default; + } + + 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. + // 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); + + // 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 selected = RowClassSelector?.Invoke(item); + + 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 selected = RowStyleSelector?.Invoke(item); + + if (string.IsNullOrEmpty(RowStyle)) return string.IsNullOrEmpty(selected) ? null : selected; + if (string.IsNullOrEmpty(selected)) return RowStyle; + return $"{RowStyle};{selected}"; + } + + + + 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; + + // Cancel (but don't dispose) any in-flight load: the load that owns this source may still be + // holding its token, so disposing here could race into an ObjectDisposedException. The owning + // load disposes it in its finally block (it's still the current source during disposal), so we + // only signal cancellation here. + _pendingDataLoadCancellationTokenSource?.Cancel(); + + _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..1109021ff7 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.scss @@ -0,0 +1,138 @@ +.bit-qkg { + width: 100%; + --bit-qkg-col-gap: 1rem; + + th { + position: relative; + } + + > thead > tr > th { + font-weight: normal; + } + + &.loading > tbody { + opacity: 0.25; + transition: opacity linear 100ms 25ms; + } + + > 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 var(--bit-clr-brd-pri); + } +} + +.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..03a2585c3a --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/BitQuickGrid.ts @@ -0,0 +1,151 @@ +namespace BitBlazorUI { + export class QuickGrid { + public static init(tableElement: any) { + // 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 }[] = []; + // Holds the teardown for an in-progress column resize drag (the document-level move/up + // listeners installed by handleMouseDown) so stop() can detach them even if disposal + // happens mid-drag, before the pointer is released. + const dragState: { cleanup: (() => void) | null } = { cleanup: null }; + QuickGrid.enableColumnResizing(tableElement, boundDragHandles, dragState); + + 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); + + // Detach any document-level listeners left by an in-progress resize drag and clear + // the active drag state, so disposal/re-init mid-drag can't keep mutating a stale th. + if (dragState.cleanup) { + dragState.cleanup(); + dragState.cleanup = null; + } + + // 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; + } + }; + } + + 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)`; + } else { + // Clear any offset left over from a previous opening so the popup isn't misaligned. + colOptions.style.transform = ''; + } + + if (typeof colOptions.scrollIntoViewIfNeeded === 'function') { + colOptions.scrollIntoViewIfNeeded(); + } else { + // 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]'); + if (autoFocusElem) { + autoFocusElem.focus(); + } + } + } + + private static enableColumnResizing(tableElement: any, boundDragHandles: { handle: any, listener: any }[], dragState: { cleanup: (() => void) | null }) { + 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(); + 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; + // 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`; + } + } + + function handleMouseUp() { + document.body.removeEventListener('mousemove', handleMouseMove); + document.body.removeEventListener('mouseup', handleMouseUp); + document.body.removeEventListener('touchmove', handleMouseMove); + document.body.removeEventListener('touchend', handleMouseUp); + document.body.removeEventListener('touchcancel', handleMouseUp); + dragState.cleanup = null; + } + + 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 }); + } + + // Expose this drag's teardown so stop() can detach the document-level listeners if + // the grid is disposed/re-initialized before the pointer is released. + dragState.cleanup = handleMouseUp; + } + }); + } + } +} 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..74e83b985a --- /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.InvokeAsync("BitBlazorUI.QuickGrid.init", tableElement); + } + + public static async ValueTask BitQuickGridCheckColumnOptionsPosition(this IJSRuntime jsRuntime, ElementReference tableElement) + { + await jsRuntime.InvokeVoidAsync("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 67% 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..cc030ea8e5 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,13 +90,19 @@ protected virtual bool IsSortableByDefault() => false; /// - /// Constructs an instance of . + /// Constructs an instance of . /// - public BitDataGridColumnBase() + public BitQuickGridColumnBase() { HeaderContent = RenderDefaultHeaderContent; } + private string SortButtonLabel() + => string.IsNullOrEmpty(Title) ? "Sort" : $"Sort by {Title}"; + + private string ColumnOptionsLabel() + => string.IsNullOrEmpty(Title) ? "Column options" : $"Column options for {Title}"; + private void RenderDefaultHeaderContent(RenderTreeBuilder __builder) { @if (HeaderTemplate is not null) @@ -105,28 +111,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/QuickGrid/Columns/BitQuickGridPropertyColumn.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridPropertyColumn.cs new file mode 100644 index 0000000000..39ec2eb8fd --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/Columns/BitQuickGridPropertyColumn.cs @@ -0,0 +1,116 @@ +using System.Linq.Expressions; + +namespace Bit.BlazorUI; + +/// +/// 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 BitQuickGridPropertyColumn : BitQuickGridColumnBase, IBitQuickGridSortBuilderColumn +{ + private Expression>? _lastAssignedProperty; + private string? _lastAssignedFormat; + private bool _titleWasExplicitlySet; + private Func? _cellTextFunc; + private BitQuickGridSort? _sortBuilder; + + /// + /// Defines the value to be displayed in this column's cells. + /// + [Parameter, EditorRequired] public Expression> Property { get; set; } = default!; + + /// + /// Optionally specifies a format string for the value. + /// + /// Using this requires the type to implement . + /// + [Parameter] public string? Format { get; set; } + + BitQuickGridSort? IBitQuickGridSortBuilderColumn.SortBuilder => _sortBuilder; + + + /// + public override Task SetParametersAsync(ParameterView parameters) + { + // 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 _); + + // When the consumer stops passing Title, the incoming ParameterView no longer contains it, so + // base.SetParametersAsync leaves the previously assigned (explicit) value in place. Clear it up + // front so a removed Title can't linger as a stale header; OnParametersSet then re-derives the + // auto-generated title from the Property when possible. + if (!_titleWasExplicitlySet) + { + Title = null; + } + + return base.SetParametersAsync(parameters); + } + + + /// + protected override void OnParametersSet() + { + // We have to do a bit of pre-processing on the lambda expression. Only do that if the Property + // or the Format has changed, so a Format-only change still rebuilds the cell formatter. + if (_lastAssignedProperty != Property || _lastAssignedFormat != Format) + { + var compiledPropertyExpression = Property.Compile(); + Func cellTextFunc; + + if (Format.HasValue()) + { + // 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); + } + else + { + throw new InvalidOperationException($"A '{nameof(Format)}' parameter was supplied, but the type '{typeof(TProp)}' does not implement '{typeof(IFormattable)}'."); + } + } + else + { + 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 (_titleWasExplicitlySet) + { + // The consumer supplied Title this render; base.SetParametersAsync already applied it, so + // there is nothing more to do. + } + 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; + } + // else: Property is a method/cast expression (no member name to derive a header from) and no + // explicit Title was supplied. SetParametersAsync already cleared any stale value, so the column + // simply has no header. + } + + /// + protected internal override void CellContent(RenderTreeBuilder builder, TGridItem item) + => builder.AddContent(0, _cellTextFunc!(item)); +} 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 67% 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..36c5bfd929 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 51% 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..46afc5dfd1 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). // @@ -24,21 +24,36 @@ 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. 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()) { - // 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, you must reference the package Microsoft.AspNetCore.Components.BitDataGrid.EntityFrameworkAdapter and call AddBitDataGridEntityFrameworkAdapter on your service collection."); + selected = executor; } } - else if (executor.IsSupported(queryable)) + + 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 register an IAsyncQueryExecutor, 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/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..6985a54e8a --- /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 (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/Bit.BlazorUI.Extras/Components/DataGrid/ItemsProvider/BitDataGridItemsProviderRequest.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/QuickGrid/ItemsProvider/BitQuickGridItemsProviderRequest.cs similarity index 51% 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..471b66baf5 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,18 @@ internal BitDataGridItemsProviderRequest( /// /// 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. public IQueryable ApplySorting(IQueryable source) => SortByColumn switch { - IBitDataGridSortBuilderColumn 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)), }; @@ -66,17 +70,22 @@ internal BitDataGridItemsProviderRequest( /// /// 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, 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)>(), + // 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)), }; - 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) + => 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/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 61% 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..092332de08 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,39 @@ 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)); + // The "total item count" handler doesn't need to do anything except cause this component to + // 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/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..0fe3673ad8 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Styles/extra-components.scss +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Styles/extra-components.scss @@ -1,10 +1,11 @@ @import "../Components/AccordionList/BitAccordionList.scss"; @import "../Components/AppShell/BitAppShell.scss"; @import "../Components/DataGrid/BitDataGrid.scss"; -@import "../Components/DataGrid/Pagination/BitDataGridPaginator.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/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/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..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 @@ -1,15 +1,15 @@ @page "/components/datagrid" @page "/components/data-grid" @inherits AppComponentBase -@using Demo.Shared.Dtos.DataGridDemo + 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." /> - - - - - - - - - - - - - - - - -
-
- - - - - - - - @(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. + Typed filters are used for numeric, date, boolean and enum columns (like Price, Stock and Category), + while text columns (like Name and Supplier) use case-insensitive text filtering. +
+
+ + + + + + + + +
+ + +
+ 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 to populate the grid. + Load sample data +
+
+ + + + + + +
+
+ + +
+ Toggle column/row borders and alternate-row striping using the Bordered and Striped parameters. +
+
+ + Borders: @(bordered ? "on" : "off") + Striping: @(striped ? "on" : "off") + +
+ + + + + + + +
+ + +
+ Render the grid in a right-to-left layout by setting the Direction parameter to BitDir.Rtl. +
+
+ + + + + + + + +
+ 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..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 @@ -1,681 +1,375 @@ -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.", - } - ], + // example 1 - basic & sorting + private readonly List basicProducts = SampleData.Generate(50); - }, - 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) + // 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(); + + // Switching to Single must drop any extra selections so the bound state (and the "N selected" + // label) matches Single semantics; the grid normalizes its internal set but does not push the + // trimmed selection back to this controlled binding. + private void SelectSingleMode() { - if (expandedRowTemplateCodes.Remove(code)) return; - - expandedRowTemplateCodes.Add(code); + selectionMode = BitDataGridSelectionMode.Single; + if (selectedProducts.Count > 1) + { + selectedProducts = selectedProducts.Take(1).ToList(); + } } - 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 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); + + // example 9 - column spanning + private readonly List spanningProducts = SampleData.Generate(40); + + // example 10 - virtualization + private List virtualProducts = SampleData.Generate(10_000); + + // example 11 - server-side + private readonly List serverAll = SampleData.Generate(523); + private bool serverLoading; + private string serverLastRequest = ""; + + // example 12 - infinite scrolling + // 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; - string typicalSampleNameFilter1 = string.Empty; - string typicalSampleNameFilter2 = string.Empty; + // example 13 - tree view + private readonly List fileRoots = FileSystemData.Build(); + private BitDataGrid? treeGrid; - string _virtualSampleNameFilter = string.Empty; - string VirtualSampleNameFilter + // 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 - 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 { - get => _virtualSampleNameFilter; - set - { - _virtualSampleNameFilter = value; - _ = dataGrid.RefreshDataAsync(); - } + Category.Electronics => "الکترونیک", + Category.Books => "کتاب", + Category.Clothing => "پوشاک", + Category.Home => "خانه", + Category.Toys => "اسباب‌بازی", + Category.Sports => "ورزش", + Category.Grocery => "خواربار", + _ => category.ToString() + }; + + + protected override Task OnInitAsync() + { + 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); - foodRecallProvider = async req => + int total = 0; + try { - try + await Task.Delay(250, request.CancellationToken); + + IEnumerable query = serverAll; + + foreach (var f in request.Filters) { - var query = new Dictionary - { - { "search", $"recalling_firm:\"{_virtualSampleNameFilter}\"" }, - { "skip", req.StartIndex }, - { "limit", req.Count } + query = f.ColumnId switch + { + // Text columns use the string operators emitted by the grid's text filter editor. + nameof(Product.Name) => query.Where(p => MatchText(p.Name, f)), + nameof(Product.Supplier) => query.Where(p => MatchText(p.Supplier, f)), + // Non-text columns receive a typed value (enum/decimal/int) with a comparison + // operator, so compare against the typed value instead of a substring of ToString(). + nameof(Product.Category) => query.Where(p => MatchComparable(p.Category, f)), + nameof(Product.Price) => query.Where(p => MatchComparable(p.Price, f)), + nameof(Product.Stock) => query.Where(p => MatchComparable(p.Stock, f)), + _ => query }; + } - var sort = req.GetSortByProperties().SingleOrDefault(); + IOrderedEnumerable? ordered = null; + foreach (var sort in request.Sorts) + { + 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 (sort != default) + if (ordered is null) { - var sortByColumnName = sort.PropertyName switch - { - nameof(FoodRecall.ReportDate) => "report_date", - _ => throw new InvalidOperationException() - }; - - query.Add("sort", $"{sortByColumnName}:{(sort.Direction == BitDataGridSortDirection.Ascending ? "asc" : "desc")}"); + 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 url = NavigationManager.GetUriWithQueryParameters("https://api.fda.gov/food/enforcement.json", query); + var filtered = query.ToList(); + total = filtered.Count; + var items = filtered.Skip(request.Skip).Take(request.Take ?? total).ToList(); - var data = await HttpClient.GetFromJsonAsync(url, AppJsonContext.Default.FoodRecallQueryResult, req.CancellationToken); + // 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 BitDataGridItemsProviderResult.From(data!.Results!, data!.Meta!.Results!.Total); - } - catch + return new BitDataGridReadResult(items, total); + } + finally + { + // A superseded request observes a cancelled token; skip writing UI state so a stale load + // can't overwrite the fresher request's status. The newer load owns serverLoading. + if (!request.CancellationToken.IsCancellationRequested) { - return BitDataGridItemsProviderResult.From([], 0); + 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); } - }; + } + } - productsItemsProvider = async req => + // Applies a text-column filter the way the grid's text editor emits it: a string value combined with + // one of the string/empty operators. Anything else is treated as "no criteria" so the row matches. + 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 { - try - { - // https://docs.microsoft.com/en-us/odata/concepts/queryoptions-overview + 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 + }; + } - var query = new Dictionary() - { - { "$top", req.Count ?? 50 }, - { "$skip", req.StartIndex } - }; + // Applies a non-text-column filter against the typed value the grid emits (enum/decimal/int) 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 (string.IsNullOrEmpty(_odataSampleNameFilter) is false) - { - query.Add("$filter", $"contains(Name,'{_odataSampleNameFilter}')"); - } + // The grid hands back a value already of the column's type; guard against an unexpected type. + 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 + }; + } - if (req.GetSortByProperties().Any()) - { - query.Add("$orderby", string.Join(", ", req.GetSortByProperties().Select(p => $"{p.PropertyName} {(p.Direction == BitDataGridSortDirection.Ascending ? "asc" : "desc")}"))); - } - var url = NavigationManager.GetUriWithQueryParameters("api/Products/GetProducts", query); + // ---- infinite scrolling ---- + private async Task> LoadMore(BitDataGridReadRequest request) + { + await Task.Delay(350, request.CancellationToken); - var data = await HttpClient.GetFromJsonAsync(url, AppJsonContext.Default.PagedResultProductDto); + IEnumerable query = infiniteAll; - return BitDataGridItemsProviderResult.From(data!.Items!, data!.TotalCount); + IOrderedEnumerable? ordered = null; + foreach (var sort in request.Sorts) + { + 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, + nameof(Product.Rating) => p => p.Rating, + _ => p => p.Id + }; + + if (ordered is null) + { + ordered = sort.Direction == BitDataGridSortDirection.Descending + ? query.OrderByDescending(key) + : query.OrderBy(key); } - catch + else { - return BitDataGridItemsProviderResult.From(new List { }, 0); + 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(); + + // Drop a superseded batch before mutating shared demo state so stale rows aren't logged. + request.CancellationToken.ThrowIfCancellationRequested(); + + infiniteRequests++; + var end = request.Skip + batch.Count; + infiniteLog = batch.Count == 0 + ? $"Batch #{infiniteRequests} → no additional rows loaded" + : $"Batch #{infiniteRequests} → loaded rows {request.Skip + 1}–{end} ({batch.Count} rows)"; + await InvokeAsync(StateHasChanged); - await base.OnInitAsync(); + return new BitDataGridReadResult(batch, 0); } + + + // ---- 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(); } + + + // ---- master detail ---- + 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(); + + + // ---- row reordering ---- + private void OnReorder(BitDataGridRowReorderEventArgs e) + { + // 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}"; + } + + + // ---- 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..d7493aaa7f --- /dev/null +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor.params.cs @@ -0,0 +1,332 @@ +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.", 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." }, + 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 = "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." }, + 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 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" }, + 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.", 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.", 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)." }, + 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 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() + { + 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.", 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.", 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.", LinkType = LinkType.Link, Href = "#BitDataGridAggregateResult" }, + ], + }, + 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.", 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." }, + ], + }, + 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.", 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." }, + 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, or null when the bound Items is not an indexable list." }, + new() { Name = "ToIndex", Type = "int?", DefaultValue = "", Description = "The destination index, or null when the bound Items is not an indexable list." }, + ], + }, + 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 = "int.MaxValue", 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 = + [ + 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 = "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() + { + 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 = "DateTime", Value = "5" }, + new() { Name = "DateTimeOffset", Value = "6" }, + new() { Name = "Enum", Value = "7" }, + ] + }, + new() + { + Id = "BitDataGridFilterOperator", + Name = "BitDataGridFilterOperator", + Description = "Comparison operators available for column filtering.", + Items = + [ + new() { Name = "Unspecified", Value = "0" }, + new() { Name = "Contains", Value = "1" }, + new() { Name = "DoesNotContain", Value = "2" }, + new() { Name = "StartsWith", Value = "3" }, + new() { Name = "EndsWith", Value = "4" }, + new() { Name = "Equals", Value = "5" }, + new() { Name = "NotEquals", Value = "6" }, + new() { Name = "GreaterThan", Value = "7" }, + new() { Name = "GreaterThanOrEqual", Value = "8" }, + new() { Name = "LessThan", Value = "9" }, + new() { Name = "LessThanOrEqual", Value = "10" }, + new() { Name = "IsEmpty", Value = "11" }, + new() { Name = "IsNotEmpty", Value = "12" }, + ] + }, + ]; +} 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..a8fbe9f2dd 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,627 @@ -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)); + // ------------------------------------------------------------------ + // 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. + // ------------------------------------------------------------------ -protected override async Task OnInitializedAsync() -{ - allCountries = _countries.AsQueryable(); -} + private const string ProductModelCode = @" -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 enum Category { Electronics, Books, Clothing, Home, Toys, Sports, Grocery } -public class MedalsModel +public class Product { - public int Gold { get; set; } - public int Silver { get; set; } - public int Bronze { get; set; } - public int Total => Gold + Silver + Bronze; + 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 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() +// Deterministic generator so the demo data is reproducible. +public static class SampleData { - allCountries = _countries.AsQueryable(); -} + 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"" }; -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 = @" -BitDataGrid? dataGrid; -string _virtualSampleNameFilter = string.Empty; -BitDataGridItemsProvider foodRecallProvider; + private const string PersianSampleDataCode = @" -string VirtualSampleNameFilter +// Deterministic generator that produces Persian sample data for the RTL demo. +public static class SampleData { - get => _virtualSampleNameFilter; - set - { - _virtualSampleNameFilter = value; - _ = dataGrid.RefreshDataAsync(); - } -} + static readonly string[] Adjectives = + { ""فوق‌العاده"", ""ممتاز"", ""اقتصادی"", ""هوشمند"", ""کلاسیک"", ""حرفه‌ای"", ""کوچک"", ""بزرگ"", ""قدیمی"", ""مدرن"", ""لوکس"", ""فشرده"" }; + static readonly string[] Nouns = + { ""ویجت"", ""گجت"", ""بلندگو"", ""دفترچه"", ""ژاکت"", ""چراغ"", ""مخلوط‌کن"", ""پهپاد"", ""کوله‌پشتی"", ""کفش"", ""دوربین"", ""لیوان"" }; + static readonly string[] Suppliers = + { ""شرکت آلفا"", ""گلوبکس"", ""اینیتک"", ""آمبرلا"", ""سویلنت"", ""صنایع استارک"", ""شرکت وین"", ""ونکا"" }; -protected override async Task OnInitializedAsync() -{ - foodRecallProvider = async req => + public static List GeneratePersian(int count, int seed = 42) { - try + 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++) { - var query = new Dictionary - { - { ""search"",$""recalling_firm:\""{_virtualSampleNameFilter}\"" }, - { ""skip"", req.StartIndex }, - { ""limit"", req.Count } - }; - - var sort = req.GetSortByProperties().SingleOrDefault(); - - if (sort != default) + list.Add(new Product { - 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); + 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)] + }); } - catch - { - return BitDataGridItemsProviderResult.From(new List { }, 0); - } - }; -} + return list; + } +}"; -//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 -{ - -} + private const string FileSystemDataCode = @" -public class FoodRecallQueryResult +public class FileNode { - [JsonPropertyName(""meta"")] - public Meta? Meta { get; set; } - - [JsonPropertyName(""results"")] - public List? Results { get; set; } + 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 class Meta +public static class FileSystemData { - [JsonPropertyName(""disclaimer"")] - public string? Disclaimer { get; set; } + public static List Build() + { + var id = 0; + var baseDate = new DateTime(2025, 1, 1); - [JsonPropertyName(""terms"")] - public string? Terms { get; set; } + 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; + } - [JsonPropertyName(""license"")] - public string? License { get; set; } + FileNode File(string name, long size) => new() { Id = ++id, Name = name, Kind = ""File"", Size = size, Modified = baseDate.AddDays(id) }; - [JsonPropertyName(""last_updated"")] - public string? LastUpdated { get; set; } + 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) + }; + } +}"; - [JsonPropertyName(""results"")] - public Results? Results { get; set; } -} + private const string SupplierModelCode = @" -public class FoodRecall +public sealed class SupplierModel { - [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 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); +}"; -public class Results -{ - [JsonPropertyName(""skip"")] - public int Skip { get; set; } - [JsonPropertyName(""limit"")] - public int Limit { get; set; } + private readonly string example1RazorCode = @" + + + + + + + +"; + private readonly string example1CsharpCode = @" +private List products = SampleData.Generate(50);" + ProductModelCode + SampleDataCode; - [JsonPropertyName(""total"")] - public int Total { get; set; } -} + private readonly string example2RazorCode = @" + + + + + + + +"; + private readonly string example2CsharpCode = @" +private List products = SampleData.Generate(200);" + ProductModelCode + SampleDataCode; -public class Openfda -{ -} -"; + private readonly string example3RazorCode = @" + + + + + +"; + private readonly string example3CsharpCode = @" +private List products = SampleData.Generate(60); +private IReadOnlyList selected = new List();" + ProductModelCode + SampleDataCode; 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); +private int nextId; -// ========== 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 +protected override void OnInitialized() => nextId = products.Max(p => p.Id) + 1; -[ApiController] -[Route(""[controller]/[action]"")] -public class ProductsController : ControllerBase +private Product CreateProduct() => new() { - 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); + 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);" + ProductModelCode + SampleDataCode; - 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; } + private readonly string example5RazorCode = @" + + + + + + +"; + private readonly string example5CsharpCode = @" +private List products = SampleData.Generate(80);" + ProductModelCode + SampleDataCode; - public PagedResult(IList items, int totalCount) - { - Items = items; - TotalCount = totalCount; - } + private readonly string example6RazorCode = @" + + +
Supplier: @p.Supplier
+
+ + + 📦 Product + + + Total: @agg.FormattedValue + + + + + +
"; + private readonly string example6CsharpCode = @" +private List products = SampleData.Generate(30);" + ProductModelCode + SampleDataCode; - public PagedResult() + private readonly string example7RazorCode = @" + + + + + +"; + private readonly string example7CsharpCode = @" +private List products = SampleData.Generate(40);" + ProductModelCode + SampleDataCode; + + private readonly string example8RazorCode = @" + + + + + + + + +"; + private readonly string example8CsharpCode = @" +private List products = SampleData.Generate(40);" + ProductModelCode + SampleDataCode; + + 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;" + ProductModelCode + SampleDataCode; + + private readonly string example10RazorCode = @" + + + + +"; + private readonly string example10CsharpCode = @" +private List products = SampleData.Generate(10_000);" + ProductModelCode + SampleDataCode; + + private readonly string example11RazorCode = @" + + + + +"; + private readonly string example11CsharpCode = @" +private bool loading; +private readonly List all = SampleData.Generate(523); + +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 - } -} - - -// ========== Client code ========== - - -BitDataGrid? productsDataGrid; -string _odataSampleNameFilter = string.Empty; -BitDataGridItemsProvider productsItemsProvider; - -string ODataSampleNameFilter -{ - get => _odataSampleNameFilter; - set - { - _odataSampleNameFilter = value; - _ = productsDataGrid.RefreshDataAsync(); - } -} + IEnumerable query = all; -protected override async Task OnInitializedAsync() -{ - productsItemsProvider = async req => - { - try + // filtering — honor the operator the grid emits, not just contains/equals + foreach (var f in request.Filters) { - // https://docs.microsoft.com/en-us/odata/concepts/queryoptions-overview - - var query = new Dictionary() + query = f.ColumnId switch { - { ""$top"", req.Count ?? 50 }, - { ""$skip"", req.StartIndex } + // 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 }; - - 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 + + // sorting (honor every active sort descriptor, not just the first) + IOrderedEnumerable? ordered = null; + foreach (var sort in request.Sorts) { - return BitDataGridItemsProviderResult.From(new List { }, 0); + Func key = sort.ColumnId switch + { + nameof(Product.Name) => p => p.Name, + nameof(Product.Price) => p => p.Price, + _ => 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); } - }; -}"; - - 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) + finally { - var query = _products.AsQueryable(); - - query = (IQueryable)odataQuery.ApplyTo(query, ignoreQueryOptions: AllowedQueryOptions.Top | AllowedQueryOptions.Skip); - - var totalCount = query.Count(); - - if (odataQuery.Skip is not null) + // 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) { - query = query.Skip(odataQuery.Skip.Value); + loading = false; + await InvokeAsync(StateHasChanged); // re-render after the load completes (runs as a callback) } - - 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 +// Applies a text-column filter the way the grid's text editor emits it. +private static bool MatchText(string value, BitDataGridFilterDescriptor f) { - public IList? Items { get; set; } - - public int TotalCount { get; set; } - - public PagedResult(IList items, int totalCount) - { - Items = items; - TotalCount = totalCount; - } - - public PagedResult() - { - - } -} - - -// ========== Client code ========== - + if (f.Operator is BitDataGridFilterOperator.IsEmpty) return string.IsNullOrEmpty(value); + if (f.Operator is BitDataGridFilterOperator.IsNotEmpty) return !string.IsNullOrEmpty(value); -BitDataGrid? productsDataGrid; -BitDataGridItemsProvider productsItemsProvider; -BitDataGridPaginationState pagination = new() { ItemsPerPage = 7 }; + var term = f.Value?.ToString(); + if (string.IsNullOrWhiteSpace(term)) return true; -protected override async Task OnInitializedAsync() -{ - productsItemsProvider = async req => + return f.Operator switch { - 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); - } + 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 }; -}"; - - 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 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 +// 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 { - 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 BitDataGridPaginationState pagination = new() { ItemsPerPage = 7 }; - -protected override async Task OnInitializedAsync() -{ - allCountries = _countries.AsQueryable(); -} + 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 HashSet expandedRowTemplateCodes = []; + private readonly string example12RazorCode = @" + + + + +"; + private readonly string example12CsharpCode = @" +private readonly List all = SampleData.Generate(2_017); -private void ToggleRowRendererExpand(string code) +private async Task> LoadMore(BitDataGridReadRequest request) { - if (expandedRowTemplateCodes.Remove(code)) return; - - expandedRowTemplateCodes.Add(code); -} + await Task.Delay(350, request.CancellationToken); // simulate a backend round-trip -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; } -} + IEnumerable query = all; -public class MedalsModel -{ - public int Gold { get; set; } - public int Silver { get; set; } - public int Bronze { get; set; } - public int Total => Gold + Silver + Bronze; -}"; + // Apply every active sort descriptor before paging out the batch. + IOrderedEnumerable? ordered = null; + foreach (var sort in request.Sorts) + { + Func key = sort.ColumnId switch + { + nameof(Product.Name) => p => p.Name, + nameof(Product.Price) => p => p.Price, + _ => 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; + + var batch = query.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 = @" + 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(); }" + FileSystemDataCode; + + private readonly string example14RazorCode = @" + + + + + + + + + + + +"; + private readonly string example14CsharpCode = @" +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 = @" + + + +"; + private readonly string example15CsharpCode = @" +private List products = SampleData.Generate(12); + +private void OnReorder(BitDataGridRowReorderEventArgs e) +{ + // 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 = @" + + + +"; + private readonly string example16CsharpCode = @" +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 = @" + {}""> + + + +"; + private readonly string example17CsharpCode = @" +private List products = SampleData.Generate(40);" + ProductModelCode + SampleDataCode; + + private readonly string example18RazorCode = @" + + + + + +"; + private readonly string example18CsharpCode = @" +private List products = SampleData.Generate(40); + +private float RowHeight(Product p) => p.Price > 500 ? 64f : 36f;" + ProductModelCode + SampleDataCode; + + private readonly string example19RazorCode = @" + + +
Nothing here yet. Try loading the sample data to populate the grid.
+
+ + + + +
"; + private readonly string example19CsharpCode = @" +private List items = new(); // empty" + ProductModelCode; + + private readonly string example20RazorCode = @" + + + + +"; + private readonly string example20CsharpCode = @" +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/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..3f9e5d89f7 --- /dev/null +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/SampleData.cs @@ -0,0 +1,57 @@ +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) + => GenerateCore(count, seed, Adjectives, Nouns, Suppliers); + + 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) + => GenerateCore(count, seed, PersianAdjectives, PersianNouns, PersianSuppliers); + + /// + /// Shared, deterministic product generator. The fixed reference date and seeded RNG keep the + /// generated data reproducible regardless of when (or in which locale) it runs. + /// + 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); + 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; + } +} 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..01a11bb529 --- /dev/null +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor @@ -0,0 +1,200 @@ +@page "/components/quickgrid" +@page "/components/quick-grid" +@inherits AppComponentBase +@using Bit.BlazorUI.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..ba7f0dd14b --- /dev/null +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.cs @@ -0,0 +1,711 @@ +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 grid. + 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 BitQuickGrid.", + }, + new() + { + Name = "RowClassSelector", + Type = "Func?", + DefaultValue = "null", + Description = @"The function to generate the CSS class of each row of the BitQuickGrid.", + }, + new() + { + Name = "RowStyle", + Type = "string?", + DefaultValue = "null", + Description = @"The CSS style of all rows of the BitQuickGrid.", + }, + new() + { + Name = "RowStyleSelector", + Type = "Func?", + DefaultValue = "null", + Description = @"The function to generate the CSS style of each row of the BitQuickGrid.", + }, + 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 the BitQuickGridColumnBase type, which all columns must derive from. It 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 = "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() + { + 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 BitQuickGrid loadingProductsDataGrid = 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; + // 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(); + } + } + + + + protected override async Task OnInitAsync() + { + allCountries = _countries.AsQueryable(); + + foodRecallProvider = async req => + { + 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 + { + { "skip", req.StartIndex }, + { "limit", req.Count ?? 50 } + }; + + // 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) + { + 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 (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); + } + }; + + 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.IsNullOrWhiteSpace(_odataSampleNameFilter) is false) + { + // 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}')"); + } + + 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, req.CancellationToken); + + 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); + } + }; + + 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..c211e2d7a7 --- /dev/null +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/QuickGrid/BitQuickGridDemo.razor.samples.cs @@ -0,0 +1,1268 @@ +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; +@using System.Text.Json.Serialization; +@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 + { + // 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 + { + { ""skip"", req.StartIndex }, + { ""limit"", req.Count ?? 50 } + }; + + // 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) + { + 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 (OperationCanceledException) when (req.CancellationToken.IsCancellationRequested) + { + throw; // a rapid refresh superseded this request; let cancellation flow through + } + 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; +@using System.Text.Json.Serialization; +@inject HttpClient HttpClient +@inject NavigationManager NavManager + + + +
+ p.Id)"" TGridItem=""ProductDto"" Virtualize ItemSize=""32""> + 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(""api/[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.IsNullOrWhiteSpace(_odataSampleNameFilter) is false) + { + // 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}')""); + } + + 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, req.CancellationToken); + + 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); + } + }; +}"; + + private readonly string example5RazorCode = @" +@using System.Text.Json; +@using System.Text.Json.Serialization; +@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(""api/[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, req.CancellationToken); + + 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); + } + }; +}"; + + 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",