From 1be48a3773f90e641c3f08dc0c0797d53ece8d97 Mon Sep 17 00:00:00 2001 From: Denis Voituron Date: Wed, 20 Nov 2024 21:14:06 +0100 Subject: [PATCH] [dev-v5] FluentListBase (#2954) * Add first classes * Add missing features * Remove FluentOption.OnChange * Add missing attributes * Add Unit Tests * Fix csproj * Add Unit Tests * Add Unit Tests * Fix doc --- .../List/Select/Examples/SelectDefault.razor | 48 +++++ .../Components/List/Select/FluentSelect.md | 24 +++ .../Components/MarkdownViewer.razor.cs | 2 +- .../Models/ApiClass.cs | 1 + src/Core/Components/Base/FluentInputBase.cs | 35 ++- src/Core/Components/Grid/FluentGrid.razor.cs | 2 + src/Core/Components/List/FluentListBase.razor | 40 ++++ .../Components/List/FluentListBase.razor.cs | 136 ++++++++++++ src/Core/Components/List/FluentOption.razor | 16 ++ .../Components/List/FluentOption.razor.cs | 69 ++++++ src/Core/Components/List/FluentSelect.razor | 30 +++ .../Components/List/FluentSelect.razor.cs | 20 ++ .../Components/List/InternalListContext.cs | 33 +++ .../TextInput/FluentTextInput.razor.cs | 8 - src/Core/Extensions/FluentInputExtensions.cs | 76 +++++++ tests/Core/Components/Base/InputBaseTests.cs | 1 + ...s.FluentSelect_Default.verified.razor.html | 7 + ...ests.FluentSelect_Enum.verified.razor.html | 6 + ...sts.FluentSelect_Label.verified.razor.html | 12 ++ ...ts.FluentSelect_Manual.verified.razor.html | 6 + ...Select_OptionFunctions.verified.razor.html | 7 + .../Components/List/FluentSelectTests.razor | 202 ++++++++++++++++++ .../Extensions/FluentInputExtensionsTests.cs | 89 ++++++++ 23 files changed, 853 insertions(+), 17 deletions(-) create mode 100644 examples/Demo/FluentUI.Demo.Client/Documentation/Components/List/Select/Examples/SelectDefault.razor create mode 100644 examples/Demo/FluentUI.Demo.Client/Documentation/Components/List/Select/FluentSelect.md create mode 100644 src/Core/Components/List/FluentListBase.razor create mode 100644 src/Core/Components/List/FluentListBase.razor.cs create mode 100644 src/Core/Components/List/FluentOption.razor create mode 100644 src/Core/Components/List/FluentOption.razor.cs create mode 100644 src/Core/Components/List/FluentSelect.razor create mode 100644 src/Core/Components/List/FluentSelect.razor.cs create mode 100644 src/Core/Components/List/InternalListContext.cs create mode 100644 src/Core/Extensions/FluentInputExtensions.cs create mode 100644 tests/Core/Components/List/FluentSelectTests.FluentSelect_Default.verified.razor.html create mode 100644 tests/Core/Components/List/FluentSelectTests.FluentSelect_Enum.verified.razor.html create mode 100644 tests/Core/Components/List/FluentSelectTests.FluentSelect_Label.verified.razor.html create mode 100644 tests/Core/Components/List/FluentSelectTests.FluentSelect_Manual.verified.razor.html create mode 100644 tests/Core/Components/List/FluentSelectTests.FluentSelect_OptionFunctions.verified.razor.html create mode 100644 tests/Core/Components/List/FluentSelectTests.razor create mode 100644 tests/Core/Extensions/FluentInputExtensionsTests.cs diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/List/Select/Examples/SelectDefault.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/List/Select/Examples/SelectDefault.razor new file mode 100644 index 000000000..183fffd1d --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/List/Select/Examples/SelectDefault.razor @@ -0,0 +1,48 @@ + + + + + + + + + @if (!String.IsNullOrEmpty(context)) + { + ➡️ + @context + } + + + + + One + Two + Three + + + + +
+ All to 'One' +
+ +
+
Selected value: @Value
+
Selected color: @SelectedColor
+
+ +@code { + + private string?[] Digits = new string?[] { null, "One", "Two", "Three" }; + private string? Value; + private Color SelectedColor; + + public static IEnumerable GetEnumValues() + { + return Enum.GetValues(typeof(Color)).Cast(); + } +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/List/Select/FluentSelect.md b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/List/Select/FluentSelect.md new file mode 100644 index 000000000..5a76aecf4 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/List/Select/FluentSelect.md @@ -0,0 +1,24 @@ +--- +title: Select +route: /List/Select +--- + +# Select + +The `Select` component allows one option to be selected from multiple options. + +View the [Usage Guidance](https://fluent2.microsoft.design/components/web/react/select/usage). + +## TEST + +{{ SelectDefault }} + +## API FluentSelect + +{{ API Type=FluentSelect }} + +{{ API Type=FluentOption }} + +## Migrating to v5 + +TODO diff --git a/examples/Tools/FluentUI.Demo.DocViewer/Components/MarkdownViewer.razor.cs b/examples/Tools/FluentUI.Demo.DocViewer/Components/MarkdownViewer.razor.cs index b4e30f6a4..1f1ee39f6 100644 --- a/examples/Tools/FluentUI.Demo.DocViewer/Components/MarkdownViewer.razor.cs +++ b/examples/Tools/FluentUI.Demo.DocViewer/Components/MarkdownViewer.razor.cs @@ -96,7 +96,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender) { var type = DocViewerService.ApiAssembly ?.GetTypes() - ?.FirstOrDefault(i => i.Name == name); + ?.FirstOrDefault(i => i.Name == name || i.Name.StartsWith($"{name}`1")); return type is null ? null : new ApiClass(DocViewerService, type); } diff --git a/examples/Tools/FluentUI.Demo.DocViewer/Models/ApiClass.cs b/examples/Tools/FluentUI.Demo.DocViewer/Models/ApiClass.cs index 39ffdc576..de7906f76 100644 --- a/examples/Tools/FluentUI.Demo.DocViewer/Models/ApiClass.cs +++ b/examples/Tools/FluentUI.Demo.DocViewer/Models/ApiClass.cs @@ -27,6 +27,7 @@ internal class ApiClass "ToString", "Dispose", "DisposeAsync", + "ValueExpression", ]; private readonly Type _component; diff --git a/src/Core/Components/Base/FluentInputBase.cs b/src/Core/Components/Base/FluentInputBase.cs index 8bab2925f..2c50324d9 100644 --- a/src/Core/Components/Base/FluentInputBase.cs +++ b/src/Core/Components/Base/FluentInputBase.cs @@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Components.Forms; using Microsoft.JSInterop; using Microsoft.FluentUI.AspNetCore.Components.Utilities; +using System.Diagnostics.CodeAnalysis; namespace Microsoft.FluentUI.AspNetCore.Components; @@ -19,6 +20,14 @@ public abstract partial class FluentInputBase : InputBase, IFlue { private FluentJSModule? _jsModule; + /// + /// Initializes a new instance of the class. + /// + protected FluentInputBase() + { + ValueExpression = () => CurrentValueOrDefault; + } + /// [Inject] private IJSRuntime JSRuntime { get; set; } = default!; @@ -29,6 +38,12 @@ public abstract partial class FluentInputBase : InputBase, IFlue /// internal FluentJSModule JSModule => _jsModule ??= new FluentJSModule(JSRuntime); + /// + /// Internal usage only: to define the default `ValueExpression`. + /// + [ExcludeFromCodeCoverage] + internal TValue CurrentValueOrDefault { get => CurrentValue ?? default!; set => CurrentValue = value; } + #region IFluentComponentBase /// @@ -114,26 +129,30 @@ public abstract partial class FluentInputBase : InputBase, IFlue [Parameter] public bool Required { get; set; } - /// - /// - /// - /// - /// + /// [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0059:Unnecessary assignment of a value", Justification = "TODO")] - protected virtual Task ChangeHandlerAsync(ChangeEventArgs e) + protected virtual async Task ChangeHandlerAsync(ChangeEventArgs e) { var isValid = TryParseValueFromString(e.Value?.ToString(), out var result, out var validationErrorMessage); if (isValid) { - CurrentValue = result; + await InvokeAsync(() => CurrentValue = result); } else { // TODO } + } - return Task.CompletedTask; + /// + /// Returns the aria-label attribute value with the label and required indicator. + /// + /// + protected virtual string? GetAriaLabelWithRequired() + { + return (AriaLabel ?? Label ?? string.Empty) + + (Required ? $", Required" : string.Empty); } #endregion diff --git a/src/Core/Components/Grid/FluentGrid.razor.cs b/src/Core/Components/Grid/FluentGrid.razor.cs index 88afe8299..809813576 100644 --- a/src/Core/Components/Grid/FluentGrid.razor.cs +++ b/src/Core/Components/Grid/FluentGrid.razor.cs @@ -2,6 +2,7 @@ // MIT License - Copyright (c) Microsoft Corporation. All rights reserved. // ------------------------------------------------------------------------ +using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.Components; using Microsoft.FluentUI.AspNetCore.Components.Utilities; using Microsoft.JSInterop; @@ -102,6 +103,7 @@ public async Task FluentGrid_MediaChangedAsync(string size) /// /// /// + [ExcludeFromCodeCoverage(Justification = "Tested via integration tests.")] protected override async ValueTask DisposeAsync(IJSObjectReference? jsModule) { if (jsModule != null) diff --git a/src/Core/Components/List/FluentListBase.razor b/src/Core/Components/List/FluentListBase.razor new file mode 100644 index 000000000..1db09b2eb --- /dev/null +++ b/src/Core/Components/List/FluentListBase.razor @@ -0,0 +1,40 @@ +@namespace Microsoft.FluentUI.AspNetCore.Components +@using Microsoft.AspNetCore.Components.Rendering +@inherits FluentInputBase +@typeparam TOption + +@code +{ + /// + /// Internal method to render the options. + /// See the method for the public API. + /// + /// + private void InternalRenderOptions(RenderTreeBuilder __builder) + { + if (Items is null) + { + @ChildContent + } + else + { + @foreach (TOption item in Items) + { + + @if (OptionTemplate is not null) + { + @OptionTemplate(item) + } + else + { + @GetOptionText(item) + } + + } + } + } +} diff --git a/src/Core/Components/List/FluentListBase.razor.cs b/src/Core/Components/List/FluentListBase.razor.cs new file mode 100644 index 000000000..4784357c4 --- /dev/null +++ b/src/Core/Components/List/FluentListBase.razor.cs @@ -0,0 +1,136 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components.Extensions; +using Microsoft.FluentUI.AspNetCore.Components.Utilities; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +public abstract partial class FluentListBase : FluentInputBase +{ + /// + /// Initializes a new instance of the class. + /// + protected FluentListBase() + { + Id = Identifier.NewId(); + } + + /// + /// Gets or sets the width of the component. + /// + [Parameter] + public string? Width { get; set; } + + /// + /// Gets or sets the height of the component. + /// + [Parameter] + public string? Height { get; set; } + + /// + /// Gets or sets the content to be rendered inside the component. + /// + [Parameter] + public virtual RenderFragment? ChildContent { get; set; } + + /// + /// Gets or sets the content source of all items to display in this list. + /// Each item must be instantiated (cannot be null). + /// + [Parameter] + public virtual IEnumerable? Items { get; set; } + + /// + /// Gets or sets the template for the items. + /// + [Parameter] + public virtual RenderFragment? OptionTemplate { get; set; } + + /// + /// Gets or sets the function used to determine which value to apply to the option value attribute. + /// + [Parameter] + public virtual Func? OptionValue { get; set; } + + /// + /// Gets or sets the function used to determine which text to display for each option. + /// + [Parameter] + public virtual Func? OptionText { get; set; } + + /// + /// Gets or sets the function used to determine if an option is initially selected. + /// + [Parameter] + public virtual Func? OptionSelected { get; set; } + + /// + /// Gets or sets the function used to determine if an option is disabled. + /// + [Parameter] + public virtual Func? OptionDisabled { get; set; } + + /// + protected virtual bool GetOptionSelected(TOption? item) + { + return OptionSelected?.Invoke(item) ?? Equals(item, CurrentValue); + } + + /// + protected virtual string? GetOptionValue(TOption? item) + { + return OptionValue?.Invoke(item) ?? item?.ToString() ?? null; + } + + /// + protected virtual string? GetOptionText(TOption? item) + { + return OptionText?.Invoke(item) ?? item?.ToString() ?? string.Empty; + } + + /// + protected virtual bool GetOptionDisabled(TOption? item) + { + return OptionDisabled?.Invoke(item) ?? false; + } + + /// + protected virtual async Task OnSelectedItemChangedHandlerAsync(TOption? item) + { + if (Disabled || item == null) + { + return; + } + + if (!Equals(item, CurrentValue)) + { + // Assign the current value and raise the change event + CurrentValue = item; + } + + await Task.CompletedTask; + } + + /// + /// Renders the list options. + /// + /// + protected virtual RenderFragment? RenderOptions() => InternalRenderOptions; + + /// + protected override bool TryParseValueFromString(string? value, [MaybeNullWhen(false)] out TOption result, [NotNullWhen(false)] out string? validationErrorMessage) + { + return this.TryParseSelectableValueFromString(value, out result, out validationErrorMessage); + } + + /// + internal InternalListContext GetCurrentContext() + { + return new InternalListContext(this); + } +} diff --git a/src/Core/Components/List/FluentOption.razor b/src/Core/Components/List/FluentOption.razor new file mode 100644 index 000000000..b4c7dab7c --- /dev/null +++ b/src/Core/Components/List/FluentOption.razor @@ -0,0 +1,16 @@ +@namespace Microsoft.FluentUI.AspNetCore.Components +@inherits FluentComponentBase + +@* This file must be updated when the `fluent-select` web-component will be available *@ + + diff --git a/src/Core/Components/List/FluentOption.razor.cs b/src/Core/Components/List/FluentOption.razor.cs new file mode 100644 index 000000000..111d08745 --- /dev/null +++ b/src/Core/Components/List/FluentOption.razor.cs @@ -0,0 +1,69 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Components; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// The Option element is used to define an item contained in a List component. +/// +public partial class FluentOption : FluentComponentBase +{ + /// + /// Gets or sets the context of the list. + /// + [CascadingParameter(Name = "ListContext")] + internal InternalListContext? InternalListContext { get; set; } + + /// + /// Gets or sets a value indicating whether the element is disabled. + /// + [Parameter] + public bool Disabled { get; set; } + + /// + /// Gets or sets the value of this option. + /// + [Parameter] + public string? Value { get; set; } + + /// + /// Gets or sets the value indicating whether the element is selected. + /// This should be used with two-way binding. + /// + /// + /// @bind-Value="model.PropertyName" + /// + [Parameter] + public bool Selected { get; set; } + + /// + /// Gets or sets a callback that updates the bound value. + /// + [Parameter] + public EventCallback SelectedChanged { get; set; } + + /// + /// Gets or sets the content to be rendered inside the component. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + private async Task OnSelectHandlerAsync() + { + if (Disabled) + { + return; + } + + Selected = !Selected; + + if (SelectedChanged.HasDelegate) + { + await SelectedChanged.InvokeAsync(Selected); + } + } +} diff --git a/src/Core/Components/List/FluentSelect.razor b/src/Core/Components/List/FluentSelect.razor new file mode 100644 index 000000000..55e75d06f --- /dev/null +++ b/src/Core/Components/List/FluentSelect.razor @@ -0,0 +1,30 @@ +@namespace Microsoft.FluentUI.AspNetCore.Components +@inherits FluentListBase +@typeparam TOption + +@* This file must be updated when the `fluent-select` web-component will be available *@ + + + @if (!string.IsNullOrEmpty(Label) || LabelTemplate is not null) + { +
+ +
+ } + +
diff --git a/src/Core/Components/List/FluentSelect.razor.cs b/src/Core/Components/List/FluentSelect.razor.cs new file mode 100644 index 000000000..e348ae4ac --- /dev/null +++ b/src/Core/Components/List/FluentSelect.razor.cs @@ -0,0 +1,20 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +using Microsoft.FluentUI.AspNetCore.Components.Utilities; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// +/// +/// +public partial class FluentSelect : FluentListBase +{ + /// + protected override string? StyleValue => new StyleBuilder(base.StyleValue) + .AddStyle("min-width", Width, when: !string.IsNullOrEmpty(Width)) + .AddStyle("height", Height, when: !string.IsNullOrEmpty(Height)) + .Build(); +} diff --git a/src/Core/Components/List/InternalListContext.cs b/src/Core/Components/List/InternalListContext.cs new file mode 100644 index 000000000..4ad886f07 --- /dev/null +++ b/src/Core/Components/List/InternalListContext.cs @@ -0,0 +1,33 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Components; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// The component cascades this so that descendant options can talk back to it. +/// It's an internal type so it doesn't show up in unrelated components by mistake +/// +internal class InternalListContext +{ + /// + /// Initializes a new instance of the class. + /// + /// + public InternalListContext(FluentListBase component) + { + ListComponent = component; + } + + /// + /// Gets the list component. + /// + public FluentListBase ListComponent { get; } + + /// + /// Gets the event callback to be invoked when the selected value is changed. + /// + public EventCallback ValueChanged { get; set; } +} diff --git a/src/Core/Components/TextInput/FluentTextInput.razor.cs b/src/Core/Components/TextInput/FluentTextInput.razor.cs index 28c4b08f4..d0947fefa 100644 --- a/src/Core/Components/TextInput/FluentTextInput.razor.cs +++ b/src/Core/Components/TextInput/FluentTextInput.razor.cs @@ -15,14 +15,6 @@ public partial class FluentTextInput : FluentInputImmediateBase, IFluen { private const string JAVASCRIPT_FILE = FluentJSModule.JAVASCRIPT_ROOT + "TextInput/FluentTextInput.razor.js"; - /// - /// Initializes a new instance of the class. - /// - public FluentTextInput() - { - ValueExpression = () => Value; - } - /// [Parameter] public ElementReference Element { get; set; } diff --git a/src/Core/Extensions/FluentInputExtensions.cs b/src/Core/Extensions/FluentInputExtensions.cs new file mode 100644 index 000000000..d28773648 --- /dev/null +++ b/src/Core/Extensions/FluentInputExtensions.cs @@ -0,0 +1,76 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Components; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; + +namespace Microsoft.FluentUI.AspNetCore.Components.Extensions; + +/// +/// Extension methods for . +/// +internal static class FluentInputExtensions +{ + public static bool TryParseSelectableValueFromString<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TValue>( + this FluentInputBase input, + string? value, + [MaybeNullWhen(false)] out TValue result, + [NotNullWhen(false)] out string? validationErrorMessage) + { + if (typeof(TValue) == typeof(bool)) + { + if (TryConvertToBool(value, out result)) + { + validationErrorMessage = null; + return true; + } + } + + else if (typeof(TValue) == typeof(bool?)) + { + if (TryConvertToNullableBool(value, out result)) + { + validationErrorMessage = null; + return true; + } + } + + else if (BindConverter.TryConvertTo(value, CultureInfo.CurrentCulture, out var parsedValue)) + { + result = parsedValue; + validationErrorMessage = null; + return true; + } + + result = default; + validationErrorMessage = $"The '{input.DisplayName ?? "Unknown Bound Field"}' field is not valid."; + return false; + } + + /// + private static bool TryConvertToBool(string? value, out TValue result) + { + if (bool.TryParse(value, out var @bool)) + { + result = (TValue)(object)@bool; + return true; + } + + result = default!; + return false; + } + + /// + private static bool TryConvertToNullableBool(string? value, out TValue result) + { + if (string.IsNullOrEmpty(value)) + { + result = default!; + return true; + } + + return TryConvertToBool(value, out result); + } +} diff --git a/tests/Core/Components/Base/InputBaseTests.cs b/tests/Core/Components/Base/InputBaseTests.cs index cbd715fc0..1818e3070 100644 --- a/tests/Core/Components/Base/InputBaseTests.cs +++ b/tests/Core/Components/Base/InputBaseTests.cs @@ -32,6 +32,7 @@ public class InputBaseTests : TestContext private static readonly Dictionary> ComponentInitializer = new() { // { typeof(FluentIcon<>), type => type.MakeGenericType(typeof(Samples.Icons.Samples.Info)) } + { typeof(FluentSelect<>), type => type.MakeGenericType(typeof(string)) } // FluentSelect }; /// diff --git a/tests/Core/Components/List/FluentSelectTests.FluentSelect_Default.verified.razor.html b/tests/Core/Components/List/FluentSelectTests.FluentSelect_Default.verified.razor.html new file mode 100644 index 000000000..db4cb60c3 --- /dev/null +++ b/tests/Core/Components/List/FluentSelectTests.FluentSelect_Default.verified.razor.html @@ -0,0 +1,7 @@ + + \ No newline at end of file diff --git a/tests/Core/Components/List/FluentSelectTests.FluentSelect_Enum.verified.razor.html b/tests/Core/Components/List/FluentSelectTests.FluentSelect_Enum.verified.razor.html new file mode 100644 index 000000000..70dcac41d --- /dev/null +++ b/tests/Core/Components/List/FluentSelectTests.FluentSelect_Enum.verified.razor.html @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/tests/Core/Components/List/FluentSelectTests.FluentSelect_Label.verified.razor.html b/tests/Core/Components/List/FluentSelectTests.FluentSelect_Label.verified.razor.html new file mode 100644 index 000000000..bc8dcc2a5 --- /dev/null +++ b/tests/Core/Components/List/FluentSelectTests.FluentSelect_Label.verified.razor.html @@ -0,0 +1,12 @@ + +
+ +
+ diff --git a/tests/Core/Components/List/FluentSelectTests.FluentSelect_Manual.verified.razor.html b/tests/Core/Components/List/FluentSelectTests.FluentSelect_Manual.verified.razor.html new file mode 100644 index 000000000..c81e75cbf --- /dev/null +++ b/tests/Core/Components/List/FluentSelectTests.FluentSelect_Manual.verified.razor.html @@ -0,0 +1,6 @@ + + diff --git a/tests/Core/Components/List/FluentSelectTests.FluentSelect_OptionFunctions.verified.razor.html b/tests/Core/Components/List/FluentSelectTests.FluentSelect_OptionFunctions.verified.razor.html new file mode 100644 index 000000000..39467d740 --- /dev/null +++ b/tests/Core/Components/List/FluentSelectTests.FluentSelect_OptionFunctions.verified.razor.html @@ -0,0 +1,7 @@ + + \ No newline at end of file diff --git a/tests/Core/Components/List/FluentSelectTests.razor b/tests/Core/Components/List/FluentSelectTests.razor new file mode 100644 index 000000000..c6ff5fab7 --- /dev/null +++ b/tests/Core/Components/List/FluentSelectTests.razor @@ -0,0 +1,202 @@ +@using Microsoft.FluentUI.AspNetCore.Components.Utilities +@using Xunit; +@using Microsoft.FluentUI.AspNetCore.Components.Tests.Samples; +@inherits TestContext +@code +{ + private readonly IEnumerable Digits = new[] { null, "One", "Two", "Three" }; + + private enum MyDigitsEnum + { + One, + Two, + Three + } + + public FluentSelectTests() + { + JSInterop.Mode = JSRuntimeMode.Loose; + Services.AddSingleton(); + } + + [Fact] + public void FluentSelect_Default() + { + // Arrange and Act + var cut = Render(@); + + // Assert + cut.Verify(); + } + + [Fact] + public void FluentSelect_Label() + { + // Arrange and Act + var cut = Render(@); + + // Assert + cut.Verify(); + } + + [Fact] + public void FluentSelect_Manual() + { + // Arrange and Act + var cut = Render( + @ + One + Two + Three + + ); + + // Assert + cut.Verify(); + } + + [Fact] + public void FluentSelect_Template() + { + // Arrange and Act + var cut = Render( + @ + + @if (!String.IsNullOrEmpty(context)) + { + [ + @context + ] + } + + ); + + // Assert + var two = cut.Find("option[value='Two']"); + Assert.Equal("[Two]", two.InnerHtml); + } + + [Fact] + public void FluentSelect_Enum() + { + MyDigitsEnum selectedColor = MyDigitsEnum.One; + + // Arrange and Act + var cut = Render(@); + + // Assert + cut.Verify(); + + // Local function + IEnumerable GetEnumValues() => Enum.GetValues(typeof(MyDigitsEnum)).Cast(); + } + + [Fact] + public void FluentSelect_OptionFunctions() + { + // Arrange && Act + // - Disable the "Two" option + // - Uppercase the text + // - Add a prefix to the value + // - Select the "One" option + var cut = Render( + @); + + // Assert + cut.Verify(); + } + + [Fact] + public void FluentSelect_Default_InitialSelection() + { + string? value = "Two"; + + // Arrange and Act + var cut = Render(@); + var two = cut.Find("option[value='Two']"); + + // Assert + Assert.True(two.HasAttribute("selected")); + Assert.Equal("true", two.GetAttribute("aria-selected")); + } + + [Fact] + public void FluentSelect_Binding_InitialSelection() + { + string? value = "Two"; + + // Arrange and Act + var cut = Render(@); + var two = cut.Find("option[value='Two']"); + + // Assert + Assert.True(two.HasAttribute("selected")); + Assert.Equal("true", two.GetAttribute("aria-selected")); + } + + [Fact] + public void FluentSelect_Binding_Updated() + { + string? value = "Two"; + + // Arrange + var cut = Render(@); + + // Act and re-render + cut.FindComponent>().SetParametersAndRender(parameters => parameters.Add(p => p.Value, "One")); + var one = cut.Find("option[value='One']"); + + // Assert + Assert.True(one.HasAttribute("selected")); + Assert.Equal("true", one.GetAttribute("aria-selected")); + } + + [Fact] + public void FluentSelect_Data() + { + // Arrange + var myData = "MyData"; + var cut = Render(@); + + // Assert + var component = cut.FindComponent>(); + Assert.Equal("MyData", component.Instance.Data); + } + + [Fact] + public void FluentSelect_Option_Clicked() + { + string? value = "One"; + + // Arrange + var cut = Render(@); + var two = cut.Find("option[value='Two']"); + + // Act + two.Click(); + + // Assert + Assert.True(two.HasAttribute("selected")); + Assert.Equal("true", two.GetAttribute("aria-selected")); + } + + [Fact] + public void FluentSelect_DisabledOption_Clicked() + { + string? value = "One"; + + // Arrange + var cut = Render(@); + var two = cut.Find("option[value='Two']"); + + // Act + two.Click(); + + // Assert + Assert.False(two.HasAttribute("selected")); + } +} diff --git a/tests/Core/Extensions/FluentInputExtensionsTests.cs b/tests/Core/Extensions/FluentInputExtensionsTests.cs new file mode 100644 index 000000000..a401f1176 --- /dev/null +++ b/tests/Core/Extensions/FluentInputExtensionsTests.cs @@ -0,0 +1,89 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.FluentUI.AspNetCore.Components.Extensions; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace Microsoft.FluentUI.AspNetCore.Components.Tests.Extensions; + +public class FluentInputExtensionsTests +{ + + [Theory] + [InlineData("Custom-Value", true, "Custom-Value", null)] + public void FluentInputExtensions_TryParseSelectableValueFromString_String(string value, bool expectedValid, string expectedResult, string? expectedValidationMessage) + { + // Arrange + var input = new FluentSelect(); + + // Act + var ok = FluentInputExtensions.TryParseSelectableValueFromString(input, value, out var result, out var validationErrorMessage); + + // Assert + Assert.True(ok == expectedValid); + Assert.Equal(expectedResult, result); + Assert.Equal(expectedValidationMessage, validationErrorMessage); + } + + [Theory] + [InlineData("10", true, 10, null)] + [InlineData("-20", true, -20, null)] + [InlineData("Invalid", false, 0, "The 'Unknown Bound Field' field is not valid.")] + public void FluentInputExtensions_TryParseSelectableValueFromString_Number(string value, bool expectedValid, int expectedResult, string? expectedValidationMessage) + { + // Arrange + var input = new FluentSelect(); + + // Act + var ok = FluentInputExtensions.TryParseSelectableValueFromString(input, value, out var result, out var validationErrorMessage); + + // Assert + Assert.True(ok == expectedValid); + Assert.Equal(expectedResult, result); + Assert.Equal(expectedValidationMessage, validationErrorMessage); + } + + [Theory] + [InlineData("True", true, true, null)] + [InlineData("False", true, false, null)] + [InlineData("Invalid", false, false, "The 'Unknown Bound Field' field is not valid.")] + public void FluentInputExtensions_TryParseSelectableValueFromString_Boolean(string value, bool expectedValid, bool expectedResult, string? expectedValidationMessage) + { + // Arrange + var input = new FluentSelect(); + + // Act + var ok = FluentInputExtensions.TryParseSelectableValueFromString(input, value, out var result, out var validationErrorMessage); + + // Assert + Assert.True(ok == expectedValid); + Assert.Equal(expectedResult, result); + Assert.Equal(expectedValidationMessage, validationErrorMessage); + } + + [Theory] + [InlineData("True", true, true, null)] + [InlineData("False", true, false, null)] + [InlineData("", true, null, null)] + [InlineData("Invalid", false, null, "The 'Unknown Bound Field' field is not valid.")] + public void FluentInputExtensions_TryParseSelectableValueFromString_BooleanNullable(string value, bool expectedValid, bool? expectedResult, string? expectedValidationMessage) + { + // Arrange + var input = new FluentSelect(); + + // Act + var ok = FluentInputExtensions.TryParseSelectableValueFromString(input, value, out var result, out var validationErrorMessage); + + // Assert + Assert.True(ok == expectedValid); + Assert.Equal(expectedResult, result); + Assert.Equal(expectedValidationMessage, validationErrorMessage); + } +}