|
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Linq;
using Microsoft.AspNetCore.Components.QuickGrid.Infrastructure;
using Microsoft.AspNetCore.Components.Web.Virtualization;
using Microsoft.JSInterop;
using Microsoft.AspNetCore.Components.Forms;
namespace Microsoft.AspNetCore.Components.QuickGrid;
/// <summary>
/// A component that displays a grid.
/// </summary>
/// <typeparam name="TGridItem">The type of data represented by each row in the grid.</typeparam>
[CascadingTypeParameter(nameof(TGridItem))]
public partial class QuickGrid<TGridItem> : IAsyncDisposable
{
/// <summary>
/// A queryable source of data for the grid.
///
/// This could be in-memory data converted to queryable using the
/// <see cref="System.Linq.Queryable.AsQueryable(System.Collections.IEnumerable)"/> extension method,
/// or an EntityFramework DataSet or an <see cref="IQueryable"/> derived from it.
///
/// You should supply either <see cref="Items"/> or <see cref="ItemsProvider"/>, but not both.
/// </summary>
[Parameter] public IQueryable<TGridItem>? Items { get; set; }
/// <summary>
/// A callback that supplies data for the grid.
///
/// You should supply either <see cref="Items"/> or <see cref="ItemsProvider"/>, but not both.
/// </summary>
[Parameter] public GridItemsProvider<TGridItem>? ItemsProvider { get; set; }
/// <summary>
/// An optional CSS class name. If given, this will be included in the class attribute of the rendered table.
/// </summary>
[Parameter] public string? Class { get; set; }
/// <summary>
/// A theme name, with default value "default". This affects which styling rules match the table.
/// </summary>
[Parameter] public string? Theme { get; set; } = "default";
/// <summary>
/// Defines the child components of this instance. For example, you may define columns by adding
/// components derived from the <see cref="ColumnBase{TGridItem}"/> base class.
/// </summary>
[Parameter] public RenderFragment? ChildContent { get; set; }
/// <summary>
/// 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 <see cref="Virtualize"/>, you should supply a value for <see cref="ItemSize"/> and must
/// ensure that every row renders with the same constant height.
///
/// Generally it's preferable not to use <see cref="Virtualize"/> if the amount of data being rendered
/// is small or if you are using pagination.
/// </summary>
[Parameter] public bool Virtualize { get; set; }
/// <summary>
/// This is applicable only when using <see cref="Virtualize"/>. It defines how many additional items will be rendered
/// before and after the visible region to reduce rendering frequency during scrolling. While higher values can improve
/// scroll smoothness by rendering more items off-screen, they can also increase initial load times. Finding a balance
/// based on your data set size and user experience requirements is recommended. The default value is 3.
/// </summary>
[Parameter] public int OverscanCount { get; set; } = 3;
/// <summary>
/// This is applicable only when using <see cref="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.
/// </summary>
[Parameter] public float ItemSize { get; set; } = 50;
/// <summary>
/// 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.
/// </summary>
[Parameter] public Func<TGridItem, object> ItemKey { get; set; } = x => x!;
/// <summary>
/// Optionally links this <see cref="QuickGrid{TGridItem}"/> instance with a <see cref="PaginationState"/> model,
/// causing the grid to fetch and render only the current page of data.
///
/// This is normally used in conjunction with a <see cref="Paginator"/> component or some other UI logic
/// that displays and updates the supplied <see cref="PaginationState"/> instance.
/// </summary>
[Parameter] public PaginationState? Pagination { get; set; }
/// <summary>
/// Gets or sets a collection of additional attributes that will be applied to the created element.
/// </summary>
[Parameter(CaptureUnmatchedValues = true)] public IReadOnlyDictionary<string, object>? AdditionalAttributes { get; set; }
[Inject] private IServiceProvider Services { get; set; } = default!;
[Inject] private IJSRuntime JS { get; set; } = default!;
private ElementReference _tableReference;
private Virtualize<(int, TGridItem)>? _virtualizeComponent;
private int _ariaBodyRowCount;
private ICollection<TGridItem> _currentNonVirtualizedViewItems = Array.Empty<TGridItem>();
// 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 readonly InternalGridContext<TGridItem> _internalGridContext;
private readonly List<ColumnBase<TGridItem>> _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 ColumnBase<TGridItem>? _displayOptionsForColumn;
private ColumnBase<TGridItem>? _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<PaginationState> _currentPageItemsChanged;
/// <summary>
/// Constructs an instance of <see cref="QuickGrid{TGridItem}"/>.
/// </summary>
public QuickGrid()
{
_columns = new();
_internalGridContext = new(this);
_currentPageItemsChanged = new(EventCallback.Factory.Create<PaginationState>(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<object?>(
EventCallback.Factory.Create<object?>(this, RefreshDataCoreAsync));
columnsFirstCollectedSubscriber.SubscribeOrMove(_internalGridContext.ColumnsFirstCollected);
}
/// <inheritdoc />
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($"{nameof(QuickGrid)} requires one of {nameof(Items)} or {nameof(ItemsProvider)}, but both were specified.");
}
// Perform a re-query only if the data source or something else has changed
var _newItemsOrItemsProvider = Items ?? (object?)ItemsProvider;
var dataSourceHasChanged = _newItemsOrItemsProvider != _lastAssignedItemsOrProvider;
if (dataSourceHasChanged)
{
_lastAssignedItemsOrProvider = _newItemsOrItemsProvider;
_asyncQueryExecutor = AsyncQueryExecutorSupplier.GetAsyncQueryExecutor(Services, Items);
}
var mustRefreshData = dataSourceHasChanged
|| (Pagination?.GetHashCode() != _lastRefreshedPaginationStateHash);
// We don't want to trigger the first data load until we've collected the initial set of columns,
// because they might perform some action like setting the default sort order, so it would be wasteful
// to have to re-query immediately
return (_columns.Count > 0 && mustRefreshData) ? RefreshDataCoreAsync() : Task.CompletedTask;
}
/// <inheritdoc />
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
_jsModule = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/Microsoft.AspNetCore.Components.QuickGrid/QuickGrid.razor.js");
_jsEventDisposable = await _jsModule.InvokeAsync<IJSObjectReference>("init", _tableReference);
}
if (_checkColumnOptionsPosition && _displayOptionsForColumn is not null)
{
_checkColumnOptionsPosition = false;
_ = _jsModule?.InvokeVoidAsync("checkColumnOptionsPosition", _tableReference).AsTask();
}
}
// Invoked by descendant columns at a special time during rendering
internal void AddColumn(ColumnBase<TGridItem> column, SortDirection? initialSortDirection, bool isDefaultSortColumn)
{
if (_collectingColumns)
{
_columns.Add(column);
if (isDefaultSortColumn && _sortByColumn is null && initialSortDirection.HasValue)
{
_sortByColumn = column;
_sortByAscending = initialSortDirection.Value != SortDirection.Descending;
}
}
}
private void StartCollectingColumns()
{
_columns.Clear();
_collectingColumns = true;
}
private void FinishCollectingColumns()
{
_collectingColumns = false;
}
/// <summary>
/// Sets the grid's current sort column to the specified <paramref name="column"/>.
/// </summary>
/// <param name="column">The column that defines the new sort order.</param>
/// <param name="direction">The direction of sorting. If the value is <see cref="SortDirection.Auto"/>, then it will toggle the direction on each call.</param>
/// <returns>A <see cref="Task"/> representing the completion of the operation.</returns>
public Task SortByColumnAsync(ColumnBase<TGridItem> column, SortDirection direction = SortDirection.Auto)
{
_sortByAscending = direction switch
{
SortDirection.Ascending => true,
SortDirection.Descending => false,
SortDirection.Auto => _sortByColumn != column || !_sortByAscending,
_ => 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();
}
/// <summary>
/// Displays the <see cref="ColumnBase{TGridItem}.ColumnOptions"/> UI for the specified column, closing any other column
/// options UI that was previously displayed.
/// </summary>
/// <param name="column">The column whose options are to be displayed, if any are available.</param>
public Task ShowColumnOptionsAsync(ColumnBase<TGridItem> column)
{
_displayOptionsForColumn = column;
_checkColumnOptionsPosition = true; // Triggers a call to JS to position the options element, apply autofocus, and any other setup
StateHasChanged();
return Task.CompletedTask;
}
/// <summary>
/// Instructs the grid to re-fetch and render the current data from the supplied data source
/// (either <see cref="Items"/> or <see cref="ItemsProvider"/>).
/// </summary>
/// <returns>A <see cref="Task"/> that represents the completion of the operation.</returns>
public async Task RefreshDataAsync()
{
await RefreshDataCoreAsync();
StateHasChanged();
}
// Same as RefreshDataAsync, except without forcing a re-render. We use this from OnParametersSetAsync
// because in that case there's going to be a re-render anyway.
private async Task RefreshDataCoreAsync()
{
// Move into a "loading" state, cancelling any earlier-but-still-pending load
_pendingDataLoadCancellationTokenSource?.Cancel();
var thisLoadCts = _pendingDataLoadCancellationTokenSource = new CancellationTokenSource();
if (_virtualizeComponent is not null)
{
// If we're using Virtualize, we have to go through its RefreshDataAsync API otherwise:
// (1) It won't know to update its own internal state if the provider output has changed
// (2) We won't know what slice of data to query for
await _virtualizeComponent.RefreshDataAsync();
_pendingDataLoadCancellationTokenSource = null;
}
else
{
// If we're not using Virtualize, we build and execute a request against the items provider directly
_lastRefreshedPaginationStateHash = Pagination?.GetHashCode();
var startIndex = Pagination is null ? 0 : (Pagination.CurrentPageIndex * Pagination.ItemsPerPage);
var request = new GridItemsProviderRequest<TGridItem>(
startIndex, Pagination?.ItemsPerPage, _sortByColumn, _sortByAscending, thisLoadCts.Token);
var result = await ResolveItemsRequestAsync(request);
if (!thisLoadCts.IsCancellationRequested)
{
_currentNonVirtualizedViewItems = result.Items;
_ariaBodyRowCount = _currentNonVirtualizedViewItems.Count;
Pagination?.SetTotalItemCountAsync(result.TotalItemCount);
_pendingDataLoadCancellationTokenSource = null;
}
}
}
// Gets called both by RefreshDataCoreAsync and directly by the Virtualize child component during scrolling
private async ValueTask<ItemsProviderResult<(int, TGridItem)>> ProvideVirtualizedItems(ItemsProviderRequest request)
{
_lastRefreshedPaginationStateHash = Pagination?.GetHashCode();
// Debounce the requests. This eliminates a lot of redundant queries at the cost of slight lag after interactions.
// TODO: Consider making this configurable, or smarter (e.g., doesn't delay on first call in a batch, then the amount
// of delay increases if you rapidly issue repeated requests, such as when scrolling a long way)
await Task.Delay(100);
if (request.CancellationToken.IsCancellationRequested)
{
return default;
}
// Combine the query parameters from Virtualize with the ones from PaginationState
var startIndex = request.StartIndex;
var count = request.Count;
if (Pagination is not null)
{
startIndex += Pagination.CurrentPageIndex * Pagination.ItemsPerPage;
count = Math.Min(request.Count, Pagination.ItemsPerPage - request.StartIndex);
}
var providerRequest = new GridItemsProviderRequest<TGridItem>(
startIndex, count, _sortByColumn, _sortByAscending, request.CancellationToken);
var providerResult = await ResolveItemsRequestAsync(providerRequest);
if (!request.CancellationToken.IsCancellationRequested)
{
// ARIA's rowcount is part of the UI, so it should reflect what the human user regards as the number of rows in the table,
// not the number of physical <tr> elements. For virtualization this means what's in the entire scrollable range, not just
// the current viewport. In the case where you're also paginating then it means what's conceptually on the current page.
// TODO: This currently assumes we always want to expand the last page to have ItemsPerPage rows, but the experience might
// be better if we let the last page only be as big as its number of actual rows.
_ariaBodyRowCount = Pagination is null ? providerResult.TotalItemCount : Pagination.ItemsPerPage;
Pagination?.SetTotalItemCountAsync(providerResult.TotalItemCount);
// We're supplying the row index along with each row's data because we need it for aria-rowindex, and we have to account for
// the virtualized start index. It might be more performant just to have some _latestQueryRowStartIndex field, but we'd have
// to make sure it doesn't get out of sync with the rows being rendered.
return new ItemsProviderResult<(int, TGridItem)>(
items: providerResult.Items.Select((x, i) => ValueTuple.Create(i + request.StartIndex + 2, x)),
totalItemCount: _ariaBodyRowCount);
}
return default;
}
// Normalizes all the different ways of configuring a data source so they have common GridItemsProvider-shaped API
private async ValueTask<GridItemsProviderResult<TGridItem>> ResolveItemsRequestAsync(GridItemsProviderRequest<TGridItem> 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 GridItemsProviderResult.From(resultArray, totalItemCount);
}
else
{
return GridItemsProviderResult.From(Array.Empty<TGridItem>(), 0);
}
}
private string AriaSortValue(ColumnBase<TGridItem> column)
=> _sortByColumn == column
? (_sortByAscending ? "ascending" : "descending")
: "none";
private string? ColumnHeaderClass(ColumnBase<TGridItem> column)
=> _sortByColumn == column
? $"{ColumnClass(column)} {(_sortByAscending ? "col-sort-asc" : "col-sort-desc")}"
: ColumnClass(column);
private string GridClass()
{
var gridClass = $"quickgrid {Class} {(_pendingDataLoadCancellationTokenSource is null ? null : "loading")}";
return AttributeUtilities.CombineClassNames(AdditionalAttributes, gridClass) ?? string.Empty;
}
private static string? ColumnClass(ColumnBase<TGridItem> column) => column.Align switch
{
Align.Start => $"col-justify-start {column.Class}",
Align.Center => $"col-justify-center {column.Class}",
Align.End => $"col-justify-end {column.Class}",
Align.Left => $"col-justify-left {column.Class}",
Align.Right => $"col-justify-right {column.Class}",
_ => column.Class,
};
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
_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.
}
}
private void CloseColumnOptions()
{
_displayOptionsForColumn = null;
}
}
|