dotnet / aspnetcore

ASP.NET Core is a cross-platform .NET framework for building modern cloud-based web applications on Windows, Mac, or Linux.
https://asp.net
MIT License
35.37k stars 9.99k forks source link

[API Proposal] Productize QuickGrid #46789

Closed Nick-Stanton closed 1 year ago

Nick-Stanton commented 1 year ago

Background and Motivation

The QuickGrid package aims to provide a simple and performant data grid component for Blazor. QuickGrid provides some basic functionality such as sorting, paging, styling and virtualization but aims to provide extensibility to developers that want to build on it.

Even through productization, the main goals of QuickGrid are:

QuickGrid can show data from three main sources:

CC: @SteveSandersonMS

Proposed API

The following propose new API in 2 new namespaces:

namespace Microsoft.AspNetCore.Components.QuickGrid;

public partial class QuickGrid<TGridItem> : IAsyncDisposable
{
    public QuickGrid();
    public IQueryable<TGridItem>? Items { get; set; }
    public GridItemsProvider<TGridItem>? ItemsProvider { get; set; }
    public string? Class { get; set; }
    public string? Theme { get; set; }
    public RenderFragment? ChildContent { get; set; }
    public bool Virtualize { get; set; }
    public float ItemSize { get; set; }
    public bool ResizableColumns { get; set; }
    public Func<TGridItem, object> ItemKey { get; set; }
    public PaginationState? Pagination { get; set; }
    protected override Task OnParametersSetAsync();
    protected override async Task OnAfterRenderAsync(bool firstRender);
    public Task SortByColumnAsync(ColumnBase<TGridItem> column, SortDirection direction);
    public void ShowColumnOptions(ColumnBase<TGridItem> column);
    public async Task RefreshDataAsync();
    public async ValueTask DisposeAsync();
}

public enum Align
{
    Left,
    Center,
    Right,
}

public abstract partial class ColumnBase<TGridItem>
{
    public ColumnBase();
    public string? Title { get; set; }
    public string? Class { get; set; }
    public Align Align { get; set; }
    public RenderFragment<ColumnBase<TGridItem>>? HeaderTemplate { get; set; }
    public RenderFragment? ColumnOptions { get; set; }
    public bool? Sortable { get; set; }
    public SortDirection? IsDefaultSort { get; set; }
    public RenderFragment<PlaceholderContext>? PlaceholderTemplate { get; set; }
    public QuickGrid<TGridItem> Grid;
    protected internal abstract void CellContent(RenderTreeBuilder builder, TGridItem item);
    protected internal RenderFragment HeaderContent { get; protected set; }
    protected virtual bool IsSortableByDefault();
    protected override void BuildRenderTree(RenderTreeBuilder builder);
}

public delegate ValueTask<GridItemsProviderResult<TGridItem>> GridItemsProvider<TGridItem>(
    GridItemsProviderRequest<TGridItem> request);

public readonly struct GridItemsProviderRequest<TGridItem>
{
    public int StartIndex { get; }
    public int? Count { get; }
    public ColumnBase<TGridItem>? SortByColumn { get; }
    public bool SortByAscending { get; }
    public CancellationToken CancellationToken { get; }
    public IQueryable<TGridItem> ApplySorting(IQueryable<TGridItem> source);
    public IReadOnlyCollection<(string PropertyName, SortDirection Direction)> GetSortByProperties();
}

public struct GridItemsProviderResult<TGridItem>
{
    public GridItemsProviderResult(ICollection<TGridItem> items, int totalItemCount);
    public ICollection<TGridItem> Items { get; set; }
    public int TotalItemCount { get; set; }
}

public static class GridItemsProviderResult
{
    public static GridItemsProviderResult<TGridItem> From<TGridItem>(ICollection<TGridItem> items, int totalItemCount);
}

public sealed class GridSort<TGridItem>
{
    public static GridSort<TGridItem> ByAscending<U>(Expression<Func<TGridItem, U>> expression);
    public static GridSort<TGridItem> ByDescending<U>(Expression<Func<TGridItem, U>> expression);
    public static GridSort<TGridItem> ThenAscending<U>(Expression<Func<TGridItem, U>> expression);
    public static GridSort<TGridItem> ThenDescending<U>(Expression<Func<TGridItem, U>> expression);
}

