From 2b609a1d350b650ce69c769634921e66e1cec929 Mon Sep 17 00:00:00 2001 From: James Yeung Date: Thu, 15 Aug 2024 20:39:06 +0800 Subject: [PATCH] feat(module: table): support multiple row grouping (#4089) * support multiple grouping * fix children * fix rows * fix group data cache and selection * update demo * update style and API --- components/table/Column.razor | 2 +- components/table/Column.razor.cs | 4 +- components/table/IFieldColumn.cs | 5 +- .../MultiLevelDynamicGroupByHelper.cs | 81 +++++++++++++++++++ components/table/Selection.razor.cs | 2 +- components/table/Table.razor | 40 ++++++--- components/table/Table.razor.RowData.cs | 32 +++++--- components/table/Table.razor.cs | 27 +++---- components/table/TableModels/GroupResult.cs | 21 +++++ components/table/TableModels/RowData.cs | 2 + components/table/style/patch.less | 9 +++ .../Components/Table/demo/GroupingRows.razor | 80 +++++++++--------- .../Components/Table/demo/grouping-rows.md | 13 +-- .../Demos/Components/Table/doc/index.en-US.md | 4 + .../Demos/Components/Table/doc/index.zh-CN.md | 6 +- 15 files changed, 233 insertions(+), 95 deletions(-) create mode 100644 components/table/Internal/MultiLevelDynamicGroupByHelper.cs create mode 100644 components/table/TableModels/GroupResult.cs diff --git a/components/table/Column.razor b/components/table/Column.razor index 0632f85c..f6784857 100644 --- a/components/table/Column.razor +++ b/components/table/Column.razor @@ -97,7 +97,7 @@ else if (IsBody && RowSpan != 0 && ColSpan != 0) @if (ColIndex == Table.TreeExpandIconColumnIndex && (Table.TreeMode || RowData.IsGrouping)) { - @if (DataItem.HasChildren) + @if (DataItem.HasChildren || RowData.IsGrouping) { } diff --git a/components/table/Column.razor.cs b/components/table/Column.razor.cs index 2c756922..bb7d3ed8 100644 --- a/components/table/Column.razor.cs +++ b/components/table/Column.razor.cs @@ -398,7 +398,7 @@ namespace AntDesign } } - IQueryable> IFieldColumn.Group(IQueryable source) + Expression> IFieldColumn.GetGroupByExpression() { var param = Expression.Parameter(typeof(TItem), "item"); @@ -413,7 +413,7 @@ namespace AntDesign var body = Expression.Convert(field, typeof(object)); var lambda = Expression.Lambda>(body, param); - return source.GroupBy(lambda); + return lambda; } private void SetSorter(SortDirection sortDirection) diff --git a/components/table/IFieldColumn.cs b/components/table/IFieldColumn.cs index 43791ad3..246d800b 100644 --- a/components/table/IFieldColumn.cs +++ b/components/table/IFieldColumn.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; using AntDesign.TableModels; @@ -34,6 +35,6 @@ namespace AntDesign internal void SetSortModel(ITableSortModel sortModel); - internal IQueryable> Group(IQueryable source); + internal Expression> GetGroupByExpression(); } } diff --git a/components/table/Internal/MultiLevelDynamicGroupByHelper.cs b/components/table/Internal/MultiLevelDynamicGroupByHelper.cs new file mode 100644 index 00000000..0cb6481c --- /dev/null +++ b/components/table/Internal/MultiLevelDynamicGroupByHelper.cs @@ -0,0 +1,81 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using System.Linq; +using System.Text; +using System.Dynamic; +using System.Collections.Concurrent; +using AntDesign.TableModels; + +namespace AntDesign.Internal; + +internal static class DynamicGroupByHelper +{ + // Cache for storing compiled functions + private static readonly ConcurrentDictionary _functionCache = new ConcurrentDictionary(); + + public static List> DynamicGroupBy( + IEnumerable source, + params Expression>[] keySelectors) + { + return DynamicGroupByInternal(source, keySelectors, 0); + } + + private static List> DynamicGroupByInternal( + IEnumerable source, + Expression>[] keySelectors, + int level) + { + if (level >= keySelectors.Length) + { + return new List> + { + new GroupResult + { + Level = level, + Key = null, + Items = source.ToList() + } + }; + } + + var keySelector = keySelectors[level]; + var keySelectorFunc = GetOrAddCachedFunction(keySelector); + + var groupedData = source.GroupBy(keySelectorFunc); + + var result = new List>(); + + foreach (var group in groupedData) + { + var groupKey = group.Key; + var children = DynamicGroupByInternal(group.AsQueryable(), keySelectors, level + 1); + + result.Add(new GroupResult + { + Level = level + 1, + Key = groupKey, + Children = children, + Items = group.ToList() + }); + } + + return result; + } + + private static Func GetOrAddCachedFunction(Expression> keySelector) + { + // Create a unique key by combining the expression string and the entity type + var key = $"{typeof(TEntity).FullName}:{keySelector.ToString()}"; + if (!_functionCache.TryGetValue(key, out var cachedFunc)) + { + cachedFunc = keySelector.Compile(); + _functionCache.TryAdd(key, cachedFunc); + } + return (Func)cachedFunc; + } +} diff --git a/components/table/Selection.razor.cs b/components/table/Selection.razor.cs index 9bae5062..633017d8 100644 --- a/components/table/Selection.razor.cs +++ b/components/table/Selection.razor.cs @@ -14,7 +14,7 @@ namespace AntDesign [Parameter] public string Key { get; set; } - [Parameter] public bool CheckStrictly { get; set; } = true; + [Parameter] public bool CheckStrictly { get; set; } [Parameter] public virtual RenderFragment CellRender { get; set; } diff --git a/components/table/Table.razor b/components/table/Table.razor index 222af32f..02c526d6 100644 --- a/components/table/Table.razor +++ b/components/table/Table.razor @@ -230,13 +230,18 @@ } - ; - } - else if (_groupedColumns.Count>0 && level==0) - { - return _groups.Select((data, index) => GetGroupRowData(data, index, level, parentRowData?.Children)) - .ForeachLoop(bodyRow()); - } + ; + } + else if (_groupedColumns.Count>0 && level==0) + { + return _groups.Select((data, index) => GetGroupRowData(data, index, level, parentRowData?.Children)) + .ForeachLoop(bodyRow()); + } + else if (_groupedColumns.Count>0 && parentRowData?.IsGrouping == true) + { + return parentRowData?.Children.Values + .ForeachLoop(bodyRow()); + } #if NET5_0_OR_GREATER else if (EnableVirtualization) { @@ -259,7 +264,7 @@ { return currentRowData => { - return @ + return @ @{ var currentDataItem = currentRowData.DataItem; var rowAttributes = OnRow?.Invoke(currentRowData); @@ -288,10 +293,12 @@ // When expand button was clicked, would trigger here to add one more child level - @if (currentDataItem.HasChildren && currentRowData.Expanded) + @if ((currentDataItem.HasChildren || currentRowData.IsGrouping) && currentRowData.Expanded) { - currentRowData.Children??=new(); + currentRowData.Children ??= new(); + currentDataItem.Children ??= []; @body(SortFilterChildren(currentDataItem.Children), currentRowData); + @groupFooter(currentRowData); } @if (!currentDataItem.HasChildren && ExpandTemplate != null && RowExpandable(currentRowData) && currentRowData.Expanded) { @@ -326,4 +333,17 @@ return builder => { }; } + + RenderFragment groupFooter(RowData currentRowData) + { + @if (GroupFooterTemplate !=null) + { + return@ + + @GroupFooterTemplate(currentRowData.GroupResult) + + ; + } + return builder => { }; + } } \ No newline at end of file diff --git a/components/table/Table.razor.RowData.cs b/components/table/Table.razor.RowData.cs index cb202bfa..53e7bb3a 100644 --- a/components/table/Table.razor.RowData.cs +++ b/components/table/Table.razor.RowData.cs @@ -58,7 +58,7 @@ namespace AntDesign } } - private RowData GetGroupRowData(IGrouping grouping, int index, int level, Dictionary> rowCache = null) + private RowData GetGroupRowData(GroupResult grouping, int index, int level, Dictionary> rowCache = null) { int rowIndex = index + 1; @@ -67,18 +67,28 @@ namespace AntDesign rowIndex += PageSize * (PageIndex - 1); } - var groupRowData = new RowData() + var hashCode = grouping.GetHashCode(); + rowCache ??= _rootRowDataCache; + + if (!rowCache.TryGetValue(hashCode, out var groupRowData) || groupRowData == null) { - Key = grouping.Key.ToString(), - IsGrouping = true, - RowIndex = rowIndex, - DataItem = new TableDataItem + groupRowData = new RowData() { - Table = this, - Children = grouping - }, - Children = grouping.Select((data, index) => GetRowData(data, index, level, rowCache)).ToDictionary(x => GetHashCode(x.Data), x => x) - }; + Key = grouping.Key.ToString(), + IsGrouping = true, + RowIndex = rowIndex, + Level = level, + GroupResult = grouping, + DataItem = new TableDataItem + { + Table = this, + }, + Children = grouping.Children.SelectMany(x => x.Key == null ? x.Items.Select((data, index) => GetRowData(data, index + rowIndex, level + 1, rowCache)) : [GetGroupRowData(x, index + rowIndex, level + 1, rowCache)]) + .ToDictionary(x => x.Data != null ? GetHashCode(x.Data) : x.GroupResult.GetHashCode(), x => x) + }; + + rowCache.Add(hashCode, groupRowData); + } return groupRowData; } diff --git a/components/table/Table.razor.cs b/components/table/Table.razor.cs index 533be7e1..cf068cf5 100644 --- a/components/table/Table.razor.cs +++ b/components/table/Table.razor.cs @@ -16,6 +16,7 @@ using AntDesign.core.Services; using AntDesign.Table.Internal; using AntDesign.Core.Reflection; using System.Diagnostics.CodeAnalysis; +using AntDesign.Internal; #if NET5_0_OR_GREATER @@ -67,7 +68,10 @@ namespace AntDesign public RenderFragment ChildContent { get; set; } [Parameter] - public RenderFragment> GroupTitleTemplate { get; set; } + public RenderFragment> GroupTitleTemplate { get; set; } + + [Parameter] + public RenderFragment> GroupFooterTemplate { get; set; } [Parameter] public RenderFragment> RowTemplate { get; set; } @@ -234,7 +238,7 @@ namespace AntDesign private QueryModel _currentQueryModel; private readonly ClassMapper _wrapperClassMapper = new(); - private List> _groups = []; + private List> _groups = []; private string TableLayoutStyle => TableLayout == null ? "" : $"table-layout: {TableLayout};"; @@ -283,16 +287,17 @@ namespace AntDesign RenderFragment ITable.GroupTitleTemplate => rowData => { + var groupResult = ((RowData)rowData).GroupResult; if (GroupTitleTemplate == null) { return builder => { - builder.AddContent(0, rowData.Key); + builder.AddContent(0, groupResult.Key); }; } return builder => { - builder.AddContent(0, GroupTitleTemplate((RowData)rowData)); + builder.AddContent(0, GroupTitleTemplate(groupResult)); }; }; @@ -607,28 +612,18 @@ namespace AntDesign return; } - var queryModel = BuildQueryModel(); - var query = queryModel.ExecuteQuery(_dataSource.AsQueryable()); - - foreach (var column in _groupedColumns) - { - var grouping = column.Group(queryModel.CurrentPagedRecords(query)); - _groups = [.. grouping]; - } - - StateHasChanged(); + var selectedKeys = _groupedColumns.Select(x => x.GetGroupByExpression()).ToArray(); + _groups = DynamicGroupByHelper.DynamicGroupBy(_showItems, selectedKeys); } public void AddGroupColumn(IFieldColumn column) { this._groupedColumns.Add(column); - GroupItems(); } public void RemoveGroupColumn(IFieldColumn column) { this._groupedColumns.Remove(column); - GroupItems(); } private void SetClass() diff --git a/components/table/TableModels/GroupResult.cs b/components/table/TableModels/GroupResult.cs new file mode 100644 index 00000000..1148beb2 --- /dev/null +++ b/components/table/TableModels/GroupResult.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; + +namespace AntDesign.TableModels; + +public class GroupResult +{ + public int Level { get; set; } + public object Key { get; set; } + internal List> Children { get; set; } = []; + public List Items { get; set; } = []; + + public override int GetHashCode() + { + return HashCode.Combine(Items); + } +} diff --git a/components/table/TableModels/RowData.cs b/components/table/TableModels/RowData.cs index 5262475c..7dc7d6f2 100644 --- a/components/table/TableModels/RowData.cs +++ b/components/table/TableModels/RowData.cs @@ -21,6 +21,8 @@ namespace AntDesign.TableModels /// public Dictionary> Children { get; set; } + public GroupResult GroupResult { get; set; } + public RowData() { } diff --git a/components/table/style/patch.less b/components/table/style/patch.less index e011f1ab..f3dece24 100644 --- a/components/table/style/patch.less +++ b/components/table/style/patch.less @@ -92,4 +92,13 @@ border-right: 2px dashed @primary-color-active; z-index: 99; } +} + +tr.@{table-prefix-cls}-grouping-footer > td { + padding: 0; + line-height: inherit; +} + +tr.@{table-prefix-cls}-row-grouping > td { + padding: @table-padding-vertical-sm @table-padding-horizontal-sm; } \ No newline at end of file diff --git a/site/AntDesign.Docs/Demos/Components/Table/demo/GroupingRows.razor b/site/AntDesign.Docs/Demos/Components/Table/demo/GroupingRows.razor index f057a88c..d317750c 100644 --- a/site/AntDesign.Docs/Demos/Components/Table/demo/GroupingRows.razor +++ b/site/AntDesign.Docs/Demos/Components/Table/demo/GroupingRows.razor @@ -1,39 +1,31 @@ @using System.ComponentModel - - -
- - @rowData.Key - - - @if (rowData.Key == "A") - { - Excellent - } - else if (rowData.Key == "B") - { - Good - } - else - { - Average - } - -
+
+ + + @(group.Level == 1 ? "Subject" : "Grade") : @group.Key + + + @if (group.Level == 1) + { + + Total: @group.Items.Sum(x => x.Score) + + } + - - - - + + + +
@code { - string GradeGroupBy(int grade) - => grade switch + string GradeGroupBy(int score) + => score switch { >= 95 => "A", >= 90 => "B", @@ -45,38 +37,38 @@ new() { Name = "John Brown", Age = 10, - Grade = 90, - Subject= "Math" - }, - new() { - Name = "John Brown", - Age = 10, - Grade = 95, + Score = 95, Subject= "Language" }, new() { Name = "John Brown", Age = 10, - Grade = 85, + Score = 90, + Subject= "Math" + }, + new() { + Name = "John Brown", + Age = 10, + Score = 85, Subject="Science" }, new() { Name = "Harry Potter", - Age = 10, - Grade = 89, - Subject= "Math" + Age = 9, + Score = 95, + Subject="Science" }, new() { Name = "Harry Potter", - Age = 10, - Grade = 90, + Age = 9, + Score = 90, Subject= "Language" }, new() { Name = "Harry Potter", - Age = 10, - Grade = 95, - Subject="Science" + Age = 9, + Score = 89, + Subject= "Math" }, }; @@ -86,7 +78,7 @@ public int Age { get; set; } - public int Grade { get; set; } + public int Score { get; set; } public string Subject { get; set; } } diff --git a/site/AntDesign.Docs/Demos/Components/Table/demo/grouping-rows.md b/site/AntDesign.Docs/Demos/Components/Table/demo/grouping-rows.md index 9c5f9409..6b7171f7 100644 --- a/site/AntDesign.Docs/Demos/Components/Table/demo/grouping-rows.md +++ b/site/AntDesign.Docs/Demos/Components/Table/demo/grouping-rows.md @@ -7,14 +7,15 @@ title: ## zh-CN -设置 `Grouping` 指定根据某列字段分组,也可以设置 `GroupBy` 委托设置分组的值。当前只支持一个属性。 +设置 `Grouping` 指定列属性值进行分组,也可以设置 `GroupBy` 委托来指定分组的值。还可以设置 `GroupTitleTemplate` 自定义分组标题。 -还可以设置 `GroupTitleTemplate` 自定义分组标题。 +**注意:目前实现的是客户端分组,因此如果 `DataSource` 绑定的是 `IQueryable`,请确保其已包含数据库分组的操作(分页、排序和筛选仍然生效)** ## en-US -Setting `Grouping` Specifies that grouping is based on a column field. -You can also set the `GroupBy` delegate to set the value for grouping. -Currently only one attribute is supported. +Set `Grouping` to specify the column property values for grouping, +and you can also set `GroupBy` delegation to specify the grouping values. +You can also set `GroupTitleTemplate` to customize the group title. -You can also set `GroupTitleTemplate` to customize the group title. \ No newline at end of file +**Note: Currently implementation is client-side grouping , +so if `DataSource` is bound to `IQueryable`, make sure it includes database grouping operations (paging, sorting, and filtering are still in effect)** \ No newline at end of file diff --git a/site/AntDesign.Docs/Demos/Components/Table/doc/index.en-US.md b/site/AntDesign.Docs/Demos/Components/Table/doc/index.en-US.md index a25d7f8e..cac40e5d 100644 --- a/site/AntDesign.Docs/Demos/Components/Table/doc/index.en-US.md +++ b/site/AntDesign.Docs/Demos/Components/Table/doc/index.en-US.md @@ -54,6 +54,7 @@ Since 0.16.0, Table has supported ordinary classes, record, interface, and abstr | Title | Table title | string | - | | TitleTemplate | Title template | RenderFragment | - | | GroupTitleTemplate | Row group title template | RenderFragment | - | +| GroupFooterTemplate | Group foot template, often used to aggregate statistics | RenderFragment> | - | | Footer | Table Footer | string | - | | FooterTemplate | Footer Template | RenderFragment | - | | Size | Table Size | [TableSize](https://github.com/ant-design-blazor/ant-design-blazor/blob/master/components/table/TableSize.cs) | - | @@ -75,6 +76,7 @@ Since 0.16.0, Table has supported ordinary classes, record, interface, and abstr | Resizable | Enable resizable column | bool | false | | FieldFilterTypeResolver | Used to resolve filter types for columns | `IFilterTypeResolver` | Injected | + ### Column The Column definition of the previous version, For .NET 6 and above, `PropertyColumn` is recommended. @@ -99,6 +101,8 @@ The Column definition of the previous version, For .NET 6 and above, `PropertyCo | FilterDropdown | Custom Filter Dropdown Template | RenderFragment | - | | FieldFilterType | Specifies what filter options to display and how to filter the data | `IFieldFilterType` | Resolved using Table's `FieldFilterTypeResolver` | | Filtered | Whether the dataSource is filtered. Filter icon will be actived when it is true. | bool | false | +| Grouping | Specify that this column is used for grouping | bool | - | +| GroupBy | A delegate that converts the value of a group | Func | - | ### PropertyColumn diff --git a/site/AntDesign.Docs/Demos/Components/Table/doc/index.zh-CN.md b/site/AntDesign.Docs/Demos/Components/Table/doc/index.zh-CN.md index c3b0b3d0..4febc1f8 100644 --- a/site/AntDesign.Docs/Demos/Components/Table/doc/index.zh-CN.md +++ b/site/AntDesign.Docs/Demos/Components/Table/doc/index.zh-CN.md @@ -53,7 +53,8 @@ cover: https://gw.alipayobjects.com/zos/alicdn/f-SbcX2Lx/Table.svg | Loading | 表格是否加载中 | bool | false | | Title | 表格标题 | string | - | | TitleTemplate | 标题模板 | RenderFragment | - | -| GroupTitleTemplate | 行分组标题模板 | RenderFragment | - | +| GroupTitleTemplate | 分组标题模板,不设置默认标题为该层级分组 Key 的字符串转换 | RenderFragment> | - | +| GroupFooterTemplate | 分组脚部模板,常用于对分组的统计 | RenderFragment> | - | | Footer | 表格尾部 | string | - | | FooterTemplate | 表格尾部模板 | RenderFragment | - | | Size | 表格尺寸大小 | [TableSize](https://github.com/ant-design-blazor/ant-design-blazor/blob/master/components/table/TableSize.cs) | - | @@ -99,7 +100,8 @@ cover: https://gw.alipayobjects.com/zos/alicdn/f-SbcX2Lx/Table.svg | FilterDropdown | 自定义列筛选器模板 | RenderFragment | - | | FieldFilterType | 筛选器配置 ,可用于自定义额外的筛选器 | `IFieldFilterType` | 由 `FieldFilterTypeResolver` 根据类型解析内置筛选器 | | OnFilter | 筛选当前数据 | Expression> | - | - +| Grouping | 指定该列用于分组 | bool | - | +| GroupBy | 一个委托,用于转换分组的值 | Func | - | ### PropertyColumn