diff --git a/src/BootstrapBlazor.Server/Components/Samples/CheckboxLists.razor b/src/BootstrapBlazor.Server/Components/Samples/CheckboxLists.razor index 418f028cccd..c054cccbc5a 100644 --- a/src/BootstrapBlazor.Server/Components/Samples/CheckboxLists.razor +++ b/src/BootstrapBlazor.Server/Components/Samples/CheckboxLists.razor @@ -120,6 +120,19 @@ + + +
+ @if (_selectedFoos != null) + { + foreach (var item in _selectedFoos) + { +
@item.Name
+ } + } +
+
+ diff --git a/src/BootstrapBlazor.Server/Components/Samples/CheckboxLists.razor.cs b/src/BootstrapBlazor.Server/Components/Samples/CheckboxLists.razor.cs index e7f7d113eb2..c3d38486a91 100644 --- a/src/BootstrapBlazor.Server/Components/Samples/CheckboxLists.razor.cs +++ b/src/BootstrapBlazor.Server/Components/Samples/CheckboxLists.razor.cs @@ -37,6 +37,11 @@ public partial class CheckboxLists [NotNull] private IEnumerable? Items5 { get; set; } + [NotNull] + private IEnumerable>? GenericItems { get; set; } + + private List? _selectedFoos; + /// /// OnInitialized method /// @@ -89,6 +94,22 @@ protected override void OnInitialized() FooItems = Foo.GenerateHobbies(LocalizerFoo); } + /// + /// + /// + /// + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + + GenericItems = new List>() + { + new() { Text = Localizer["item1"], Value = new Foo() { Name = LocalizerFoo["Foo.Name", "001"] } }, + new() { Text = Localizer["item2"], Value = new Foo() { Name = LocalizerFoo["Foo.Name", "002"] } }, + new() { Text = Localizer["item3"], Value = new Foo() { Name = LocalizerFoo["Foo.Name", "003"] } }, + }; + } + [NotNull] private Foo? Model { get; set; } diff --git a/src/BootstrapBlazor/Components/Checkbox/CheckboxList.razor b/src/BootstrapBlazor/Components/Checkbox/CheckboxList.razor index b2a6ae0df1b..da2432aefb7 100644 --- a/src/BootstrapBlazor/Components/Checkbox/CheckboxList.razor +++ b/src/BootstrapBlazor/Components/Checkbox/CheckboxList.razor @@ -25,7 +25,7 @@ else
@foreach (var item in Items) { -
+
> + +@if (IsShowLabel) +{ + +} + +@if (IsButton) +{ +
+
+ @foreach (var item in Items) + { + + @item.Text + + } +
+
+} +else +{ +
+ @foreach (var item in Items) + { +
+ +
+ } +
+} diff --git a/src/BootstrapBlazor/Components/Checkbox/CheckboxListGeneric.razor.cs b/src/BootstrapBlazor/Components/Checkbox/CheckboxListGeneric.razor.cs new file mode 100644 index 00000000000..6719599f0c5 --- /dev/null +++ b/src/BootstrapBlazor/Components/Checkbox/CheckboxListGeneric.razor.cs @@ -0,0 +1,244 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License +// See the LICENSE file in the project root for more information. +// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone + +using Microsoft.Extensions.Localization; +using System.Reflection; + +namespace BootstrapBlazor.Components; + +/// +/// CheckboxList 组件基类 +/// +public partial class CheckboxListGeneric : IModelEqualityComparer +{ + /// + /// 获得 组件样式 + /// + private string? ClassString => CssBuilder.Default("checkbox-list form-control") + .AddClass("no-border", !ShowBorder && ValidCss != "is-invalid") + .AddClass("is-vertical", IsVertical) + .AddClass(CssClass).AddClass(ValidCss) + .Build(); + + /// + /// 获得 组件内部 Checkbox 项目样式 + /// + protected string? CheckboxItemClassString => CssBuilder.Default("checkbox-item") + .AddClass(CheckboxItemClass) + .Build(); + + private string? ButtonClassString => CssBuilder.Default("checkbox-list is-button") + .AddClassFromAttributes(AdditionalAttributes) + .Build(); + + private string? ButtonGroupClassString => CssBuilder.Default("btn-group") + .AddClass("disabled", IsDisabled) + .AddClass("btn-group-vertical", IsVertical) + .Build(); + + private string? GetButtonItemClassString(SelectedItem item) => CssBuilder.Default("btn") + .AddClass($"active bg-{Color.ToDescriptionString()}", IsEquals(item.Value)) + .Build(); + + /// + /// 获得/设置 数据主键标识标签 默认为
用于判断数据主键标签,如果模型未设置主键时可使用 参数自定义判断
数据模型支持联合主键 + ///
+ [Parameter] + [NotNull] + public Type? CustomKeyAttribute { get; set; } = typeof(KeyAttribute); + + /// + /// 获得/设置 比较数据是否相同回调方法 默认为 null + /// 提供此回调方法时忽略 属性 + /// + [Parameter] + public Func? ModelEqualityComparer { get; set; } + + /// + /// 获得/设置 数据源 + /// + [Parameter] + [NotNull] + public IEnumerable>? Items { get; set; } + + /// + /// 获得/设置 是否为按钮样式 默认 false + /// + [Parameter] + public bool IsButton { get; set; } + + /// + /// 获得/设置 Checkbox 组件布局样式 + /// + [Parameter] + public string? CheckboxItemClass { get; set; } + + /// + /// 获得/设置 是否显示边框 默认为 true + /// + [Parameter] + public bool ShowBorder { get; set; } = true; + + /// + /// 获得/设置 是否为竖向排列 默认为 false + /// + [Parameter] + public bool IsVertical { get; set; } + + /// + /// 获得/设置 按钮颜色 默认为 None 未设置 + /// + [Parameter] + public Color Color { get; set; } + + /// + /// 获得/设置 SelectedItemChanged 方法 + /// + [Parameter] + public Func>, List, Task>? OnSelectedChanged { get; set; } + + /// + /// 获得/设置 最多选中数量 + /// + [Parameter] + public int MaxSelectedCount { get; set; } + + /// + /// 获得/设置 超过最大选中数量时回调委托 + /// + [Parameter] + public Func? OnMaxSelectedCountExceed { get; set; } + + [Inject] + [NotNull] + private IStringLocalizerFactory? LocalizerFactory { get; set; } + + /// + /// 获得 当前选项是否被禁用 + /// + /// + /// + protected bool GetDisabledState(SelectedItem item) => IsDisabled || item.IsDisabled; + + private Func>? _onBeforeStateChangedCallback; + + /// + /// OnInitialized 方法 + /// + protected override void OnInitialized() + { + base.OnInitialized(); + + // 处理 Required 标签 + if (EditContext != null && FieldIdentifier != null) + { + var pi = FieldIdentifier.Value.Model.GetType().GetPropertyByName(FieldIdentifier.Value.FieldName); + if (pi != null) + { + var required = pi.GetCustomAttribute(true); + if (required != null) + { + Rules.Add(new RequiredValidator() + { + LocalizerFactory = LocalizerFactory, + ErrorMessage = required.ErrorMessage, + AllowEmptyString = required.AllowEmptyStrings + }); + } + } + } + } + + /// + /// OnParametersSet 方法 + /// + protected override void OnParametersSet() + { + base.OnParametersSet(); + + if (IsButton && Color == Color.None) + { + Color = Color.Primary; + } + + Items ??= []; + + _onBeforeStateChangedCallback = MaxSelectedCount > 0 ? new Func>(OnBeforeStateChanged) : null; + + // set item active + if (Value != null) + { + var item = Items.FirstOrDefault(i => IsEquals(i.Value)); + if (item != null) + { + item.Active = true; + } + } + } + + private bool IsEquals(TValue? val) => Value != null && Value.Find(v => Equals(v, val)) != null; + + private async Task OnBeforeStateChanged(CheckboxState state) + { + var ret = true; + if (state == CheckboxState.Checked) + { + var items = Items.Where(i => i.Active).ToList(); + ret = items.Count < MaxSelectedCount; + } + + if (!ret && OnMaxSelectedCountExceed != null) + { + await OnMaxSelectedCountExceed(); + } + return ret; + } + + /// + /// Checkbox 组件选项状态改变时触发此方法 + /// + /// + /// + private async Task OnStateChanged(SelectedItem item, bool v) + { + item.Active = v; + var vals = new List(); + if (Value != null) + { + vals.AddRange(Value); + } + + var val = vals.Find(i => IsEquals(item.Value)); + if (v && val == null) + { + vals.Add(item.Value); + } + else + { + vals.Remove(val); + } + + CurrentValue = vals; + + if (OnSelectedChanged != null) + { + await OnSelectedChanged(Items, CurrentValue); + } + else + { + StateHasChanged(); + } + } + + /// + /// 点击选择框方法 + /// + private Task OnClick(SelectedItem item) => OnStateChanged(item, !item.Active); + + /// + /// + /// + public bool Equals(TValue? x, TValue? y) => this.Equals(x, y); +} diff --git a/test/UnitTest/Components/CheckboxListGenericTest.cs b/test/UnitTest/Components/CheckboxListGenericTest.cs new file mode 100644 index 00000000000..bf3cc5b70da --- /dev/null +++ b/test/UnitTest/Components/CheckboxListGenericTest.cs @@ -0,0 +1,260 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License +// See the LICENSE file in the project root for more information. +// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone + +using Microsoft.EntityFrameworkCore.Metadata.Internal; +using Microsoft.Extensions.Localization; +using System.ComponentModel.DataAnnotations; + +namespace UnitTest.Components; + +public class CheckboxListGenericTest : BootstrapBlazorTestBase +{ + [Fact] + public void EditorForm_Ok() + { + var dummy = new Dummy() { Data = [new() { Id = 2, Name = "Test2" }] }; + var items = new List>() + { + new(new Foo() { Id = 1, Name = "Test1" }, "Test 1"), + new(new Foo() { Id = 2, Name = "Test2" }, "Test 2") + }; + var cut = Context.RenderComponent(builder => + { + builder.Add(a => a.Model, dummy); + builder.AddChildContent>(pb => + { + pb.Add(a => a.Items, items); + pb.Add(a => a.Value, dummy.Data); + pb.Add(a => a.ValueExpression, Utility.GenerateValueExpression(dummy, nameof(dummy.Data), typeof(List))); + }); + }); + // 断言生成 CheckboxList + Assert.Contains("form-check is-label", cut.Markup); + + // 提交表单触发客户端验证 + var form = cut.Find("form"); + form.Submit(); + Assert.Contains("is-valid", cut.Markup); + } + + [Fact] + public void ShowBorder_Ok() + { + var items = new List>() + { + new(new Foo() { Id = 1, Name = "Test1" }, "Test 1"), + new(new Foo() { Id = 2, Name = "Test2" }, "Test 2") + }; + var cut = Context.RenderComponent>(pb => + { + pb.Add(a => a.Items, items); + }); + Assert.DoesNotContain("no-border", cut.Markup); + + cut.SetParametersAndRender(pb => + { + pb.Add(a => a.ShowBorder, false); + }); + Assert.Contains("no-border", cut.Markup); + } + + [Fact] + public void IsVertical_Ok() + { + var cut = Context.RenderComponent>(); + Assert.DoesNotContain("is-vertical", cut.Markup); + + cut.SetParametersAndRender(pb => + { + pb.Add(a => a.IsVertical, true); + pb.Add(a => a.CustomKeyAttribute, typeof(KeyAttribute)); + pb.Add(a => a.ModelEqualityComparer, new Func((x, y) => x.Id == y.Id)); + }); + Assert.Contains("is-vertical", cut.Markup); + } + + [Fact] + public async Task NullItem_Ok() + { + var items = new List>() + { + new(null, "Select ..."), + new(new Foo() { Id = 2, Name = "Test2" }, "Test 2") + }; + var cut = Context.RenderComponent>(pb => + { + pb.Add(a => a.Items, items); + }); + cut.Contains("Select ..."); + + var checkboxes = cut.FindComponents>(); + await cut.InvokeAsync(async () => + { + await checkboxes[0].Instance.OnToggleClick(); + }); + Assert.Null(cut.Instance.Value[0]); + } + + [Fact] + public void IsDisabled_Ok() + { + var items = new List>() + { + new(new Foo() { Id = 1, Name = "Test1" }, "Test 1"), + new(new Foo() { Id = 2, Name = "Test2" }, "Test 2") { IsDisabled = true } + }; + var cut = Context.RenderComponent>(pb => + { + pb.Add(a => a.Items, items); + }); + cut.Contains("form-check is-label disabled"); + + cut.SetParametersAndRender(pb => + { + pb.Add(a => a.Items, new List>() + { + new(new Foo() { Id = 1, Name = "Test1" }, "Test 1"), + new(new Foo() { Id = 2, Name = "Test2" }, "Test 2") + }); + pb.Add(a => a.IsDisabled, true); + }); + cut.Contains("form-check is-label disabled"); + } + + [Fact] + public void CheckboxItemClass_Ok() + { + var cut = Context.RenderComponent>(builder => + { + builder.Add(a => a.CheckboxItemClass, "test-item"); + }); + Assert.DoesNotContain("test-item", cut.Markup); + + var items = new List>() + { + new(new Foo() { Id = 1, Name = "Test1" }, "Test 1"), + new(new Foo() { Id = 2, Name = "Test2" }, "Test 2") + }; + cut.SetParametersAndRender(pb => + { + pb.Add(a => a.Items, items); + }); + Assert.Contains("test-item", cut.Markup); + } + + [Fact] + public async Task StringValue_Ok() + { + var items = new List>() + { + new(new Foo() { Id = 1, Name = "Test1" }, "Test 1"), + new(new Foo() { Id = 2, Name = "Test2" }, "Test 2") + }; + var cut = Context.RenderComponent>(pb => + { + pb.Add(a => a.Items, items); + }); + Assert.Contains("checkbox-list", cut.Markup); + + var selected = false; + cut.SetParametersAndRender(pb => + { + pb.Add(a => a.OnSelectedChanged, (v1, v2) => + { + selected = true; + return Task.CompletedTask; + }); + }); + // 字符串值选中事件 + var item = cut.FindComponent>(); + await cut.InvokeAsync(item.Instance.OnToggleClick); + Assert.True(selected); + } + + [Fact] + public async Task IsButton_Ok() + { + var items = new List>() + { + new(new Foo() { Id = 1, Name = "Test1" }, "Test 1"), + new(new Foo() { Id = 2, Name = "Test2" }, "Test 2") + }; + var cut = Context.RenderComponent>(pb => + { + pb.Add(a => a.IsButton, true); + pb.Add(a => a.Color, Color.None); + pb.Add(a => a.Items, items); + }); + var item = cut.Find(".btn"); + await cut.InvokeAsync(() => + { + item.Click(); + }); + cut.Contains("btn active bg-primary"); + } + + [Fact] + public async Task OnMaxSelectedCountExceed_Ok() + { + bool max = false; + var items = new List>() + { + new(new Foo() { Id = 1, Name = "Test1" }, "Test 1"), + new(new Foo() { Id = 2, Name = "Test2" }, "Test 2"), + new(new Foo() { Id = 3, Name = "Test3" }, "Test 3") + }; + var cut = Context.RenderComponent>(pb => + { + pb.Add(a => a.MaxSelectedCount, 2); + pb.Add(a => a.Items, items); + pb.Add(a => a.OnMaxSelectedCountExceed, () => + { + max = true; + return Task.CompletedTask; + }); + }); + var checkboxes = cut.FindComponents>(); + Assert.Equal(3, checkboxes.Count); + + await cut.InvokeAsync(async () => + { + await checkboxes[0].Instance.OnToggleClick(); + }); + Assert.Equal(CheckboxState.Checked, checkboxes[0].Instance.State); + + await cut.InvokeAsync(async () => + { + await checkboxes[1].Instance.OnToggleClick(); + }); + Assert.Equal(CheckboxState.Checked, checkboxes[1].Instance.State); + + // 选中第三个由于限制无法选中 + await cut.InvokeAsync(async () => + { + await checkboxes[2].Instance.OnToggleClick(); + }); + Assert.Equal(CheckboxState.Checked, checkboxes[0].Instance.State); + Assert.Equal(CheckboxState.Checked, checkboxes[1].Instance.State); + Assert.Equal(CheckboxState.UnChecked, checkboxes[2].Instance.State); + Assert.True(max); + + // 取消选择第一个 + max = false; + await cut.InvokeAsync(async () => + { + await checkboxes[0].Instance.OnToggleClick(); + }); + Assert.Equal(CheckboxState.UnChecked, checkboxes[0].Instance.State); + Assert.Equal(CheckboxState.Checked, checkboxes[1].Instance.State); + Assert.Equal(CheckboxState.UnChecked, checkboxes[2].Instance.State); + Assert.False(max); + } + + private class Dummy + { + [Required] + public List? Data { get; set; } + } +}