public interface ISortBuilderColumn<TGridItem>
{
    public GridSort<TGridItem>? SortBuilder { get; }
}

public class PaginationState
{
    public int ItemsPerPage { get; set; }
    public int CurrentPageIndex { get; }
    public int? TotalItemCount { get; }
    public int? LastPageIndex;
    public event EventHandler<int?>? TotalItemCountChanged;
    public override int GetHashCode();
    public Task SetCurrentPageIndexAsync(int pageIndex);
}

public partial class Paginator : IDisposable
{
    public Paginator();
    public PaginationState Value { get; set; }
    public RenderFragment? SummaryTemplate { get; set; }
    public void Dispose();
    protected override void OnParametersSet();
    protected override void BuildRenderTree(RenderTreeBuilder builder);
}

public class PropertyColumn<TGridItem, TProp> : ColumnBase<TGridItem>, ISortBuilderColumn<TGridItem>
{
    public Expression<Func<TGridItem, TProp>> Property { get; set; }
    public string? Format { get; set; }
    GridSort<TGridItem>? ISortBuilderColumn<TGridItem>.SortBuilder;
    protected override void OnParametersSet();
    protected internal override void CellContent(RenderTreeBuilder builder, TGridItem item);
}

public enum SortDirection
{
    Ascending,
    Descending,
    Auto,
}

public class TemplateColumn<TGridItem> : ColumnBase<TGridItem>, ISortBuilderColumn<TGridItem>
{
    public RenderFragment<TGridItem> ChildContent { get; set; }
    public GridSort<TGridItem>? SortBy { get; set; }
    GridSort<TGridItem>? ISortBuilderColumn<TGridItem>.SortBuilder;
    protected internal override void CellContent(RenderTreeBuilder builder, TGridItem item);
    protected override bool IsSortableByDefault();
}
namespace Microsoft.AspNetCore.Components.QuickGrid.Infrastructure;

public sealed class ColumnsCollectedNotifier<TGridItem> : IComponent
{
    public void Attach(RenderHandle renderHandle);
    public Task SetParametersAsync(ParameterView parameters);
}

public sealed class Defer : ComponentBase
{
    public RenderFragment? ChildContent { get; set; }
}

public static class EventHandlers
{
}

public interface IAsyncQueryExecutor
{
    bool IsSupported<T>(IQueryable<T> queryable);
    Task<int> CountAsync<T>(IQueryable<T> queryable);
    Task<T[]> ToArrayAsync<T>(IQueryable<T> queryable);
}

New API in an existing namespace:

namespace Microsoft.Extensions.DependencyInjection;

+public static class EntityFrameworkAdapterServiceCollectionExtensions
+{
+    public static void AddQuickGridEntityFrameworkAdapter(this IServiceCollection services);
+}

Usage Examples

For a complete set of interactive examples, please see https://aspnet.github.io/quickgridsamples/sample.

Risks

This was originally an experimental package, and the only changes since the first draft of this proposal were to make it compliant with the aspnetcore repo's analyzer/build configuration. This issue will be marked with the appropriate label when ready, but in the meantime feel free to review the attached PR if you are able.

The only other risk I can think of is the cost of additional E2E tests on our CI. The testing story for this package should be designed with that consideration.

ghost commented 1 year ago

Thank you for submitting this for API review. This will be reviewed by @dotnet/aspnet-api-review at the next meeting of the ASP.NET Core API Review group. Please ensure you take a look at the API review process documentation and ensure that:

halter73 commented 1 year ago

This adds two new NuGet pacakges, right? Microsoft.AspNetCore.Components.QuickGrid and Microsoft.AspNetCore.Components.QuickGrid.EntityFrameworkAdapter? Will the main QuickGrid.dll be included in the Microsoft.AspNetCore.App shared framework? I assume the EntityFrameworkAdapter cannot be included because of the EF dependency.

I'm also assuming Paginator, ColumnBase, etc... derive from ComponentBase. Do these derive directly from it or from some child type?

Nick-Stanton commented 1 year ago

Will the main QuickGrid.dll be included in the Microsoft.AspNetCore.App shared framework?

Currently neither project is included in the shared framework, and I assume it would be preferred if they stayed that way. A dev bringing them in on NuGet would match the current experience for those using the experimental package. This also matches what we did for the CustomElements package, which was productized in June.

