/
QuickGrid.razor.cs
440 lines (382 loc) · 20.6 KB
/
QuickGrid.razor.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
// 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 rid.
///
/// 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 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;
}
}