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
This commit is contained in:
James Yeung 2024-08-15 20:39:06 +08:00 committed by GitHub
parent 4115dcec69
commit 2b609a1d35
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 233 additions and 95 deletions

View File

@ -97,7 +97,7 @@ else if (IsBody && RowSpan != 0 && ColSpan != 0)
@if (ColIndex == Table.TreeExpandIconColumnIndex && (Table.TreeMode || RowData.IsGrouping))
{
<span class="ant-table-row-indent indent-level-@RowData.Level" style="padding-left: @((CssSizeLength)(RowData.Level * Table.IndentSize));"></span>
@if (DataItem.HasChildren)
@if (DataItem.HasChildren || RowData.IsGrouping)
{
<button type="button" @onclick="ToggleTreeNode" @onclick:stopPropagation class="ant-table-row-expand-icon @(RowData?.Expanded == true ? "ant-table-row-expand-icon-expanded" : "ant-table-row-expand-icon-collapsed")" aria-label="@(RowData?.Expanded == true ? Table.Locale.Collapse : Table.Locale.Expand)"></button>
}

View File

@ -398,7 +398,7 @@ namespace AntDesign
}
}
IQueryable<IGrouping<object, TItem>> IFieldColumn.Group<TItem>(IQueryable<TItem> source)
Expression<Func<TItem, object>> IFieldColumn.GetGroupByExpression<TItem>()
{
var param = Expression.Parameter(typeof(TItem), "item");
@ -413,7 +413,7 @@ namespace AntDesign
var body = Expression.Convert(field, typeof(object));
var lambda = Expression.Lambda<Func<TItem, object>>(body, param);
return source.GroupBy(lambda);
return lambda;
}
private void SetSorter(SortDirection sortDirection)

View File

@ -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<IGrouping<object, TItem>> Group<TItem>(IQueryable<TItem> source);
internal Expression<Func<TItem, object>> GetGroupByExpression<TItem>();
}
}

View File

@ -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<string, Delegate> _functionCache = new ConcurrentDictionary<string, Delegate>();
public static List<GroupResult<TEntity>> DynamicGroupBy<TEntity>(
IEnumerable<TEntity> source,
params Expression<Func<TEntity, object>>[] keySelectors)
{
return DynamicGroupByInternal(source, keySelectors, 0);
}
private static List<GroupResult<TEntity>> DynamicGroupByInternal<TEntity>(
IEnumerable<TEntity> source,
Expression<Func<TEntity, object>>[] keySelectors,
int level)
{
if (level >= keySelectors.Length)
{
return new List<GroupResult<TEntity>>
{
new GroupResult<TEntity>
{
Level = level,
Key = null,
Items = source.ToList()
}
};
}
var keySelector = keySelectors[level];
var keySelectorFunc = GetOrAddCachedFunction(keySelector);
var groupedData = source.GroupBy(keySelectorFunc);
var result = new List<GroupResult<TEntity>>();
foreach (var group in groupedData)
{
var groupKey = group.Key;
var children = DynamicGroupByInternal(group.AsQueryable(), keySelectors, level + 1);
result.Add(new GroupResult<TEntity>
{
Level = level + 1,
Key = groupKey,
Children = children,
Items = group.ToList()
});
}
return result;
}
private static Func<TEntity, object> GetOrAddCachedFunction<TEntity>(Expression<Func<TEntity, object>> 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<TEntity, object>)cachedFunc;
}
}

View File

@ -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<CellData> CellRender { get; set; }

View File

@ -230,13 +230,18 @@
}
</td>
</tr>
;
}
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 @<TableRowWrapper RowData="currentRowData" @key="currentRowData.RowIndex">
return @<TableRowWrapper RowData="currentRowData" @key="currentRowData.Key">
@{
var currentDataItem = currentRowData.DataItem;
var rowAttributes = OnRow?.Invoke(currentRowData);
@ -288,10 +293,12 @@
</CascadingValue>
// 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<TItem> currentRowData)
{
@if (GroupFooterTemplate !=null)
{
return@<tr class="ant-table-expanded-row ant-table-expanded-row-level-1 ant-table-grouping-footer" >
<td colspan="@(ColumnContext.Columns.Count+1)" class="ant-table-cell">
@GroupFooterTemplate(currentRowData.GroupResult)
</td>
</tr>;
}
return builder => { };
}
}

View File