I'm also assuming Paginator, ColumnBase, etc... derive from ComponentBase. Do these derive directly from it or from some child type?

I believe that Paginator, ColumnBase, and QuickGrid directly derive from ComponentBase as Blazor components but I'm not 100% sure.

@SteveSandersonMS please correct me if I'm mistaken. 😃

halter73 commented 1 year ago

API Review Notes:

In the future, let's try to include the attributes included on public members like [Parameter].

  1. Do we want to use IQueryable? Should we push for a IAsyncQueryable interface? https://github.com/dotnet/runtime/issues/77698 IAsyncQueryable doesn't exist yet.
  2. What happens if you set both Items and ItemsProvider? It throws.
  3. Do like GridItemsProvider<TGridItem> delegate? Vs Func? The Func is way too long.
  4. Why is GridItemsProviderRequest<TGridItem>.Count nullable? Count is null hen you're not paginating.
  5. Is GridItemsProviderRequest<TGridItem> too big to be struct? It can be called fairly quickly, and it is readonly.
  6. Do we want a public constructor for GridItemsProviderRequest<TGridItem> so we can test custom GridItemsProvider<TGridItem> implementations. Let's add init to these.
  7. Why do we have a Class property on the grid and column base when you can already add arbitrary attributes to a compenent? We think it might be important for perf
  8. What about the (string PropertyName, SortDirection Direction) ValueTuple? Let's turn that into a readonly struct for better API docks
  9. Why float for ItemSize? Because browsers use subpixels. And Virtualize already uses float.
  10. Should IAsyncQueryExecutor be moved out of the Infrastructure namespace since people could actually want to implement it unlike the other things in the namespace.
  11. Can ShowColumnOptions be async like SortByColumnAsync? Probably.
  12. Why is there IsSortableByDefault(), Sortable, and IsDefaultSort in ColumnBase? Can't there be a default sort direction even if it isn't the default column to be sorted first? Why is Sortable tri-state? There is default behavior when Sortable is null.
  13. Should we rename Sortable to IsSortable for consistency. Maybe, but we'd rather not break existing users just for this.
  14. Should the first SortDirection be Auto since that's the default? Yes.

We weren't able to cover everything in one meeting. We'll follow up with the final approved API shape later, but here is the modified API so far.

namespace Microsoft.AspNetCore.Components.QuickGrid;

public partial class QuickGrid<TGridItem> : IAsyncDisposable
{
    public QuickGrid();
    [Parameter] public IQueryable<TGridItem>? Items { get; set; }
    [Parameter] public GridItemsProvider<TGridItem>? ItemsProvider { get; set; }
    [Parameter] public string? Class { get; set; }
    // public IReadOnlyDictionary<string, object?> Attributes { get; set; } // Possible alternative to Class
    [Parameter] public string? Theme { get; set; }
    [Parameter] public RenderFragment? ChildContent { get; set; }
    [Parameter] public bool Virtualize { get; set; }
    [Parameter] public float ItemSize { get; set; }
    [Parameter] public bool ResizableColumns { get; set; }
    [Parameter] public Func<TGridItem, object> ItemKey { get; set; }
    [Parameter] public PaginationState? Pagination { get; set; }
    protected override Task OnParametersSetAsync();
    protected override async Task OnAfterRenderAsync(bool firstRender);
    public Task SortByColumnAsync(ColumnBase<TGridItem> column, SortDirection direction);
    public Task ShowColumnOptions(ColumnBase<TGridItem> column);
    public async Task RefreshDataAsync();
    public async ValueTask DisposeAsync();
}

public enum Align
{
    Left,
    Center,
    Right,
}

public abstract partial class ColumnBase<TGridItem>
{
    public ColumnBase();
    [Parameter] public string? Title { get; set; }
    [Parameter] public string? Class { get; set; }
    [Parameter] public Align Align { get; set; }
    [Parameter] public RenderFragment<ColumnBase<TGridItem>>? HeaderTemplate { get; set; }
    [Parameter] public RenderFragment? ColumnOptions { get; set; }
    [Parameter] public bool? Sortable { get; set; }
    [Parameter] public SortDirection? IsDefaultSort { get; set; }
    [Parameter] public RenderFragment<PlaceholderContext>? PlaceholderTemplate { get; set; }
    public QuickGrid<TGridItem> Grid;
    protected internal abstract void CellContent(RenderTreeBuilder builder, TGridItem item);
    protected internal RenderFragment HeaderContent { get; protected set; }
    protected virtual bool IsSortableByDefault();
    protected override void BuildRenderTree(RenderTreeBuilder builder);
}

