feat(Table): add RowTemplate (#3854)

* refactor: 精简代码

* feat: 增加 TableRowContext 上下文类

* refactor: 精简代码

* feat: 增加 RowTemplate 模板参数

* refactor: 增加 RowTemplate 模板逻辑

* doc: 增加 CustomTableRow 组件

* doc: 增加 RowTemplate 文档

* revert: 撤销代码写法

* test: 增加单元测试

* doc: 更新文档

* doc: 增加描述信息

* test: 更新单元测试

* refactor: 更新单元测试

* test: 更新单元测试

* test: 更新单元测试

* test: 更新单元测试
This commit is contained in:
Argo Zhang 2024-07-15 13:37:09 +08:00 committed by GitHub
parent 1d89e8fa57
commit 3b5274a315
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 296 additions and 121 deletions

View File

@ -0,0 +1,6 @@
<td>
<DateTimePicker Value="@Context.Row.DateTime" OnValueChanged="OnDateTimeChanged"></DateTimePicker>
</td>
<td>@Context.Row.Name</td>
<td>@Context.Row.Address</td>
<td>@Context.Row.Count</td>

View File

@ -0,0 +1,38 @@
// Copyright (c) Argo Zhang (argo@163.com). All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
// Website: https://www.blazor.zone or https://argozhang.github.io/
namespace BootstrapBlazor.Server.Components.Components;
/// <summary>
/// 自定义行组件
/// </summary>
public partial class CustomTableRow
{
/// <summary>
/// 获得/设置 行上下文数据实例
/// </summary>
[Parameter]
[NotNull]
public TableRowContext<Foo>? Context { get; set; }
/// <summary>
/// 获得/设置 值改变回调方法
/// </summary>
[Parameter]
public Func<TableRowContext<Foo>, Task>? OnValueChanged { get; set; }
private async Task OnDateTimeChanged(DateTime? dt)
{
// 通知数据源数据已更新
Context.Row.DateTime = dt;
Context.Row.Count = Random.Shared.Next(1, 100);
if (OnValueChanged != null)
{
await OnValueChanged(Context);
}
// 仅更新本组件不更新父 Table 组件
StateHasChanged();
}
}

View File

@ -142,3 +142,23 @@
</TableColumns>
</Table>
</DemoBlock>
<DemoBlock Title="@Localizer["RowTemplateTitle"]"
Introduction="@Localizer["RowTemplateIntro"]"
Name="RowTemplate">
<section ignore>@((MarkupString)Localizer["RowTemplateDesc"].Value)</section>
<Table TItem="Foo" IsPagination="true" PageItemsSource="@PageItemsSource"
IsStriped="true" IsBordered="true"
OnQueryAsync="@OnQueryAsync">
<TableColumns>
<TableColumn @bind-Field="@context.DateTime" Width="180" />
<TableColumn @bind-Field="@context.Name" Width="100" />
<TableColumn @bind-Field="@context.Address" />
<TableColumn @bind-Field="@context.Count" />
</TableColumns>
<RowTemplate>
<CustomTableRow Context="@context" OnValueChanged="UpdateRowValue"></CustomTableRow>
</RowTemplate>
</Table>
</DemoBlock>

View File

@ -98,4 +98,14 @@ public partial class TablesRow
}
private static string? SetRowClassFormatter(Foo item) => item.Count > 60 ? "row-highlight" : null;
private Task UpdateRowValue(TableRowContext<Foo> context)
{
// 触发内部数据变化通知
// 本例中 context.Row 就是数据源内数据,此处不需要进行数据处理
// 如果使用数据库此处可以根据数据变化项进行数据库更新即可
// 此处不需要 StateHasChanged 更新 UI
return Task.CompletedTask;
}
}

View File