@ -58,7 +58,7 @@ namespace AntDesign
}
}
private RowData<TItem> GetGroupRowData(IGrouping<object, TItem> grouping, int index, int level, Dictionary<int, RowData<TItem>> rowCache = null)
private RowData<TItem> GetGroupRowData(GroupResult<TItem> grouping, int index, int level, Dictionary<int, RowData<TItem>> rowCache = null)
{
int rowIndex = index + 1;
@ -67,18 +67,28 @@ namespace AntDesign
rowIndex += PageSize * (PageIndex - 1);
}
var groupRowData = new RowData<TItem>()
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<TItem>
groupRowData = new RowData<TItem>()
{
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<TItem>
{
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;
}

View File

@ -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<TItem> ChildContent { get; set; }
[Parameter]
public RenderFragment<RowData<TItem>> GroupTitleTemplate { get; set; }
public RenderFragment<GroupResult<TItem>> GroupTitleTemplate { get; set; }
[Parameter]
public RenderFragment<GroupResult<TItem>> GroupFooterTemplate { get; set; }
[Parameter]
public RenderFragment<RowData<TItem>> RowTemplate { get; set; }
@ -234,7 +238,7 @@ namespace AntDesign
private QueryModel _currentQueryModel;
private readonly ClassMapper _wrapperClassMapper = new();
private List<IGrouping<object, TItem>> _groups = [];
private List<GroupResult<TItem>> _groups = [];
private string TableLayoutStyle => TableLayout == null ? "" : $"table-layout: {TableLayout};";
@ -283,16 +287,17 @@ namespace AntDesign
RenderFragment<RowData> ITable.GroupTitleTemplate => rowData =>
{
var groupResult = ((RowData<TItem>)rowData).GroupResult;
if (GroupTitleTemplate == null)
{
return builder =>
{
builder.AddContent(0, rowData.Key);
builder.AddContent(0, groupResult.Key);
};
}
return builder =>
{
builder.AddContent(0, GroupTitleTemplate((RowData<TItem>)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<TItem>()).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()

View File

@ -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<TEntity>
{
public int Level { get; set; }
public object Key { get; set; }
internal List<GroupResult<TEntity>> Children { get; set; } = [];
public List<TEntity> Items { get; set; } = [];
public override int GetHashCode()
{
return HashCode.Combine(Items);
}
}

View File

@ -21,6 +21,8 @@ namespace AntDesign.TableModels
/// </summary>
public Dictionary<int, RowData<TItem>> Children { get; set; }
public GroupResult<TItem> GroupResult { get; set; }
public RowData()
{ }

View File

@ -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;
}

View File

@ -1,39 +1,31 @@
@using System.ComponentModel
<Table DataSource="data">
<GroupTitleTemplate Context="rowData">
<div style="display: flex; align-items: center;">
<span style="min-width: 120px;">
@rowData.Key
</span>
<span style="margin-left: -30px;">
@if (rowData.Key == "A")
{
<Tag Color="red-inverse">Excellent</Tag>
}
else if (rowData.Key == "B")
{
<Tag Color="purple-inverse">Good</Tag>
}
else
{
<Tag Color="rgb(143, 201, 146)">Average</Tag>
}
</span>
</div>
<Table DataSource="data" Bordered>
<GroupTitleTemplate Context="group">
<span style="min-width: 120px;">
@(group.Level == 1 ? "Subject" : "Grade") : @group.Key
</span>
</GroupTitleTemplate>
<GroupFooterTemplate Context="group">
@if (group.Level == 1)
{
<span style="padding: 8px; float: right; ">
Total: @group.Items.Sum(x => x.Score)
</span>
}
</GroupFooterTemplate>
<ColumnDefinitions>
<Selection />
<PropertyColumn Property="c=>c.Name" />
<PropertyColumn Property="c=>c.Age" Width="12%" Sortable />
<PropertyColumn Property="c=>c.Subject" Width="30%" />
<PropertyColumn Property="c=>c.Grade" Width="30%" Grouping GroupBy="GradeGroupBy" />
<PropertyColumn Property="c=>c.Name" Grouping />
<PropertyColumn Property="c=>c.Age" Width="12%" />
<PropertyColumn Property="c=>c.Subject" Width="30%" />
<PropertyColumn Property="c=>c.Score" Width="30%" Grouping GroupBy="GradeGroupBy" Sortable />
</ColumnDefinitions>
</Table>
@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; }
}

View File

@ -7,14 +7,15 @@ title:
## zh-CN
设置 `Grouping` 指定根据某列字段分组,也可以设置 `GroupBy` 委托设置分组的值。当前只支持一个属性
设置 `Grouping` 指定列属性值进行分组,也可以设置 `GroupBy` 委托来指定分组的值。还可以设置 `GroupTitleTemplate` 自定义分组标题
还可以设置 `GroupTitleTemplate` 自定义分组标题。
**注意:目前实现的是客户端分组,因此如果 `DataSource` 绑定的是 `IQueryable<T>`,请确保其已包含数据库分组的操作(分页、排序和筛选仍然生效)**
## 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.
**Note: Currently implementation is client-side grouping ,
so if `DataSource` is bound to `IQueryable<T>`, make sure it includes database grouping operations (paging, sorting, and filtering are still in effect)**

View File

@ -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<GroupResult<TItem>> | - |
| 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<TData, object> | - |
### PropertyColumn

View File

@ -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<GroupResult<TItem>> | - |
| GroupFooterTemplate | 分组脚部模板,常用于对分组的统计 | RenderFragment<GroupResult<TItem>> | - |
| 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<Func<TData, TData, bool>> | - |
| Grouping | 指定该列用于分组 | bool | - |
| GroupBy | 一个委托,用于转换分组的值 | Func<TData, object> | - |
### PropertyColumn