public delegate ValueTask<GridItemsProviderResult<TGridItem>> GridItemsProvider<TGridItem>(
    GridItemsProviderRequest<TGridItem> request);

public readonly struct GridItemsProviderRequest<TGridItem>
{
    public int StartIndex { get; init; }
    public int? Count { get; init; }
    public ColumnBase<TGridItem>? SortByColumn { get; init; }
    public bool SortByAscending { get; init; }
    public CancellationToken CancellationToken { get; init; }
    public IReadOnlyCollection<SortedProperty> GetSortByProperties();
    public IQueryable<TGridItem> ApplySorting(IQueryable<TGridItem> source);
}

public readonly struct SortedProperty
{
     public string PropertyName { get; required init; }
     public SortDirection Direction { get; init; }
}

public struct GridItemsProviderResult<TGridItem>
{
    public GridItemsProviderResult(ICollection<TGridItem> items, int totalItemCount);
    public ICollection<TGridItem> Items { get; set; }
    public int TotalItemCount { get; set; }
}

public static class GridItemsProviderResult
{
    public static GridItemsProviderResult<TGridItem> From<TGridItem>(ICollection<TGridItem> items, int totalItemCount);
}

public sealed class GridSort<TGridItem>
{
    public static GridSort<TGridItem> ByAscending<U>(Expression<Func<TGridItem, U>> expression);
    public static GridSort<TGridItem> ByDescending<U>(Expression<Func<TGridItem, U>> expression);
    public GridSort<TGridItem> ThenAscending<U>(Expression<Func<TGridItem, U>> expression);
    public GridSort<TGridItem> ThenDescending<U>(Expression<Func<TGridItem, U>> expression);
}

public interface ISortBuilderColumn<TGridItem>
{
    public GridSort<TGridItem>? SortBuilder { get; }
}

public class PaginationState
{
    public PaginationState();
    public int ItemsPerPage { get; set; }
    public int CurrentPageIndex { get; }
    public int? TotalItemCount { get; }
    public int? LastPageIndex;
    public event EventHandler<int?>? TotalItemCountChanged;
    public override int GetHashCode();
    public Task SetCurrentPageIndexAsync(int pageIndex);
}

public partial class Paginator : IDisposable
{
    public Paginator();
    [Parameter, EditorRequired] public PaginationState Value { get; set; }
    [Parameter] public RenderFragment? SummaryTemplate { get; set; }
    public void Dispose();
    protected override void OnParametersSet();
    protected override void BuildRenderTree(RenderTreeBuilder builder);
}

public class PropertyColumn<TGridItem, TProp> : ColumnBase<TGridItem>, ISortBuilderColumn<TGridItem>
{
    [Parameter, EditorRequired] public Expression<Func<TGridItem, TProp>> Property { get; set; }
    [Parameter] public string? Format { get; set; }
    GridSort<TGridItem>? ISortBuilderColumn<TGridItem>.SortBuilder;
    protected override void OnParametersSet();
    protected internal override void CellContent(RenderTreeBuilder builder, TGridItem item);
}

public enum SortDirection
{
    Auto,
    Ascending,
    Descending,
}

public class TemplateColumn<TGridItem> : ColumnBase<TGridItem>, ISortBuilderColumn<TGridItem>
{
    public TemplateColumn();
    [Parameter] public RenderFragment<TGridItem> ChildContent { get; set; }
    [Parameter] public GridSort<TGridItem>? SortBy { get; set; }
    GridSort<TGridItem>? ISortBuilderColumn<TGridItem>.SortBuilder;
    protected internal override void CellContent(RenderTreeBuilder builder, TGridItem item);
    protected override bool IsSortableByDefault();
}

public interface IAsyncQueryExecutor
{
    bool IsSupported<T>(IQueryable<T> queryable);
    Task<int> CountAsync<T>(IQueryable<T> queryable);
    Task<T[]> ToArrayAsync<T>(IQueryable<T> queryable);
}
namespace Microsoft.AspNetCore.Components.QuickGrid.Infrastructure;

