feat(SelectTable): support Sort/Filter/Search function (#2831)

* refactor: 重构代码消除建议信息

* refactor: 更新注释

* refactor: 代码格式化

* refactor: 增加 IColumnCollection 接口

* feat: 移除 Items 改用 OnQueryAsync 回调支持排序过滤

* doc: 更新示例

* test: 增加参数异常单元测试

* test: 更新单元测试
This commit is contained in:
Argo Zhang
2024-01-23 13:42:43 +08:00
committed by GitHub
parent 6cf8acae08
commit 0eb86f1174
11 changed files with 160 additions and 68 deletions

View File

@@ -5,7 +5,7 @@
<h4>下拉框为表格用于展示复杂类型的选择需求</h4>
<DemoBlock Title="@Localizer["NormalTitle"]" Introduction="@Localizer["NormalIntro"]" Name="Normal">
<SelectTable TItem="Foo" @bind-Value="@_foo" Items="_items" GetTextCallback="@GetTextCallback" TableMinWidth="300">
<SelectTable TItem="Foo" @bind-Value="@_foo" OnQueryAsync="OnQueryAsync" GetTextCallback="@GetTextCallback" TableMinWidth="300">
<TableColumns>
<TableColumn @bind-Field="@context.Name"></TableColumn>
<TableColumn @bind-Field="@context.Address"></TableColumn>
@@ -14,7 +14,7 @@
</DemoBlock>
<DemoBlock Title="@Localizer["ColorTitle"]" Introduction="@Localizer["ColorIntro"]" Name="Color">
<SelectTable TItem="Foo" @bind-Value="@_colorFoo" Items="_colorItems" GetTextCallback="@GetTextCallback" Color="Color.Info">
<SelectTable TItem="Foo" @bind-Value="@_colorFoo" OnQueryAsync="OnQueryAsync" GetTextCallback="@GetTextCallback" Color="Color.Info">
<TableColumns>
<TableColumn @bind-Field="@context.Name"></TableColumn>
<TableColumn @bind-Field="@context.Address"></TableColumn>
@@ -23,7 +23,7 @@
</DemoBlock>
<DemoBlock Title="@Localizer["IsDisabledTitle"]" Introduction="@Localizer["IsDisabledIntro"]" Name="IsDisabled">
<SelectTable TItem="Foo" @bind-Value="@_disabledFoo" Items="_disabledItems" GetTextCallback="@GetTextCallback" IsDisabled="true">
<SelectTable TItem="Foo" @bind-Value="@_disabledFoo" OnQueryAsync="OnQueryAsync" GetTextCallback="@GetTextCallback" IsDisabled="true">
<TableColumns>
<TableColumn @bind-Field="@context.Name"></TableColumn>
<TableColumn @bind-Field="@context.Address"></TableColumn>
@@ -32,7 +32,7 @@
</DemoBlock>
<DemoBlock Title="@Localizer["TemplateTitle"]" Introduction="@Localizer["TemplateIntro"]" Name="ValueTemplate">
<SelectTable TItem="Foo" @bind-Value="@_templateFoo" Items="_templateItems" ShowAppendArrow="false">
<SelectTable TItem="Foo" @bind-Value="@_templateFoo" OnQueryAsync="OnQueryAsync" ShowAppendArrow="false">
<TableColumns>
<TableColumn @bind-Field="@context.Name"></TableColumn>
<TableColumn @bind-Field="@context.Address"></TableColumn>
@@ -69,7 +69,7 @@
<ValidateForm Model="Model">
<div class="row g-3">
<div class="col-12">
<SelectTable TItem="Foo" @bind-Value="@Model.Foo" Items="_validateFormItems" GetTextCallback="@GetTextCallback" DisplayText="Test">
<SelectTable TItem="Foo" @bind-Value="@Model.Foo" OnQueryAsync="OnQueryAsync" GetTextCallback="@GetTextCallback" DisplayText="Test">
<TableColumns>
<TableColumn @bind-Field="@context.Name"></TableColumn>
<TableColumn @bind-Field="@context.Address"></TableColumn>
@@ -83,4 +83,13 @@
</ValidateForm>
</DemoBlock>
<DemoBlock Title="@Localizer["ValidateFormTitle"]" Introduction="@Localizer["ValidateFormIntro"]" Name="Sortable">
<SelectTable TItem="Foo" @bind-Value="@Model.Foo" OnQueryAsync="OnQueryAsync" GetTextCallback="@GetTextCallback" DisplayText="Test">
<TableColumns>
<TableColumn @bind-Field="@context.Name" Sortable="true"></TableColumn>
<TableColumn @bind-Field="@context.Address" Sortable="true" Filterable="true"></TableColumn>
</TableColumns>
</SelectTable>
</DemoBlock>
<AttributeTable Items="@GetAttributes()" />

View File

@@ -17,16 +17,6 @@ public partial class SelectTables
[NotNull]
private IStringLocalizer<SelectTables>? Localizer { get; set; }
private List<Foo> _items = default!;
private List<Foo> _colorItems = default!;
private List<Foo> _templateItems = default!;
private List<Foo> _disabledItems = default!;
private List<Foo> _validateFormItems = default!;
private Foo? _foo;
private Foo? _colorFoo;
@@ -35,21 +25,54 @@ public partial class SelectTables
private Foo? _disabledFoo;
private SelectTableMode Model = new();
private Foo? _sortableFoo;
private readonly SelectTableMode Model = new();
private static string? GetTextCallback(Foo foo) => foo.Name;
private List<Foo> _items = default!;
private IEnumerable<Foo> _filterItems = default!;
/// <summary>
///
/// <inheritdoc/>
/// </summary>
protected override void OnInitialized()
{
base.OnInitialized();
_items = Foo.GenerateFoo(LocalizerFoo);
_colorItems = Foo.GenerateFoo(LocalizerFoo);
_templateItems = Foo.GenerateFoo(LocalizerFoo);
_disabledItems = Foo.GenerateFoo(LocalizerFoo);
_validateFormItems = Foo.GenerateFoo(LocalizerFoo);
_filterItems = Foo.GenerateFoo(LocalizerFoo);
}
private static string? GetTextCallback(Foo? foo) => foo?.Name;
private Task<QueryData<Foo>> OnQueryAsync(QueryPageOptions options)
{
// 此处代码拷贝后需要自行更改根据 options 中的条件从数据库中获取数据集合
return Task.FromResult(new QueryData<Foo>()
{
Items = _items
});
}
private Task<QueryData<Foo>> OnFilterQueryAsync(QueryPageOptions options)
{
// 此处代码拷贝后需要自行更改根据 options 中的条件从数据库中获取数据集合
_filterItems = _filterItems.Where(options.ToFilter().GetFilterFunc<Foo>());
if (!string.IsNullOrEmpty(options.SortName))
{
_filterItems = _filterItems.Sort(options.SortName, options.SortOrder);
}
return Task.FromResult(new QueryData<Foo>()
{
Items = _filterItems.ToList(),
IsAdvanceSearch = true,
IsFiltered = true,
IsSearch = true,
IsSorted = true
});
}
class SelectTableMode
{

View File

@@ -30,7 +30,7 @@ public partial class Rate
/// <returns></returns>
private bool IsPartialStar(int i) => (Value + 1 - i) is > 0 and < 1;
private string? GetIcon(int i) => Value >= i ? StarIcon : UnStarIcon;
private string GetIcon(int i) => Value >= i ? StarIcon : UnStarIcon;
private string GetWidthStyle(int i) => $"width: {Math.Round(Value + 1 - i, 2) * 100}%;";

View File

@@ -36,7 +36,7 @@
</CascadingValue>
<RenderTemplate>
<div class="dropdown-menu dropdown-table" style="@GetStyleString">
<Table Items="Items" TableColumns="TableColumns" IsFixedHeader="true" IsBordered="true" RenderMode="TableRenderMode.Table" ClickToSelect="true" OnClickRowCallback="OnClickRowCallback"></Table>
<Table TableColumns="TableColumns" IsFixedHeader="true" IsBordered="true" RenderMode="TableRenderMode.Table" ClickToSelect="true" OnClickRowCallback="OnClickRowCallback" OnQueryAsync="OnQueryAsync"></Table>
</div>
</RenderTemplate>
</div>

View File

@@ -11,7 +11,7 @@ namespace BootstrapBlazor.Components;
/// </summary>
/// <typeparam name="TItem"></typeparam>
[CascadingTypeParameter(nameof(TItem))]
public partial class SelectTable<TItem> : ITable where TItem : class, new()
public partial class SelectTable<TItem> : IColumnCollection where TItem : class, new()
{
/// <summary>
/// 获得/设置 TableHeader 实例
@@ -20,11 +20,12 @@ public partial class SelectTable<TItem> : ITable where TItem : class, new()
public RenderFragment<TItem>? TableColumns { get; set; }
/// <summary>
/// 获得/设置 绑定数据集
/// 异步查询回调方法
/// </summary>
[Parameter]
[EditorRequired]
[NotNull]
public IEnumerable<TItem>? Items { get; set; }
public Func<QueryPageOptions, Task<QueryData<TItem>>>? OnQueryAsync { get; set; }
/// <summary>
/// 获得/设置 颜色 默认 Color.None 无设置
@@ -42,13 +43,14 @@ public partial class SelectTable<TItem> : ITable where TItem : class, new()
/// 获得/设置 弹窗表格最小宽度 默认为 null 未设置使用样式中的默认值
/// </summary>
[Parameter]
[NotNull]
public int? TableMinWidth { get; set; }
/// <summary>
/// 获得 显示文字回调方法 默认 null
/// </summary>
[Parameter]
[NotNull]
[EditorRequired]
public Func<TItem, string?>? GetTextCallback { get; set; }
/// <summary>
@@ -70,17 +72,6 @@ public partial class SelectTable<TItem> : ITable where TItem : class, new()
/// </summary>
public List<ITableColumn> Columns { get; } = [];
List<ITableColumn> ITable.Columns { get => Columns; }
[ExcludeFromCodeCoverage]
Dictionary<string, IFilterAction> ITable.Filters { get; } = [];
[ExcludeFromCodeCoverage]
Func<Task>? ITable.OnFilterAsync { get => null; }
[ExcludeFromCodeCoverage]
IEnumerable<ITableColumn> ITable.GetVisibleColumns() => Columns;
/// <summary>
/// 获得 样式集合
/// </summary>
@@ -124,7 +115,6 @@ public partial class SelectTable<TItem> : ITable where TItem : class, new()
/// <summary>
/// 获得/设置 Value 显示模板 默认 null
/// </summary>
/// <remarks>默认通过 <code></code></remarks>
[Parameter]
public RenderFragment<TItem>? Template { get; set; }
@@ -172,7 +162,16 @@ public partial class SelectTable<TItem> : ITable where TItem : class, new()
{
base.OnParametersSet();
Items ??= [];
if(OnQueryAsync == null)
{
throw new InvalidOperationException("Please set OnQueryAsync value");
}
if (GetTextCallback == null)
{
throw new InvalidOperationException("Please set GetTextCallback value");
}
PlaceHolder ??= Localizer[nameof(PlaceHolder)];
DropdownIcon ??= IconTheme.GetIconByKey(ComponentIcons.SelectDropdownIcon);
}
@@ -187,7 +186,7 @@ public partial class SelectTable<TItem> : ITable where TItem : class, new()
/// 获得 Text 显示文字
/// </summary>
/// <returns></returns>
private string? GetText() => Value == default ? null : GetTextCallback?.Invoke(Value) ?? Value.ToString();
private string? GetText() => Value == default ? null : GetTextCallback(Value);
private async Task OnClickRowCallback(TItem item)
{

View File

@@ -19,7 +19,8 @@ export function init(id) {
EventHandler.on(popover.toggleMenu, 'click', '.tree-node', e => {
if (popover.isPopover) {
popover.hide()
} else {
}
else {
const dropdown = bootstrap.Dropdown.getInstance(popover.toggleElement)
if (dropdown) {
dropdown.hide()

View File

@@ -0,0 +1,16 @@
// 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>
/// 列集合接口
/// </summary>
public interface IColumnCollection
{
/// <summary>
/// 获得 ITableColumn 集合
/// </summary>
List<ITableColumn> Columns { get; }
}

View File

@@ -7,13 +7,8 @@ namespace BootstrapBlazor.Components;
/// <summary>
/// ITable 接口
/// </summary>
public interface ITable
public interface ITable : IColumnCollection
{
/// <summary>
/// 获得 ITableColumn 集合
/// </summary>
List<ITableColumn> Columns { get; }
/// <summary>
/// 获得 ITable 实例配置的可见列集合
/// </summary>

View File

@@ -415,14 +415,14 @@ public class TableColumn<TItem, TType> : BootstrapComponentBase, ITableColumn
/// 获得/设置 Table 实例
/// </summary>
[CascadingParameter]
protected ITable? Table { get; set; }
protected IColumnCollection? Columns { get; set; }
/// <summary>
/// 组件初始化方法
/// </summary>
protected override void OnInitialized()
{
Table?.Columns.Add(this);
Columns?.Columns.Add(this);
if (FieldExpression != null)
{
_fieldIdentifier = FieldIdentifier.Create(FieldExpression);

View File

@@ -189,7 +189,7 @@ public static class DialogServiceExtensions
}
/// <summary>
/// 弹出保存对话窗方法
/// 弹出保存按钮对话窗方法
/// </summary>
/// <typeparam name="TComponent"></typeparam>
/// <param name="service">DialogService 服务实例</param>
@@ -219,7 +219,7 @@ public static class DialogServiceExtensions
}
/// <summary>
/// 弹出保存对话窗
/// 弹出带关闭按钮对话窗方法
/// </summary>
/// <typeparam name="TComponent"></typeparam>
/// <param name="service"></param>

View File

@@ -12,22 +12,39 @@ public class SelectTableTest : BootstrapBlazorTestBase
[Fact]
public void Items_Ok()
{
var localizer = Context.Services.GetRequiredService<IStringLocalizer<Foo>>();
var items = Foo.GenerateFoo(localizer, 4);
var cut = Context.RenderComponent<BootstrapBlazorRoot>(pb =>
{
pb.AddChildContent<SelectTable<Foo>>();
pb.Add(a => a.EnableErrorLogger, false);
pb.AddChildContent<SelectTable<Foo>>(pb =>
{
pb.Add(a => a.OnQueryAsync, options => OnFilterQueryAsync(options, items));
pb.Add(a => a.GetTextCallback, foo => foo.Name);
});
});
var table = cut.FindComponent<SelectTable<Foo>>();
Assert.Throws<InvalidOperationException>(() =>
{
table.SetParametersAndRender(pb =>
{
pb.Add(a => a.OnQueryAsync, null);
});
});
var rows = cut.FindAll("tbody > tr");
Assert.Empty(rows);
}
[Fact]
public void TableMinWidth_Ok()
{
var localizer = Context.Services.GetRequiredService<IStringLocalizer<Foo>>();
var items = Foo.GenerateFoo(localizer, 4);
var cut = Context.RenderComponent<BootstrapBlazorRoot>(pb =>
{
pb.AddChildContent<SelectTable<Foo>>(pb =>
{
pb.Add(a => a.TableMinWidth, 300);
pb.Add(a => a.OnQueryAsync, options => OnFilterQueryAsync(options, items));
pb.Add(a => a.GetTextCallback, foo => foo.Name);
});
});
Assert.Contains("data-bb-min-width=\"300\"", cut.Markup);
@@ -36,11 +53,15 @@ public class SelectTableTest : BootstrapBlazorTestBase
[Fact]
public void Color_Ok()
{
var localizer = Context.Services.GetRequiredService<IStringLocalizer<Foo>>();
var items = Foo.GenerateFoo(localizer, 4);
var cut = Context.RenderComponent<BootstrapBlazorRoot>(pb =>
{
pb.AddChildContent<SelectTable<Foo>>(pb =>
{
pb.Add(a => a.Color, Color.Danger);
pb.Add(a => a.GetTextCallback, foo => foo.Name);
pb.Add(a => a.OnQueryAsync, options => OnFilterQueryAsync(options, items));
});
});
cut.Contains("border-danger");
@@ -49,11 +70,15 @@ public class SelectTableTest : BootstrapBlazorTestBase
[Fact]
public void ShowAppendArrow_Ok()
{
var localizer = Context.Services.GetRequiredService<IStringLocalizer<Foo>>();
var items = Foo.GenerateFoo(localizer, 4);
var cut = Context.RenderComponent<BootstrapBlazorRoot>(pb =>
{
pb.AddChildContent<SelectTable<Foo>>(pb =>
{
pb.Add(a => a.ShowAppendArrow, false);
pb.Add(a => a.OnQueryAsync, options => OnFilterQueryAsync(options, items));
pb.Add(a => a.GetTextCallback, foo => foo.Name);
});
});
cut.DoesNotContain("form-select-append");
@@ -68,7 +93,8 @@ public class SelectTableTest : BootstrapBlazorTestBase
{
pb.AddChildContent<SelectTable<Foo>>(pb =>
{
pb.Add(a => a.Items, items);
pb.Add(a => a.OnQueryAsync, options => OnFilterQueryAsync(options, items));
pb.Add(a => a.GetTextCallback, foo => foo.Name);
pb.Add(a => a.TableColumns, foo => builder =>
{
builder.OpenComponent<TableColumn<Foo, string>>(0);
@@ -105,9 +131,10 @@ public class SelectTableTest : BootstrapBlazorTestBase
var items = Foo.GenerateFoo(localizer, 4);
var cut = Context.RenderComponent<BootstrapBlazorRoot>(pb =>
{
pb.Add(a => a.EnableErrorLogger, false);
pb.AddChildContent<SelectTable<Foo>>(pb =>
{
pb.Add(a => a.Items, items);
pb.Add(a => a.OnQueryAsync, options => OnFilterQueryAsync(options, items));
pb.Add(a => a.Value, items[0]);
pb.Add(a => a.TableColumns, foo => builder =>
{
@@ -131,19 +158,20 @@ public class SelectTableTest : BootstrapBlazorTestBase
{
pb.Add(a => a.GetTextCallback, foo => null);
});
Assert.Contains("value=\"BootstrapBlazor.Server.Data.Foo\"", cut.Markup);
table.SetParametersAndRender(pb =>
{
pb.Add(a => a.GetTextCallback, null);
});
Assert.Contains("value=\"BootstrapBlazor.Server.Data.Foo\"", cut.Markup);
table.SetParametersAndRender(pb =>
{
pb.Add(a => a.Value, null);
});
Assert.DoesNotContain("value=\"\"", cut.Markup);
Assert.Throws<InvalidOperationException>(() =>
{
table.SetParametersAndRender(pb =>
{
pb.Add(a => a.GetTextCallback, null);
});
});
}
[Fact]
@@ -155,7 +183,7 @@ public class SelectTableTest : BootstrapBlazorTestBase
{
pb.AddChildContent<SelectTable<Foo>>(pb =>
{
pb.Add(a => a.Items, items);
pb.Add(a => a.OnQueryAsync, options => OnFilterQueryAsync(options, items));
pb.Add(a => a.Value, items[0]);
pb.Add(a => a.Height, 100);
pb.Add(a => a.TableColumns, foo => builder =>
@@ -170,6 +198,7 @@ public class SelectTableTest : BootstrapBlazorTestBase
builder.AddAttribute(2, "FieldExpression", Utility.GenerateValueExpression(foo, "Address", typeof(string)));
builder.CloseComponent();
});
pb.Add(a => a.GetTextCallback, foo => foo.Name);
});
});
Assert.Contains($"height: 100px;", cut.Markup);
@@ -185,13 +214,14 @@ public class SelectTableTest : BootstrapBlazorTestBase
{
pb.AddChildContent<SelectTable<Foo>>(pb =>
{
pb.Add(a => a.Items, items);
pb.Add(a => a.OnQueryAsync, options => OnFilterQueryAsync(options, items));
pb.Add(a => a.Value, items[0]);
pb.Add(a => a.OnValueChanged, foo =>
{
v = foo;
return Task.CompletedTask;
});
pb.Add(a => a.GetTextCallback, foo => foo.Name);
pb.Add(a => a.TableColumns, foo => builder =>
{
builder.OpenComponent<TableColumn<Foo, string>>(0);
@@ -245,7 +275,8 @@ public class SelectTableTest : BootstrapBlazorTestBase
model.Foo = v;
return Task.CompletedTask;
});
pb.Add(a => a.Items, items);
pb.Add(a => a.GetTextCallback, foo => foo.Name);
pb.Add(a => a.OnQueryAsync, options => OnFilterQueryAsync(options, items));
pb.Add(a => a.TableColumns, foo => builder =>
{
builder.OpenComponent<TableColumn<Foo, string>>(0);
@@ -279,6 +310,24 @@ public class SelectTableTest : BootstrapBlazorTestBase
Assert.True(invalid);
}
private Task<QueryData<Foo>> OnFilterQueryAsync(QueryPageOptions options, IEnumerable<Foo> _filterItems)
{
_filterItems = _filterItems.Where(options.ToFilter().GetFilterFunc<Foo>());
if (!string.IsNullOrEmpty(options.SortName))
{
_filterItems = _filterItems.Sort(options.SortName, options.SortOrder);
}
return Task.FromResult(new QueryData<Foo>()
{
Items = _filterItems.ToList(),
IsAdvanceSearch = true,
IsFiltered = true,
IsSearch = true,
IsSorted = true
});
}
class SelectTableModel()
{
public Foo? Foo { get; set; }