diff --git a/.github/instructions/blazor.instructions.md b/.github/instructions/blazor.instructions.md new file mode 100644 index 0000000000..9bc52fb2c4 --- /dev/null +++ b/.github/instructions/blazor.instructions.md @@ -0,0 +1,77 @@ +--- +description: 'Blazor component and application patterns' +applyTo: '**/*.razor, **/*.razor.cs, **/*.razor.css' +--- + +## Blazor Code Style and Structure + +- Write idiomatic and efficient Blazor and C# code. +- Follow .NET and Blazor conventions. +- Use Razor Components appropriately for component-based UI development. +- Prefer inline functions for smaller components but separate complex logic into code-behind or service classes. +- Async/await should be used where applicable to ensure non-blocking UI operations. + +## Naming Conventions + +- Follow PascalCase for component names, method names, and public members. +- Use camelCase for private fields and local variables. +- Prefix interface names with "I" (e.g., IUserService). + +## Blazor and .NET Specific Guidelines + +- Utilize Blazor's built-in features for component lifecycle (e.g., OnInitializedAsync, OnParametersSetAsync). +- Use data binding effectively with @bind. +- Leverage Dependency Injection for services in Blazor. +- Structure Blazor components and services following Separation of Concerns. +- Always use the latest version C#, currently C# 13 features like record types, pattern matching, and global usings. + +## Error Handling and Validation + +- Implement proper error handling for Blazor pages and API calls. +- Use logging for error tracking in the backend and consider capturing UI-level errors in Blazor with tools like ErrorBoundary. +- Implement validation using FluentValidation or DataAnnotations in forms. + +## Blazor API and Performance Optimization + +- Utilize Blazor server-side or WebAssembly optimally based on the project requirements. +- Use asynchronous methods (async/await) for API calls or UI actions that could block the main thread. +- Optimize Razor components by reducing unnecessary renders and using StateHasChanged() efficiently. +- Minimize the component render tree by avoiding re-renders unless necessary, using ShouldRender() where appropriate. +- Use EventCallbacks for handling user interactions efficiently, passing only minimal data when triggering events. + +## Caching Strategies + +- Implement in-memory caching for frequently used data, especially for Blazor Server apps. Use IMemoryCache for lightweight caching solutions. +- For Blazor WebAssembly, utilize localStorage or sessionStorage to cache application state between user sessions. +- Consider Distributed Cache strategies (like Redis or SQL Server Cache) for larger applications that need shared state across multiple users or clients. +- Cache API calls by storing responses to avoid redundant calls when data is unlikely to change, thus improving the user experience. + +## State Management Libraries + +- Use Blazor's built-in Cascading Parameters and EventCallbacks for basic state sharing across components. +- Implement advanced state management solutions using libraries like Fluxor or BlazorState when the application grows in complexity. +- For client-side state persistence in Blazor WebAssembly, consider using Blazored.LocalStorage or Blazored.SessionStorage to maintain state between page reloads. +- For server-side Blazor, use Scoped Services and the StateContainer pattern to manage state within user sessions while minimizing re-renders. + +## API Design and Integration + +- Use HttpClient or other appropriate services to communicate with external APIs or your own backend. +- Implement error handling for API calls using try-catch and provide proper user feedback in the UI. + +## Testing and Debugging in Visual Studio + +- All unit testing and integration testing should be done in Visual Studio Enterprise. +- Test Blazor components and services using bUnit and xUnit. +- Debug Blazor UI issues using browser developer tools and Visual Studio's debugging tools for backend and server-side issues. +- For performance profiling and optimization, rely on Visual Studio's diagnostics tools. +- Do not use grep to search file contents. Use the Select-String PowerShell cmdlet or Visual Studio's built-in search functionality. + +## Security and Authentication + +- Implement Authentication and Authorization in the Blazor app where necessary using ASP.NET Identity or JWT tokens for API authentication. +- Use HTTPS for all web communication and ensure proper CORS policies are implemented. + +## API Documentation and Swagger + +- Use Swagger/OpenAPI for API documentation for your backend API services. +- Ensure XML documentation for models and API methods for enhancing Swagger documentation. diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Field/Examples/FieldMessageMultipleConditions.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Field/Examples/FieldMessageMultipleConditions.razor index 4fa04b3004..2753490da1 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Field/Examples/FieldMessageMultipleConditions.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Field/Examples/FieldMessageMultipleConditions.razor @@ -2,6 +2,7 @@ TextInputType="TextInputType.Password" @bind-Value="@MyValue" Immediate="true" + Required="true" MessageCondition="@(i => i.When(() => MyValue.Length < 8) .Display("8+ characters", MessageState.Error) .When(() => MyValue.Any(char.IsDigit) == false) diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Field/Examples/FieldStatesExample.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Field/Examples/FieldStatesExample.razor index a4ba4b3896..c456288dd3 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Field/Examples/FieldStatesExample.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Field/Examples/FieldStatesExample.razor @@ -1,19 +1,46 @@ - - - - - - - + + + + + + + + + + + +
+ Submit +
+
+ +@code { + [SupplyParameterFromForm] + private Model model { get; set; } = new(); + + private void HandleValidSubmit() + { + Console.WriteLine("HandleValidSubmit called"); + + // Process the valid form + } + + private class Model + { + public string? Text { get; set; } = string.Empty; + } +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Forms/Examples/BasicForm.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Forms/Examples/BasicForm.razor index 32b0d265eb..1bed569009 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Forms/Examples/BasicForm.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Forms/Examples/BasicForm.razor @@ -8,7 +8,8 @@

Starfleet Starship Database

- This form uses the Fluent UI input components. It uses a `DataAnnotationsValidator` and `FluentValidationSummary`. + This form uses the Fluent UI input components. It uses a `DataAnnotationsValidator`, a `FluentValidationSummary` + and `FluentValidationMessage`s. On the `EditForm` component, the `novalidate="true"` attribute is set to disable the browser's native validation UI, allowing the Fluent UI components to handle validation feedback.

@@ -16,39 +17,48 @@

New Ship Entry Form

- - - - - -
- -
-
- -
-
- - Select classification ... - Exploration - Diplomacy - Defense - -
-
- -
-
- -
-
- -
-
- -
- Submit -
+ + + + + +
+ + @* *@ +
+
+ + @* *@ +
+
+ +
+
+ + Select classification ... + Exploration + Diplomacy + Defense + + @* *@ +
+
+ + @* *@ +
+
+ + @* *@ +
+
+ + @* *@ +
+
+ +
+ Submit +
Star Trek, ©1966-2023 CBS Studios, Inc. and Paramount Pictures
diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Forms/Examples/BasicForm.razor.css b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Forms/Examples/BasicForm.razor.css new file mode 100644 index 0000000000..174ebda420 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Forms/Examples/BasicForm.razor.css @@ -0,0 +1,4 @@ +form div:not(.fluent-stack-vertical) { + display: inline-flex; + margin-bottom: 1rem; +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Forms/Forms.md b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Forms/Forms.md index b190b5a7ca..69ed74239a 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Forms/Forms.md +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Forms/Forms.md @@ -8,6 +8,7 @@ icon: Form The Fluent UI Razor components work with a validation summary in the same way the standard Blazor (input) components do. An extra component is provided to make it possible to show a validation summary that follows the Fluent Design guidelines: - FluentValidationSummary +- FluentValidationMessage See the [documentation](https://learn.microsoft.com/en-us/aspnet/core/blazor/forms/validation?view=aspnetcore-10.0#validation-summary-and-validation-message-components) on the Learn site for more information on the standard components. As the Fluent component is based on the standard component, the same documentation applies @@ -25,3 +26,6 @@ Not all of the library's input components are used in this form. No data is actu {{ API Type=FluentValidationSummary }} +## API FluentValidationMessage + +{{ API Type=FluentValidationMessage }} diff --git a/examples/Tools/FluentUI.Demo.SampleData/Starship.cs b/examples/Tools/FluentUI.Demo.SampleData/Starship.cs index 4f8b0871ba..d5189a7704 100644 --- a/examples/Tools/FluentUI.Demo.SampleData/Starship.cs +++ b/examples/Tools/FluentUI.Demo.SampleData/Starship.cs @@ -22,6 +22,14 @@ public class Starship [StringLength(16, ErrorMessage = "Identifier too long (16 character limit)")] public string? Identifier { get; set; } + /// + /// The unique identifier for the starship. + /// + [Required] + [MinLength(8, ErrorMessage = "Password is too short")] + [RegularExpression(@"[A-Z][a-z]{\d}+", ErrorMessage = "Password must contain at least one uppercase letter, one lowercase letter and one digit")] + public string? Password { get; set; } + /// /// The description of the starship. /// @@ -46,7 +54,7 @@ public class Starship /// Maximum accommodation capacity of the starship. /// [Required(ErrorMessage = "Maximum accommodation is required")] - [Range(1, 100000, ErrorMessage = "Accommodation invalid (1-100000)")] + //[Range(1, 100000, ErrorMessage = "Accommodation invalid (1-100000)")] public string? MaximumAccommodation { get; set; } /// diff --git a/src/Core/Components/Base/FluentInputBase.cs b/src/Core/Components/Base/FluentInputBase.cs index f5461a963c..8d39207631 100644 --- a/src/Core/Components/Base/FluentInputBase.cs +++ b/src/Core/Components/Base/FluentInputBase.cs @@ -3,6 +3,7 @@ // ------------------------------------------------------------------------ using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Forms; using Microsoft.FluentUI.AspNetCore.Components.Utilities; @@ -84,6 +85,9 @@ protected FluentInputBase(LibraryConfiguration configuration) #region IFluentField + /// + LambdaExpression? IFluentField.ValueExpression => ValueExpression; + /// public virtual bool FocusLost { get; protected set; } @@ -241,6 +245,8 @@ public virtual async ValueTask DisposeAsync() _cachedServices?.DisposeTooltipAsync(this); _cachedServices?.Dispose(); await JSModule.DisposeAsync(); + + GC.SuppressFinalize(this); } /// diff --git a/src/Core/Components/Field/Condition/FluentFieldCondition.cs b/src/Core/Components/Field/Condition/FluentFieldCondition.cs index 3f59d8ece0..e2b4230174 100644 --- a/src/Core/Components/Field/Condition/FluentFieldCondition.cs +++ b/src/Core/Components/Field/Condition/FluentFieldCondition.cs @@ -76,23 +76,20 @@ public bool Build(FluentFieldConditionOptions? options = null) { if (item.Condition.Invoke()) { - var coloredMessage = FluentFieldParameterSelector.StateToMessageTemplate(item.State, item.Message); - messages.Add(builder => { - builder.OpenElement(0, "div"); - builder.AddContent(1, FluentField.CreateIcon(item.Icon ?? FluentFieldParameterSelector.StateToIcon(item.State))); - - if (item.State is null) - { - builder.AddContent(2, item.Message); - } - else + builder.OpenComponent(0); + builder.AddComponentParameter(1, "As", TextTag.Span); + builder.AddComponentParameter(2, "Color", item.State == MessageState.Error ? Color.Error : Color.Info); + //builder.AddAttribute(4, "slot", "message"); + builder.AddAttribute(5, "style", "display: flex; align-items: center;"); + builder.AddAttribute(6, "ChildContent", (RenderFragment)(contentBuilder => { - builder.AddContent(2, coloredMessage); - } + contentBuilder.AddContent(0, FluentField.CreateIcon(item.Icon ?? FluentFieldParameterCollector.StateToIcon(item.State))); + contentBuilder.AddContent(1, item.Message); + })); - builder.CloseElement(); + builder.CloseComponent(); }); } } diff --git a/src/Core/Components/Field/FluentField.razor b/src/Core/Components/Field/FluentField.razor index a0646bc293..0d111bfd7d 100644 --- a/src/Core/Components/Field/FluentField.razor +++ b/src/Core/Components/Field/FluentField.razor @@ -4,60 +4,78 @@ @if (HideFluentField) { - @ChildContent + @ChildContent } else { - - - @if (HasLabel) - { - - } - - @if (ChildContent is not null) - { - @if (Parameters.HasInputComponent || !IncludeInputSlot) - { - @ChildContent - } - else - { -
- @ChildContent -
- } - } - - @if (HasMessageOrCondition && Parameters.MessageCondition?.Invoke(InputComponent ?? this) == true) - { - - - @CreateIcon(Parameters.MessageIcon) - - @if (Parameters.MessageTemplate is not null) - { - @Parameters.MessageTemplate - } - else - { - @Parameters.Message - } - - } - -
+ + + @if (HasLabel) + { + + } + + @if (ChildContent is not null) + { + @if (Parameters.HasInputComponent || !IncludeInputSlot) + { + @ChildContent + } + else + { +
+ @ChildContent +
+ } + } + + @if (ValidationMessages.Where(x => !string.Equals(x, Parameters.Message, StringComparison.Ordinal)).Any() || + HasMessageOrCondition && Parameters.MessageCondition?.Invoke(InputComponent ?? this) == true) + { +
+ @foreach (var validationMessage in ValidationMessages) + { + if (!string.Equals(validationMessage, Parameters.Message, StringComparison.Ordinal)) + { + + + @CreateIcon(FluentStatus.ErrorIcon) + @validationMessage + + } + } + + @if (HasMessageOrCondition && Parameters.MessageCondition?.Invoke(InputComponent ?? this) == true) + { + @if (Parameters.MessageTemplate is not null) + { + @Parameters.MessageTemplate + } + else + { + + + @CreateIcon(Parameters.MessageIcon) + @Parameters.Message + + } + } +
+ } + + +
} diff --git a/src/Core/Components/Field/FluentField.razor.cs b/src/Core/Components/Field/FluentField.razor.cs index 8d04d20b86..892b08de0f 100644 --- a/src/Core/Components/Field/FluentField.razor.cs +++ b/src/Core/Components/Field/FluentField.razor.cs @@ -2,7 +2,10 @@ // This file is licensed to you under the MIT License. // ------------------------------------------------------------------------ +using System.Linq.Expressions; +using System.Reflection; using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Forms; using Microsoft.FluentUI.AspNetCore.Components.Utilities; namespace Microsoft.FluentUI.AspNetCore.Components; @@ -13,9 +16,16 @@ namespace Microsoft.FluentUI.AspNetCore.Components; public partial class FluentField : FluentComponentBase, IFluentField { private readonly string _defaultId = Identifier.NewId(); + private EditContext? _previousEditContext; + private LambdaExpression? _previousFieldAccessor; + private readonly EventHandler? _validationStateChangedHandler; + private FieldIdentifier _fieldIdentifier; /// - public FluentField(LibraryConfiguration configuration) : base(configuration) { } + public FluentField(LibraryConfiguration configuration) : base(configuration) + { + _validationStateChangedHandler = (sender, eventArgs) => StateHasChanged(); + } [Inject] private LibraryConfiguration Configuration { get; set; } = default!; @@ -23,6 +33,7 @@ public FluentField(LibraryConfiguration configuration) : base(configuration) { } /// protected string? ClassValue => DefaultClassBuilder .AddClass(Configuration.DefaultStyles.FluentFieldClass, when: HasLabel) + .AddClass("invalid", when: ValidationMessages.Any()) .Build(); /// @@ -39,6 +50,12 @@ public FluentField(LibraryConfiguration configuration) : base(configuration) { } [CascadingParameter(Name = "HideFluentField")] internal bool HideFluentField { get; set; } + /// + /// Gets or sets the for the form. + /// + [CascadingParameter] + private EditContext? CurrentEditContext { get; set; } + /// /// Gets or sets an existing FieldInput component to use in the field. /// Setting this parameter will define the parameters @@ -49,6 +66,21 @@ public FluentField(LibraryConfiguration configuration) : base(configuration) { } [Parameter] public IFluentField? InputComponent { get; set; } + /// + /// Gets or sets the for which validation messages should be displayed. + /// If set, this parameter takes precedence over . + /// + [Parameter] + public FieldIdentifier? Field { get; set; } + + /// + /// Gets or sets the field for which validation messages should be displayed. + /// + [Parameter] + public LambdaExpression? For { get; set; } + + LambdaExpression? IFluentField.ValueExpression => For; + /// /// Gets or sets the ID of the FieldInput component to associate with the field. /// @@ -121,7 +153,69 @@ public FluentField(LibraryConfiguration configuration) : base(configuration) { } [Parameter] public FieldSize? Size { get; set; } - private FluentFieldParameterSelector Parameters => new(this, Localizer); + private FluentFieldParameterCollector Parameters => new(this, Localizer); + + /// + protected override void OnParametersSet() + { + base.OnParametersSet(); + + if (Field != null) + { + _fieldIdentifier = Field.Value; + } + else if (For != null) + { + if (For != _previousFieldAccessor) + { + _fieldIdentifier = CreateFieldIdentifier(For); + _previousFieldAccessor = For; + } + } + else if (InputComponent?.ValueExpression != null) + { + if (InputComponent.ValueExpression != _previousFieldAccessor) + { + _fieldIdentifier = CreateFieldIdentifier(InputComponent.ValueExpression); + _previousFieldAccessor = InputComponent.ValueExpression; + } + } + + if (CurrentEditContext != _previousEditContext) + { + DetachValidationStateChangedListener(); + if (CurrentEditContext != null) + { + CurrentEditContext.OnValidationStateChanged += _validationStateChangedHandler; + } + + _previousEditContext = CurrentEditContext; + } + } + + /// + public override ValueTask DisposeAsync() + { + DetachValidationStateChangedListener(); + GC.SuppressFinalize(this); + return base.DisposeAsync(); + } + + private void DetachValidationStateChangedListener() + { + if (_previousEditContext != null) + { + _previousEditContext.OnValidationStateChanged -= _validationStateChangedHandler; + } + } + + private static FieldIdentifier CreateFieldIdentifier(LambdaExpression accessor) + { + var method = typeof(FieldIdentifier).GetMethod("Create", BindingFlags.Public | BindingFlags.Static)!; + return (FieldIdentifier)method.MakeGenericMethod(accessor.ReturnType).Invoke(null, [accessor])!; + } + + private IEnumerable ValidationMessages => CurrentEditContext?.GetValidationMessages(_fieldIdentifier) ?? []; internal string? GetId(string slot) { @@ -156,7 +250,8 @@ private bool HasMessage => !string.IsNullOrWhiteSpace(Parameters.Message) || Parameters.MessageTemplate is not null || Parameters.MessageIcon is not null - || Parameters.MessageState is not null; + || Parameters.MessageState is not null + || ValidationMessages.Any(); private bool HasMessageOrCondition => HasMessage || Parameters.MessageCondition is not null; diff --git a/src/Core/Components/Field/FluentField.razor.css b/src/Core/Components/Field/FluentField.razor.css index 211553c33c..378ae7d529 100644 --- a/src/Core/Components/Field/FluentField.razor.css +++ b/src/Core/Components/Field/FluentField.razor.css @@ -11,3 +11,7 @@ fluent-field[label-position='before'] > fluent-label[slot='label'] { fluent-field label[disabled] { color: var(--colorNeutralForegroundDisabled); } + +fluent-field [slot="message"] { + display: block; +} diff --git a/src/Core/Components/Field/FluentFieldParameterSelector.cs b/src/Core/Components/Field/FluentFieldParameterCollector.cs similarity index 68% rename from src/Core/Components/Field/FluentFieldParameterSelector.cs rename to src/Core/Components/Field/FluentFieldParameterCollector.cs index 3ca8ab6f8c..fdf7fe6e71 100644 --- a/src/Core/Components/Field/FluentFieldParameterSelector.cs +++ b/src/Core/Components/Field/FluentFieldParameterCollector.cs @@ -2,6 +2,7 @@ // This file is licensed to you under the MIT License. // ------------------------------------------------------------------------ +using System.Linq.Expressions; using Microsoft.AspNetCore.Components; using Microsoft.FluentUI.AspNetCore.Components.Localization; @@ -11,18 +12,21 @@ namespace Microsoft.FluentUI.AspNetCore.Components; /// Helper class for selecting parameters for a FluentField, /// from the component itself or from an existing FieldInput component. /// -internal class FluentFieldParameterSelector : IFluentField +internal class FluentFieldParameterCollector : IFluentField { private readonly FluentField _component; private readonly IFluentLocalizer _localizer; /// - internal FluentFieldParameterSelector(FluentField component, IFluentLocalizer localizer) + internal FluentFieldParameterCollector(FluentField component, IFluentLocalizer localizer) { _component = component; _localizer = localizer; } + /// + public LambdaExpression? ValueExpression => _component.For ?? _component.InputComponent?.ValueExpression; + /// public bool HasInputComponent => _component.InputComponent != null; @@ -92,14 +96,14 @@ public Icon? MessageIcon /// public RenderFragment? MessageTemplate { - get => _component.MessageTemplate ?? _component.InputComponent?.MessageTemplate ?? StateToMessageTemplate(MessageState, Message); + get => _component.MessageTemplate ?? _component.InputComponent?.MessageTemplate; //?? StateToMessageTemplate(MessageState, Message); set => throw new NotSupportedException(); } /// public Func? MessageCondition { - get => _component.MessageCondition ?? _component.InputComponent?.MessageCondition ?? FluentFieldCondition.Always; + get => _component.MessageCondition ?? _component.InputComponent?.MessageCondition ?? FluentFieldCondition.Never; set => throw new NotSupportedException(); } @@ -118,7 +122,7 @@ public MessageState? MessageState Components.MessageState.Success => FluentStatus.SuccessIcon, Components.MessageState.Error => FluentStatus.ErrorIcon, Components.MessageState.Warning => FluentStatus.WarningIcon, - _ => null + _ => null, }; } @@ -130,49 +134,7 @@ public MessageState? MessageState Components.MessageState.Success => localizer[LanguageResource.Field_SuccessMessage], Components.MessageState.Error => localizer[LanguageResource.Field_ErrorMessage], Components.MessageState.Warning => localizer[LanguageResource.Field_WarningMessage], - _ => null - }; - } - - /// - internal static RenderFragment? StateToMessageTemplate(MessageState? state, string? message) - { - return state switch - { - Components.MessageState.Success => builder => - { - builder.OpenComponent(0, typeof(FluentText)); - builder.AddAttribute(1, "ChildContent", (RenderFragment)(contentBuilder => - { - contentBuilder.AddContent(0, message); - })); - builder.AddAttribute(2, "Color", Color.Success); - builder.CloseComponent(); - } - , - Components.MessageState.Error => builder => - { - builder.OpenComponent(0, typeof(FluentText)); - builder.AddAttribute(1, "ChildContent", (RenderFragment)(contentBuilder => - { - contentBuilder.AddContent(0, message); - })); - builder.AddAttribute(2, "Color", Color.Error); - builder.CloseComponent(); - } - , - Components.MessageState.Warning => builder => - { - builder.OpenComponent(0, typeof(FluentText)); - builder.AddAttribute(1, "ChildContent", (RenderFragment)(contentBuilder => - { - contentBuilder.AddContent(0, message); - })); - builder.AddAttribute(2, "Color", Color.Info); - builder.CloseComponent(); - } - , - _ => null + _ => null, }; } } diff --git a/src/Core/Components/Field/IFluentField.cs b/src/Core/Components/Field/IFluentField.cs index 2c25ea9c29..0a06b9b1a2 100644 --- a/src/Core/Components/Field/IFluentField.cs +++ b/src/Core/Components/Field/IFluentField.cs @@ -2,6 +2,7 @@ // This file is licensed to you under the MIT License. // ------------------------------------------------------------------------ +using System.Linq.Expressions; using Microsoft.AspNetCore.Components; namespace Microsoft.FluentUI.AspNetCore.Components; @@ -11,6 +12,11 @@ namespace Microsoft.FluentUI.AspNetCore.Components; /// public interface IFluentField { + /// + /// Gets the that identifies the field to which the component is bound. + /// + LambdaExpression? ValueExpression { get; } + /// /// Gets a value indicating whether the input component has already lost the focus. /// As long as the user has been in this field at least once and has left it, this property remains false. @@ -76,3 +82,4 @@ public interface IFluentField /// MessageState? MessageState { get; set; } } + diff --git a/src/Core/Components/Field/Readme.md b/src/Core/Components/Field/Readme.md index 2d0aa3e630..0b4e0148ad 100644 --- a/src/Core/Components/Field/Readme.md +++ b/src/Core/Components/Field/Readme.md @@ -1,13 +1,13 @@ # FluentField -The `FluentField` component is a wrapper around a input component that add a label and a message to the input. +The `FluentField` component is a wrapper around an input component that adds a label and a message to the input. -> **Note:** The `InputComponent` attribute should only be set with FluentUI-Blazor components. -> Don't use it in standalone mode. +> **Note:** The `InputComponent` attribute should only be set with Fluent UI Blazor components. +> Do not use this in standalone mode. -## Usage in FluentUI-Blazor lib +## Usage in the Fluent UI Blazor library -You can use it by enclosing your component in a FluentField like this. +You can use the component by enclosing your component in a FluentField like this. You need to specify the `InputComponent="@this"`, `ForId="@Id"`, `Class="@ClassValue"` and `Style="@StyleValue"` properties for the component to work correctly. diff --git a/src/Core/Components/Forms/FluentValidationMessage.razor b/src/Core/Components/Forms/FluentValidationMessage.razor new file mode 100644 index 0000000000..a53a466b55 --- /dev/null +++ b/src/Core/Components/Forms/FluentValidationMessage.razor @@ -0,0 +1,12 @@ +@namespace Microsoft.FluentUI.AspNetCore.Components +@inherits FluentComponentBase +@typeparam TValue + +@foreach (var message in CurrentEditContext.GetValidationMessages(_fieldIdentifier)) +{ +
+ @CreateIcon(Icon) + @message +
+} + diff --git a/src/Core/Components/Forms/FluentValidationMessage.razor.cs b/src/Core/Components/Forms/FluentValidationMessage.razor.cs new file mode 100644 index 0000000000..132a96b89c --- /dev/null +++ b/src/Core/Components/Forms/FluentValidationMessage.razor.cs @@ -0,0 +1,130 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using System.Linq.Expressions; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Forms; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Displays a list of validation messages for a specified field within a cascaded . +/// +public partial class FluentValidationMessage : FluentComponentBase +{ + private EditContext? _previousEditContext; + private Expression>? _previousFieldAccessor; + private readonly EventHandler? _validationStateChangedHandler; + private FieldIdentifier _fieldIdentifier; + + [CascadingParameter] + private EditContext CurrentEditContext { get; set; } = default!; + + /// + /// Gets or sets the for which validation messages should be displayed. + /// If set, this parameter takes precedence over . + /// + [Parameter] + public FieldIdentifier? Field { get; set; } + + /// + /// Gets or sets the field for which validation messages should be displayed. + /// + [Parameter] + public Expression>? For { get; set; } + + /// + /// Gets or sets the icon to be displayed with the validation message. + /// The default is . + /// + [Parameter] + public Icon? Icon { get; set; } = new CoreIcons.Filled.Size20.DismissCircle(); + + /// + protected string? ClassValue => DefaultClassBuilder + .AddClass("fluent-validation-message") + .Build(); + + /// + protected string? StyleValue => DefaultStyleBuilder + .Build(); + + /// ` + /// Constructs an instance of . + /// + public FluentValidationMessage(LibraryConfiguration configuration) : base(configuration) + { + _validationStateChangedHandler = (sender, eventArgs) => StateHasChanged(); + } + + /// + protected override void OnParametersSet() + { + if (CurrentEditContext == null) + { + throw new InvalidOperationException($"{GetType()} requires a cascading parameter " + + $"of type {nameof(EditContext)}. For example, you can use {GetType()} inside " + + $"an {nameof(EditForm)}."); + } + + if (Field != null) + { + _fieldIdentifier = Field.Value; + } + else + { + if (For == null) + { + throw new InvalidOperationException($"{GetType()} requires a value for either " + + $"the {nameof(Field)} or {nameof(For)} parameter."); + } + + if (For != _previousFieldAccessor) + { + _fieldIdentifier = FieldIdentifier.Create(For); + _previousFieldAccessor = For; + } + } + + if (CurrentEditContext != _previousEditContext) + { + DetachValidationStateChangedListener(); + CurrentEditContext.OnValidationStateChanged += _validationStateChangedHandler; + _previousEditContext = CurrentEditContext; + } + } + + /// + public override ValueTask DisposeAsync() + { + DetachValidationStateChangedListener(); + GC.SuppressFinalize(this); + return base.DisposeAsync(); + } + + private void DetachValidationStateChangedListener() + { + if (_previousEditContext != null) + { + _previousEditContext.OnValidationStateChanged -= _validationStateChangedHandler; + } + } + + internal static RenderFragment? CreateIcon(Icon? icon) + { + if (icon is null) + { + return null; + } + + return builder => + { + builder.OpenComponent(0, typeof(FluentIcon)); + builder.AddAttribute(1, "Value", icon); + builder.AddAttribute(2, "Width", "12px"); + builder.AddAttribute(3, "Margin", "0px 4px 0 0"); + builder.CloseComponent(); + }; + } +} diff --git a/src/Core/Components/Forms/FluentValidationMessage.razor.css b/src/Core/Components/Forms/FluentValidationMessage.razor.css new file mode 100644 index 0000000000..8c9ffb771e --- /dev/null +++ b/src/Core/Components/Forms/FluentValidationMessage.razor.css @@ -0,0 +1,7 @@ +.fluent-validation-message { + display: flex; + color: var(--colorPaletteRedForeground1); + font-size: var(--fontSizeBase200); + align-items: center; + column-gap: var(--spacingHorizontalXS); +} diff --git a/src/Core/Components/TextInput/FluentTextInput.razor.cs b/src/Core/Components/TextInput/FluentTextInput.razor.cs index cfe39651b9..9e8d837deb 100644 --- a/src/Core/Components/TextInput/FluentTextInput.razor.cs +++ b/src/Core/Components/TextInput/FluentTextInput.razor.cs @@ -3,6 +3,10 @@ // ------------------------------------------------------------------------ using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.ComponentModel.DataAnnotations; +using System.Linq.Expressions; + using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Web; using Microsoft.FluentUI.AspNetCore.Components.Utilities; @@ -20,11 +24,14 @@ public partial class FluentTextInput : FluentInputImmediateBase, IFluen /// public FluentTextInput(LibraryConfiguration configuration) : base(configuration) { - // Default conditions for the message MessageCondition = (field) => { field.MessageIcon = FluentStatus.ErrorIcon; - field.Message = Localizer[Localization.LanguageResource.TextInput_RequiredMessage]; + + // 1. Start with the component's Message parameter if provided. + // 2. Fallback: Search for a [Required] attribute ErrorMessage on the bound property via EditContext/FieldIdentifier. + // 3. Fallback: Use the default localized string. + field.Message = Message ?? GetRequiredAttributeMessage() ?? Localizer[Localization.LanguageResource.TextInput_RequiredMessage]; return FocusLost && (Required ?? false) @@ -243,4 +250,25 @@ protected virtual Task FocusOutHandlerAsync(FocusEventArgs e) FocusLost = true; return Task.CompletedTask; } + + private string? GetRequiredAttributeMessage() + { + if (ValueExpression is null) + { + return null; + } + + var expression = ValueExpression.Body; + if (expression is UnaryExpression { NodeType: ExpressionType.Convert } unary) + { + expression = unary.Operand; + } + + if (expression is MemberExpression memberExpression) + { + return memberExpression.Member.GetCustomAttribute()?.ErrorMessage; + } + + return null; + } } diff --git a/tests/Core/Components/Base/ComponentBaseTests.cs b/tests/Core/Components/Base/ComponentBaseTests.cs index e2caa17d1f..36c339d72e 100644 --- a/tests/Core/Components/Base/ComponentBaseTests.cs +++ b/tests/Core/Components/Base/ComponentBaseTests.cs @@ -25,6 +25,7 @@ public class ComponentBaseTests : Bunit.BunitContext typeof(DialogOptions), typeof(FluentRadio<>), // TODO: To update typeof(FluentTab), // Excluded because the Tab content in rendered in the parent FluentTabs component + typeof(FluentValidationMessage<>), // Excluded because requires special handling of EditContext ]; /// diff --git a/tests/Core/Components/Base/InputBaseTests.cs b/tests/Core/Components/Base/InputBaseTests.cs index 8460be920c..4a15aaa64b 100644 --- a/tests/Core/Components/Base/InputBaseTests.cs +++ b/tests/Core/Components/Base/InputBaseTests.cs @@ -75,7 +75,7 @@ public InputBaseTests(ITestOutputHelper testOutputHelper) [InlineData("LabelWidth", "150px", null, "width: 150px;", "Set_LabelPosition_Before")] [InlineData("LabelPosition", LabelPosition.Before, "label-position", "before")] [InlineData("Message", "my-message", null, null, "Add_MessageCondition_AlwaysTrue")] - [InlineData("MessageState", MessageState.Success, null, "color: var(--success);", "Add_MessageCondition_AlwaysTrue")] + [InlineData("MessageState", MessageState.Success, null, "color: var(--info);", "Add_MessageCondition_AlwaysTrue")] [InlineData("InputSlot", "input", null, "slot=\"input\"")] [InlineData("LostFocus", "input", null, null, "Check_LostFocus")] public void InputBase_DefaultProperties(string attributeName, object attributeValue, string? htmlAttribute = null, object? htmlValue = null, string? extraCondition = null) diff --git a/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Default.verified.razor.html b/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Default.verified.razor.html index 90912623b6..ef4658ab20 100644 --- a/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Default.verified.razor.html +++ b/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Default.verified.razor.html @@ -1,5 +1,8 @@ - - - - - \ No newline at end of file + + + + + + + + \ No newline at end of file diff --git a/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_EnableThreeState.verified.razor.html b/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_EnableThreeState.verified.razor.html index 937668acd3..dc26da6ab8 100644 --- a/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_EnableThreeState.verified.razor.html +++ b/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_EnableThreeState.verified.razor.html @@ -1,5 +1,8 @@ - - - - - \ No newline at end of file + + + + + + + + \ No newline at end of file diff --git a/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_LabelTemplate.verified.razor.html b/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_LabelTemplate.verified.razor.html index 6e31f3f593..a013101436 100644 --- a/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_LabelTemplate.verified.razor.html +++ b/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_LabelTemplate.verified.razor.html @@ -1,7 +1,10 @@ - - - - - - + + + + + + + + + \ No newline at end of file diff --git a/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Shape-999.verified.razor.html b/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Shape-999.verified.razor.html index 29cc55a58e..4320fdc92c 100644 --- a/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Shape-999.verified.razor.html +++ b/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Shape-999.verified.razor.html @@ -1,5 +1,8 @@ - - - - - \ No newline at end of file + + + + + + + + \ No newline at end of file diff --git a/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Shape-[empty].verified.razor.html b/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Shape-[empty].verified.razor.html index 2588f2fe03..88c76e7b54 100644 --- a/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Shape-[empty].verified.razor.html +++ b/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Shape-[empty].verified.razor.html @@ -1,5 +1,8 @@ - - - - - \ No newline at end of file + + + + + + + + \ No newline at end of file diff --git a/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Shape-circular.verified.razor.html b/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Shape-circular.verified.razor.html index 13ab6e6e43..ba2bbf1126 100644 --- a/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Shape-circular.verified.razor.html +++ b/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Shape-circular.verified.razor.html @@ -1,5 +1,8 @@ - - - - - \ No newline at end of file + + + + + + + + \ No newline at end of file diff --git a/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Shape-square.verified.razor.html b/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Shape-square.verified.razor.html index 2589ee0c88..3060e6433d 100644 --- a/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Shape-square.verified.razor.html +++ b/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Shape-square.verified.razor.html @@ -1,5 +1,8 @@ - - - - - \ No newline at end of file + + + + + + + + \ No newline at end of file diff --git a/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_ShowIndeterminate.verified.razor.html b/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_ShowIndeterminate.verified.razor.html index 937668acd3..dc26da6ab8 100644 --- a/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_ShowIndeterminate.verified.razor.html +++ b/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_ShowIndeterminate.verified.razor.html @@ -1,5 +1,8 @@ - - - - - \ No newline at end of file + + + + + + + + \ No newline at end of file diff --git a/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Size-999.verified.razor.html b/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Size-999.verified.razor.html index c132c366c2..d85f36a1e7 100644 --- a/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Size-999.verified.razor.html +++ b/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Size-999.verified.razor.html @@ -1,5 +1,8 @@ - - - - - \ No newline at end of file + + + + + + + + \ No newline at end of file diff --git a/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Size-[empty].verified.razor.html b/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Size-[empty].verified.razor.html index 2588f2fe03..88c76e7b54 100644 --- a/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Size-[empty].verified.razor.html +++ b/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Size-[empty].verified.razor.html @@ -1,5 +1,8 @@ - - - - - \ No newline at end of file + + + + + + + + \ No newline at end of file diff --git a/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Size-large.verified.razor.html b/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Size-large.verified.razor.html index 2778e249d4..6309cfed07 100644 --- a/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Size-large.verified.razor.html +++ b/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Size-large.verified.razor.html @@ -1,5 +1,8 @@ - - - - - \ No newline at end of file + + + + + + + + \ No newline at end of file diff --git a/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Size-medium.verified.razor.html b/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Size-medium.verified.razor.html index 7b1eff3c11..269a7c7cdc 100644 --- a/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Size-medium.verified.razor.html +++ b/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Size-medium.verified.razor.html @@ -1,5 +1,8 @@ - - - - - \ No newline at end of file + + + + + + + + \ No newline at end of file diff --git a/tests/Core/Components/Field/FluentFieldParameterSelectorTests.razor b/tests/Core/Components/Field/FluentFieldParameterCollectorTests.razor similarity index 65% rename from tests/Core/Components/Field/FluentFieldParameterSelectorTests.razor rename to tests/Core/Components/Field/FluentFieldParameterCollectorTests.razor index 2d5dde5d6f..fc9a61c070 100644 --- a/tests/Core/Components/Field/FluentFieldParameterSelectorTests.razor +++ b/tests/Core/Components/Field/FluentFieldParameterCollectorTests.razor @@ -1,11 +1,12 @@ @using Microsoft.FluentUI.AspNetCore.Components.Extensions @using Microsoft.FluentUI.AspNetCore.Components.Utilities +@using System.Linq.Expressions @using Xunit @using Bunit @inherits FluentUITestContext @code { - public FluentFieldParameterSelectorTests() + public FluentFieldParameterCollectorTests() { JSInterop.Mode = JSRuntimeMode.Loose; Services.AddFluentUIComponents(); @@ -37,7 +38,7 @@ yield return new object?[] { "Message", null, null, null }; yield return new object?[] { "MessageIcon", null, null, null }; yield return new object?[] { "MessageTemplate", null, null, null }; - yield return new object?[] { "MessageCondition", null, FluentFieldCondition.Always, null }; + yield return new object?[] { "MessageCondition", null, FluentFieldCondition.Never, null }; yield return new object?[] { "MessageState", null, null, null }; // With default State @@ -47,30 +48,31 @@ yield return new object?[] { "MessageIcon", null, FluentStatus.SuccessIcon, MessageState.Success }; yield return new object?[] { "MessageIcon", null, FluentStatus.WarningIcon, MessageState.Warning }; yield return new object?[] { "MessageIcon", null, FluentStatus.ErrorIcon, MessageState.Error }; - yield return new object?[] { "MessageTemplate", null, FluentFieldParameterSelector.StateToMessageTemplate(MessageState.Success, "Valid data"), MessageState.Success }; - yield return new object?[] { "MessageTemplate", null, FluentFieldParameterSelector.StateToMessageTemplate(MessageState.Warning, "Please, check this value"), MessageState.Warning }; - yield return new object?[] { "MessageTemplate", null, FluentFieldParameterSelector.StateToMessageTemplate(MessageState.Error, "An error occurred"), MessageState.Error }; + //The collector doesn't create a defaukt template based on the state anymore + //yield return new object?[] { "MessageTemplate", null, FluentFieldParameterCollector.StateToMessageTemplate(MessageState.Success, "Valid data"), MessageState.Success }; + //yield return new object?[] { "MessageTemplate", null, FluentFieldParameterCollector.StateToMessageTemplate(MessageState.Warning, "Please, check this value"), MessageState.Warning }; + //yield return new object?[] { "MessageTemplate", null, FluentFieldParameterCollector.StateToMessageTemplate(MessageState.Error, "An error occurred"), MessageState.Error }; } [Theory] [MemberData(nameof(GetTestData))] - public void FluentFieldParameterSelector_Standalone(string name, object? value, object? expectedValue, MessageState? state) + public void FluentFieldParameterCollector_Standalone(string name, object? value, object? expectedValue, MessageState? state) { CommonTest(null, name, value, expectedValue, state); } [Theory] [MemberData(nameof(GetTestData))] - public void FluentFieldParameterSelector_InputComponent(string name, object? value, object? expectedValue, MessageState? state) + public void FluentFieldParameterCollector_InputComponent(string name, object? value, object? expectedValue, MessageState? state) { CommonTest(new MockFluentField(), name, value, expectedValue, state); } [Theory] [MemberData(nameof(GetTestData))] - public void FluentFieldParameterSelector_SetNotSupported(string name, object? value, object? expectedValue, MessageState? state) + public void FluentFieldParameterCollector_SetNotSupported(string name, object? value, object? expectedValue, MessageState? state) { - var selector = new FluentFieldParameterSelector(new FluentField(LibraryConfiguration.Empty), FluentLocalizer); + var selector = new FluentFieldParameterCollector(new FluentField(LibraryConfiguration.Empty), FluentLocalizer); Assert.ThrowsAny(() => { @@ -83,16 +85,16 @@ } [Fact] - public void FluentFieldParameterSelector_FocusLost() + public void FluentFieldParameterCollector_FocusLost() { // Arrange var field = new FluentField(LibraryConfiguration.Empty) { FocusLost = true }; - var selector = new FluentFieldParameterSelector(field, FluentLocalizer); + var selector = new FluentFieldParameterCollector(field, FluentLocalizer); // Act Assert.ThrowsAny(() => { - selector.FocusLost = true; + selector.FocusLost = true; }); // Assert @@ -100,6 +102,49 @@ Assert.True(field.FocusLost); } + [Fact] + public void FluentFieldParameterCollector_ValueExpression_Standalone() + { + // Arrange + Expression> expression = () => "test"; + var field = new FluentField(LibraryConfiguration.Empty); + typeof(FluentField).GetProperty("For")?.SetValue(field, expression); + var collector = new FluentFieldParameterCollector(field, FluentLocalizer); + + // Act & Assert + Assert.Equal(expression, collector.ValueExpression); + } + + [Fact] + public void FluentFieldParameterCollector_ValueExpression_InputComponent() + { + // Arrange + Expression> expression = () => "test"; + var inputComponent = new MockFluentField { ValueExpression = expression }; + var field = new FluentField(LibraryConfiguration.Empty); + typeof(FluentField).GetProperty("InputComponent")?.SetValue(field, inputComponent); + var collector = new FluentFieldParameterCollector(field, FluentLocalizer); + + // Act & Assert + Assert.Equal(expression, collector.ValueExpression); + } + + [Fact] + public void FluentFieldParameterCollector_ValueExpression_Precedence() + { + // Arrange + Expression> expressionFor = () => "for"; + Expression> expressionInput = () => "input"; + var inputComponent = new MockFluentField { ValueExpression = expressionInput }; + var field = new FluentField(LibraryConfiguration.Empty); + typeof(FluentField).GetProperty("For")?.SetValue(field, expressionFor); + typeof(FluentField).GetProperty("InputComponent")?.SetValue(field, inputComponent); + var collector = new FluentFieldParameterCollector(field, FluentLocalizer); + + // Act & Assert + Assert.Equal(expressionFor, collector.ValueExpression); + } + private void CommonTest(MockFluentField? inputComponent, string name, object? value, object? expectedValue, MessageState? state) { Dictionary attributes; @@ -137,7 +182,7 @@ // Act var instance = cut.FindComponent().Instance; - var selector = new FluentFieldParameterSelector(instance, FluentLocalizer); + var selector = new FluentFieldParameterCollector(instance, FluentLocalizer); var propValue = selector.GetType().GetProperty(name)?.GetValue(selector); // Assert @@ -163,7 +208,8 @@ internal class MockFluentField : IFluentField { - public bool FocusLost { get; } + public LambdaExpression? ValueExpression { get; set; } + public bool FocusLost { get; set; } public string? Label { get; set; } public RenderFragment? LabelTemplate { get; set; } public LabelPosition? LabelPosition { get; set; } diff --git a/tests/Core/Components/Field/FluentFieldTests.FluentField_Default.verified.razor.html b/tests/Core/Components/Field/FluentFieldTests.FluentField_Default.verified.razor.html index 2789ae02a6..99c2ac8c43 100644 --- a/tests/Core/Components/Field/FluentFieldTests.FluentField_Default.verified.razor.html +++ b/tests/Core/Components/Field/FluentFieldTests.FluentField_Default.verified.razor.html @@ -1,7 +1,10 @@ - - - -
Field content
- My message -
+ + + + + +
Field content
+
+ + \ No newline at end of file diff --git a/tests/Core/Components/Field/FluentFieldTests.FluentField_DefaultTemplate.verified.razor.html b/tests/Core/Components/Field/FluentFieldTests.FluentField_DefaultTemplate.verified.razor.html index e794938556..4dd412181d 100644 --- a/tests/Core/Components/Field/FluentFieldTests.FluentField_DefaultTemplate.verified.razor.html +++ b/tests/Core/Components/Field/FluentFieldTests.FluentField_DefaultTemplate.verified.razor.html @@ -1,14 +1,14 @@ - - - -
- Field content -
- - My - message - -
\ No newline at end of file + + + + + +
+ Field content +
+
+ + \ No newline at end of file diff --git a/tests/Core/Components/Field/FluentFieldTests.FluentField_MessageIcon.verified.razor.html b/tests/Core/Components/Field/FluentFieldTests.FluentField_MessageIcon.verified.razor.html index acbb9a68dc..b7d99bc132 100644 --- a/tests/Core/Components/Field/FluentFieldTests.FluentField_MessageIcon.verified.razor.html +++ b/tests/Core/Components/Field/FluentFieldTests.FluentField_MessageIcon.verified.razor.html @@ -1,8 +1,15 @@ - - -
Field content
- - My message -
\ No newline at end of file + + + + +
Field content
+
+ + + My message +
+
+ + \ No newline at end of file diff --git a/tests/Core/Components/Field/FluentFieldTests.FluentField_MessageState-99.verified.razor.html b/tests/Core/Components/Field/FluentFieldTests.FluentField_MessageState-99.verified.razor.html index 6ff1c797e0..6943f09c7e 100644 --- a/tests/Core/Components/Field/FluentFieldTests.FluentField_MessageState-99.verified.razor.html +++ b/tests/Core/Components/Field/FluentFieldTests.FluentField_MessageState-99.verified.razor.html @@ -1,5 +1,8 @@ - - -
Field content
- -
+ + + + +
Field content
+
+ + \ No newline at end of file diff --git a/tests/Core/Components/Field/FluentFieldTests.FluentField_MessageState-[empty].verified.razor.html b/tests/Core/Components/Field/FluentFieldTests.FluentField_MessageState-[empty].verified.razor.html index 3e8dc0fcd1..6943f09c7e 100644 --- a/tests/Core/Components/Field/FluentFieldTests.FluentField_MessageState-[empty].verified.razor.html +++ b/tests/Core/Components/Field/FluentFieldTests.FluentField_MessageState-[empty].verified.razor.html @@ -1,5 +1,8 @@ - - -
Field content
- -
\ No newline at end of file + + + + +
Field content
+
+ + \ No newline at end of file diff --git a/tests/Core/Components/Field/FluentFieldTests.FluentField_MessageState-error.verified.razor.html b/tests/Core/Components/Field/FluentFieldTests.FluentField_MessageState-error.verified.razor.html index 91b4451568..6943f09c7e 100644 --- a/tests/Core/Components/Field/FluentFieldTests.FluentField_MessageState-error.verified.razor.html +++ b/tests/Core/Components/Field/FluentFieldTests.FluentField_MessageState-error.verified.razor.html @@ -1,10 +1,8 @@ - - -
Field content
- - - An error occurred - -
+ + + + +
Field content
+
+ + \ No newline at end of file diff --git a/tests/Core/Components/Field/FluentFieldTests.FluentField_MessageState-success.verified.razor.html b/tests/Core/Components/Field/FluentFieldTests.FluentField_MessageState-success.verified.razor.html index 65d2342381..6943f09c7e 100644 --- a/tests/Core/Components/Field/FluentFieldTests.FluentField_MessageState-success.verified.razor.html +++ b/tests/Core/Components/Field/FluentFieldTests.FluentField_MessageState-success.verified.razor.html @@ -1,10 +1,8 @@ - - -
Field content
- - - Valid data - -
+ + + + +
Field content
+
+ + \ No newline at end of file diff --git a/tests/Core/Components/Field/FluentFieldTests.FluentField_MessageState-warning.verified.razor.html b/tests/Core/Components/Field/FluentFieldTests.FluentField_MessageState-warning.verified.razor.html index bad2eb6cf1..6943f09c7e 100644 --- a/tests/Core/Components/Field/FluentFieldTests.FluentField_MessageState-warning.verified.razor.html +++ b/tests/Core/Components/Field/FluentFieldTests.FluentField_MessageState-warning.verified.razor.html @@ -1,10 +1,8 @@ - - -
Field content
- - - Please, check this value - -
+ + + + +
Field content
+
+ + \ No newline at end of file diff --git a/tests/Core/Components/Field/FluentFieldTests.FluentField_Size-99.verified.razor.html b/tests/Core/Components/Field/FluentFieldTests.FluentField_Size-99.verified.razor.html index a17d787dce..5fe443066e 100644 --- a/tests/Core/Components/Field/FluentFieldTests.FluentField_Size-99.verified.razor.html +++ b/tests/Core/Components/Field/FluentFieldTests.FluentField_Size-99.verified.razor.html @@ -1,7 +1,10 @@ - - - -
Field content
- -
\ No newline at end of file + + + + + +
Field content
+
+ + \ No newline at end of file diff --git a/tests/Core/Components/Field/FluentFieldTests.FluentField_Size-[empty].verified.razor.html b/tests/Core/Components/Field/FluentFieldTests.FluentField_Size-[empty].verified.razor.html index 4ee844932a..99c2ac8c43 100644 --- a/tests/Core/Components/Field/FluentFieldTests.FluentField_Size-[empty].verified.razor.html +++ b/tests/Core/Components/Field/FluentFieldTests.FluentField_Size-[empty].verified.razor.html @@ -1,7 +1,10 @@ - - - -
Field content
- -
\ No newline at end of file + + + + + +
Field content
+
+ + \ No newline at end of file diff --git a/tests/Core/Components/Field/FluentFieldTests.FluentField_Size-large.verified.razor.html b/tests/Core/Components/Field/FluentFieldTests.FluentField_Size-large.verified.razor.html index 75bf1bbd37..26e4ecd734 100644 --- a/tests/Core/Components/Field/FluentFieldTests.FluentField_Size-large.verified.razor.html +++ b/tests/Core/Components/Field/FluentFieldTests.FluentField_Size-large.verified.razor.html @@ -1,7 +1,10 @@ - - - -
Field content
- -
\ No newline at end of file + + + + + +
Field content
+
+ + \ No newline at end of file diff --git a/tests/Core/Components/Field/FluentFieldTests.FluentField_Size-medium.verified.razor.html b/tests/Core/Components/Field/FluentFieldTests.FluentField_Size-medium.verified.razor.html index 69fe813560..ab85ea3ad7 100644 --- a/tests/Core/Components/Field/FluentFieldTests.FluentField_Size-medium.verified.razor.html +++ b/tests/Core/Components/Field/FluentFieldTests.FluentField_Size-medium.verified.razor.html @@ -1,7 +1,10 @@ - - - -
Field content
- -
\ No newline at end of file + + + + + +
Field content
+
+ + \ No newline at end of file diff --git a/tests/Core/Components/Field/FluentFieldTests.FluentField_Size-small.verified.razor.html b/tests/Core/Components/Field/FluentFieldTests.FluentField_Size-small.verified.razor.html index 7c504f08ee..f51ee2806b 100644 --- a/tests/Core/Components/Field/FluentFieldTests.FluentField_Size-small.verified.razor.html +++ b/tests/Core/Components/Field/FluentFieldTests.FluentField_Size-small.verified.razor.html @@ -1,7 +1,10 @@ - - - -
Field content
- -
\ No newline at end of file + + + + + +
Field content
+
+ + \ No newline at end of file diff --git a/tests/Core/Components/Field/FluentFieldTests.razor b/tests/Core/Components/Field/FluentFieldTests.razor index 29f88cede7..e7a883b13a 100644 --- a/tests/Core/Components/Field/FluentFieldTests.razor +++ b/tests/Core/Components/Field/FluentFieldTests.razor @@ -1,5 +1,8 @@ @using Microsoft.FluentUI.AspNetCore.Components.Extensions @using Microsoft.FluentUI.AspNetCore.Components.Utilities +@using Microsoft.AspNetCore.Components.Forms +@using System.Linq.Expressions +@using Bunit @using Xunit; @inherits FluentUITestContext @code @@ -175,7 +178,7 @@ #pragma warning disable BL0005 // Component parameter should not be set outside of its component. var field2 = new FluentField(LibraryConfiguration.Empty) { - InputComponent = new FluentFieldParameterSelectorTests.MockFluentField() + InputComponent = new FluentFieldParameterCollectorTests.MockFluentField() }; Assert.Throws(() => { @@ -228,4 +231,179 @@ // Assert Assert.Equal("Field content", cut.Markup); } + + [Fact] + public void FluentField_ValidationMessages_DisplaysErrors() + { + // Arrange + var model = new TestModel { Name = "" }; + var editContext = new EditContext(model); + var messageStore = new ValidationMessageStore(editContext); + messageStore.Add(editContext.Field(nameof(TestModel.Name)), "Name is required"); + + // Act + var cut = Render( + @ + Field content + ); + + // Assert + Assert.Contains("Name is required", cut.Markup); + } + + [Fact] + public void FluentField_ValidationMessages_ExcludesMessageParameter() + { + // Arrange + var model = new TestModel { Name = "" }; + var editContext = new EditContext(model); + var messageStore = new ValidationMessageStore(editContext); + messageStore.Add(editContext.Field(nameof(TestModel.Name)), "Custom Message"); + + // Act + var cut = Render( + @ + Field content + ); + + // Assert + // "Custom Message" should be excluded because it matches the Message parameter + Assert.DoesNotContain("Custom Message", cut.Markup); + } + + [Fact] + public void FluentField_ValidationMessages_WithCondition() + { + // Arrange + var model = new TestModel { Name = "" }; + var editContext = new EditContext(model); + var messageStore = new ValidationMessageStore(editContext); + messageStore.Add(editContext.Field(nameof(TestModel.Name)), "Validation Error"); + + // Act + var cut = Render( + @ + Field content + ); + + // Assert + Assert.Contains("Validation Error", cut.Markup); + Assert.Contains("Condition Met", cut.Markup); + } + + private class TestModel + { + public string? Name { get; set; } + public string? Description { get; set; } + } + + private class MockFluentField : IFluentField + { + public LambdaExpression? ValueExpression { get; set; } + public bool FocusLost { get; set; } + public string? Label { get; set; } + public RenderFragment? LabelTemplate { get; set; } + public LabelPosition? LabelPosition { get; set; } + public string? LabelWidth { get; set; } + public bool? Required { get; set; } + public bool? Disabled { get; set; } + public string? Message { get; set; } + public Icon? MessageIcon { get; set; } + public RenderFragment? MessageTemplate { get; set; } + public Func? MessageCondition { get; set; } + public MessageState? MessageState { get; set; } + } + + [Fact] + public void FluentField_Field_Parameter_Takes_Precedence() + { + // Arrange + var model = new TestModel { Name = "Name", Description = "Desc" }; + var editContext = new EditContext(model); + var messageStore = new ValidationMessageStore(editContext); + + var nameField = editContext.Field(nameof(TestModel.Name)); + var descField = editContext.Field(nameof(TestModel.Description)); + + messageStore.Add(nameField, "Name error"); + messageStore.Add(descField, "Description error"); + + // Act + var cut = Render( + @ + Field content + ); + + // Assert + Assert.Contains("Description error", cut.Markup); + Assert.DoesNotContain("Name error", cut.Markup); + } + + [Fact] + public void FluentField_Uses_InputComponent_ValueExpression() + { + // Arrange + var model = new TestModel { Name = "Name" }; + var editContext = new EditContext(model); + var messageStore = new ValidationMessageStore(editContext); + var nameField = editContext.Field(nameof(TestModel.Name)); + messageStore.Add(nameField, "Name error"); + + var inputComponent = new MockFluentField + { + ValueExpression = (Expression>)(() => model.Name) + }; + + // Act + var cut = Render(parameters => parameters + .AddCascadingValue(editContext) + .Add(p => p.InputComponent, inputComponent) + ); + + // Assert + Assert.Contains("Name error", cut.Markup); + } + + [Fact] + public void FluentField_Swapping_For_Updates_Identifier() + { + // Arrange + var model = new TestModel { Name = "", Description = "" }; + var editContext = new EditContext(model); + var messageStore = new ValidationMessageStore(editContext); + messageStore.Add(editContext.Field(nameof(TestModel.Name)), "Name Error"); + messageStore.Add(editContext.Field(nameof(TestModel.Description)), "Desc Error"); + + // Act & Assert 1 + var cut1 = Render(parameters => parameters + .AddCascadingValue(editContext) + .Add(p => p.For, (Expression>)(() => model.Name)) + ); + + Assert.Contains("Name Error", cut1.Markup); + Assert.DoesNotContain("Desc Error", cut1.Markup); + + // Act & Assert 2 + var cut2 = Render(parameters => parameters + .AddCascadingValue(editContext) + .Add(p => p.For, (Expression>)(() => model.Description)) + ); + + Assert.Contains("Desc Error", cut2.Markup); + Assert.DoesNotContain("Name Error", cut2.Markup); + } + + [Fact] + public void FluentField_IFluentField_ValueExpression_Returns_For() + { + // Arrange + var model = new TestModel { Name = "test" }; + Expression> expression = () => model.Name; + var cut = Render(parameters => parameters.Add(p => p.For, expression)); + var field = cut.Instance; + IFluentField ifield = field; + + // Act & Assert + Assert.Equal(expression, ifield.ValueExpression); + } } diff --git a/tests/Core/Components/Forms/FluentValidationMessageTests.cs b/tests/Core/Components/Forms/FluentValidationMessageTests.cs new file mode 100644 index 0000000000..3566466b69 --- /dev/null +++ b/tests/Core/Components/Forms/FluentValidationMessageTests.cs @@ -0,0 +1,189 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using Bunit; +using Microsoft.AspNetCore.Components.Forms; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Microsoft.FluentUI.AspNetCore.Components.Tests.Components.Forms; + +public class FluentValidationMessageTests : Verify.FluentUITestContext +{ + [Fact] + public void FluentValidationMessage_Throws_When_No_EditContext() + { + // Arrange & Act + void cut() => Render>(parameters => parameters + .Add(p => p.For, () => "test") + ); + + // Assert + Assert.Throws(cut); + } + + [Fact] + public void FluentValidationMessage_Throws_When_No_Field_Or_For() + { + // Arrange + var model = new TestModel(); + var editContext = new EditContext(model); + + // Act + void cut() => Render>(parameters => parameters + .AddCascadingValue(editContext) + ); + + // Assert + Assert.Throws(cut); + } + + [Fact] + public async Task FluentValidationMessage_Renders_Validation_Messages() + { + // Arrange + var model = new TestModel { Name = "" }; + var editContext = new EditContext(model); + var messageStore = new ValidationMessageStore(editContext); + + // Act + var cut = Render>(parameters => parameters + .AddCascadingValue(editContext) + .Add(p => p.For, () => model.Name) + ); + + await cut.InvokeAsync(() => + { + messageStore.Add(editContext.Field(nameof(TestModel.Name)), "Name is required"); + editContext.NotifyValidationStateChanged(); + }); + + // Assert + var message = cut.Find(".fluent-validation-message"); + Assert.NotNull(message); + Assert.Contains("Name is required", message.InnerHtml); + Assert.NotNull(cut.Find("svg")); + } + + [Fact] + public async Task FluentValidationMessage_Updates_When_Validation_State_Changes() + { + // Arrange + var model = new TestModel { Name = "Initial" }; + var editContext = new EditContext(model); + var messageStore = new ValidationMessageStore(editContext); + + var cut = Render>(parameters => parameters + .AddCascadingValue(editContext) + .Add(p => p.For, () => model.Name) + ); + + // Act + await cut.InvokeAsync(() => + { + messageStore.Add(editContext.Field(nameof(TestModel.Name)), "Name is invalid"); + editContext.NotifyValidationStateChanged(); + }); + + // Assert + Assert.Contains("Name is invalid", cut.Markup); + + // Act: Clear messages + await cut.InvokeAsync(() => + { + messageStore.Clear(); + editContext.NotifyValidationStateChanged(); + }); + + // Assert: No messages should be rendered + Assert.DoesNotContain("Name is invalid", cut.Markup); + Assert.Empty(cut.FindAll(".fluent-validation-message")); + } + + [Fact] + public async Task FluentValidationMessage_Renders_No_Icon_When_Null() + { + // Arrange + var model = new TestModel { Name = "" }; + var editContext = new EditContext(model); + var messageStore = new ValidationMessageStore(editContext); + + // Act + var cut = Render>(parameters => parameters + .AddCascadingValue(editContext) + .Add(p => p.For, () => model.Name) + .Add(p => p.Icon, null) + ); + + await cut.InvokeAsync(() => + { + messageStore.Add(editContext.Field(nameof(TestModel.Name)), "Error"); + editContext.NotifyValidationStateChanged(); + }); + + // Assert + Assert.Contains("Error", cut.Markup); + Assert.Empty(cut.FindAll("svg")); + } + + [Fact] + public async Task FluentValidationMessage_Uses_Field_Parameter() + { + // Arrange + var model = new TestModel { Description = "" }; + var editContext = new EditContext(model); + var messageStore = new ValidationMessageStore(editContext); + var fieldIdentifier = new FieldIdentifier(model, nameof(TestModel.Description)); + + // Act + var cut = Render>(parameters => parameters + .AddCascadingValue(editContext) + .Add(p => p.Field, fieldIdentifier) + ); + + await cut.InvokeAsync(() => + { + messageStore.Add(fieldIdentifier, "Description is required"); + editContext.NotifyValidationStateChanged(); + }); + + // Assert + Assert.Contains("Description is required", cut.Markup); + } + + [Fact] + public async Task FluentValidationMessage_Field_Takes_Precedence_Over_For() + { + // Arrange + var model = new TestModel { Name = "", Description = "" }; + var editContext = new EditContext(model); + var messageStore = new ValidationMessageStore(editContext); + var nameField = new FieldIdentifier(model, nameof(TestModel.Name)); + var descriptionField = new FieldIdentifier(model, nameof(TestModel.Description)); + + // Act + var cut = Render>(parameters => parameters + .AddCascadingValue(editContext) + .Add(p => p.For, () => model.Name) + .Add(p => p.Field, descriptionField) + ); + + await cut.InvokeAsync(() => + { + messageStore.Add(nameField, "Name error"); + messageStore.Add(descriptionField, "Description error"); + editContext.NotifyValidationStateChanged(); + }); + + // Assert + Assert.Contains("Description error", cut.Markup); + Assert.DoesNotContain("Name error", cut.Markup); + } + + private class TestModel + { + public string Name { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + } +} diff --git a/tests/Core/Components/List/FluentComboboxTests.FluentCombobox_Default.verified.razor.html b/tests/Core/Components/List/FluentComboboxTests.FluentCombobox_Default.verified.razor.html index be37b5d982..0b5c4911d2 100644 --- a/tests/Core/Components/List/FluentComboboxTests.FluentCombobox_Default.verified.razor.html +++ b/tests/Core/Components/List/FluentComboboxTests.FluentCombobox_Default.verified.razor.html @@ -1,12 +1,15 @@ - - - - - - One - Two - Three - - - - \ No newline at end of file + + + + + + + + One + Two + Three + + + + + \ No newline at end of file diff --git a/tests/Core/Components/List/FluentComboboxTests.FluentCombobox_Enum.verified.razor.html b/tests/Core/Components/List/FluentComboboxTests.FluentCombobox_Enum.verified.razor.html index 81576b7f09..609f546b15 100644 --- a/tests/Core/Components/List/FluentComboboxTests.FluentCombobox_Enum.verified.razor.html +++ b/tests/Core/Components/List/FluentComboboxTests.FluentCombobox_Enum.verified.razor.html @@ -1,11 +1,14 @@ - - - - - One - Two - Three - - - - + + + + + + + One + Two + Three + + + + + \ No newline at end of file diff --git a/tests/Core/Components/List/FluentComboboxTests.FluentCombobox_Label.verified.razor.html b/tests/Core/Components/List/FluentComboboxTests.FluentCombobox_Label.verified.razor.html index 27fc861c55..bad7d8dccc 100644 --- a/tests/Core/Components/List/FluentComboboxTests.FluentCombobox_Label.verified.razor.html +++ b/tests/Core/Components/List/FluentComboboxTests.FluentCombobox_Label.verified.razor.html @@ -1,14 +1,17 @@ - - - - - - - One - Two - Three - - - - + + + + + + + + + One + Two + Three + + + + + \ No newline at end of file diff --git a/tests/Core/Components/List/FluentComboboxTests.FluentCombobox_Manual.verified.razor.html b/tests/Core/Components/List/FluentComboboxTests.FluentCombobox_Manual.verified.razor.html index fcb5863ae3..bd9824a4de 100644 --- a/tests/Core/Components/List/FluentComboboxTests.FluentCombobox_Manual.verified.razor.html +++ b/tests/Core/Components/List/FluentComboboxTests.FluentCombobox_Manual.verified.razor.html @@ -1,11 +1,14 @@ - - - - - One - Two - Three - - - - \ No newline at end of file + + + + + + + One + Two + Three + + + + + \ No newline at end of file diff --git a/tests/Core/Components/List/FluentComboboxTests.FluentCombobox_OptionFunctions.verified.razor.html b/tests/Core/Components/List/FluentComboboxTests.FluentCombobox_OptionFunctions.verified.razor.html index fcb6289269..0803ac7a4f 100644 --- a/tests/Core/Components/List/FluentComboboxTests.FluentCombobox_OptionFunctions.verified.razor.html +++ b/tests/Core/Components/List/FluentComboboxTests.FluentCombobox_OptionFunctions.verified.razor.html @@ -1,12 +1,15 @@ - - - - - - ONE - TWO - THREE - - - - \ No newline at end of file + + + + + + + + ONE + TWO + THREE + + + + + \ No newline at end of file diff --git a/tests/Core/Components/List/FluentSelectTests.FluentSelect_Default.verified.razor.html b/tests/Core/Components/List/FluentSelectTests.FluentSelect_Default.verified.razor.html index 8d45ea9194..1c85557d3e 100644 --- a/tests/Core/Components/List/FluentSelectTests.FluentSelect_Default.verified.razor.html +++ b/tests/Core/Components/List/FluentSelectTests.FluentSelect_Default.verified.razor.html @@ -1,12 +1,15 @@ - - - - - - One - Two - Three - - - - + + + + + + + + One + Two + Three + + + + + \ 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 index d8af037844..7abddf485b 100644 --- a/tests/Core/Components/List/FluentSelectTests.FluentSelect_Enum.verified.razor.html +++ b/tests/Core/Components/List/FluentSelectTests.FluentSelect_Enum.verified.razor.html @@ -1,11 +1,14 @@ - - - - - One - Two - Three - - - - + + + + + + + One + Two + Three + + + + + \ 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 index c4eabc0bca..dd3b595443 100644 --- a/tests/Core/Components/List/FluentSelectTests.FluentSelect_Label.verified.razor.html +++ b/tests/Core/Components/List/FluentSelectTests.FluentSelect_Label.verified.razor.html @@ -1,15 +1,17 @@ - - - - - - - One - Two - Three - - - - + + + + + + + + + One + Two + Three + + + + + \ No newline at end of file diff --git a/tests/Core/Components/List/FluentSelectTests.FluentSelect_Manual.verified.razor.html b/tests/Core/Components/List/FluentSelectTests.FluentSelect_Manual.verified.razor.html index 852ea02b34..21cc68238c 100644 --- a/tests/Core/Components/List/FluentSelectTests.FluentSelect_Manual.verified.razor.html +++ b/tests/Core/Components/List/FluentSelectTests.FluentSelect_Manual.verified.razor.html @@ -1,11 +1,14 @@ - - - - - One - Two - Three - - - - + + + + + + + One + Two + Three + + + + + \ No newline at end of file diff --git a/tests/Core/Components/List/FluentSelectTests.FluentSelect_OptionFunctions.verified.razor.html b/tests/Core/Components/List/FluentSelectTests.FluentSelect_OptionFunctions.verified.razor.html index f29747171b..e9a3b3a127 100644 --- a/tests/Core/Components/List/FluentSelectTests.FluentSelect_OptionFunctions.verified.razor.html +++ b/tests/Core/Components/List/FluentSelectTests.FluentSelect_OptionFunctions.verified.razor.html @@ -1,12 +1,15 @@ - - - - - - ONE - TWO - THREE - - - - + + + + + + + + ONE + TWO + THREE + + + + + \ No newline at end of file diff --git a/tests/Core/Components/Radio/FluentRadioGroupTests.FluentRadioGroup_Default.verified.razor.html b/tests/Core/Components/Radio/FluentRadioGroupTests.FluentRadioGroup_Default.verified.razor.html index 7fb637dfda..bfad181edc 100644 --- a/tests/Core/Components/Radio/FluentRadioGroupTests.FluentRadioGroup_Default.verified.razor.html +++ b/tests/Core/Components/Radio/FluentRadioGroupTests.FluentRadioGroup_Default.verified.razor.html @@ -1,5 +1,8 @@ - - - - - + + + + + + + + \ No newline at end of file diff --git a/tests/Core/Components/Radio/FluentRadioGroupTests.FluentRadioGroup_Items.verified.razor.html b/tests/Core/Components/Radio/FluentRadioGroupTests.FluentRadioGroup_Items.verified.razor.html index 2252d11e68..d857f7f4e2 100644 --- a/tests/Core/Components/Radio/FluentRadioGroupTests.FluentRadioGroup_Items.verified.razor.html +++ b/tests/Core/Components/Radio/FluentRadioGroupTests.FluentRadioGroup_Items.verified.razor.html @@ -1,28 +1,27 @@ - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/Core/Components/Radio/FluentRadioGroupTests.FluentRadioGroup_WithChildContent.verified.razor.html b/tests/Core/Components/Radio/FluentRadioGroupTests.FluentRadioGroup_WithChildContent.verified.razor.html index d145f1ae41..ddb98d1f62 100644 --- a/tests/Core/Components/Radio/FluentRadioGroupTests.FluentRadioGroup_WithChildContent.verified.razor.html +++ b/tests/Core/Components/Radio/FluentRadioGroupTests.FluentRadioGroup_WithChildContent.verified.razor.html @@ -1,27 +1,24 @@ - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/Core/Components/Radio/FluentRadioTests.FluentRadio_Default.verified.razor.html b/tests/Core/Components/Radio/FluentRadioTests.FluentRadio_Default.verified.razor.html index a453e5460d..62b20e1cd4 100644 --- a/tests/Core/Components/Radio/FluentRadioTests.FluentRadio_Default.verified.razor.html +++ b/tests/Core/Components/Radio/FluentRadioTests.FluentRadio_Default.verified.razor.html @@ -1,10 +1,12 @@ - - - - - - - - - - + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/Core/Components/Radio/FluentRadioTests.FluentRadio_HorizontalOrientation.verified.razor.html b/tests/Core/Components/Radio/FluentRadioTests.FluentRadio_HorizontalOrientation.verified.razor.html index 6302e10f1c..db35e0e247 100644 --- a/tests/Core/Components/Radio/FluentRadioTests.FluentRadio_HorizontalOrientation.verified.razor.html +++ b/tests/Core/Components/Radio/FluentRadioTests.FluentRadio_HorizontalOrientation.verified.razor.html @@ -1,20 +1,19 @@ - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/Core/Components/Radio/FluentRadioTests.FluentRadio_Label.verified.razor.html b/tests/Core/Components/Radio/FluentRadioTests.FluentRadio_Label.verified.razor.html index a96fc588c0..9d2507f05b 100644 --- a/tests/Core/Components/Radio/FluentRadioTests.FluentRadio_Label.verified.razor.html +++ b/tests/Core/Components/Radio/FluentRadioTests.FluentRadio_Label.verified.razor.html @@ -1,13 +1,14 @@ - - - - - - - - - - - + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/Core/Components/Radio/FluentRadioTests.FluentRadio_LabelTemplate.verified.razor.html b/tests/Core/Components/Radio/FluentRadioTests.FluentRadio_LabelTemplate.verified.razor.html index 1e30b0dd81..4b7a51aa74 100644 --- a/tests/Core/Components/Radio/FluentRadioTests.FluentRadio_LabelTemplate.verified.razor.html +++ b/tests/Core/Components/Radio/FluentRadioTests.FluentRadio_LabelTemplate.verified.razor.html @@ -1,13 +1,15 @@ - - - - - - - - - - - + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/Core/Components/Radio/FluentRadioTests.FluentRadio_LabelWidth.verified.razor.html b/tests/Core/Components/Radio/FluentRadioTests.FluentRadio_LabelWidth.verified.razor.html index 1957ee46d3..1251f22005 100644 --- a/tests/Core/Components/Radio/FluentRadioTests.FluentRadio_LabelWidth.verified.razor.html +++ b/tests/Core/Components/Radio/FluentRadioTests.FluentRadio_LabelWidth.verified.razor.html @@ -1,13 +1,14 @@ - - - - - - - - - - - + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/Core/Components/Slider/FluentSliderTests.FluentSlider_Default.verified.razor.html b/tests/Core/Components/Slider/FluentSliderTests.FluentSlider_Default.verified.razor.html index 9b3919b073..bc48ef06bd 100644 --- a/tests/Core/Components/Slider/FluentSliderTests.FluentSlider_Default.verified.razor.html +++ b/tests/Core/Components/Slider/FluentSliderTests.FluentSlider_Default.verified.razor.html @@ -1,5 +1,8 @@ - - - - - \ No newline at end of file + + + + + + + + \ No newline at end of file diff --git a/tests/Core/Components/Slider/FluentSliderTests.FluentSlider_Slot_Thumb.verified.razor.html b/tests/Core/Components/Slider/FluentSliderTests.FluentSlider_Slot_Thumb.verified.razor.html index 000eba463b..caba336429 100644 --- a/tests/Core/Components/Slider/FluentSliderTests.FluentSlider_Slot_Thumb.verified.razor.html +++ b/tests/Core/Components/Slider/FluentSliderTests.FluentSlider_Slot_Thumb.verified.razor.html @@ -1,9 +1,12 @@ - - - - - - - \ No newline at end of file + + + + + + + + + + \ No newline at end of file diff --git a/tests/Core/Components/Switch/FluentSwitchTests.FluentCheckbox_LabelTemplate.verified.razor.html b/tests/Core/Components/Switch/FluentSwitchTests.FluentCheckbox_LabelTemplate.verified.razor.html index a2cec02432..7495f82973 100644 --- a/tests/Core/Components/Switch/FluentSwitchTests.FluentCheckbox_LabelTemplate.verified.razor.html +++ b/tests/Core/Components/Switch/FluentSwitchTests.FluentCheckbox_LabelTemplate.verified.razor.html @@ -1,8 +1,12 @@ - - - - - - + + + + + + + + + \ No newline at end of file diff --git a/tests/Core/Components/Switch/FluentSwitchTests.FluentCheckbox_ReadOnly.verified.razor.html b/tests/Core/Components/Switch/FluentSwitchTests.FluentCheckbox_ReadOnly.verified.razor.html index 684053cb51..09272b2283 100644 --- a/tests/Core/Components/Switch/FluentSwitchTests.FluentCheckbox_ReadOnly.verified.razor.html +++ b/tests/Core/Components/Switch/FluentSwitchTests.FluentCheckbox_ReadOnly.verified.razor.html @@ -1,5 +1,8 @@ - - - - - \ No newline at end of file + + + + + + + + \ No newline at end of file diff --git a/tests/Core/Components/Switch/FluentSwitchTests.FluentCheckbox_Required.verified.razor.html b/tests/Core/Components/Switch/FluentSwitchTests.FluentCheckbox_Required.verified.razor.html index 354d12805e..b28e493965 100644 --- a/tests/Core/Components/Switch/FluentSwitchTests.FluentCheckbox_Required.verified.razor.html +++ b/tests/Core/Components/Switch/FluentSwitchTests.FluentCheckbox_Required.verified.razor.html @@ -1,5 +1,8 @@ - - - - - \ No newline at end of file + + + + + + + + \ No newline at end of file diff --git a/tests/Core/Components/Switch/FluentSwitchTests.FluentSwitch_Default.verified.razor.html b/tests/Core/Components/Switch/FluentSwitchTests.FluentSwitch_Default.verified.razor.html index 5c085f5efb..56bfa04837 100644 --- a/tests/Core/Components/Switch/FluentSwitchTests.FluentSwitch_Default.verified.razor.html +++ b/tests/Core/Components/Switch/FluentSwitchTests.FluentSwitch_Default.verified.razor.html @@ -1,5 +1,8 @@ - - - - - \ No newline at end of file + + + + + + + + \ No newline at end of file diff --git a/tests/Core/Components/Switch/FluentSwitchTests.razor b/tests/Core/Components/Switch/FluentSwitchTests.razor index 831e0776fa..14a1779839 100644 --- a/tests/Core/Components/Switch/FluentSwitchTests.razor +++ b/tests/Core/Components/Switch/FluentSwitchTests.razor @@ -122,7 +122,7 @@ var cut = Render(@); // Act - var label = cut.Find("fluent-field > label").InnerHtml.Trim(' ', '\n', '\r'); + var label = cut.Find("fluent-field > label").InnerHtml.Trim(' ', '\n', '\r', '\t'); // Assert Assert.Equal(expectedLabel, label);