[EditorBrowsable(EditorBrowsableState.Never)]
public sealed class ColumnsCollectedNotifier<TGridItem> : IComponent
{
    public void Attach(RenderHandle renderHandle);
    public Task SetParametersAsync(ParameterView parameters);
}

[EditorBrowsable(EditorBrowsableState.Never)]
public sealed class Defer : ComponentBase
{
    public Defer();
    public RenderFragment? ChildContent { get; set; }
}

[EventHandler("onclosecolumnoptions", typeof(EventArgs), enableStopPropagation: true, enablePreventDefault: true)]
public static class EventHandlers
{
}

New API in an existing namespace:

namespace Microsoft.Extensions.DependencyInjection;

+public static class EntityFrameworkAdapterServiceCollectionExtensions
+{
+    public static void AddQuickGridEntityFrameworkAdapter(this IServiceCollection services);
+}
halter73 commented 1 year ago

API Review Notes:

API Approved!

// Microsoft.AspNetCore.Components.QuickGrid.dll

namespace Microsoft.AspNetCore.Components.QuickGrid;

public partial class QuickGrid<TGridItem> : ComponentBase, IAsyncDisposable
{
    public QuickGrid();
    [Parameter] public IQueryable<TGridItem>? Items { get; set; }
    [Parameter] public GridItemsProvider<TGridItem>? ItemsProvider { get; set; }
    [Parameter] public string? Class { get; set; }
    [Parameter] public string? Theme { get; set; }
    [Parameter] public RenderFragment? ChildContent { get; set; }
    [Parameter] public bool Virtualize { get; set; }
    [Parameter] public float ItemSize { get; set; }
    [Parameter] public Func<TGridItem, object> ItemKey { get; set; }
    [Parameter] public PaginationState? Pagination { get; set; }
    protected override Task OnParametersSetAsync();
    protected override async Task OnAfterRenderAsync(bool firstRender);
    public Task SortByColumnAsync(ColumnBase<TGridItem> column, SortDirection direction);
    public Task ShowColumnOptions(ColumnBase<TGridItem> column);
    public async Task RefreshDataAsync();
    public async ValueTask DisposeAsync();
}

public enum Align
{
    Start,
    Center,
    End,
    Left,
    Right,
}

public abstract partial class ColumnBase<TGridItem> : ComponentBase
{
    public ColumnBase();
    [Parameter] public string? Title { get; set; }
    [Parameter] public string? Class { get; set; }
    [Parameter] public Align Align { get; set; }
    [Parameter] public RenderFragment<ColumnBase<TGridItem>>? HeaderTemplate { get; set; }
    [Parameter] public RenderFragment? ColumnOptions { get; set; }
    [Parameter] public bool? Sortable { get; set; }
    [Parameter] public SortDirection InitialSortDirection { get; set; }
    [Parameter] public bool IsDefaultSortColumn { get; set; }
    [Parameter] public GridSort<TGridItem>? SortBy { get; set; }
    [Parameter] public RenderFragment<PlaceholderContext>? PlaceholderTemplate { get; set; }
    public QuickGrid<TGridItem> Grid { get; }
    protected internal abstract void CellContent(RenderTreeBuilder builder, TGridItem item);
    protected internal RenderFragment HeaderContent { get; protected set; }
    protected virtual bool IsSortableByDefault();
    protected override void BuildRenderTree(RenderTreeBuilder builder);
}

public delegate ValueTask<GridItemsProviderResult<TGridItem>> GridItemsProvider<TGridItem>(
    GridItemsProviderRequest<TGridItem> request);

public readonly struct GridItemsProviderRequest<TGridItem>
{
    public int StartIndex { get; init; }
    public int? Count { get; init; }
    public ColumnBase<TGridItem>? SortByColumn { get; init; }
    public bool SortByAscending { get; init; }
    public CancellationToken CancellationToken { get; init; }
    public IReadOnlyCollection<SortedProperty> GetSortByProperties();
    public IQueryable<TGridItem> ApplySorting(IQueryable<TGridItem> source);
}

public readonly struct SortedProperty
{
     public string PropertyName { get; required init; }
     public SortDirection Direction { get; init; }
}