@ -5422,7 +5422,10 @@
"RowNumberText": "Number",
"ClickToSelectP3": "Currently selected row:",
"ClickToSelectNoneText": "None",
"PlaceHolder": "Cannot be empty, within 50 characters"
"PlaceHolder": "Cannot be empty, within 50 characters",
"RowTemplateTitle": "Row Template",
"RowTemplateIntro": "By setting the <code>RowTemplate</code> template, you can implement custom row cell linkage logic by sub-packaging components to achieve performance optimization and avoid the problem of refreshing the entire table component after linkage due to cell data refresh.",
"RowTemplateDesc": "In this example, a custom component is used to select a date, link the quantity column, randomly generate a random number, and save it to the original data, so there is no need to refresh the entire <code>Table</code> component."
},
"BootstrapBlazor.Server.Components.Samples.Table.TablesDynamic": {
"TablesDynamicTitle": "Table Dynamic",

View File

@ -5422,7 +5422,10 @@
"RowNumberText": "序号",
"ClickToSelectP3": "当前选中行:",
"ClickToSelectNoneText": "无",
"PlaceHolder": "不可为空50字以内"
"PlaceHolder": "不可为空50字以内",
"RowTemplateTitle": "行模板",
"RowTemplateIntro": "通过设置 <code>RowTemplate</code> 模板,可通过分装组件的方式实现自定义行内单元格联动逻辑,达到性能最优化,避免单元格数据刷新导致联动后需要刷新整个表格组件的问题,可用于销售类软件,调整单价时总价列变化需求",
"RowTemplateDesc": "本例中通过自定义组件,实现选择日期后,联动数量列,随机生成一个随机数字,并且保存到原始数据中,从而不需要刷新整个 <code>Table</code> 组件"
},
"BootstrapBlazor.Server.Components.Samples.Table.TablesDynamic": {
"TablesDynamicTitle": "Table 表格",

View File

@ -249,38 +249,46 @@
{
@RenderExtendButtons(item)
}
@foreach (var col in GetVisibleColumns())
@if (RowTemplate != null)
{
var cellClass = "";
string? value = null;
RenderFragment? valueTemplate = null;
<div class="table-cell">
<label>
@col.GetDisplayName()
</label>
@if (col.OnCellRender != null)
{
var cell = new TableCellArgs { Row = item, ColumnName = col.GetFieldName() };
col.OnCellRender(cell);
cellClass = cell.Class;
value = cell.Value;
valueTemplate = cell.ValueTemplate;
}
<span class="@cellClass">
@if (valueTemplate != null)
var columns = GetVisibleColumns();
@RowTemplate(new (item, columns));
}
else
{
@foreach (var col in GetVisibleColumns())
{
var cellClass = "";
string? value = null;
RenderFragment? valueTemplate = null;
<div class="table-cell">
<label>
@col.GetDisplayName()
</label>
@if (col.OnCellRender != null)
{
@valueTemplate
var cell = new TableCellArgs { Row = item, ColumnName = col.GetFieldName() };
col.OnCellRender(cell);
cellClass = cell.Class;
value = cell.Value;
valueTemplate = cell.ValueTemplate;
}
else if (value != null)
{
@value
}
else
{
@GetValue(col, item)
}
</span>
</div>
<span class="@cellClass">
@if (valueTemplate != null)
{
@valueTemplate
}
else if (value != null)
{
@value
}
else
{
@GetValue(col, item)
}
</span>
</div>
}
}
@if (ShowExtendButtons && !IsExtendButtonsInRowHeader)
{
@ -656,14 +664,15 @@
RenderFragment<TItem> RenderRow => item =>
@<DynamicElement TagName="tr" class="@GetRowClassString(item)"
TriggerContextMenu="ContextMenuZone != null" OnContextMenu="e => OnContextMenu(e, item)" @ontouchstart="e => OnTouchStart(e, item)" @ontouchend="OnTouchEnd"
TriggerContextMenu="ContextMenuZone != null" OnContextMenu="e => OnContextMenu(e, item)"
@ontouchstart="e => OnTouchStart(e, item)" @ontouchend="OnTouchEnd"
TriggerClick="@(ClickToSelect || OnClickRowCallback != null)" OnClick="() => ClickRow(item)"
TriggerDoubleClick="@(DoubleClickToEdit || OnDoubleClickRowCallback != null)" OnDoubleClick="() => DoubleClickRow(item)">
@if (ShowDetails())
{
<td class="@DetailColumnClassString" style="@DetailColumnStyleString">
<div class="@GetDetailBarClassString(item)">
@if (ShowDetailRow == null || ShowDetailRow.Invoke(item))
@if (ShowDetailRow == null || ShowDetailRow(item))
{
<i class="@GetDetailCaretClassString(item)" @onclick:stopPropagation @onclick="() => ExpandDetailRow(item)"></i>
}
@ -696,88 +705,96 @@
{
@RenderRowExtendButtons(item)
}
@foreach (var col in GetVisibleColumns())
@if (RowTemplate != null)
{
if (CheckShownWithBreakpoint(col))
var columns = GetVisibleColumns();
@RowTemplate(new (item, columns));
}
else
{
@foreach (var col in GetVisibleColumns())
{
if (colIndex > 1)
if (CheckShownWithBreakpoint(col))
{
colIndex--;
continue;
}
var cellClass = "";
var colspan = 0;
string? value = null;
RenderFragment? valueTemplate = null;
if (col.OnCellRender != null)
{
var cell = new TableCellArgs { Row = item, ColumnName = col.GetFieldName() };
col.OnCellRender(cell);
cellClass = cell.Class;
colspan = cell.Colspan;
valueTemplate = cell.ValueTemplate;
value = cell.Value;
colIndex = colspan;
}
<td colspan="@GetColSpan(colspan)" class="@GetFixedCellClassString(col, cellClass)" style="@GetFixedCellStyleString(col)">
@{
var isFirstColOfTree = IsTree && index++ == 0;
var degree = 0;
var isExpend = false;
var hasChildren = false;
if (isFirstColOfTree)
{
var treeItem = TreeNodeCache.Find(TreeRows, item, out degree);
if(treeItem != null)
{
isExpend = treeItem.IsExpand;
hasChildren = treeItem.HasChildren;
}
}
var hesTreeChildren = isFirstColOfTree && hasChildren;
if (colIndex > 1)
{
colIndex--;
continue;
}
<DynamicElement TagName="div" TriggerClick="@hesTreeChildren"
OnClick="@ToggleTreeRow(item)" StopPropagation="hesTreeChildren"
class="@GetCellClassString(col, hesTreeChildren, isInCell)" style="@GetCellStyleString(col)">
@if (isFirstColOfTree)
{
<div class="is-node" style="@GetTreeStyleString(degree)">
@if (hesTreeChildren)
var cellClass = "";
var colspan = 0;
string? value = null;
RenderFragment? valueTemplate = null;
if (col.OnCellRender != null)
{
var cell = new TableCellArgs { Row = item, ColumnName = col.GetFieldName() };
col.OnCellRender(cell);
cellClass = cell.Class;
colspan = cell.Colspan;
valueTemplate = cell.ValueTemplate;
value = cell.Value;
colIndex = colspan;
}
<td colspan="@GetColSpan(colspan)" class="@GetFixedCellClassString(col, cellClass)" style="@GetFixedCellStyleString(col)">
@{
var isFirstColOfTree = IsTree && index++ == 0;
var degree = 0;
var isExpend = false;
var hasChildren = false;
if (isFirstColOfTree)
{
<i class="@GetTreeClassString(isExpend)"></i>
var treeItem = TreeNodeCache.Find(TreeRows, item, out degree);
if(treeItem != null)
{
isExpend = treeItem.IsExpand;
hasChildren = treeItem.HasChildren;
}
}
</div>
var hesTreeChildren = isFirstColOfTree && hasChildren;
}
@if (IsExcel)
{
@RenderExcelCell(col, item, ItemChangedType.Update)
}
else if(isInCell)
{
@RenderCell(col, EditModel, AddInCell ? ItemChangedType.Add : ItemChangedType.Update)
}
else
{
var triggerDoubleClick = OnDoubleClickCellCallback != null;
<DynamicElement TagName="div" TriggerDoubleClick="triggerDoubleClick" GenerateElement="false"
OnDoubleClick="TriggerDoubleClickCell(col, item)" StopPropagation="true"
class="@GetDoubleClickCellClassString(triggerDoubleClick)">
@if (valueTemplate != null)
<DynamicElement TagName="div" TriggerClick="@hesTreeChildren"
OnClick="@ToggleTreeRow(item)" StopPropagation="hesTreeChildren"
class="@GetCellClassString(col, hesTreeChildren, isInCell)" style="@GetCellStyleString(col)">
@if (isFirstColOfTree)
{
<div class="is-node" style="@GetTreeStyleString(degree)">
@if (hesTreeChildren)
{
@valueTemplate
<i class="@GetTreeClassString(isExpend)"></i>
}
else if (value != null)
{
@value
}
else
{
@GetValue(col, item)
}
</DynamicElement>
}
</DynamicElement>
</td>
</div>
}
@if (IsExcel)
{
@RenderExcelCell(col, item, ItemChangedType.Update)
}
else if(isInCell)
{
@RenderCell(col, EditModel, AddInCell ? ItemChangedType.Add : ItemChangedType.Update)
}
else
{
var triggerDoubleClick = OnDoubleClickCellCallback != null;
<DynamicElement TagName="div" TriggerDoubleClick="triggerDoubleClick" GenerateElement="false"
OnDoubleClick="TriggerDoubleClickCell(col, item)" StopPropagation="true"
class="@GetDoubleClickCellClassString(triggerDoubleClick)">
@if (valueTemplate != null)
{
@valueTemplate
}
else if (value != null)
{
@value
}
else
{
@GetValue(col, item)
}
</DynamicElement>
}
</DynamicElement>
</td>
}
}
}
@if (ShowExtendButtons && !IsExtendButtonsInRowHeader)

View File

@ -450,6 +450,12 @@ public partial class Table<TItem> : ITable, IModelEqualityComparer<TItem> where
[Parameter]
public RenderFragment<TItem>? DetailRowTemplate { get; set; }
/// <summary>
/// 获得/设置 行模板
/// </summary>
[Parameter]
public RenderFragment<TableRowContext<TItem>>? RowTemplate { get; set; }
/// <summary>
/// 获得/设置 TableHeader 实例
/// </summary>

View File

@ -7,9 +7,6 @@ namespace BootstrapBlazor.Components;
/// <summary>
/// TableColumn 上下文类
/// </summary>
/// <remarks>
/// 构造方法
/// </remarks>
/// <param name="model"></param>
/// <param name="value"></param>
public class TableColumnContext<TItem, TValue>(TItem model, TValue value)
@ -23,5 +20,5 @@ public class TableColumnContext<TItem, TValue>(TItem model, TValue value)
/// <summary>
/// 获得/设置 当前绑定字段数据实例
/// </summary>
public TValue Value { get; } = value;
public TValue Value => value;
}

View File

@ -0,0 +1,24 @@
// Copyright (c) Argo Zhang (argo@163.com). All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
// Website: https://www.blazor.zone or https://argozhang.github.io/
namespace BootstrapBlazor.Components;
/// <summary>
/// TableRow 上下文类
/// </summary>
/// <param name="model"></param>
/// <param name="columns"></param>
public class TableRowContext<TItem>(TItem model, IEnumerable<ITableColumn> columns)
{
/// <summary>
/// 获得/设置 行数据实例
/// </summary>
[NotNull]
public TItem Row { get; } = model ?? throw new ArgumentNullException(nameof(model));
/// <summary>
/// 获得/设置 当前绑定字段数据实例
/// </summary>
public IEnumerable<ITableColumn> Columns => columns;
}

View File

@ -150,10 +150,10 @@ public class AutoGenerateClassTest
attrInterface.IsReadonlyWhenEdit = true;
Assert.True(attrInterface.IsReadonlyWhenEdit);
attrInterface.GetTooltipTextCallback = new Func<object?, Task<string?>>(_ => Task.FromResult((string?)"Test"));
attrInterface.GetTooltipTextCallback = _ => Task.FromResult((string?)"Test");
Assert.NotNull(attrInterface.GetTooltipTextCallback);
attrInterface.CustomSearch = new Func<ITableColumn, string?, SearchFilterAction>((_, _) => new SearchFilterAction("test", "test"));
attrInterface.CustomSearch = (_, _) => new SearchFilterAction("test", "test");
Assert.NotNull(attrInterface.CustomSearch);
attrInterface.Searchable = null;

View File

@ -274,6 +274,7 @@ public class ButtonTest : BootstrapBlazorTestBase
await cut.InvokeAsync(() =>
{
var button = cut.FindComponent<Button>();
button.SetParametersAndRender(pb =>
{
pb.Add(a => a.TooltipText, "Tooltip-Button");

View File

@ -6,6 +6,7 @@ using AngleSharp.Dom;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Options;
namespace UnitTest.Components;
@ -132,7 +133,8 @@ public class ContextMenuTest : BootstrapBlazorTestBase
TriggerTouchStart(row);
TriggerTouchStart(row);
await Task.Delay(500);
var options = Context.Services.GetRequiredService<IOptions<BootstrapBlazorOptions>>();
await Task.Delay(100 + options.Value.ContextMenuOptions.OnTouchDelay);
row.TouchEnd();
});
}
@ -197,7 +199,8 @@ public class ContextMenuTest : BootstrapBlazorTestBase
TriggerTouchStart(row);
TriggerTouchStart(row);
await Task.Delay(500);
var options = Context.Services.GetRequiredService<IOptions<BootstrapBlazorOptions>>();
await Task.Delay(100 + options.Value.ContextMenuOptions.OnTouchDelay);
row.TouchEnd();
});
}

View File

@ -44,6 +44,10 @@ public class TableColumnTest
{
builder.AddContent(0, col.GetFieldName());
}));
SetValue("ToolboxTemplate", new RenderFragment<ITableColumn>(col => builder =>
{
builder.AddContent(0, "test");
}));
SetValue("Filter", new TableFilter());
SetValue("FormatString", "test");
SetValue("Formatter", new Func<object?, Task<string>>(val =>

View File

@ -7492,6 +7492,12 @@ public class TableTest : TableTestBase
Assert.Throws<ArgumentNullException>(() => new TableColumnContext<Foo?, string>(null, "Test-Value"));
}
[Fact]
public void TableRowContext_Exception()
{
Assert.Throws<ArgumentNullException>(() => new TableRowContext<Foo?>(null, [new InternalTableColumn("Name", typeof(string))]));
}
[Fact]
public void PlaceHolder_Ok()
{
@ -7984,6 +7990,43 @@ public class TableTest : TableTestBase
});
}
[Theory]
[InlineData(TableRenderMode.Table)]
[InlineData(TableRenderMode.CardView)]
public void RowTemplate_Ok(TableRenderMode mode)
{
var localizer = Context.Services.GetRequiredService<IStringLocalizer<Foo>>();
var items = Foo.GenerateFoo(localizer, 2);
IEnumerable<ITableColumn>? columns = null;
var cut = Context.RenderComponent<BootstrapBlazorRoot>(pb =>
{
pb.AddChildContent<Table<Foo>>(pb =>
{
pb.Add(a => a.RenderMode, mode);
pb.Add(a => a.Items, items);
pb.Add(a => a.TableColumns, foo => builder =>
{
builder.OpenComponent<TableColumn<Foo, string>>(0);
builder.AddAttribute(1, "Field", "Name");
builder.AddAttribute(2, "FieldExpression", Utility.GenerateValueExpression(foo, "Name", typeof(string)));
builder.CloseComponent();
});
pb.Add(a => a.RowTemplate, context => builder =>
{
builder.OpenElement(0, "div");
builder.AddContent(1, $"template-{context.Row.Name}");
builder.CloseElement();
columns = context.Columns;
});
});
});
Assert.Contains($"template-{items[0].Name}", cut.Markup);
Assert.NotNull(columns);
Assert.Single(columns);
}
private static DataTable CreateDataTable(IStringLocalizer<Foo> localizer)
{
var userData = new DataTable();

View File

@ -47,7 +47,7 @@ public class ITableColumnExtensionsTest
ComponentType = typeof(NullSwitch),
ComponentParameters = [],
Ignore = true,
EditTemplate = new RenderFragment<object>(obj => builder => builder.AddContent(0, "test")),
EditTemplate = obj => builder => builder.AddContent(0, "test"),
Items = new List<SelectedItem>(),
Lookup = new List<SelectedItem>(),
LookupStringComparison = StringComparison.Ordinal,
@ -72,7 +72,7 @@ public class ITableColumnExtensionsTest
DefaultSortOrder = SortOrder.Desc,
Filter = new TableFilter(),
Filterable = true,
FilterTemplate = new RenderFragment(builder => builder.AddContent(0, "test-filter")),
FilterTemplate = builder => builder.AddContent(0, "test-filter"),
Fixed = true,
FormatString = "test-format",
Formatter = obj =>
@ -80,15 +80,15 @@ public class ITableColumnExtensionsTest
var ret = "test-formatter";
return Task.FromResult<string?>(ret);
},
HeaderTemplate = new RenderFragment<ITableColumn>(col => builder => builder.AddContent(0, "test-header")),
ToolboxTemplate = new RenderFragment<ITableColumn>(col => builder => builder.AddContent(0, "test-toolbox")),
HeaderTemplate = col => builder => builder.AddContent(0, "test-header"),
ToolboxTemplate = col => builder => builder.AddContent(0, "test-toolbox"),
OnCellRender = args => { },
Searchable = true,
SearchTemplate = new RenderFragment<object>(obj => builder => builder.AddContent(0, "test-search")),
SearchTemplate = obj => builder => builder.AddContent(0, "test-search"),
ShownWithBreakPoint = BreakPoint.Large,
ShowTips = true,
Sortable = true,
Template = new RenderFragment<object>(obj => builder => builder.AddContent(0, "test-template")),
Template = obj => builder => builder.AddContent(0, "test-template"),
TextEllipsis = true,
Visible = false,
IsVisibleWhenAdd = false,
@ -105,7 +105,7 @@ public class ITableColumnExtensionsTest
Order = -1,
IsMarkupString = true,
GetTooltipTextCallback = _ => Task.FromResult<string?>(null),
CustomSearch = new Func<ITableColumn, string?, SearchFilterAction>((_, _) => new SearchFilterAction("test", "test"))
CustomSearch = (_, _) => new SearchFilterAction("test", "test")
};
col.CopyValue(attr);
Assert.NotNull(col.ComponentType);