diff --git a/src/BootstrapBlazor.Server/Components/Samples/CheckboxLists.razor b/src/BootstrapBlazor.Server/Components/Samples/CheckboxLists.razor index c054cccbc5a..be0128c7343 100644 --- a/src/BootstrapBlazor.Server/Components/Samples/CheckboxLists.razor +++ b/src/BootstrapBlazor.Server/Components/Samples/CheckboxLists.razor @@ -120,7 +120,7 @@ - +
@if (_selectedFoos != null) diff --git a/src/BootstrapBlazor.Server/Components/Samples/Radios.razor b/src/BootstrapBlazor.Server/Components/Samples/Radios.razor index b123ad6f91a..598411afc0d 100644 --- a/src/BootstrapBlazor.Server/Components/Samples/Radios.razor +++ b/src/BootstrapBlazor.Server/Components/Samples/Radios.razor @@ -125,6 +125,13 @@ + + +
+
@_selectedFoo.Name
+
+
+ diff --git a/src/BootstrapBlazor.Server/Components/Samples/Radios.razor.cs b/src/BootstrapBlazor.Server/Components/Samples/Radios.razor.cs index af6d4f1c9a4..a925646c023 100644 --- a/src/BootstrapBlazor.Server/Components/Samples/Radios.razor.cs +++ b/src/BootstrapBlazor.Server/Components/Samples/Radios.razor.cs @@ -3,6 +3,9 @@ // See the LICENSE file in the project root for more information. // Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone +using DocumentFormat.OpenXml.Office2010.Excel; +using DocumentFormat.OpenXml.Wordprocessing; + namespace BootstrapBlazor.Server.Components.Samples; /// @@ -52,6 +55,12 @@ private Task OnSelectedChanged(IEnumerable values, string val) return Task.CompletedTask; } + [NotNull] + private IEnumerable>? GenericItems { get; set; } + + [NotNull] + private Foo? _selectedFoo = default; + /// /// OnInitialized 方法 /// @@ -85,6 +94,15 @@ protected override void OnInitialized() Model = Foo.Generate(LocalizerFoo); FooItems = Foo.GetCompleteItems(LocalizerFoo); + + GenericItems = new List>() + { + new() { Text = Localizer["item1"], Value = new Foo() { Id = 1, Name = LocalizerFoo["Foo.Name", "001"] } }, + new() { Text = Localizer["item2"], Value = new Foo() { Id = 2, Name = LocalizerFoo["Foo.Name", "002"] } }, + new() { Text = Localizer["item3"], Value = new Foo() { Id = 3, Name = LocalizerFoo["Foo.Name", "003"] } }, + }; + + _selectedFoo = new Foo() { Id = 1, Name = LocalizerFoo["Foo.Name", "001"] }; } private Task OnItemChanged(IEnumerable values, SelectedItem val) diff --git a/src/BootstrapBlazor.Server/Locales/en-US.json b/src/BootstrapBlazor.Server/Locales/en-US.json index 5e1703492b5..8f7067f494d 100644 --- a/src/BootstrapBlazor.Server/Locales/en-US.json +++ b/src/BootstrapBlazor.Server/Locales/en-US.json @@ -2376,7 +2376,9 @@ "StatusText2": "Not selected", "StatusText3": "Indeterminate", "Checkbox2Text": "Two-way binding", - "Checkbox3Text": "Handwritten labels" + "Checkbox3Text": "Handwritten labels", + "RadioListGenericTitle": "Generic List", + "RadioListGenericIntro": "Enable generic support through SelectedItem<TValue>" }, "BootstrapBlazor.Server.Components.Samples.CheckboxLists": { "Title": "CheckboxList", @@ -3024,7 +3026,9 @@ "RadiosItem1": "Option one", "RadiosItem2": "Option two", "RadiosAdd1": "Beijing", - "RadiosAdd2": "Shanghai" + "RadiosAdd2": "Shanghai", + "RadioListGenericTitle": "Generic List", + "RadioListGenericIntro": "Enable generic support through SelectedItem<TValue>" }, "BootstrapBlazor.Server.Components.Samples.Rates": { "RatesTitle": "Rate", diff --git a/src/BootstrapBlazor.Server/Locales/zh-CN.json b/src/BootstrapBlazor.Server/Locales/zh-CN.json index 90ab5c6286b..748e9a10264 100644 --- a/src/BootstrapBlazor.Server/Locales/zh-CN.json +++ b/src/BootstrapBlazor.Server/Locales/zh-CN.json @@ -2426,7 +2426,9 @@ "MaxSelectedCountIntro": "通过设置 MaxSelectedCount 属性控制最大可选数量,通过 OnMaxSelectedCountExceed 回调处理逻辑", "MaxSelectedCountDesc": "选中节点超过 2 个时,弹出 Toast 提示栏", "OnMaxSelectedCountExceedTitle": "可选最大数量提示", - "OnMaxSelectedCountExceedContent": "最多只能选择 {0} 项" + "OnMaxSelectedCountExceedContent": "最多只能选择 {0} 项", + "CheckboxListGenericTitle": "泛型支持", + "CheckboxListGenericIntro": "通过 SelectedItem<TValue> 开启泛型支持" }, "BootstrapBlazor.Server.Components.Samples.ColorPickers": { "Title": "ColorPicker 颜色拾取器", @@ -3024,7 +3026,9 @@ "RadiosItem1": "选项一", "RadiosItem2": "选项二", "RadiosAdd1": "北京", - "RadiosAdd2": "上海" + "RadiosAdd2": "上海", + "RadioListGenericTitle": "泛型支持", + "RadioListGenericIntro": "通过 SelectedItem<TValue> 开启泛型支持" }, "BootstrapBlazor.Server.Components.Samples.Rates": { "RatesTitle": "Rate 评分", diff --git a/src/BootstrapBlazor/BootstrapBlazor.csproj b/src/BootstrapBlazor/BootstrapBlazor.csproj index 8d382af527a..f9172afda8f 100644 --- a/src/BootstrapBlazor/BootstrapBlazor.csproj +++ b/src/BootstrapBlazor/BootstrapBlazor.csproj @@ -1,7 +1,7 @@ - 9.1.6-beta03 + 9.1.6 diff --git a/src/BootstrapBlazor/Components/Radio/RadioListGeneric.razor b/src/BootstrapBlazor/Components/Radio/RadioListGeneric.razor index 80d79de6185..38ef9053f4c 100644 --- a/src/BootstrapBlazor/Components/Radio/RadioListGeneric.razor +++ b/src/BootstrapBlazor/Components/Radio/RadioListGeneric.razor @@ -6,7 +6,7 @@ { } -@* @if (IsButton) +@if (IsButton) {
@@ -29,4 +29,3 @@ else }
} - *@ diff --git a/src/BootstrapBlazor/Components/Radio/RadioListGeneric.razor.cs b/src/BootstrapBlazor/Components/Radio/RadioListGeneric.razor.cs index 4fd9e7b3c8f..3aac9ddf62b 100644 --- a/src/BootstrapBlazor/Components/Radio/RadioListGeneric.razor.cs +++ b/src/BootstrapBlazor/Components/Radio/RadioListGeneric.razor.cs @@ -8,8 +8,7 @@ namespace BootstrapBlazor.Components; /// /// 单选框组合组件 /// -[ExcludeFromCodeCoverage] -public partial class RadioListGeneric +public partial class RadioListGeneric : IModelEqualityComparer { /// /// 获得/设置 值为可为空枚举类型时是否自动添加空值 默认 false 自定义空值显示文本请参考 @@ -30,56 +29,137 @@ public partial class RadioListGeneric [Parameter] public RenderFragment>? ItemTemplate { get; set; } - //private string? GroupName => Id; - - //private string? ClassString => CssBuilder.Default("radio-list form-control") - // .AddClass("no-border", !ShowBorder && ValidCss != "is-invalid") - // .AddClass("is-vertical", IsVertical) - // .AddClass(CssClass).AddClass(ValidCss) - // .Build(); - - //private string? ButtonClassString => CssBuilder.Default("radio-list btn-group") - // .AddClass("disabled", IsDisabled) - // .AddClass("btn-group-vertical", IsVertical) - // .AddClassFromAttributes(AdditionalAttributes) - // .Build(); - - //private string? GetButtonItemClassString(SelectedItem item) => CssBuilder.Default("btn") - // .AddClass($"active bg-{Color.ToDescriptionString()}", CurrentValueAsString == item.Value) - // .Build(); - - ///// - ///// - ///// - //protected override void OnParametersSet() - //{ - // var t = NullableUnderlyingType ?? typeof(TValue); - // if (t.IsEnum && Items == null) - // { - // Items = t.ToSelectList((NullableUnderlyingType != null && IsAutoAddNullItem) ? new SelectedItem(default, NullItemText) : null); - // } - - // base.OnParametersSet(); - //} - - ///// - ///// 点击选择框方法 - ///// - //private async Task OnClick(SelectedItem item) - //{ - // if (!IsDisabled) - // { - // if (OnSelectedChanged != null) - // { - // await OnSelectedChanged(Items, Value); - // } - // StateHasChanged(); - // } - //} - - //private CheckboxState CheckState(SelectedItem item) => this.Equals(Value, item.Value) ? CheckboxState.Checked : CheckboxState.UnChecked; - - //private RenderFragment? GetChildContent(SelectedItem item) => ItemTemplate == null - // ? null - // : ItemTemplate(item); + /// + /// 获得/设置 是否为按钮样式 默认 false + /// + [Parameter] + public bool IsButton { get; set; } + + /// + /// 获得/设置 是否显示边框 默认为 true + /// + [Parameter] + public bool ShowBorder { get; set; } = true; + + /// + /// 获得/设置 是否为竖向排列 默认为 false + /// + [Parameter] + public bool IsVertical { get; set; } + + /// + /// 获得/设置 按钮颜色 默认为 None 未设置 + /// + [Parameter] + public Color Color { get; set; } + + /// + /// 获得/设置 数据源 + /// + [Parameter] + [NotNull] + public IEnumerable>? Items { get; set; } + + /// + /// 获得/设置 SelectedItemChanged 方法 + /// + [Parameter] + public Func? OnSelectedChanged { get; set; } + + /// + /// 获得/设置 数据主键标识标签 默认为
用于判断数据主键标签,如果模型未设置主键时可使用 参数自定义判断
数据模型支持联合主键 + ///
+ [Parameter] + [NotNull] + public Type? CustomKeyAttribute { get; set; } = typeof(KeyAttribute); + + /// + /// 获得/设置 比较数据是否相同回调方法 默认为 null + /// 提供此回调方法时忽略 属性 + /// + [Parameter] + public Func? ModelEqualityComparer { get; set; } + + /// + /// 获得 当前选项是否被禁用 + /// + /// + /// + private bool GetDisabledState(SelectedItem item) => IsDisabled || item.IsDisabled; + + private string? GroupName => Id; + + private string? ClassString => CssBuilder.Default("radio-list form-control") + .AddClass("no-border", !ShowBorder && ValidCss != "is-invalid") + .AddClass("is-vertical", IsVertical) + .AddClass(CssClass).AddClass(ValidCss) + .Build(); + + private string? ButtonClassString => CssBuilder.Default("radio-list btn-group") + .AddClass("disabled", IsDisabled) + .AddClass("btn-group-vertical", IsVertical) + .AddClassFromAttributes(AdditionalAttributes) + .Build(); + + private string? GetButtonItemClassString(SelectedItem item) => CssBuilder.Default("btn") + .AddClass($"active bg-{Color.ToDescriptionString()}", Equals(Value, item.Value)) + .Build(); + + /// + /// + /// + protected override void OnParametersSet() + { + base.OnParametersSet(); + + if (IsButton && Color == Color.None) + { + Color = Color.Primary; + } + + var t = NullableUnderlyingType ?? typeof(TValue); + if (t.IsEnum && Items == null) + { + Items = t.ToSelectList((NullableUnderlyingType != null && IsAutoAddNullItem) ? new SelectedItem(default, NullItemText) : null); + } + + Items ??= []; + + // set item active + if (Value != null) + { + var item = Items.FirstOrDefault(i => Equals(Value, i.Value)); + if (item == null) + { + Value = default; + } + } + } + + /// + /// 点击选择框方法 + /// + private async Task OnClick(SelectedItem item) + { + if (!IsDisabled) + { + CurrentValue = item.Value; + if (OnSelectedChanged != null) + { + await OnSelectedChanged(Value); + } + StateHasChanged(); + } + } + + private CheckboxState CheckState(SelectedItem item) => this.Equals(Value, item.Value) ? CheckboxState.Checked : CheckboxState.UnChecked; + + private RenderFragment? GetChildContent(SelectedItem item) => ItemTemplate == null + ? null + : ItemTemplate(item); + + /// + /// + /// + public bool Equals(TValue? x, TValue? y) => this.Equals(x, y); } diff --git a/test/UnitTest/Components/RadioListGenericTest.cs b/test/UnitTest/Components/RadioListGenericTest.cs new file mode 100644 index 00000000000..7d4e8190e2b --- /dev/null +++ b/test/UnitTest/Components/RadioListGenericTest.cs @@ -0,0 +1,240 @@ +// 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 System.ComponentModel.DataAnnotations; + +namespace UnitTest.Components; + +public class RadioListGenericTest : BootstrapBlazorTestBase +{ + [Fact] + public void Enum_NoItems() + { + var v = EnumEducation.Middle; + var cut = Context.RenderComponent>(pb => + { + pb.Add(a => a.Value, v); + }); + Assert.Contains("radio-list form-control", cut.Markup); + Assert.Contains("form-check-input", cut.Markup); + } + + [Fact] + public void EnumValue_Ok() + { + var v = EnumEducation.Middle; + var cut = Context.RenderComponent>(pb => + { + pb.Add(a => a.Items, typeof(EnumEducation).ToSelectList()); + pb.Add(a => a.Value, v); + }); + Assert.Contains("form-check-input", cut.Markup); + } + + [Fact] + public void EnumNullValue_Ok() + { + EnumEducation? v = null; + var cut = Context.RenderComponent>(pb => + { + pb.Add(a => a.Value, v); + pb.Add(a => a.NullItemText, "Test-Null"); + pb.Add(a => a.IsAutoAddNullItem, true); + }); + var items = cut.FindAll("[type='radio']"); + Assert.Equal(3, items.Count); + Assert.Contains("Test-Null", cut.Markup); + Assert.Contains("form-check-input", cut.Markup); + } + + [Fact] + public async Task SelectedItem_Ok() + { + var cut = Context.RenderComponent>(pb => + { + pb.Add(a => a.Items, new List> + { + new(new Foo() { Id = 1, Name = "张三 001" }, "Test1"), + new(new Foo() { Id = 2, Name = "张三 002" }, "Test2") + }); + pb.Add(a => a.IsVertical, true); + }); + cut.Contains("is-vertical"); + var item = cut.Find(".form-check-input"); + await cut.InvokeAsync(() => item.Click()); + Assert.Equal("张三 001", cut.Instance.Value.Name); + } + + [Fact] + public void IsDisabled_Ok() + { + var cut = Context.RenderComponent>(pb => + { + pb.Add(a => a.Items, new List> + { + new(new Foo() { Id = 1, Name = "张三 001" }, "Test1"), + new(new Foo() { Id = 2, Name = "张三 002" }, "Test2") { IsDisabled = true } + }); + }); + + // 候选项被禁用 + cut.Contains("disabled=\"disabled\""); + + // 组件被禁用 + cut.SetParametersAndRender(pb => + { + pb.Add(a => a.IsDisabled, true); + }); + cut.Contains("form-check disabled"); + } + + [Fact] + public void ShowLabel_Ok() + { + var cut = Context.RenderComponent>(pb => + { + pb.Add(a => a.Items, new List> + { + new(new Foo() { Id = 1, Name = "张三 001" }, "Test1"), + new(new Foo() { Id = 2, Name = "张三 002" }, "Test2") + }); + pb.Add(a => a.ShowLabel, true); + pb.Add(a => a.DisplayText, "test-label"); + }); + cut.Contains("test-label"); + cut.Contains("form-label"); + } + + [Fact] + public void NotInItems_Ok() + { + var cut = Context.RenderComponent>(); + Assert.Null(cut.Instance.Value); + Assert.Equal("
", cut.Markup); + + // 组件值为 test + // 组件给的候选 Items 中无 test 选项 + var v = new Foo() { Id = 3, Name = "test" }; + cut.SetParametersAndRender(pb => + { + pb.Add(a => a.Items, new List> + { + new(new Foo() { Id = 1, Name = "张三 001" }, "Test1"), + new(new Foo() { Id = 2, Name = "张三 002" }, "Test2") + }); + pb.Add(a => a.Value, v); + pb.Add(a => a.CustomKeyAttribute, typeof(KeyAttribute)); + pb.Add(a => a.ModelEqualityComparer, new Func((x, y) => x.Id == y.Id)); + }); + Assert.Null(cut.Instance.Value); + + v = new Foo() { Id = 3, Name = "test" }; + cut.SetParametersAndRender(pb => + { + pb.Add(a => a.Items, new List>()); + pb.Add(a => a.Value, v); + }); + Assert.Null(cut.Instance.Value); + } + + [Fact] + public async Task OnSelectedChanged_Ok() + { + var selected = false; + var v = EnumEducation.Middle; + var cut = Context.RenderComponent>(pb => + { + pb.Add(a => a.Items, typeof(EnumEducation).ToSelectList()); + pb.Add(a => a.Value, v); + pb.Add(a => a.OnSelectedChanged, v => + { + selected = true; + return Task.CompletedTask; + }); + }); + var item = cut.Find(".form-check-input"); + await cut.InvokeAsync(() => item.Click()); + Assert.True(selected); + } + + [Fact] + public async Task OnSelectedChanged_SelectedItem_Ok() + { + var selected = false; + var v = EnumEducation.Middle; + var cut = Context.RenderComponent>(pb => + { + pb.Add(a => a.Items, typeof(EnumEducation).ToSelectList()); + pb.Add(a => a.Value, v); + pb.Add(a => a.OnSelectedChanged, v => + { + selected = true; + return Task.CompletedTask; + }); + }); + var item = cut.Find(".form-check-input"); + await cut.InvokeAsync(() => item.Click()); + Assert.True(selected); + Assert.Equal(EnumEducation.Primary, cut.Instance.Value); + } + + [Fact] + public void ItemTemplate_Ok() + { + var v = new Foo() { Id = 3, Name = "test" }; + var cut = Context.RenderComponent>(pb => + { + pb.Add(a => a.Items, new List> + { + new(new Foo() { Id = 1, Name = "张三 001" }, "Test1"), + new(new Foo() { Id = 2, Name = "张三 002" }, "Test2") + }); + pb.Add(a => a.Value, v); + pb.Add(a => a.ItemTemplate, v => builder => + { + builder.AddContent(0, $"
item-template-{v.Value?.Name}
"); + }); + }); + cut.Contains("item-template-张三 001"); + cut.Contains("item-template-张三 002"); + } + + [Fact] + public async Task IsButton_Ok() + { + var cut = Context.RenderComponent>(pb => + { + pb.Add(a => a.IsButton, true); + pb.Add(a => a.Items, typeof(EnumEducation).ToSelectList()); + pb.Add(a => a.Value, EnumEducation.Middle); + }); + cut.Contains("radio-list btn-group"); + + cut.SetParametersAndRender(pb => + { + pb.Add(a => a.Color, Color.Danger); + }); + cut.Contains("btn active bg-danger"); + + var btn = cut.Find(".btn"); + await cut.InvokeAsync(() => + { + btn.Click(); + }); + cut.Contains("btn active bg-danger"); + } + + [Fact] + public void ShowBorder_Ok() + { + var cut = Context.RenderComponent>(pb => + { + pb.Add(a => a.ShowBorder, false); + pb.Add(a => a.Items, typeof(EnumEducation).ToSelectList()); + pb.Add(a => a.Value, EnumEducation.Middle); + }); + cut.Contains("no-border"); + } +}