public struct GridItemsProviderResult<TGridItem>
{
    public ICollection<TGridItem> Items { get; required init; }
    public int TotalItemCount { get; init; }
}

public static class GridItemsProviderResult
{
    public static GridItemsProviderResult<TGridItem> From<TGridItem>(ICollection<TGridItem> items, int totalItemCount);
}

public sealed class GridSort<TGridItem>
{
    public static GridSort<TGridItem> ByAscending<U>(Expression<Func<TGridItem, U>> expression);
    public static GridSort<TGridItem> ByDescending<U>(Expression<Func<TGridItem, U>> expression);
    public GridSort<TGridItem> ThenAscending<U>(Expression<Func<TGridItem, U>> expression);
    public GridSort<TGridItem> ThenDescending<U>(Expression<Func<TGridItem, U>> expression);
}

public class PaginationState
{
    public PaginationState();
    public int ItemsPerPage { get; set; }
    public int CurrentPageIndex { get; }
    public int? TotalItemCount { get; }
    public int? LastPageIndex { get; }
    public event EventHandler<int?>? TotalItemCountChanged;
    public override int GetHashCode();
    public Task SetCurrentPageIndexAsync(int pageIndex);
}

public partial class Paginator : ComponentBase, IDisposable
{
    public Paginator();
    [Parameter, EditorRequired] public PaginationState State { get; set; }
    [Parameter] public RenderFragment? SummaryTemplate { get; set; }
    public void Dispose();
    protected override void OnParametersSet();
    protected override void BuildRenderTree(RenderTreeBuilder builder);
}

public class PropertyColumn<TGridItem, TProp> : ColumnBase<TGridItem>, ISortBuilderColumn<TGridItem>
{
    [Parameter, EditorRequired] public Expression<Func<TGridItem, TProp>> Property { get; set; }
    [Parameter] public string? Format { get; set; }
    protected override void OnParametersSet();
    protected internal override void CellContent(RenderTreeBuilder builder, TGridItem item);
}

public enum SortDirection
{
    Auto,
    Ascending,
    Descending,
}

public class TemplateColumn<TGridItem> : ColumnBase<TGridItem>, ISortBuilderColumn<TGridItem>
{
    public TemplateColumn();
    [Parameter] public RenderFragment<TGridItem> ChildContent { get; set; }
    protected internal override void CellContent(RenderTreeBuilder builder, TGridItem item);
    protected override bool IsSortableByDefault();
}

public interface IAsyncQueryExecutor
{
    bool IsSupported<T>(IQueryable<T> queryable);
    Task<int> CountAsync<T>(IQueryable<T> queryable);
    Task<T[]> ToArrayAsync<T>(IQueryable<T> queryable);
}
// Microsoft.AspNetCore.Components.QuickGrid.dll

namespace Microsoft.AspNetCore.Components.QuickGrid.Infrastructure;

[EditorBrowsable(EditorBrowsableState.Never)]
public sealed class ColumnsCollectedNotifier<TGridItem> : IComponent
{
    public void Attach(RenderHandle renderHandle);
    public Task SetParametersAsync(ParameterView parameters);
}

[EditorBrowsable(EditorBrowsableState.Never)]
public sealed class Defer : ComponentBase
{
    public Defer();
    public RenderFragment? ChildContent { get; set; }
}

[EventHandler("onclosecolumnoptions", typeof(EventArgs), enableStopPropagation: true, enablePreventDefault: true)]
public static class EventHandlers
{
}

New API in an existing namespace:

// Microsoft.AspNetCore.Components.QuickGrid.EntityFrameworkAdapter.dll

namespace Microsoft.Extensions.DependencyInjection;

+public static class EntityFrameworkAdapterServiceCollectionExtensions
+{
+    public static void AddQuickGridEntityFrameworkAdapter(this IServiceCollection services);
+}
halter73 commented 1 year ago

Based on internal discussion that QuickGrid<TGridItem>.Attributes isn't really feasible, we've decided to go back to QuickGrid<TGridItem>.Class as originally proposed. I've edited the API approval comment above to reflect the updated API.

public partial class QuickGrid<TGridItem> : ComponentBase, IAsyncDisposable
{
-    [Parameter] public IReadOnlyDictionary<string, object?> Attributes { get; set; }
+    [Parameter] public string? Class { get; set; }
}