diff --git a/eng/Dependencies.props b/eng/Dependencies.props index dd9f4f86ca..fa5b1bbbf2 100644 --- a/eng/Dependencies.props +++ b/eng/Dependencies.props @@ -134,6 +134,8 @@ and are generated based on the last package release. + + diff --git a/eng/SharedFramework.External.props b/eng/SharedFramework.External.props index 3d3f44d186..83705b2145 100644 --- a/eng/SharedFramework.External.props +++ b/eng/SharedFramework.External.props @@ -90,6 +90,7 @@ --> <_CompilationOnlyReference Include="System.Buffers" /> + <_CompilationOnlyReference Include="System.ComponentModel.Annotations" /> 4.5.0 4.4.0 + 4.3.0 4.3.2 4.5.2 diff --git a/src/Components/Blazor/Build/src/targets/BuiltInBclLinkerDescriptor.xml b/src/Components/Blazor/Build/src/targets/BuiltInBclLinkerDescriptor.xml index ba6c7faa11..4b442b1bb8 100644 --- a/src/Components/Blazor/Build/src/targets/BuiltInBclLinkerDescriptor.xml +++ b/src/Components/Blazor/Build/src/targets/BuiltInBclLinkerDescriptor.xml @@ -9,4 +9,9 @@ to implement timers. Fixes https://github.com/aspnet/Blazor/issues/239 --> + + + + + diff --git a/src/Components/Blazor/Build/test/RuntimeDependenciesResolverTest.cs b/src/Components/Blazor/Build/test/RuntimeDependenciesResolverTest.cs index c7435cacdf..8f00691a28 100644 --- a/src/Components/Blazor/Build/test/RuntimeDependenciesResolverTest.cs +++ b/src/Components/Blazor/Build/test/RuntimeDependenciesResolverTest.cs @@ -84,6 +84,8 @@ namespace Microsoft.AspNetCore.Blazor.Build.Test "System.Collections.dll", "System.ComponentModel.Composition.dll", "System.ComponentModel.dll", + "System.ComponentModel.Annotations.dll", + "System.ComponentModel.DataAnnotations.dll", "System.Core.dll", "System.Data.dll", "System.Diagnostics.Debug.dll", diff --git a/src/Components/Components/src/Forms/DataAnnotationsValidator.cs b/src/Components/Components/src/Forms/DataAnnotationsValidator.cs new file mode 100644 index 0000000000..1cf02723bb --- /dev/null +++ b/src/Components/Components/src/Forms/DataAnnotationsValidator.cs @@ -0,0 +1,28 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.AspNetCore.Components.Forms +{ + /// + /// Adds Data Annotations validation support to an . + /// + public class DataAnnotationsValidator : ComponentBase + { + [CascadingParameter] EditContext CurrentEditContext { get; set; } + + /// + protected override void OnInit() + { + if (CurrentEditContext == null) + { + throw new InvalidOperationException($"{nameof(DataAnnotationsValidator)} requires a cascading " + + $"parameter of type {nameof(EditContext)}. For example, you can use {nameof(DataAnnotationsValidator)} " + + $"inside an {nameof(EditForm)}."); + } + + CurrentEditContext.AddDataAnnotationsValidation(); + } + } +} diff --git a/src/Components/Components/src/Forms/EditContext.cs b/src/Components/Components/src/Forms/EditContext.cs new file mode 100644 index 0000000000..fd09a241f4 --- /dev/null +++ b/src/Components/Components/src/Forms/EditContext.cs @@ -0,0 +1,191 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.AspNetCore.Components.Forms +{ + /// + /// Holds metadata related to a data editing process, such as flags to indicate which + /// fields have been modified and the current set of validation messages. + /// + public sealed class EditContext + { + // Note that EditContext tracks state for any FieldIdentifier you give to it, plus + // the underlying storage is sparse. As such, none of the APIs have a "field not found" + // error state. If you give us an unrecognized FieldIdentifier, that just means we + // didn't yet track any state for it, so we behave as if it's in the default state + // (valid and unmodified). + private readonly Dictionary _fieldStates = new Dictionary(); + + /// + /// Constructs an instance of . + /// + /// The model object for the . This object should hold the data being edited, for example as a set of properties. + public EditContext(object model) + { + // The only reason we disallow null is because you'd almost always want one, and if you + // really don't, you can pass an empty object then ignore it. Ensuring it's nonnull + // simplifies things for all consumers of EditContext. + Model = model ?? throw new ArgumentNullException(nameof(model)); + } + + /// + /// An event that is raised when a field value changes. + /// + public event EventHandler OnFieldChanged; + + /// + /// An event that is raised when validation is requested. + /// + public event EventHandler OnValidationRequested; + + /// + /// An event that is raised when validation state has changed. + /// + public event EventHandler OnValidationStateChanged; + + /// + /// Supplies a corresponding to a specified field name + /// on this 's . + /// + /// The name of the editable field. + /// A corresponding to a specified field name on this 's . + public FieldIdentifier Field(string fieldName) + => new FieldIdentifier(Model, fieldName); + + /// + /// Gets the model object for this . + /// + public object Model { get; } + + /// + /// Signals that the value for the specified field has changed. + /// + /// Identifies the field whose value has been changed. + public void NotifyFieldChanged(in FieldIdentifier fieldIdentifier) + { + GetFieldState(fieldIdentifier, ensureExists: true).IsModified = true; + OnFieldChanged?.Invoke(this, new FieldChangedEventArgs(fieldIdentifier)); + } + + /// + /// Signals that some aspect of validation state has changed. + /// + public void NotifyValidationStateChanged() + { + OnValidationStateChanged?.Invoke(this, ValidationStateChangedEventArgs.Empty); + } + + /// + /// Clears any modification flag that may be tracked for the specified field. + /// + /// Identifies the field whose modification flag (if any) should be cleared. + public void MarkAsUnmodified(in FieldIdentifier fieldIdentifier) + { + if (_fieldStates.TryGetValue(fieldIdentifier, out var state)) + { + state.IsModified = false; + } + } + + /// + /// Clears all modification flags within this . + /// + public void MarkAsUnmodified() + { + foreach (var state in _fieldStates) + { + state.Value.IsModified = false; + } + } + + /// + /// Determines whether any of the fields in this have been modified. + /// + /// True if any of the fields in this have been modified; otherwise false. + public bool IsModified() + { + // If necessary, we could consider caching the overall "is modified" state and only recomputing + // when there's a call to NotifyFieldModified/NotifyFieldUnmodified + foreach (var state in _fieldStates) + { + if (state.Value.IsModified) + { + return true; + } + } + + return false; + } + + /// + /// Gets the current validation messages across all fields. + /// + /// This method does not perform validation itself. It only returns messages determined by previous validation actions. + /// + /// The current validation messages. + public IEnumerable GetValidationMessages() + { + // Since we're only enumerating the fields for which we have a non-null state, the cost of this grows + // based on how many fields have been modified or have associated validation messages + foreach (var state in _fieldStates) + { + foreach (var message in state.Value.GetValidationMessages()) + { + yield return message; + } + } + } + + /// + /// Gets the current validation messages for the specified field. + /// + /// This method does not perform validation itself. It only returns messages determined by previous validation actions. + /// + /// Identifies the field whose current validation messages should be returned. + /// The current validation messages for the specified field. + public IEnumerable GetValidationMessages(FieldIdentifier fieldIdentifier) + { + if (_fieldStates.TryGetValue(fieldIdentifier, out var state)) + { + foreach (var message in state.GetValidationMessages()) + { + yield return message; + } + } + } + + /// + /// Determines whether the specified fields in this has been modified. + /// + /// True if the field has been modified; otherwise false. + public bool IsModified(in FieldIdentifier fieldIdentifier) + => _fieldStates.TryGetValue(fieldIdentifier, out var state) + ? state.IsModified + : false; + + /// + /// Validates this . + /// + /// True if there are no validation messages after validation; otherwise false. + public bool Validate() + { + OnValidationRequested?.Invoke(this, ValidationRequestedEventArgs.Empty); + return !GetValidationMessages().Any(); + } + + internal FieldState GetFieldState(in FieldIdentifier fieldIdentifier, bool ensureExists) + { + if (!_fieldStates.TryGetValue(fieldIdentifier, out var state) && ensureExists) + { + state = new FieldState(fieldIdentifier); + _fieldStates.Add(fieldIdentifier, state); + } + + return state; + } + } +} diff --git a/src/Components/Components/src/Forms/EditContextDataAnnotationsExtensions.cs b/src/Components/Components/src/Forms/EditContextDataAnnotationsExtensions.cs new file mode 100644 index 0000000000..6542114a8d --- /dev/null +++ b/src/Components/Components/src/Forms/EditContextDataAnnotationsExtensions.cs @@ -0,0 +1,101 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Reflection; + +namespace Microsoft.AspNetCore.Components.Forms +{ + /// + /// Extension methods to add DataAnnotations validation to an . + /// + public static class EditContextDataAnnotationsExtensions + { + private static ConcurrentDictionary<(Type ModelType, string FieldName), PropertyInfo> _propertyInfoCache + = new ConcurrentDictionary<(Type, string), PropertyInfo>(); + + /// + /// Adds DataAnnotations validation support to the . + /// + /// The . + public static EditContext AddDataAnnotationsValidation(this EditContext editContext) + { + if (editContext == null) + { + throw new ArgumentNullException(nameof(editContext)); + } + + var messages = new ValidationMessageStore(editContext); + + // Perform object-level validation on request + editContext.OnValidationRequested += + (sender, eventArgs) => ValidateModel((EditContext)sender, messages); + + // Perform per-field validation on each field edit + editContext.OnFieldChanged += + (sender, eventArgs) => ValidateField(editContext, messages, eventArgs.FieldIdentifier); + + return editContext; + } + + private static void ValidateModel(EditContext editContext, ValidationMessageStore messages) + { + var validationContext = new ValidationContext(editContext.Model); + var validationResults = new List(); + Validator.TryValidateObject(editContext.Model, validationContext, validationResults, true); + + // Transfer results to the ValidationMessageStore + messages.Clear(); + foreach (var validationResult in validationResults) + { + foreach (var memberName in validationResult.MemberNames) + { + messages.Add(editContext.Field(memberName), validationResult.ErrorMessage); + } + } + + editContext.NotifyValidationStateChanged(); + } + + private static void ValidateField(EditContext editContext, ValidationMessageStore messages, in FieldIdentifier fieldIdentifier) + { + if (TryGetValidatableProperty(fieldIdentifier, out var propertyInfo)) + { + var propertyValue = propertyInfo.GetValue(fieldIdentifier.Model); + var validationContext = new ValidationContext(fieldIdentifier.Model) + { + MemberName = propertyInfo.Name + }; + var results = new List(); + + Validator.TryValidateProperty(propertyValue, validationContext, results); + messages.Clear(fieldIdentifier); + messages.AddRange(fieldIdentifier, results.Select(result => result.ErrorMessage)); + + // We have to notify even if there were no messages before and are still no messages now, + // because the "state" that changed might be the completion of some async validation task + editContext.NotifyValidationStateChanged(); + } + } + + private static bool TryGetValidatableProperty(in FieldIdentifier fieldIdentifier, out PropertyInfo propertyInfo) + { + var cacheKey = (ModelType: fieldIdentifier.Model.GetType(), fieldIdentifier.FieldName); + if (!_propertyInfoCache.TryGetValue(cacheKey, out propertyInfo)) + { + // DataAnnotations only validates public properties, so that's all we'll look for + // If we can't find it, cache 'null' so we don't have to try again next time + propertyInfo = cacheKey.ModelType.GetProperty(cacheKey.FieldName); + + // No need to lock, because it doesn't matter if we write the same value twice + _propertyInfoCache[cacheKey] = propertyInfo; + } + + return propertyInfo != null; + } + } +} diff --git a/src/Components/Components/src/Forms/EditContextExpressionExtensions.cs b/src/Components/Components/src/Forms/EditContextExpressionExtensions.cs new file mode 100644 index 0000000000..3d856e241f --- /dev/null +++ b/src/Components/Components/src/Forms/EditContextExpressionExtensions.cs @@ -0,0 +1,35 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq.Expressions; + +namespace Microsoft.AspNetCore.Components.Forms +{ + /// + /// Provides extension methods to simplify using with expressions. + /// + public static class EditContextExpressionExtensions + { + /// + /// Gets the current validation messages for the specified field. + /// + /// This method does not perform validation itself. It only returns messages determined by previous validation actions. + /// + /// The . + /// Identifies the field whose current validation messages should be returned. + /// The current validation messages for the specified field. + public static IEnumerable GetValidationMessages(this EditContext editContext, Expression> accessor) + => editContext.GetValidationMessages(FieldIdentifier.Create(accessor)); + + /// + /// Determines whether the specified fields in this has been modified. + /// + /// The . + /// Identifies the field whose current validation messages should be returned. + /// True if the field has been modified; otherwise false. + public static bool IsModified(this EditContext editContext, Expression> accessor) + => editContext.IsModified(FieldIdentifier.Create(accessor)); + } +} diff --git a/src/Components/Components/src/Forms/EditContextFieldClassExtensions.cs b/src/Components/Components/src/Forms/EditContextFieldClassExtensions.cs new file mode 100644 index 0000000000..bd92563535 --- /dev/null +++ b/src/Components/Components/src/Forms/EditContextFieldClassExtensions.cs @@ -0,0 +1,46 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Linq; +using System.Linq.Expressions; + +namespace Microsoft.AspNetCore.Components.Forms +{ + /// + /// Provides extension methods to describe the state of + /// fields as CSS class names. + /// + public static class EditContextFieldClassExtensions + { + /// + /// Gets a string that indicates the status of the specified field. This will include + /// some combination of "modified", "valid", or "invalid", depending on the status of the field. + /// + /// The . + /// An identifier for the field. + /// A string that indicates the status of the field. + public static string FieldClass(this EditContext editContext, Expression> accessor) + => FieldClass(editContext, FieldIdentifier.Create(accessor)); + + /// + /// Gets a string that indicates the status of the specified field. This will include + /// some combination of "modified", "valid", or "invalid", depending on the status of the field. + /// + /// The . + /// An identifier for the field. + /// A string that indicates the status of the field. + public static string FieldClass(this EditContext editContext, in FieldIdentifier fieldIdentifier) + { + var isValid = !editContext.GetValidationMessages(fieldIdentifier).Any(); + if (editContext.IsModified(fieldIdentifier)) + { + return isValid ? "modified valid" : "modified invalid"; + } + else + { + return isValid ? "valid" : "invalid"; + } + } + } +} diff --git a/src/Components/Components/src/Forms/EditForm.cs b/src/Components/Components/src/Forms/EditForm.cs new file mode 100644 index 0000000000..9ad279a519 --- /dev/null +++ b/src/Components/Components/src/Forms/EditForm.cs @@ -0,0 +1,139 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components.RenderTree; + +namespace Microsoft.AspNetCore.Components.Forms +{ + /// + /// Renders a form element that cascades an to descendants. + /// + public class EditForm : ComponentBase + { + private readonly Func _handleSubmitDelegate; // Cache to avoid per-render allocations + + private EditContext _fixedEditContext; + + /// + /// Constructs an instance of . + /// + public EditForm() + { + _handleSubmitDelegate = HandleSubmitAsync; + } + + /// + /// Supplies the edit context explicitly. If using this parameter, do not + /// also supply , since the model value will be taken + /// from the property. + /// + [Parameter] EditContext EditContext { get; set; } + + /// + /// Specifies the top-level model object for the form. An edit context will + /// be constructed for this model. If using this parameter, do not also supply + /// a value for . + /// + [Parameter] object Model { get; set; } + + /// + /// Specifies the content to be rendered inside this . + /// + [Parameter] RenderFragment ChildContent { get; set; } + + /// + /// A callback that will be invoked when the form is submitted. + /// + /// If using this parameter, you are responsible for triggering any validation + /// manually, e.g., by calling . + /// + [Parameter] EventCallback OnSubmit { get; set; } + + /// + /// A callback that will be invoked when the form is submitted and the + /// is determined to be valid. + /// + [Parameter] EventCallback OnValidSubmit { get; set; } + + /// + /// A callback that will be invoked when the form is submitted and the + /// is determined to be invalid. + /// + [Parameter] EventCallback OnInvalidSubmit { get; set; } + + /// + protected override void OnParametersSet() + { + if ((EditContext == null) == (Model == null)) + { + throw new InvalidOperationException($"{nameof(EditForm)} requires a {nameof(Model)} " + + $"parameter, or an {nameof(EditContext)} parameter, but not both."); + } + + // If you're using OnSubmit, it becomes your responsibility to trigger validation manually + // (e.g., so you can display a "pending" state in the UI). In that case you don't want the + // system to trigger a second validation implicitly, so don't combine it with the simplified + // OnValidSubmit/OnInvalidSubmit handlers. + if (OnSubmit.HasDelegate && (OnValidSubmit.HasDelegate || OnInvalidSubmit.HasDelegate)) + { + throw new InvalidOperationException($"When supplying an {nameof(OnSubmit)} parameter to " + + $"{nameof(EditForm)}, do not also supply {nameof(OnValidSubmit)} or {nameof(OnInvalidSubmit)}."); + } + + // Update _fixedEditContext if we don't have one yet, or if they are supplying a + // potentially new EditContext, or if they are supplying a different Model + if (_fixedEditContext == null || EditContext != null || Model != _fixedEditContext.Model) + { + _fixedEditContext = EditContext ?? new EditContext(Model); + } + } + + /// + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + base.BuildRenderTree(builder); + + // If _fixedEditContext changes, tear down and recreate all descendants. + // This is so we can safely use the IsFixed optimization on CascadingValue, + // optimizing for the common case where _fixedEditContext never changes. + builder.OpenRegion(_fixedEditContext.GetHashCode()); + + builder.OpenElement(0, "form"); + builder.AddAttribute(1, "onsubmit", _handleSubmitDelegate); + builder.OpenComponent>(2); + builder.AddAttribute(3, "IsFixed", true); + builder.AddAttribute(4, "Value", _fixedEditContext); + builder.AddAttribute(5, RenderTreeBuilder.ChildContent, ChildContent?.Invoke(_fixedEditContext)); + builder.CloseComponent(); + builder.CloseElement(); + + builder.CloseRegion(); + } + + private async Task HandleSubmitAsync() + { + if (OnSubmit.HasDelegate) + { + // When using OnSubmit, the developer takes control of the validation lifecycle + await OnSubmit.InvokeAsync(_fixedEditContext); + } + else + { + // Otherwise, the system implicitly runs validation on form submission + var isValid = _fixedEditContext.Validate(); // This will likely become ValidateAsync later + + if (isValid && OnValidSubmit.HasDelegate) + { + await OnValidSubmit.InvokeAsync(_fixedEditContext); + } + + if (!isValid && OnInvalidSubmit.HasDelegate) + { + await OnInvalidSubmit.InvokeAsync(_fixedEditContext); + } + } + } + } +} diff --git a/src/Components/Components/src/Forms/FieldChangedEventArgs.cs b/src/Components/Components/src/Forms/FieldChangedEventArgs.cs new file mode 100644 index 0000000000..9bf18dd486 --- /dev/null +++ b/src/Components/Components/src/Forms/FieldChangedEventArgs.cs @@ -0,0 +1,21 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Components.Forms +{ + /// + /// Provides information about the event. + /// + public sealed class FieldChangedEventArgs + { + /// + /// Identifies the field whose value has changed. + /// + public FieldIdentifier FieldIdentifier { get; } + + internal FieldChangedEventArgs(in FieldIdentifier fieldIdentifier) + { + FieldIdentifier = fieldIdentifier; + } + } +} diff --git a/src/Components/Components/src/Forms/FieldIdentifier.cs b/src/Components/Components/src/Forms/FieldIdentifier.cs new file mode 100644 index 0000000000..a113cccbe7 --- /dev/null +++ b/src/Components/Components/src/Forms/FieldIdentifier.cs @@ -0,0 +1,113 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Linq.Expressions; + +namespace Microsoft.AspNetCore.Components.Forms +{ + /// + /// Uniquely identifies a single field that can be edited. This may correspond to a property on a + /// model object, or can be any other named value. + /// + public readonly struct FieldIdentifier + { + /// + /// Initializes a new instance of the structure. + /// + /// An expression that identifies an object member. + public static FieldIdentifier Create(Expression> accessor) + { + if (accessor == null) + { + throw new ArgumentNullException(nameof(accessor)); + } + + ParseAccessor(accessor, out var model, out var fieldName); + return new FieldIdentifier(model, fieldName); + } + + /// + /// Initializes a new instance of the structure. + /// + /// The object that owns the field. + /// The name of the editable field. + public FieldIdentifier(object model, string fieldName) + { + if (model == null) + { + throw new ArgumentNullException(nameof(model)); + } + + if (model.GetType().IsValueType) + { + throw new ArgumentException("The model must be a reference-typed object.", nameof(model)); + } + + Model = model; + + // Note that we do allow an empty string. This is used by some validation systems + // as a place to store object-level (not per-property) messages. + FieldName = fieldName ?? throw new ArgumentNullException(nameof(fieldName)); + } + + /// + /// Gets the object that owns the editable field. + /// + public object Model { get; } + + /// + /// Gets the name of the editable field. + /// + public string FieldName { get; } + + /// + public override int GetHashCode() + => (Model, FieldName).GetHashCode(); + + /// + public override bool Equals(object obj) + => obj is FieldIdentifier otherIdentifier + && otherIdentifier.Model == Model + && string.Equals(otherIdentifier.FieldName, FieldName, StringComparison.Ordinal); + + private static void ParseAccessor(Expression> accessor, out object model, out string fieldName) + { + var accessorBody = accessor.Body; + + // Unwrap casts to object + if (accessorBody is UnaryExpression unaryExpression + && unaryExpression.NodeType == ExpressionType.Convert + && unaryExpression.Type == typeof(object)) + { + accessorBody = unaryExpression.Operand; + } + + if (!(accessorBody is MemberExpression memberExpression)) + { + throw new ArgumentException($"The provided expression contains a {accessorBody.GetType().Name} which is not supported. {nameof(FieldIdentifier)} only supports simple member accessors (fields, properties) of an object."); + } + + // Identify the field name. We don't mind whether it's a property or field, or even something else. + fieldName = memberExpression.Member.Name; + + // Get a reference to the model object + // i.e., given an value like "(something).MemberName", determine the runtime value of "(something)", + switch (memberExpression.Expression) + { + case ConstantExpression constantExpression: + model = constantExpression.Value; + break; + default: + // It would be great to cache this somehow, but it's unclear there's a reasonable way to do + // so, given that it embeds captured values such as "this". We could consider special-casing + // for "() => something.Member" and building a cache keyed by "something.GetType()" with values + // of type Func so we can cheaply map from "something" to "something.Member". + var modelLambda = Expression.Lambda(memberExpression.Expression); + var modelLambdaCompiled = (Func)modelLambda.Compile(); + model = modelLambdaCompiled(); + break; + } + } + } +} diff --git a/src/Components/Components/src/Forms/FieldState.cs b/src/Components/Components/src/Forms/FieldState.cs new file mode 100644 index 0000000000..3a8d1f0eb3 --- /dev/null +++ b/src/Components/Components/src/Forms/FieldState.cs @@ -0,0 +1,52 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; + +namespace Microsoft.AspNetCore.Components.Forms +{ + internal class FieldState + { + private readonly FieldIdentifier _fieldIdentifier; + + // We track which ValidationMessageStore instances have a nonempty set of messages for this field so that + // we can quickly evaluate the list of messages for the field without having to query all stores. This is + // relevant because each validation component may define its own message store, so there might be as many + // stores are there are fields or UI elements. + private HashSet _validationMessageStores; + + public FieldState(FieldIdentifier fieldIdentifier) + { + _fieldIdentifier = fieldIdentifier; + } + + public bool IsModified { get; set; } + + public IEnumerable GetValidationMessages() + { + if (_validationMessageStores != null) + { + foreach (var store in _validationMessageStores) + { + foreach (var message in store[_fieldIdentifier]) + { + yield return message; + } + } + } + } + + public void AssociateWithValidationMessageStore(ValidationMessageStore validationMessageStore) + { + if (_validationMessageStores == null) + { + _validationMessageStores = new HashSet(); + } + + _validationMessageStores.Add(validationMessageStore); + } + + public void DissociateFromValidationMessageStore(ValidationMessageStore validationMessageStore) + => _validationMessageStores?.Remove(validationMessageStore); + } +} diff --git a/src/Components/Components/src/Forms/InputComponents/InputBase.cs b/src/Components/Components/src/Forms/InputComponents/InputBase.cs new file mode 100644 index 0000000000..a1c61da5bd --- /dev/null +++ b/src/Components/Components/src/Forms/InputComponents/InputBase.cs @@ -0,0 +1,206 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Components.Forms +{ + /// + /// A base class for form input components. This base class automatically + /// integrates with an , which must be supplied + /// as a cascading parameter. + /// + public abstract class InputBase : ComponentBase + { + private bool _previousParsingAttemptFailed; + private ValidationMessageStore _parsingValidationMessages; + private Type _nullableUnderlyingType; + + [CascadingParameter] EditContext CascadedEditContext { get; set; } + + /// + /// Gets a value for the component's 'id' attribute. + /// + [Parameter] protected string Id { get; private set; } + + /// + /// Gets a value for the component's 'class' attribute. + /// + [Parameter] protected string Class { get; private set; } + + /// + /// Gets or sets the value of the input. This should be used with two-way binding. + /// + /// + /// bind-Value="@model.PropertyName" + /// + [Parameter] T Value { get; set; } + + /// + /// Gets or sets a callback that updates the bound value. + /// + [Parameter] Action ValueChanged { get; set; } + + /// + /// Gets or sets an expression that identifies the bound value. + /// + [Parameter] Expression> ValueExpression { get; set; } + + /// + /// Gets the associated . + /// + protected EditContext EditContext { get; private set; } + + /// + /// Gets the for the bound value. + /// + protected FieldIdentifier FieldIdentifier { get; private set; } + + /// + /// Gets or sets the current value of the input. + /// + protected T CurrentValue + { + get => Value; + set + { + var hasChanged = !EqualityComparer.Default.Equals(value, Value); + if (hasChanged) + { + Value = value; + ValueChanged?.Invoke(value); + EditContext.NotifyFieldChanged(FieldIdentifier); + } + } + } + + /// + /// Gets or sets the current value of the input, represented as a string. + /// + protected string CurrentValueAsString + { + get => FormatValueAsString(CurrentValue); + set + { + _parsingValidationMessages?.Clear(); + + bool parsingFailed; + + if (_nullableUnderlyingType != null && string.IsNullOrEmpty(value)) + { + // Assume if it's a nullable type, null/empty inputs should correspond to default(T) + // Then all subclasses get nullable support almost automatically (they just have to + // not reject Nullable based on the type itself). + parsingFailed = false; + CurrentValue = default; + } + else if (TryParseValueFromString(value, out var parsedValue, out var validationErrorMessage)) + { + parsingFailed = false; + CurrentValue = parsedValue; + } + else + { + parsingFailed = true; + + if (_parsingValidationMessages == null) + { + _parsingValidationMessages = new ValidationMessageStore(EditContext); + } + + _parsingValidationMessages.Add(FieldIdentifier, validationErrorMessage); + + // Since we're not writing to CurrentValue, we'll need to notify about modification from here + EditContext.NotifyFieldChanged(FieldIdentifier); + } + + // We can skip the validation notification if we were previously valid and still are + if (parsingFailed || _previousParsingAttemptFailed) + { + EditContext.NotifyValidationStateChanged(); + _previousParsingAttemptFailed = parsingFailed; + } + } + } + + /// + /// Formats the value as a string. Derived classes can override this to determine the formating used for . + /// + /// The value to format. + /// A string representation of the value. + protected virtual string FormatValueAsString(T value) + => value?.ToString(); + + /// + /// Parses a string to create an instance of . Derived classes can override this to change how + /// interprets incoming values. + /// + /// The string value to be parsed. + /// An instance of . + /// If the value could not be parsed, provides a validation error message. + /// True if the value could be parsed; otherwise false. + protected abstract bool TryParseValueFromString(string value, out T result, out string validationErrorMessage); + + /// + /// Gets a string that indicates the status of the field being edited. This will include + /// some combination of "modified", "valid", or "invalid", depending on the status of the field. + /// + protected string FieldClass + => EditContext.FieldClass(FieldIdentifier); + + /// + /// Gets a CSS class string that combines the and + /// properties. Derived components should typically use this value for the primary HTML element's + /// 'class' attribute. + /// + protected string CssClass + => string.IsNullOrEmpty(Class) + ? FieldClass // Never null or empty + : $"{Class} {FieldClass}"; + + /// + public override Task SetParametersAsync(ParameterCollection parameters) + { + parameters.SetParameterProperties(this); + + if (EditContext == null) + { + // This is the first run + // Could put this logic in OnInit, but its nice to avoid forcing people who override OnInit to call base.OnInit() + + if (CascadedEditContext == null) + { + throw new InvalidOperationException($"{GetType()} requires a cascading parameter " + + $"of type {nameof(Forms.EditContext)}. For example, you can use {GetType().FullName} inside " + + $"an {nameof(EditForm)}."); + } + + if (ValueExpression == null) + { + throw new InvalidOperationException($"{GetType()} requires a value for the 'ValueExpression' " + + $"parameter. Normally this is provided automatically when using 'bind-Value'."); + } + + EditContext = CascadedEditContext; + FieldIdentifier = FieldIdentifier.Create(ValueExpression); + _nullableUnderlyingType = Nullable.GetUnderlyingType(typeof(T)); + } + else if (CascadedEditContext != EditContext) + { + // Not the first run + + // We don't support changing EditContext because it's messy to be clearing up state and event + // handlers for the previous one, and there's no strong use case. If a strong use case + // emerges, we can consider changing this. + throw new InvalidOperationException($"{GetType()} does not support changing the " + + $"{nameof(Forms.EditContext)} dynamically."); + } + + // For derived components, retain the usual lifecycle with OnInit/OnParametersSet/etc. + return base.SetParametersAsync(ParameterCollection.Empty); + } + } +} diff --git a/src/Components/Components/src/Forms/InputComponents/InputCheckbox.cs b/src/Components/Components/src/Forms/InputComponents/InputCheckbox.cs new file mode 100644 index 0000000000..4ccef84347 --- /dev/null +++ b/src/Components/Components/src/Forms/InputComponents/InputCheckbox.cs @@ -0,0 +1,40 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.AspNetCore.Components.RenderTree; + +namespace Microsoft.AspNetCore.Components.Forms +{ + /* This is exactly equivalent to a .razor file containing: + * + * @inherits InputBase + * + * + * The only reason it's not implemented as a .razor file is that we don't presently have the ability to compile those + * files within this project. Developers building their own input components should use Razor syntax. + */ + + /// + /// An input component for editing values. + /// + public class InputCheckbox : InputBase + { + /// + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + base.BuildRenderTree(builder); + builder.OpenElement(0, "input"); + builder.AddAttribute(1, "type", "checkbox"); + builder.AddAttribute(2, "id", Id); + builder.AddAttribute(3, "class", CssClass); + builder.AddAttribute(4, "value", BindMethods.GetValue(CurrentValue)); + builder.AddAttribute(5, "onchange", BindMethods.SetValueHandler(__value => CurrentValue = __value, CurrentValue)); + builder.CloseElement(); + } + + /// + protected override bool TryParseValueFromString(string value, out bool result, out string validationErrorMessage) + => throw new NotImplementedException($"This component does not parse string inputs. Bind to the '{nameof(CurrentValue)}' property, not '{nameof(CurrentValueAsString)}'."); + } +} diff --git a/src/Components/Components/src/Forms/InputComponents/InputDate.cs b/src/Components/Components/src/Forms/InputComponents/InputDate.cs new file mode 100644 index 0000000000..b193f2333e --- /dev/null +++ b/src/Components/Components/src/Forms/InputComponents/InputDate.cs @@ -0,0 +1,109 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.AspNetCore.Components.RenderTree; + +namespace Microsoft.AspNetCore.Components.Forms +{ + /// + /// An input component for editing date values. + /// Supported types are and . + /// + public class InputDate : InputBase + { + const string dateFormat = "yyyy-MM-dd"; // Compatible with HTML date inputs + + [Parameter] string ParsingErrorMessage { get; set; } = "The {0} field must be a date."; + + /// + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + base.BuildRenderTree(builder); + builder.OpenElement(0, "input"); + builder.AddAttribute(1, "type", "date"); + builder.AddAttribute(2, "id", Id); + builder.AddAttribute(3, "class", CssClass); + builder.AddAttribute(4, "value", BindMethods.GetValue(CurrentValueAsString)); + builder.AddAttribute(5, "onchange", BindMethods.SetValueHandler(__value => CurrentValueAsString = __value, CurrentValueAsString)); + builder.CloseElement(); + } + + /// + protected override string FormatValueAsString(T value) + { + switch (value) + { + case DateTime dateTimeValue: + return dateTimeValue.ToString(dateFormat); + case DateTimeOffset dateTimeOffsetValue: + return dateTimeOffsetValue.ToString(dateFormat); + default: + return string.Empty; // Handles null for Nullable, etc. + } + } + + /// + protected override bool TryParseValueFromString(string value, out T result, out string validationErrorMessage) + { + // Unwrap nullable types. We don't have to deal with receiving empty values for nullable + // types here, because the underlying InputBase already covers that. + var targetType = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T); + + bool success; + if (targetType == typeof(DateTime)) + { + success = TryParseDateTime(value, out result); + } + else if (targetType == typeof(DateTimeOffset)) + { + success = TryParseDateTimeOffset(value, out result); + } + else + { + throw new InvalidOperationException($"The type '{targetType}' is not a supported date type."); + } + + if (success) + { + validationErrorMessage = null; + return true; + } + else + { + validationErrorMessage = string.Format(ParsingErrorMessage, FieldIdentifier.FieldName); + return false; + } + } + + static bool TryParseDateTime(string value, out T result) + { + var success = DateTime.TryParse(value, out var parsedValue); + if (success) + { + result = (T)(object)parsedValue; + return true; + } + else + { + result = default; + return false; + } + } + + static bool TryParseDateTimeOffset(string value, out T result) + { + var success = DateTimeOffset.TryParse(value, out var parsedValue); + if (success) + { + result = (T)(object)parsedValue; + return true; + } + else + { + result = default; + return false; + } + } + } +} diff --git a/src/Components/Components/src/Forms/InputComponents/InputNumber.cs b/src/Components/Components/src/Forms/InputComponents/InputNumber.cs new file mode 100644 index 0000000000..e8394ab6b3 --- /dev/null +++ b/src/Components/Components/src/Forms/InputComponents/InputNumber.cs @@ -0,0 +1,162 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Globalization; +using Microsoft.AspNetCore.Components.RenderTree; + +namespace Microsoft.AspNetCore.Components.Forms +{ + /// + /// An input component for editing numeric values. + /// Supported numeric types are , , , , . + /// + public class InputNumber : InputBase + { + delegate bool Parser(string value, out T result); + private static Parser _parser; + private static string _stepAttributeValue; // Null by default, so only allows whole numbers as per HTML spec + + // Determine the parsing logic once per T and cache it, so we don't have to consider all the possible types on each parse + static InputNumber() + { + // Unwrap Nullable, because InputBase already deals with the Nullable aspect + // of it for us. We will only get asked to parse the T for nonempty inputs. + var targetType = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T); + + if (targetType == typeof(int)) + { + _parser = TryParseInt; + } + else if (targetType == typeof(long)) + { + _parser = TryParseLong; + } + else if (targetType == typeof(float)) + { + _parser = TryParseFloat; + _stepAttributeValue = "any"; + } + else if (targetType == typeof(double)) + { + _parser = TryParseDouble; + _stepAttributeValue = "any"; + } + else if (targetType == typeof(decimal)) + { + _parser = TryParseDecimal; + _stepAttributeValue = "any"; + } + else + { + throw new InvalidOperationException($"The type '{targetType}' is not a supported numeric type."); + } + } + + [Parameter] string ParsingErrorMessage { get; set; } = "The {0} field must be a number."; + + /// + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + base.BuildRenderTree(builder); + builder.OpenElement(0, "input"); + builder.AddAttribute(1, "type", "number"); + builder.AddAttribute(2, "step", _stepAttributeValue); + builder.AddAttribute(3, "id", Id); + builder.AddAttribute(4, "class", CssClass); + builder.AddAttribute(5, "value", BindMethods.GetValue(CurrentValueAsString)); + builder.AddAttribute(6, "onchange", BindMethods.SetValueHandler(__value => CurrentValueAsString = __value, CurrentValueAsString)); + builder.CloseElement(); + } + + /// + protected override bool TryParseValueFromString(string value, out T result, out string validationErrorMessage) + { + if (_parser(value, out result)) + { + validationErrorMessage = null; + return true; + } + else + { + validationErrorMessage = string.Format(ParsingErrorMessage, FieldIdentifier.FieldName); + return false; + } + } + + static bool TryParseInt(string value, out T result) + { + var success = int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedValue); + if (success) + { + result = (T)(object)parsedValue; + return true; + } + else + { + result = default; + return false; + } + } + + static bool TryParseLong(string value, out T result) + { + var success = long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedValue); + if (success) + { + result = (T)(object)parsedValue; + return true; + } + else + { + result = default; + return false; + } + } + + static bool TryParseFloat(string value, out T result) + { + var success = float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsedValue); + if (success) + { + result = (T)(object)parsedValue; + return true; + } + else + { + result = default; + return false; + } + } + + static bool TryParseDouble(string value, out T result) + { + var success = double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsedValue); + if (success) + { + result = (T)(object)parsedValue; + return true; + } + else + { + result = default; + return false; + } + } + + static bool TryParseDecimal(string value, out T result) + { + var success = decimal.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsedValue); + if (success) + { + result = (T)(object)parsedValue; + return true; + } + else + { + result = default; + return false; + } + } + } +} diff --git a/src/Components/Components/src/Forms/InputComponents/InputSelect.cs b/src/Components/Components/src/Forms/InputComponents/InputSelect.cs new file mode 100644 index 0000000000..c396ba0b99 --- /dev/null +++ b/src/Components/Components/src/Forms/InputComponents/InputSelect.cs @@ -0,0 +1,58 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.AspNetCore.Components.RenderTree; + +namespace Microsoft.AspNetCore.Components.Forms +{ + /// + /// A dropdown selection component. + /// + public class InputSelect : InputBase + { + [Parameter] RenderFragment ChildContent { get; set; } + + /// + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + base.BuildRenderTree(builder); + builder.OpenElement(0, "select"); + builder.AddAttribute(1, "id", Id); + builder.AddAttribute(2, "class", CssClass); + builder.AddAttribute(3, "value", BindMethods.GetValue(CurrentValueAsString)); + builder.AddAttribute(4, "onchange", BindMethods.SetValueHandler(__value => CurrentValueAsString = __value, CurrentValueAsString)); + builder.AddContent(5, ChildContent); + builder.CloseElement(); + } + + /// + protected override bool TryParseValueFromString(string value, out T result, out string validationErrorMessage) + { + if (typeof(T) == typeof(string)) + { + result = (T)(object)value; + validationErrorMessage = null; + return true; + } + else if (typeof(T).IsEnum) + { + // There's no non-generic Enum.TryParse (https://github.com/dotnet/corefx/issues/692) + try + { + result = (T)Enum.Parse(typeof(T), value); + validationErrorMessage = null; + return true; + } + catch (ArgumentException) + { + result = default; + validationErrorMessage = $"The {FieldIdentifier.FieldName} field is not valid."; + return false; + } + } + + throw new InvalidOperationException($"{GetType()} does not support the type '{typeof(T)}'."); + } + } +} diff --git a/src/Components/Components/src/Forms/InputComponents/InputText.cs b/src/Components/Components/src/Forms/InputComponents/InputText.cs new file mode 100644 index 0000000000..44c7eaf6ea --- /dev/null +++ b/src/Components/Components/src/Forms/InputComponents/InputText.cs @@ -0,0 +1,44 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNetCore.Components.RenderTree; + +namespace Microsoft.AspNetCore.Components.Forms +{ + // TODO: Support maxlength etc. + + /* This is almost equivalent to a .razor file containing: + * + * @inherits InputBase + * + * + * The only reason it's not implemented as a .razor file is that we don't presently have the ability to compile those + * files within this project. Developers building their own input components should use Razor syntax. + */ + + /// + /// An input component for editing values. + /// + public class InputText : InputBase + { + /// + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + base.BuildRenderTree(builder); + builder.OpenElement(0, "input"); + builder.AddAttribute(1, "id", Id); + builder.AddAttribute(2, "class", CssClass); + builder.AddAttribute(3, "value", BindMethods.GetValue(CurrentValue)); + builder.AddAttribute(4, "onchange", BindMethods.SetValueHandler(__value => CurrentValue = __value, CurrentValue)); + builder.CloseElement(); + } + + /// + protected override bool TryParseValueFromString(string value, out string result, out string validationErrorMessage) + { + result = value; + validationErrorMessage = null; + return true; + } + } +} diff --git a/src/Components/Components/src/Forms/InputComponents/InputTextArea.cs b/src/Components/Components/src/Forms/InputComponents/InputTextArea.cs new file mode 100644 index 0000000000..2ddc5cf3be --- /dev/null +++ b/src/Components/Components/src/Forms/InputComponents/InputTextArea.cs @@ -0,0 +1,44 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNetCore.Components.RenderTree; + +namespace Microsoft.AspNetCore.Components.Forms +{ + // TODO: Support rows/cols/etc + + /* This is almost equivalent to a .razor file containing: + * + * @inherits InputBase + * + * + * The only reason it's not implemented as a .razor file is that we don't presently have the ability to compile those + * files within this project. Developers building their own input components should use Razor syntax. + */ + + /// + /// A multiline input component for editing values. + /// + public class InputTextArea : InputBase + { + /// + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + base.BuildRenderTree(builder); + builder.OpenElement(0, "textarea"); + builder.AddAttribute(1, "id", Id); + builder.AddAttribute(2, "class", CssClass); + builder.AddAttribute(3, "value", BindMethods.GetValue(CurrentValue)); + builder.AddAttribute(4, "onchange", BindMethods.SetValueHandler(__value => CurrentValue = __value, CurrentValue)); + builder.CloseElement(); + } + + /// + protected override bool TryParseValueFromString(string value, out string result, out string validationErrorMessage) + { + result = value; + validationErrorMessage = null; + return true; + } + } +} diff --git a/src/Components/Components/src/Forms/ValidationMessage.cs b/src/Components/Components/src/Forms/ValidationMessage.cs new file mode 100644 index 0000000000..176824931d --- /dev/null +++ b/src/Components/Components/src/Forms/ValidationMessage.cs @@ -0,0 +1,96 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Linq.Expressions; +using Microsoft.AspNetCore.Components.RenderTree; + +namespace Microsoft.AspNetCore.Components.Forms +{ + /// + /// Displays a list of validation messages for a specified field within a cascaded . + /// + public class ValidationMessage : ComponentBase, IDisposable + { + private EditContext _previousEditContext; + private Expression> _previousFieldAccessor; + private readonly EventHandler _validationStateChangedHandler; + private FieldIdentifier _fieldIdentifier; + + [CascadingParameter] EditContext CurrentEditContext { get; set; } + + /// + /// Specifies the field for which validation messages should be displayed. + /// + [Parameter] Expression> For { get; set; } + + /// ` + /// Constructs an instance of . + /// + public ValidationMessage() + { + _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 (For == null) // Not possible except if you manually specify T + { + throw new InvalidOperationException($"{GetType()} requires a value for the " + + $"{nameof(For)} parameter."); + } + else if (For != _previousFieldAccessor) + { + _fieldIdentifier = FieldIdentifier.Create(For); + _previousFieldAccessor = For; + } + + if (CurrentEditContext != _previousEditContext) + { + DetachValidationStateChangedListener(); + CurrentEditContext.OnValidationStateChanged += _validationStateChangedHandler; + _previousEditContext = CurrentEditContext; + } + } + + /// + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + base.BuildRenderTree(builder); + + foreach (var message in CurrentEditContext.GetValidationMessages(_fieldIdentifier)) + { + builder.OpenElement(0, "div"); + builder.AddAttribute(1, "class", "validation-message"); + builder.AddContent(2, message); + builder.CloseElement(); + } + } + + private void HandleValidationStateChanged(object sender, ValidationStateChangedEventArgs eventArgs) + { + StateHasChanged(); + } + + void IDisposable.Dispose() + { + DetachValidationStateChangedListener(); + } + + private void DetachValidationStateChangedListener() + { + if (_previousEditContext != null) + { + _previousEditContext.OnValidationStateChanged -= _validationStateChangedHandler; + } + } + } +} diff --git a/src/Components/Components/src/Forms/ValidationMessageStore.cs b/src/Components/Components/src/Forms/ValidationMessageStore.cs new file mode 100644 index 0000000000..2b520d68df --- /dev/null +++ b/src/Components/Components/src/Forms/ValidationMessageStore.cs @@ -0,0 +1,105 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; + +namespace Microsoft.AspNetCore.Components.Forms +{ + /// + /// Holds validation messages for an . + /// + public sealed class ValidationMessageStore + { + private readonly EditContext _editContext; + private readonly Dictionary> _messages = new Dictionary>(); + + /// + /// Creates an instance of . + /// + /// The with which this store should be associated. + public ValidationMessageStore(EditContext editContext) + { + _editContext = editContext ?? throw new ArgumentNullException(nameof(editContext)); + } + + /// + /// Adds a validation message for the specified field. + /// + /// The identifier for the field. + /// The validation message. + public void Add(in FieldIdentifier fieldIdentifier, string message) + => GetOrCreateMessagesListForField(fieldIdentifier).Add(message); + + /// + /// Adds the messages from the specified collection for the specified field. + /// + /// The identifier for the field. + /// The validation messages to be added. + public void AddRange(in FieldIdentifier fieldIdentifier, IEnumerable messages) + => GetOrCreateMessagesListForField(fieldIdentifier).AddRange(messages); + + /// + /// Gets the validation messages within this for the specified field. + /// + /// To get the validation messages across all validation message stores, use instead + /// + /// The identifier for the field. + /// The validation messages for the specified field within this . + public IEnumerable this[FieldIdentifier fieldIdentifier] + => _messages.TryGetValue(fieldIdentifier, out var messages) ? messages : Enumerable.Empty(); + + /// + /// Gets the validation messages within this for the specified field. + /// + /// To get the validation messages across all validation message stores, use instead + /// + /// The identifier for the field. + /// The validation messages for the specified field within this . + public IEnumerable this[Expression> accessor] + => this[FieldIdentifier.Create(accessor)]; + + /// + /// Removes all messages within this . + /// + public void Clear() + { + foreach (var fieldIdentifier in _messages.Keys) + { + DissociateFromField(fieldIdentifier); + } + + _messages.Clear(); + } + + /// + /// Removes all messages within this for the specified field. + /// + /// The identifier for the field. + public void Clear(in FieldIdentifier fieldIdentifier) + { + DissociateFromField(fieldIdentifier); + _messages.Remove(fieldIdentifier); + } + + private List GetOrCreateMessagesListForField(in FieldIdentifier fieldIdentifier) + { + if (!_messages.TryGetValue(fieldIdentifier, out var messagesForField)) + { + messagesForField = new List(); + _messages.Add(fieldIdentifier, messagesForField); + AssociateWithField(fieldIdentifier); + } + + return messagesForField; + } + + private void AssociateWithField(in FieldIdentifier fieldIdentifier) + => _editContext.GetFieldState(fieldIdentifier, ensureExists: true).AssociateWithValidationMessageStore(this); + + private void DissociateFromField(in FieldIdentifier fieldIdentifier) + => _editContext.GetFieldState(fieldIdentifier, ensureExists: false)?.DissociateFromValidationMessageStore(this); + } +} diff --git a/src/Components/Components/src/Forms/ValidationMessageStoreExpressionExtensions.cs b/src/Components/Components/src/Forms/ValidationMessageStoreExpressionExtensions.cs new file mode 100644 index 0000000000..6304c6e2c3 --- /dev/null +++ b/src/Components/Components/src/Forms/ValidationMessageStoreExpressionExtensions.cs @@ -0,0 +1,41 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq.Expressions; + +namespace Microsoft.AspNetCore.Components.Forms +{ + /// + /// Provides extension methods to simplify using with expressions. + /// + public static class ValidationMessageStoreExpressionExtensions + { + /// + /// Adds a validation message for the specified field. + /// + /// The . + /// Identifies the field for which to add the message. + /// The validation message. + public static void Add(this ValidationMessageStore store, Expression> accessor, string message) + => store.Add(FieldIdentifier.Create(accessor), message); + + /// + /// Adds the messages from the specified collection for the specified field. + /// + /// The . + /// Identifies the field for which to add the messages. + /// The validation messages to be added. + public static void AddRange(this ValidationMessageStore store, Expression> accessor, IEnumerable messages) + => store.AddRange(FieldIdentifier.Create(accessor), messages); + + /// + /// Removes all messages within this for the specified field. + /// + /// The . + /// Identifies the field for which to remove the messages. + public static void Clear(this ValidationMessageStore store, Expression> accessor) + => store.Clear(FieldIdentifier.Create(accessor)); + } +} diff --git a/src/Components/Components/src/Forms/ValidationRequestedEventArgs.cs b/src/Components/Components/src/Forms/ValidationRequestedEventArgs.cs new file mode 100644 index 0000000000..cd7f0db2b6 --- /dev/null +++ b/src/Components/Components/src/Forms/ValidationRequestedEventArgs.cs @@ -0,0 +1,17 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Components.Forms +{ + /// + /// Provides information about the event. + /// + public sealed class ValidationRequestedEventArgs + { + internal static readonly ValidationRequestedEventArgs Empty = new ValidationRequestedEventArgs(); + + internal ValidationRequestedEventArgs() + { + } + } +} diff --git a/src/Components/Components/src/Forms/ValidationStateChangedEventArgs.cs b/src/Components/Components/src/Forms/ValidationStateChangedEventArgs.cs new file mode 100644 index 0000000000..0ac4af6658 --- /dev/null +++ b/src/Components/Components/src/Forms/ValidationStateChangedEventArgs.cs @@ -0,0 +1,17 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Components.Forms +{ + /// + /// Provides information about the event. + /// + public sealed class ValidationStateChangedEventArgs + { + internal static readonly ValidationStateChangedEventArgs Empty = new ValidationStateChangedEventArgs(); + + internal ValidationStateChangedEventArgs() + { + } + } +} diff --git a/src/Components/Components/src/Forms/ValidationSummary.cs b/src/Components/Components/src/Forms/ValidationSummary.cs new file mode 100644 index 0000000000..094b7779fc --- /dev/null +++ b/src/Components/Components/src/Forms/ValidationSummary.cs @@ -0,0 +1,93 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.AspNetCore.Components.RenderTree; + +namespace Microsoft.AspNetCore.Components.Forms +{ + // Note: there's no reason why developers strictly need to use this. It's equally valid to + // put a @foreach(var message in context.GetValidationMessages()) { ... } inside a form. + // This component is for convenience only, plus it implements a few small perf optimizations. + + /// + /// Displays a list of validation messages from a cascaded . + /// + public class ValidationSummary : ComponentBase, IDisposable + { + private EditContext _previousEditContext; + private readonly EventHandler _validationStateChangedHandler; + + [CascadingParameter] EditContext CurrentEditContext { get; set; } + + /// ` + /// Constructs an instance of . + /// + public ValidationSummary() + { + _validationStateChangedHandler = (sender, eventArgs) => StateHasChanged(); + } + + /// + protected override void OnParametersSet() + { + if (CurrentEditContext == null) + { + throw new InvalidOperationException($"{nameof(ValidationSummary)} requires a cascading parameter " + + $"of type {nameof(EditContext)}. For example, you can use {nameof(ValidationSummary)} inside " + + $"an {nameof(EditForm)}."); + } + + if (CurrentEditContext != _previousEditContext) + { + DetachValidationStateChangedListener(); + CurrentEditContext.OnValidationStateChanged += _validationStateChangedHandler; + _previousEditContext = CurrentEditContext; + } + } + + /// + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + base.BuildRenderTree(builder); + + // As an optimization, only evaluate the messages enumerable once, and + // only produce the enclosing
    if there's at least one message + var messagesEnumerator = CurrentEditContext.GetValidationMessages().GetEnumerator(); + if (messagesEnumerator.MoveNext()) + { + builder.OpenElement(0, "ul"); + builder.AddAttribute(1, "class", "validation-errors"); + + do + { + builder.OpenElement(2, "li"); + builder.AddAttribute(3, "class", "validation-message"); + builder.AddContent(4, messagesEnumerator.Current); + builder.CloseElement(); + } + while (messagesEnumerator.MoveNext()); + + builder.CloseElement(); + } + } + + private void HandleValidationStateChanged(object sender, ValidationStateChangedEventArgs eventArgs) + { + StateHasChanged(); + } + + void IDisposable.Dispose() + { + DetachValidationStateChangedListener(); + } + + private void DetachValidationStateChangedListener() + { + if (_previousEditContext != null) + { + _previousEditContext.OnValidationStateChanged -= _validationStateChangedHandler; + } + } + } +} diff --git a/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj b/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj index b9026cff09..0974eb1c35 100644 --- a/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj +++ b/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj @@ -10,6 +10,7 @@ + diff --git a/src/Components/Components/test/Forms/EditContextDataAnnotationsExtensionsTest.cs b/src/Components/Components/test/Forms/EditContextDataAnnotationsExtensionsTest.cs new file mode 100644 index 0000000000..bb7837e2f0 --- /dev/null +++ b/src/Components/Components/test/Forms/EditContextDataAnnotationsExtensionsTest.cs @@ -0,0 +1,170 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.ComponentModel.DataAnnotations; +using Xunit; + +namespace Microsoft.AspNetCore.Components.Forms +{ + public class EditContextDataAnnotationsExtensionsTest + { + [Fact] + public void CannotUseNullEditContext() + { + var editContext = (EditContext)null; + var ex = Assert.Throws(() => editContext.AddDataAnnotationsValidation()); + Assert.Equal("editContext", ex.ParamName); + } + + [Fact] + public void ReturnsEditContextForChaining() + { + var editContext = new EditContext(new object()); + var returnValue = editContext.AddDataAnnotationsValidation(); + Assert.Same(editContext, returnValue); + } + + [Fact] + public void GetsValidationMessagesFromDataAnnotations() + { + // Arrange + var model = new TestModel { IntFrom1To100 = 101 }; + var editContext = new EditContext(model).AddDataAnnotationsValidation(); + + // Act + var isValid = editContext.Validate(); + + // Assert + Assert.False(isValid); + + Assert.Equal(new string[] + { + "RequiredString:required", + "IntFrom1To100:range" + }, + editContext.GetValidationMessages()); + + Assert.Equal(new string[] { "RequiredString:required" }, + editContext.GetValidationMessages(editContext.Field(nameof(TestModel.RequiredString)))); + + // This shows we're including non-[Required] properties in the validation results, i.e, + // that we're correctly passing "validateAllProperties: true" to DataAnnotations + Assert.Equal(new string[] { "IntFrom1To100:range" }, + editContext.GetValidationMessages(editContext.Field(nameof(TestModel.IntFrom1To100)))); + } + + [Fact] + public void ClearsExistingValidationMessagesOnFurtherRuns() + { + // Arrange + var model = new TestModel { IntFrom1To100 = 101 }; + var editContext = new EditContext(model).AddDataAnnotationsValidation(); + + // Act/Assert 1: Initially invalid + Assert.False(editContext.Validate()); + + // Act/Assert 2: Can become valid + model.RequiredString = "Hello"; + model.IntFrom1To100 = 100; + Assert.True(editContext.Validate()); + } + + [Fact] + public void NotifiesValidationStateChangedAfterObjectValidation() + { + // Arrange + var model = new TestModel { IntFrom1To100 = 101 }; + var editContext = new EditContext(model).AddDataAnnotationsValidation(); + var onValidationStateChangedCount = 0; + editContext.OnValidationStateChanged += (sender, eventArgs) => onValidationStateChangedCount++; + + // Act/Assert 1: Notifies after invalid results + Assert.False(editContext.Validate()); + Assert.Equal(1, onValidationStateChangedCount); + + // Act/Assert 2: Notifies after valid results + model.RequiredString = "Hello"; + model.IntFrom1To100 = 100; + Assert.True(editContext.Validate()); + Assert.Equal(2, onValidationStateChangedCount); + + // Act/Assert 3: Notifies even if results haven't changed. Later we might change the + // logic to track the previous results and compare with the new ones, but that's just + // an optimization. It's legal to notify regardless. + Assert.True(editContext.Validate()); + Assert.Equal(3, onValidationStateChangedCount); + } + + [Fact] + public void PerformsPerPropertyValidationOnFieldChange() + { + // Arrange + var model = new TestModel { IntFrom1To100 = 101 }; + var independentTopLevelModel = new object(); // To show we can validate things on any model, not just the top-level one + var editContext = new EditContext(independentTopLevelModel).AddDataAnnotationsValidation(); + var onValidationStateChangedCount = 0; + var requiredStringIdentifier = new FieldIdentifier(model, nameof(TestModel.RequiredString)); + var intFrom1To100Identifier = new FieldIdentifier(model, nameof(TestModel.IntFrom1To100)); + editContext.OnValidationStateChanged += (sender, eventArgs) => onValidationStateChangedCount++; + + // Act/Assert 1: Notify about RequiredString + // Only RequiredString gets validated, even though IntFrom1To100 also holds an invalid value + editContext.NotifyFieldChanged(requiredStringIdentifier); + Assert.Equal(1, onValidationStateChangedCount); + Assert.Equal(new[] { "RequiredString:required" }, editContext.GetValidationMessages()); + + // Act/Assert 2: Fix RequiredString, but only notify about IntFrom1To100 + // Only IntFrom1To100 gets validated; messages for RequiredString are left unchanged + model.RequiredString = "This string is very cool and very legal"; + editContext.NotifyFieldChanged(intFrom1To100Identifier); + Assert.Equal(2, onValidationStateChangedCount); + Assert.Equal(new string[] + { + "RequiredString:required", + "IntFrom1To100:range" + }, + editContext.GetValidationMessages()); + + // Act/Assert 3: Notify about RequiredString + editContext.NotifyFieldChanged(requiredStringIdentifier); + Assert.Equal(3, onValidationStateChangedCount); + Assert.Equal(new[] { "IntFrom1To100:range" }, editContext.GetValidationMessages()); + } + + [Theory] + [InlineData(nameof(TestModel.ThisWillNotBeValidatedBecauseItIsAField))] + [InlineData(nameof(TestModel.ThisWillNotBeValidatedBecauseItIsInternal))] + [InlineData("ThisWillNotBeValidatedBecauseItIsPrivate")] + [InlineData("This does not correspond to anything")] + [InlineData("")] + public void IgnoresFieldChangesThatDoNotCorrespondToAValidatableProperty(string fieldName) + { + // Arrange + var editContext = new EditContext(new TestModel()).AddDataAnnotationsValidation(); + var onValidationStateChangedCount = 0; + editContext.OnValidationStateChanged += (sender, eventArgs) => onValidationStateChangedCount++; + + // Act/Assert: Ignores field changes that don't correspond to a validatable property + editContext.NotifyFieldChanged(editContext.Field(fieldName)); + Assert.Equal(0, onValidationStateChangedCount); + + // Act/Assert: For sanity, observe that we would have validated if it was a validatable property + editContext.NotifyFieldChanged(editContext.Field(nameof(TestModel.RequiredString))); + Assert.Equal(1, onValidationStateChangedCount); + } + + class TestModel + { + [Required(ErrorMessage = "RequiredString:required")] public string RequiredString { get; set; } + + [Range(1, 100, ErrorMessage = "IntFrom1To100:range")] public int IntFrom1To100 { get; set; } + +#pragma warning disable 649 + [Required] public string ThisWillNotBeValidatedBecauseItIsAField; + [Required] string ThisWillNotBeValidatedBecauseItIsPrivate { get; set; } + [Required] internal string ThisWillNotBeValidatedBecauseItIsInternal { get; set; } +#pragma warning restore 649 + } + } +} diff --git a/src/Components/Components/test/Forms/EditContextTest.cs b/src/Components/Components/test/Forms/EditContextTest.cs new file mode 100644 index 0000000000..5c8e7af36e --- /dev/null +++ b/src/Components/Components/test/Forms/EditContextTest.cs @@ -0,0 +1,240 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Linq; +using Xunit; + +namespace Microsoft.AspNetCore.Components.Forms +{ + public class EditContextTest + { + [Fact] + public void CannotUseNullModel() + { + var ex = Assert.Throws(() => new EditContext(null)); + Assert.Equal("model", ex.ParamName); + } + + [Fact] + public void CanGetModel() + { + var model = new object(); + var editContext = new EditContext(model); + Assert.Same(model, editContext.Model); + } + + [Fact] + public void CanConstructFieldIdentifiersForRootModel() + { + // Arrange/Act + var model = new object(); + var editContext = new EditContext(model); + var fieldIdentifier = editContext.Field("testFieldName"); + + // Assert + Assert.Same(model, fieldIdentifier.Model); + Assert.Equal("testFieldName", fieldIdentifier.FieldName); + } + + [Fact] + public void IsInitiallyUnmodified() + { + var editContext = new EditContext(new object()); + Assert.False(editContext.IsModified()); + } + + [Fact] + public void TracksFieldsAsModifiedWhenValueChanged() + { + // Arrange + var editContext = new EditContext(new object()); + var fieldOnThisModel1 = editContext.Field("field1"); + var fieldOnThisModel2 = editContext.Field("field2"); + var fieldOnOtherModel = new FieldIdentifier(new object(), "field on other model"); + + // Act + editContext.NotifyFieldChanged(fieldOnThisModel1); + editContext.NotifyFieldChanged(fieldOnOtherModel); + + // Assert + Assert.True(editContext.IsModified()); + Assert.True(editContext.IsModified(fieldOnThisModel1)); + Assert.False(editContext.IsModified(fieldOnThisModel2)); + Assert.True(editContext.IsModified(fieldOnOtherModel)); + } + + [Fact] + public void CanClearIndividualModifications() + { + // Arrange + var editContext = new EditContext(new object()); + var fieldThatWasModified = editContext.Field("field1"); + var fieldThatRemainsModified = editContext.Field("field2"); + var fieldThatWasNeverModified = editContext.Field("field that was never modified"); + editContext.NotifyFieldChanged(fieldThatWasModified); + editContext.NotifyFieldChanged(fieldThatRemainsModified); + + // Act + editContext.MarkAsUnmodified(fieldThatWasModified); + editContext.MarkAsUnmodified(fieldThatWasNeverModified); + + // Assert + Assert.True(editContext.IsModified()); + Assert.False(editContext.IsModified(fieldThatWasModified)); + Assert.True(editContext.IsModified(fieldThatRemainsModified)); + Assert.False(editContext.IsModified(fieldThatWasNeverModified)); + } + + [Fact] + public void CanClearAllModifications() + { + // Arrange + var editContext = new EditContext(new object()); + var field1 = editContext.Field("field1"); + var field2 = editContext.Field("field2"); + editContext.NotifyFieldChanged(field1); + editContext.NotifyFieldChanged(field2); + + // Act + editContext.MarkAsUnmodified(); + + // Assert + Assert.False(editContext.IsModified()); + Assert.False(editContext.IsModified(field1)); + Assert.False(editContext.IsModified(field2)); + } + + [Fact] + public void RaisesEventWhenFieldIsChanged() + { + // Arrange + var editContext = new EditContext(new object()); + var field1 = new FieldIdentifier(new object(), "fieldname"); // Shows it can be on a different model + var didReceiveNotification = false; + editContext.OnFieldChanged += (sender, eventArgs) => + { + Assert.Same(editContext, sender); + Assert.Equal(field1, eventArgs.FieldIdentifier); + didReceiveNotification = true; + }; + + // Act + editContext.NotifyFieldChanged(field1); + + // Assert + Assert.True(didReceiveNotification); + } + + [Fact] + public void CanEnumerateValidationMessagesAcrossAllStoresForSingleField() + { + // Arrange + var editContext = new EditContext(new object()); + var store1 = new ValidationMessageStore(editContext); + var store2 = new ValidationMessageStore(editContext); + var field = new FieldIdentifier(new object(), "field"); + var fieldWithNoState = new FieldIdentifier(new object(), "field with no state"); + store1.Add(field, "Store 1 message 1"); + store1.Add(field, "Store 1 message 2"); + store1.Add(new FieldIdentifier(new object(), "otherfield"), "Message for other field that should not appear in results"); + store2.Add(field, "Store 2 message 1"); + + // Act/Assert: Can pick out the messages for a field + Assert.Equal(new[] + { + "Store 1 message 1", + "Store 1 message 2", + "Store 2 message 1", + }, editContext.GetValidationMessages(field).OrderBy(x => x)); // Sort because the order isn't defined + + // Act/Assert: It's fine to ask for messages for a field with no associated state + Assert.Empty(editContext.GetValidationMessages(fieldWithNoState)); + + // Act/Assert: After clearing a single store, we only see the results from other stores + store1.Clear(field); + Assert.Equal(new[] { "Store 2 message 1", }, editContext.GetValidationMessages(field)); + } + + [Fact] + public void CanEnumerateValidationMessagesAcrossAllStoresForAllFields() + { + // Arrange + var editContext = new EditContext(new object()); + var store1 = new ValidationMessageStore(editContext); + var store2 = new ValidationMessageStore(editContext); + var field1 = new FieldIdentifier(new object(), "field1"); + var field2 = new FieldIdentifier(new object(), "field2"); + store1.Add(field1, "Store 1 field 1 message 1"); + store1.Add(field1, "Store 1 field 1 message 2"); + store1.Add(field2, "Store 1 field 2 message 1"); + store2.Add(field1, "Store 2 field 1 message 1"); + + // Act/Assert + Assert.Equal(new[] + { + "Store 1 field 1 message 1", + "Store 1 field 1 message 2", + "Store 1 field 2 message 1", + "Store 2 field 1 message 1", + }, editContext.GetValidationMessages().OrderBy(x => x)); // Sort because the order isn't defined + + // Act/Assert: After clearing a single store, we only see the results from other stores + store1.Clear(); + Assert.Equal(new[] { "Store 2 field 1 message 1", }, editContext.GetValidationMessages()); + } + + [Fact] + public void IsValidWithNoValidationMessages() + { + // Arrange + var editContext = new EditContext(new object()); + + // Act + var isValid = editContext.Validate(); + + // assert + Assert.True(isValid); + } + + [Fact] + public void IsInvalidWithValidationMessages() + { + // Arrange + var editContext = new EditContext(new object()); + var messages = new ValidationMessageStore(editContext); + messages.Add( + new FieldIdentifier(new object(), "some field"), + "Some message"); + + // Act + var isValid = editContext.Validate(); + + // assert + Assert.False(isValid); + } + + [Fact] + public void RequestsValidationWhenValidateIsCalled() + { + // Arrange + var editContext = new EditContext(new object()); + var messages = new ValidationMessageStore(editContext); + editContext.OnValidationRequested += (sender, eventArgs) => + { + Assert.Same(editContext, sender); + Assert.NotNull(eventArgs); + messages.Add( + new FieldIdentifier(new object(), "some field"), + "Some message"); + }; + + // Act + var isValid = editContext.Validate(); + + // assert + Assert.False(isValid); + Assert.Equal(new[] { "Some message" }, editContext.GetValidationMessages()); + } + } +} diff --git a/src/Components/Components/test/Forms/FieldIdentifierTest.cs b/src/Components/Components/test/Forms/FieldIdentifierTest.cs new file mode 100644 index 0000000000..f19751a45d --- /dev/null +++ b/src/Components/Components/test/Forms/FieldIdentifierTest.cs @@ -0,0 +1,198 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using Xunit; + +namespace Microsoft.AspNetCore.Components.Forms +{ + public class FieldIdentifierTest + { + [Fact] + public void CannotUseNullModel() + { + var ex = Assert.Throws(() => new FieldIdentifier(null, "somefield")); + Assert.Equal("model", ex.ParamName); + } + + [Fact] + public void CannotUseValueTypeModel() + { + var ex = Assert.Throws(() => new FieldIdentifier(DateTime.Now, "somefield")); + Assert.Equal("model", ex.ParamName); + Assert.StartsWith("The model must be a reference-typed object.", ex.Message); + } + + [Fact] + public void CannotUseNullFieldName() + { + var ex = Assert.Throws(() => new FieldIdentifier(new object(), null)); + Assert.Equal("fieldName", ex.ParamName); + } + + [Fact] + public void CanUseEmptyFieldName() + { + var fieldIdentifier = new FieldIdentifier(new object(), string.Empty); + Assert.Equal(string.Empty, fieldIdentifier.FieldName); + } + + [Fact] + public void CanGetModelAndFieldName() + { + // Arrange/Act + var model = new object(); + var fieldIdentifier = new FieldIdentifier(model, "someField"); + + // Assert + Assert.Same(model, fieldIdentifier.Model); + Assert.Equal("someField", fieldIdentifier.FieldName); + } + + [Fact] + public void DistinctModelsProduceDistinctHashCodesAndNonEquality() + { + // Arrange + var fieldIdentifier1 = new FieldIdentifier(new object(), "field"); + var fieldIdentifier2 = new FieldIdentifier(new object(), "field"); + + // Act/Assert + Assert.NotEqual(fieldIdentifier1.GetHashCode(), fieldIdentifier2.GetHashCode()); + Assert.False(fieldIdentifier1.Equals(fieldIdentifier2)); + } + + [Fact] + public void DistinctFieldNamesProduceDistinctHashCodesAndNonEquality() + { + // Arrange + var model = new object(); + var fieldIdentifier1 = new FieldIdentifier(model, "field1"); + var fieldIdentifier2 = new FieldIdentifier(model, "field2"); + + // Act/Assert + Assert.NotEqual(fieldIdentifier1.GetHashCode(), fieldIdentifier2.GetHashCode()); + Assert.False(fieldIdentifier1.Equals(fieldIdentifier2)); + } + + [Fact] + public void SameContentsProduceSameHashCodesAndEquality() + { + // Arrange + var model = new object(); + var fieldIdentifier1 = new FieldIdentifier(model, "field"); + var fieldIdentifier2 = new FieldIdentifier(model, "field"); + + // Act/Assert + Assert.Equal(fieldIdentifier1.GetHashCode(), fieldIdentifier2.GetHashCode()); + Assert.True(fieldIdentifier1.Equals(fieldIdentifier2)); + } + + [Fact] + public void FieldNamesAreCaseSensitive() + { + // Arrange + var model = new object(); + var fieldIdentifierLower = new FieldIdentifier(model, "field"); + var fieldIdentifierPascal = new FieldIdentifier(model, "Field"); + + // Act/Assert + Assert.Equal("field", fieldIdentifierLower.FieldName); + Assert.Equal("Field", fieldIdentifierPascal.FieldName); + Assert.NotEqual(fieldIdentifierLower.GetHashCode(), fieldIdentifierPascal.GetHashCode()); + Assert.False(fieldIdentifierLower.Equals(fieldIdentifierPascal)); + } + + [Fact] + public void CanCreateFromExpression_Property() + { + var model = new TestModel(); + var fieldIdentifier = FieldIdentifier.Create(() => model.StringProperty); + Assert.Same(model, fieldIdentifier.Model); + Assert.Equal(nameof(model.StringProperty), fieldIdentifier.FieldName); + } + + [Fact] + public void CannotCreateFromExpression_NonMember() + { + var ex = Assert.Throws(() => + FieldIdentifier.Create(() => new TestModel())); + Assert.Equal($"The provided expression contains a NewExpression which is not supported. {nameof(FieldIdentifier)} only supports simple member accessors (fields, properties) of an object.", ex.Message); + } + + [Fact] + public void CanCreateFromExpression_Field() + { + var model = new TestModel(); + var fieldIdentifier = FieldIdentifier.Create(() => model.StringField); + Assert.Same(model, fieldIdentifier.Model); + Assert.Equal(nameof(model.StringField), fieldIdentifier.FieldName); + } + + [Fact] + public void CanCreateFromExpression_WithCastToObject() + { + // This case is needed because, if a component is declared as receiving + // an Expression>, then any value types will be implicitly cast + var model = new TestModel(); + Expression> accessor = () => model.IntProperty; + var fieldIdentifier = FieldIdentifier.Create(accessor); + Assert.Same(model, fieldIdentifier.Model); + Assert.Equal(nameof(model.IntProperty), fieldIdentifier.FieldName); + } + + [Fact] + public void CanCreateFromExpression_MemberOfConstantExpression() + { + var fieldIdentifier = FieldIdentifier.Create(() => StringPropertyOnThisClass); + Assert.Same(this, fieldIdentifier.Model); + Assert.Equal(nameof(StringPropertyOnThisClass), fieldIdentifier.FieldName); + } + + [Fact] + public void CanCreateFromExpression_MemberOfChildObject() + { + var parentModel = new ParentModel { Child = new TestModel() }; + var fieldIdentifier = FieldIdentifier.Create(() => parentModel.Child.StringField); + Assert.Same(parentModel.Child, fieldIdentifier.Model); + Assert.Equal(nameof(TestModel.StringField), fieldIdentifier.FieldName); + } + + [Fact] + public void CanCreateFromExpression_MemberOfIndexedCollectionEntry() + { + var models = new List() { null, new TestModel() }; + var fieldIdentifier = FieldIdentifier.Create(() => models[1].StringField); + Assert.Same(models[1], fieldIdentifier.Model); + Assert.Equal(nameof(TestModel.StringField), fieldIdentifier.FieldName); + } + + [Fact] + public void CanCreateFromExpression_MemberOfObjectWithCast() + { + var model = new TestModel(); + var fieldIdentifier = FieldIdentifier.Create(() => ((TestModel)(object)model).StringField); + Assert.Same(model, fieldIdentifier.Model); + Assert.Equal(nameof(TestModel.StringField), fieldIdentifier.FieldName); + } + + string StringPropertyOnThisClass { get; set; } + + class TestModel + { + public string StringProperty { get; set; } + + public int IntProperty { get; set; } + +#pragma warning disable 649 + public string StringField; +#pragma warning restore 649 + } + + class ParentModel + { + public TestModel Child { get; set; } + } + } +} diff --git a/src/Components/Components/test/Forms/InputBaseTest.cs b/src/Components/Components/test/Forms/InputBaseTest.cs new file mode 100644 index 0000000000..cf0e33a0b6 --- /dev/null +++ b/src/Components/Components/test/Forms/InputBaseTest.cs @@ -0,0 +1,472 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components.RenderTree; +using Microsoft.AspNetCore.Components.Test.Helpers; +using Xunit; + +namespace Microsoft.AspNetCore.Components.Forms +{ + public class InputBaseTest + { + [Fact] + public async Task ThrowsOnFirstRenderIfNoEditContextIsSupplied() + { + // Arrange + var inputComponent = new TestInputComponent(); + var testRenderer = new TestRenderer(); + var componentId = testRenderer.AssignRootComponentId(inputComponent); + + // Act/Assert + var ex = await Assert.ThrowsAsync( + () => testRenderer.RenderRootComponentAsync(componentId)); + Assert.StartsWith($"{typeof(TestInputComponent)} requires a cascading parameter of type {nameof(EditContext)}", ex.Message); + } + + [Fact] + public async Task ThrowsIfEditContextChanges() + { + // Arrange + var model = new TestModel(); + var rootComponent = new TestInputHostComponent> { EditContext = new EditContext(model), ValueExpression = () => model.StringProperty }; + await RenderAndGetTestInputComponentAsync(rootComponent); + + // Act/Assert + rootComponent.EditContext = new EditContext(model); + var ex = Assert.Throws(() => rootComponent.TriggerRender()); + Assert.StartsWith($"{typeof(TestInputComponent)} does not support changing the EditContext dynamically", ex.Message); + } + + [Fact] + public async Task ThrowsIfNoValueExpressionIsSupplied() + { + // Arrange + var model = new TestModel(); + var rootComponent = new TestInputHostComponent> { EditContext = new EditContext(model) }; + + // Act/Assert + var ex = await Assert.ThrowsAsync(() => RenderAndGetTestInputComponentAsync(rootComponent)); + Assert.Contains($"{typeof(TestInputComponent)} requires a value for the 'ValueExpression' parameter. Normally this is provided automatically when using 'bind-Value'.", ex.Message); + } + + [Fact] + public async Task GetsCurrentValueFromValueParameter() + { + // Arrange + var model = new TestModel(); + var rootComponent = new TestInputHostComponent> + { + EditContext = new EditContext(model), + Value = "some value", + ValueExpression = () => model.StringProperty + }; + + // Act + var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent); + + // Assert + Assert.Equal("some value", inputComponent.CurrentValue); + } + + [Fact] + public async Task ExposesIdToSubclass() + { + // Arrange + var model = new TestModel(); + var rootComponent = new TestInputHostComponent> + { + Id = "test-id", + EditContext = new EditContext(model), + ValueExpression = () => model.StringProperty + }; + + // Act + var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent); + + // Assert + Assert.Same(rootComponent.Id, inputComponent.Id); + } + + [Fact] + public async Task ExposesEditContextToSubclass() + { + // Arrange + var model = new TestModel(); + var rootComponent = new TestInputHostComponent> + { + EditContext = new EditContext(model), + Value = "some value", + ValueExpression = () => model.StringProperty + }; + + // Act + var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent); + + // Assert + Assert.Same(rootComponent.EditContext, inputComponent.EditContext); + } + + [Fact] + public async Task ExposesFieldIdentifierToSubclass() + { + // Arrange + var model = new TestModel(); + var rootComponent = new TestInputHostComponent> + { + EditContext = new EditContext(model), + Value = "some value", + ValueExpression = () => model.StringProperty + }; + + // Act + var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent); + + // Assert + Assert.Equal(FieldIdentifier.Create(() => model.StringProperty), inputComponent.FieldIdentifier); + } + + [Fact] + public async Task CanReadBackChangesToCurrentValue() + { + // Arrange + var model = new TestModel(); + var rootComponent = new TestInputHostComponent> + { + EditContext = new EditContext(model), + Value = "initial value", + ValueExpression = () => model.StringProperty + }; + var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent); + Assert.Equal("initial value", inputComponent.CurrentValue); + + // Act + inputComponent.CurrentValue = "new value"; + + // Assert + Assert.Equal("new value", inputComponent.CurrentValue); + } + + [Fact] + public async Task WritingToCurrentValueInvokesValueChangedIfDifferent() + { + // Arrange + var model = new TestModel(); + var valueChangedCallLog = new List(); + var rootComponent = new TestInputHostComponent> + { + EditContext = new EditContext(model), + Value = "initial value", + ValueChanged = val => valueChangedCallLog.Add(val), + ValueExpression = () => model.StringProperty + }; + var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent); + Assert.Empty(valueChangedCallLog); + + // Act + inputComponent.CurrentValue = "new value"; + + // Assert + Assert.Single(valueChangedCallLog, "new value"); + } + + [Fact] + public async Task WritingToCurrentValueDoesNotInvokeValueChangedIfUnchanged() + { + // Arrange + var model = new TestModel(); + var valueChangedCallLog = new List(); + var rootComponent = new TestInputHostComponent> + { + EditContext = new EditContext(model), + Value = "initial value", + ValueChanged = val => valueChangedCallLog.Add(val), + ValueExpression = () => model.StringProperty + }; + var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent); + Assert.Empty(valueChangedCallLog); + + // Act + inputComponent.CurrentValue = "initial value"; + + // Assert + Assert.Empty(valueChangedCallLog); + } + + [Fact] + public async Task WritingToCurrentValueNotifiesEditContext() + { + // Arrange + var model = new TestModel(); + var rootComponent = new TestInputHostComponent> + { + EditContext = new EditContext(model), + Value = "initial value", + ValueExpression = () => model.StringProperty + }; + var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent); + Assert.False(rootComponent.EditContext.IsModified(() => model.StringProperty)); + + // Act + inputComponent.CurrentValue = "new value"; + + // Assert + Assert.True(rootComponent.EditContext.IsModified(() => model.StringProperty)); + } + + [Fact] + public async Task SuppliesFieldClassCorrespondingToFieldState() + { + // Arrange + var model = new TestModel(); + var rootComponent = new TestInputHostComponent> + { + EditContext = new EditContext(model), + ValueExpression = () => model.StringProperty + }; + var fieldIdentifier = FieldIdentifier.Create(() => model.StringProperty); + + // Act/Assert: Initally, it's valid and unmodified + var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent); + Assert.Equal("valid", inputComponent.FieldClass); + Assert.Equal("valid", inputComponent.CssClass); // Same because no Class was specified + + // Act/Assert: Modify the field + rootComponent.EditContext.NotifyFieldChanged(fieldIdentifier); + Assert.Equal("modified valid", inputComponent.FieldClass); + Assert.Equal("modified valid", inputComponent.CssClass); + + // Act/Assert: Make it invalid + var messages = new ValidationMessageStore(rootComponent.EditContext); + messages.Add(fieldIdentifier, "I do not like this value"); + Assert.Equal("modified invalid", inputComponent.FieldClass); + Assert.Equal("modified invalid", inputComponent.CssClass); + + // Act/Assert: Clear the modification flag + rootComponent.EditContext.MarkAsUnmodified(fieldIdentifier); + Assert.Equal("invalid", inputComponent.FieldClass); + Assert.Equal("invalid", inputComponent.CssClass); + + // Act/Assert: Make it valid + messages.Clear(); + Assert.Equal("valid", inputComponent.FieldClass); + Assert.Equal("valid", inputComponent.CssClass); + } + + [Fact] + public async Task CssClassCombinesClassWithFieldClass() + { + // Arrange + var model = new TestModel(); + var rootComponent = new TestInputHostComponent> + { + Class = "my-class other-class", + EditContext = new EditContext(model), + ValueExpression = () => model.StringProperty + }; + var fieldIdentifier = FieldIdentifier.Create(() => model.StringProperty); + + // Act/Assert + var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent); + Assert.Equal("valid", inputComponent.FieldClass); + Assert.Equal("my-class other-class valid", inputComponent.CssClass); + + // Act/Assert: Retains custom class when changing field class + rootComponent.EditContext.NotifyFieldChanged(fieldIdentifier); + Assert.Equal("modified valid", inputComponent.FieldClass); + Assert.Equal("my-class other-class modified valid", inputComponent.CssClass); + } + + [Fact] + public async Task SuppliesCurrentValueAsStringWithFormatting() + { + // Arrange + var model = new TestModel(); + var rootComponent = new TestInputHostComponent + { + EditContext = new EditContext(model), + Value = new DateTime(1915, 3, 2), + ValueExpression = () => model.DateProperty + }; + var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent); + + // Act/Assert + Assert.Equal("1915/03/02", inputComponent.CurrentValueAsString); + } + + [Fact] + public async Task ParsesCurrentValueAsStringWhenChanged_Valid() + { + // Arrange + var model = new TestModel(); + var valueChangedArgs = new List(); + var rootComponent = new TestInputHostComponent + { + EditContext = new EditContext(model), + ValueChanged = valueChangedArgs.Add, + ValueExpression = () => model.DateProperty + }; + var fieldIdentifier = FieldIdentifier.Create(() => model.DateProperty); + var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent); + var numValidationStateChanges = 0; + rootComponent.EditContext.OnValidationStateChanged += (sender, eventArgs) => { numValidationStateChanges++; }; + + // Act + inputComponent.CurrentValueAsString = "1991/11/20"; + + // Assert + var receivedParsedValue = valueChangedArgs.Single(); + Assert.Equal(1991, receivedParsedValue.Year); + Assert.Equal(11, receivedParsedValue.Month); + Assert.Equal(20, receivedParsedValue.Day); + Assert.True(rootComponent.EditContext.IsModified(fieldIdentifier)); + Assert.Empty(rootComponent.EditContext.GetValidationMessages(fieldIdentifier)); + Assert.Equal(0, numValidationStateChanges); + } + + [Fact] + public async Task ParsesCurrentValueAsStringWhenChanged_Invalid() + { + // Arrange + var model = new TestModel(); + var valueChangedArgs = new List(); + var rootComponent = new TestInputHostComponent + { + EditContext = new EditContext(model), + ValueChanged = valueChangedArgs.Add, + ValueExpression = () => model.DateProperty + }; + var fieldIdentifier = FieldIdentifier.Create(() => model.DateProperty); + var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent); + var numValidationStateChanges = 0; + rootComponent.EditContext.OnValidationStateChanged += (sender, eventArgs) => { numValidationStateChanges++; }; + + // Act/Assert 1: Transition to invalid + inputComponent.CurrentValueAsString = "1991/11/40"; + Assert.Empty(valueChangedArgs); + Assert.True(rootComponent.EditContext.IsModified(fieldIdentifier)); + Assert.Equal(new[] { "Bad date value" }, rootComponent.EditContext.GetValidationMessages(fieldIdentifier)); + Assert.Equal(1, numValidationStateChanges); + + // Act/Assert 2: Transition to valid + inputComponent.CurrentValueAsString = "1991/11/20"; + var receivedParsedValue = valueChangedArgs.Single(); + Assert.Equal(1991, receivedParsedValue.Year); + Assert.Equal(11, receivedParsedValue.Month); + Assert.Equal(20, receivedParsedValue.Day); + Assert.True(rootComponent.EditContext.IsModified(fieldIdentifier)); + Assert.Empty(rootComponent.EditContext.GetValidationMessages(fieldIdentifier)); + Assert.Equal(2, numValidationStateChanges); + } + + private static TComponent FindComponent(CapturedBatch batch) + => batch.ReferenceFrames + .Where(f => f.FrameType == RenderTreeFrameType.Component) + .Select(f => f.Component) + .OfType() + .Single(); + + private static async Task RenderAndGetTestInputComponentAsync(TestInputHostComponent hostComponent) where TComponent: TestInputComponent + { + var testRenderer = new TestRenderer(); + var componentId = testRenderer.AssignRootComponentId(hostComponent); + await testRenderer.RenderRootComponentAsync(componentId); + return FindComponent(testRenderer.Batches.Single()); + } + + class TestModel + { + public string StringProperty { get; set; } + + public DateTime DateProperty { get; set; } + } + + class TestInputComponent : InputBase + { + // Expose protected members publicly for tests + + public new T CurrentValue + { + get => base.CurrentValue; + set { base.CurrentValue = value; } + } + + public new string CurrentValueAsString + { + get => base.CurrentValueAsString; + set { base.CurrentValueAsString = value; } + } + + public new string Id => base.Id; + + public new string CssClass => base.CssClass; + + public new EditContext EditContext => base.EditContext; + + public new FieldIdentifier FieldIdentifier => base.FieldIdentifier; + + public new string FieldClass => base.FieldClass; + + protected override bool TryParseValueFromString(string value, out T result, out string validationErrorMessage) + { + throw new NotImplementedException(); + } + } + + class TestDateInputComponent : TestInputComponent + { + protected override string FormatValueAsString(DateTime value) + => value.ToString("yyyy/MM/dd"); + + protected override bool TryParseValueFromString(string value, out DateTime result, out string validationErrorMessage) + { + if (DateTime.TryParse(value, out result)) + { + validationErrorMessage = null; + return true; + } + else + { + validationErrorMessage = "Bad date value"; + return false; + } + } + } + + class TestInputHostComponent : AutoRenderComponent where TComponent: TestInputComponent + { + public string Id { get; set; } + + public string Class { get; set; } + + public EditContext EditContext { get; set; } + + public TValue Value { get; set; } + + public Action ValueChanged { get; set; } + + public Expression> ValueExpression { get; set; } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.OpenComponent>(0); + builder.AddAttribute(1, "Value", EditContext); + builder.AddAttribute(2, RenderTreeBuilder.ChildContent, new RenderFragment(childBuilder => + { + childBuilder.OpenComponent(0); + childBuilder.AddAttribute(0, "Value", Value); + childBuilder.AddAttribute(1, "ValueChanged", ValueChanged); + childBuilder.AddAttribute(2, "ValueExpression", ValueExpression); + childBuilder.AddAttribute(3, nameof(Id), Id); + childBuilder.AddAttribute(4, nameof(Class), Class); + childBuilder.CloseComponent(); + })); + builder.CloseComponent(); + } + } + } +} diff --git a/src/Components/Components/test/Forms/ValidationMessageStoreTest.cs b/src/Components/Components/test/Forms/ValidationMessageStoreTest.cs new file mode 100644 index 0000000000..75e4ef3452 --- /dev/null +++ b/src/Components/Components/test/Forms/ValidationMessageStoreTest.cs @@ -0,0 +1,96 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Xunit; + +namespace Microsoft.AspNetCore.Components.Forms +{ + public class ValidationMessageStoreTest + { + [Fact] + public void CannotUseNullEditContext() + { + var ex = Assert.Throws(() => new ValidationMessageStore(null)); + Assert.Equal("editContext", ex.ParamName); + } + + [Fact] + public void CanCreateForEditContext() + { + new ValidationMessageStore(new EditContext(new object())); + } + + [Fact] + public void CanAddMessages() + { + // Arrange + var messages = new ValidationMessageStore(new EditContext(new object())); + var field1 = new FieldIdentifier(new object(), "field1"); + var field2 = new FieldIdentifier(new object(), "field2"); + var field3 = new FieldIdentifier(new object(), "field3"); + + // Act + messages.Add(field1, "Field 1 message 1"); + messages.Add(field1, "Field 1 message 2"); + messages.Add(field2, "Field 2 message 1"); + + // Assert + Assert.Equal(new[] { "Field 1 message 1", "Field 1 message 2" }, messages[field1]); + Assert.Equal(new[] { "Field 2 message 1" }, messages[field2]); + Assert.Empty(messages[field3]); + } + + [Fact] + public void CanAddMessagesByRange() + { + // Arrange + var messages = new ValidationMessageStore(new EditContext(new object())); + var field1 = new FieldIdentifier(new object(), "field1"); + var entries = new[] { "A", "B", "C" }; + + // Act + messages.AddRange(field1, entries); + + // Assert + Assert.Equal(entries, messages[field1]); + } + + [Fact] + public void CanClearMessagesForSingleField() + { + // Arrange + var messages = new ValidationMessageStore(new EditContext(new object())); + var field1 = new FieldIdentifier(new object(), "field1"); + var field2 = new FieldIdentifier(new object(), "field2"); + messages.Add(field1, "Field 1 message 1"); + messages.Add(field1, "Field 1 message 2"); + messages.Add(field2, "Field 2 message 1"); + + // Act + messages.Clear(field1); + + // Assert + Assert.Empty(messages[field1]); + Assert.Equal(new[] { "Field 2 message 1" }, messages[field2]); + } + + [Fact] + public void CanClearMessagesForAllFields() + { + // Arrange + var messages = new ValidationMessageStore(new EditContext(new object())); + var field1 = new FieldIdentifier(new object(), "field1"); + var field2 = new FieldIdentifier(new object(), "field2"); + messages.Add(field1, "Field 1 message 1"); + messages.Add(field2, "Field 2 message 1"); + + // Act + messages.Clear(); + + // Assert + Assert.Empty(messages[field1]); + Assert.Empty(messages[field2]); + } + } +} diff --git a/src/Components/test/E2ETest/Tests/FormsTest.cs b/src/Components/test/E2ETest/Tests/FormsTest.cs new file mode 100644 index 0000000000..f7046bc256 --- /dev/null +++ b/src/Components/test/E2ETest/Tests/FormsTest.cs @@ -0,0 +1,327 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using BasicTestApp; +using BasicTestApp.FormsTest; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures; +using OpenQA.Selenium; +using OpenQA.Selenium.Support.UI; +using System; +using System.Linq; +using System.Threading.Tasks; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.AspNetCore.Components.E2ETest.Tests +{ + public class FormsTest : BasicTestAppTestBase + { + public FormsTest( + BrowserFixture browserFixture, + ToggleExecutionModeServerFixture serverFixture, + ITestOutputHelper output) + : base(browserFixture, serverFixture, output) + { + // On WebAssembly, page reloads are expensive so skip if possible + Navigate(ServerPathBase, noReload: !serverFixture.UsingAspNetHost); + } + + [Fact] + public async Task EditFormWorksWithDataAnnotationsValidator() + { + var appElement = MountTestComponent(); + var userNameInput = appElement.FindElement(By.ClassName("user-name")).FindElement(By.TagName("input")); + var acceptsTermsInput = appElement.FindElement(By.ClassName("accepts-terms")).FindElement(By.TagName("input")); + var submitButton = appElement.FindElement(By.TagName("button")); + var messagesAccessor = CreateValidationMessagesAccessor(appElement); + + // Editing a field doesn't trigger validation on its own + userNameInput.SendKeys("Bert\t"); + acceptsTermsInput.Click(); // Accept terms + acceptsTermsInput.Click(); // Un-accept terms + await Task.Delay(500); // There's no expected change to the UI, so just wait a moment before asserting + WaitAssert.Empty(messagesAccessor); + Assert.Empty(appElement.FindElements(By.Id("last-callback"))); + + // Submitting the form does validate + submitButton.Click(); + WaitAssert.Equal(new[] { "You must accept the terms" }, messagesAccessor); + WaitAssert.Equal("OnInvalidSubmit", () => appElement.FindElement(By.Id("last-callback")).Text); + + // Can make another field invalid + userNameInput.Clear(); + submitButton.Click(); + WaitAssert.Equal(new[] { "Please choose a username", "You must accept the terms" }, messagesAccessor); + WaitAssert.Equal("OnInvalidSubmit", () => appElement.FindElement(By.Id("last-callback")).Text); + + // Can make valid + userNameInput.SendKeys("Bert\t"); + acceptsTermsInput.Click(); + submitButton.Click(); + WaitAssert.Empty(messagesAccessor); + WaitAssert.Equal("OnValidSubmit", () => appElement.FindElement(By.Id("last-callback")).Text); + } + + [Fact] + public void InputTextInteractsWithEditContext() + { + var appElement = MountTestComponent(); + var nameInput = appElement.FindElement(By.ClassName("name")).FindElement(By.TagName("input")); + var messagesAccessor = CreateValidationMessagesAccessor(appElement); + + // Validates on edit + WaitAssert.Equal("valid", () => nameInput.GetAttribute("class")); + nameInput.SendKeys("Bert\t"); + WaitAssert.Equal("modified valid", () => nameInput.GetAttribute("class")); + + // Can become invalid + nameInput.SendKeys("01234567890123456789\t"); + WaitAssert.Equal("modified invalid", () => nameInput.GetAttribute("class")); + WaitAssert.Equal(new[] { "That name is too long" }, messagesAccessor); + + // Can become valid + nameInput.Clear(); + nameInput.SendKeys("Bert\t"); + WaitAssert.Equal("modified valid", () => nameInput.GetAttribute("class")); + WaitAssert.Empty(messagesAccessor); + } + + [Fact] + public void InputNumberInteractsWithEditContext_NonNullableInt() + { + var appElement = MountTestComponent(); + var ageInput = appElement.FindElement(By.ClassName("age")).FindElement(By.TagName("input")); + var messagesAccessor = CreateValidationMessagesAccessor(appElement); + + // Validates on edit + WaitAssert.Equal("valid", () => ageInput.GetAttribute("class")); + ageInput.SendKeys("123\t"); + WaitAssert.Equal("modified valid", () => ageInput.GetAttribute("class")); + + // Can become invalid + ageInput.SendKeys("e100\t"); + WaitAssert.Equal("modified invalid", () => ageInput.GetAttribute("class")); + WaitAssert.Equal(new[] { "The AgeInYears field must be a number." }, messagesAccessor); + + // Empty is invalid, because it's not a nullable int + ageInput.Clear(); + ageInput.SendKeys("\t"); + WaitAssert.Equal("modified invalid", () => ageInput.GetAttribute("class")); + WaitAssert.Equal(new[] { "The AgeInYears field must be a number." }, messagesAccessor); + + // Zero is within the allowed range + ageInput.SendKeys("0\t"); + WaitAssert.Equal("modified valid", () => ageInput.GetAttribute("class")); + WaitAssert.Empty(messagesAccessor); + } + + [Fact] + public void InputNumberInteractsWithEditContext_NullableFloat() + { + var appElement = MountTestComponent(); + var heightInput = appElement.FindElement(By.ClassName("height")).FindElement(By.TagName("input")); + var messagesAccessor = CreateValidationMessagesAccessor(appElement); + + // Validates on edit + WaitAssert.Equal("valid", () => heightInput.GetAttribute("class")); + heightInput.SendKeys("123.456\t"); + WaitAssert.Equal("modified valid", () => heightInput.GetAttribute("class")); + + // Can become invalid + heightInput.SendKeys("e100\t"); + WaitAssert.Equal("modified invalid", () => heightInput.GetAttribute("class")); + WaitAssert.Equal(new[] { "The OptionalHeight field must be a number." }, messagesAccessor); + + // Empty is valid, because it's a nullable float + heightInput.Clear(); + heightInput.SendKeys("\t"); + WaitAssert.Equal("modified valid", () => heightInput.GetAttribute("class")); + WaitAssert.Empty(messagesAccessor); + } + + [Fact] + public void InputTextAreaInteractsWithEditContext() + { + var appElement = MountTestComponent(); + var descriptionInput = appElement.FindElement(By.ClassName("description")).FindElement(By.TagName("textarea")); + var messagesAccessor = CreateValidationMessagesAccessor(appElement); + + // Validates on edit + WaitAssert.Equal("valid", () => descriptionInput.GetAttribute("class")); + descriptionInput.SendKeys("Hello\t"); + WaitAssert.Equal("modified valid", () => descriptionInput.GetAttribute("class")); + + // Can become invalid + descriptionInput.SendKeys("too long too long too long too long too long\t"); + WaitAssert.Equal("modified invalid", () => descriptionInput.GetAttribute("class")); + WaitAssert.Equal(new[] { "Description is max 20 chars" }, messagesAccessor); + + // Can become valid + descriptionInput.Clear(); + descriptionInput.SendKeys("Hello\t"); + WaitAssert.Equal("modified valid", () => descriptionInput.GetAttribute("class")); + WaitAssert.Empty(messagesAccessor); + } + + [Fact] + public void InputDateInteractsWithEditContext_NonNullableDateTime() + { + var appElement = MountTestComponent(); + var renewalDateInput = appElement.FindElement(By.ClassName("renewal-date")).FindElement(By.TagName("input")); + var messagesAccessor = CreateValidationMessagesAccessor(appElement); + + // Validates on edit + WaitAssert.Equal("valid", () => renewalDateInput.GetAttribute("class")); + renewalDateInput.SendKeys("01/01/2000\t"); + WaitAssert.Equal("modified valid", () => renewalDateInput.GetAttribute("class")); + + // Can become invalid + renewalDateInput.SendKeys("0/0/0"); + WaitAssert.Equal("modified invalid", () => renewalDateInput.GetAttribute("class")); + WaitAssert.Equal(new[] { "The RenewalDate field must be a date." }, messagesAccessor); + + // Empty is invalid, because it's not nullable + renewalDateInput.SendKeys($"{Keys.Backspace}\t{Keys.Backspace}\t{Keys.Backspace}\t"); + WaitAssert.Equal("modified invalid", () => renewalDateInput.GetAttribute("class")); + WaitAssert.Equal(new[] { "The RenewalDate field must be a date." }, messagesAccessor); + + // Can become valid + renewalDateInput.SendKeys("01/01/01\t"); + WaitAssert.Equal("modified valid", () => renewalDateInput.GetAttribute("class")); + WaitAssert.Empty(messagesAccessor); + } + + [Fact] + public void InputDateInteractsWithEditContext_NullableDateTimeOffset() + { + var appElement = MountTestComponent(); + var expiryDateInput = appElement.FindElement(By.ClassName("expiry-date")).FindElement(By.TagName("input")); + var messagesAccessor = CreateValidationMessagesAccessor(appElement); + + // Validates on edit + WaitAssert.Equal("valid", () => expiryDateInput.GetAttribute("class")); + expiryDateInput.SendKeys("01/01/2000\t"); + WaitAssert.Equal("modified valid", () => expiryDateInput.GetAttribute("class")); + + // Can become invalid + expiryDateInput.SendKeys("111111111"); + WaitAssert.Equal("modified invalid", () => expiryDateInput.GetAttribute("class")); + WaitAssert.Equal(new[] { "The OptionalExpiryDate field must be a date." }, messagesAccessor); + + // Empty is valid, because it's nullable + expiryDateInput.SendKeys($"{Keys.Backspace}\t{Keys.Backspace}\t{Keys.Backspace}\t"); + WaitAssert.Equal("modified valid", () => expiryDateInput.GetAttribute("class")); + WaitAssert.Empty(messagesAccessor); + } + + [Fact] + public void InputSelectInteractsWithEditContext() + { + var appElement = MountTestComponent(); + var ticketClassInput = new SelectElement(appElement.FindElement(By.ClassName("ticket-class")).FindElement(By.TagName("select"))); + var select = ticketClassInput.WrappedElement; + var messagesAccessor = CreateValidationMessagesAccessor(appElement); + + // Validates on edit + WaitAssert.Equal("valid", () => select.GetAttribute("class")); + ticketClassInput.SelectByText("First class"); + WaitAssert.Equal("modified valid", () => select.GetAttribute("class")); + + // Can become invalid + ticketClassInput.SelectByText("(select)"); + WaitAssert.Equal("modified invalid", () => select.GetAttribute("class")); + WaitAssert.Equal(new[] { "The TicketClass field is not valid." }, messagesAccessor); + } + + [Fact] + public void InputCheckboxInteractsWithEditContext() + { + var appElement = MountTestComponent(); + var acceptsTermsInput = appElement.FindElement(By.ClassName("accepts-terms")).FindElement(By.TagName("input")); + var messagesAccessor = CreateValidationMessagesAccessor(appElement); + + // Validates on edit + WaitAssert.Equal("valid", () => acceptsTermsInput.GetAttribute("class")); + acceptsTermsInput.Click(); + WaitAssert.Equal("modified valid", () => acceptsTermsInput.GetAttribute("class")); + + // Can become invalid + acceptsTermsInput.Click(); + WaitAssert.Equal("modified invalid", () => acceptsTermsInput.GetAttribute("class")); + WaitAssert.Equal(new[] { "Must accept terms" }, messagesAccessor); + } + + [Fact] + public void CanWireUpINotifyPropertyChangedToEditContext() + { + var appElement = MountTestComponent(); + var userNameInput = appElement.FindElement(By.ClassName("user-name")).FindElement(By.TagName("input")); + var acceptsTermsInput = appElement.FindElement(By.ClassName("accepts-terms")).FindElement(By.TagName("input")); + var submitButton = appElement.FindElement(By.TagName("button")); + var messagesAccessor = CreateValidationMessagesAccessor(appElement); + var submissionStatus = appElement.FindElement(By.Id("submission-status")); + + // Editing a field triggers validation immediately + WaitAssert.Equal("valid", () => userNameInput.GetAttribute("class")); + userNameInput.SendKeys("Too long too long\t"); + WaitAssert.Equal("modified invalid", () => userNameInput.GetAttribute("class")); + WaitAssert.Equal(new[] { "That name is too long" }, messagesAccessor); + + // Submitting the form validates remaining fields + submitButton.Click(); + WaitAssert.Equal(new[] { "That name is too long", "You must accept the terms" }, messagesAccessor); + WaitAssert.Equal("modified invalid", () => userNameInput.GetAttribute("class")); + WaitAssert.Equal("invalid", () => acceptsTermsInput.GetAttribute("class")); + + // Can make fields valid + userNameInput.Clear(); + userNameInput.SendKeys("Bert\t"); + WaitAssert.Equal("modified valid", () => userNameInput.GetAttribute("class")); + acceptsTermsInput.Click(); + WaitAssert.Equal("modified valid", () => acceptsTermsInput.GetAttribute("class")); + WaitAssert.Equal(string.Empty, () => submissionStatus.Text); + submitButton.Click(); + WaitAssert.True(() => submissionStatus.Text.StartsWith("Submitted")); + + // Fields can revert to unmodified + WaitAssert.Equal("valid", () => userNameInput.GetAttribute("class")); + WaitAssert.Equal("valid", () => acceptsTermsInput.GetAttribute("class")); + } + + [Fact] + public void ValidationMessageDisplaysMessagesForField() + { + var appElement = MountTestComponent(); + var emailContainer = appElement.FindElement(By.ClassName("email")); + var emailInput = emailContainer.FindElement(By.TagName("input")); + var emailMessagesAccessor = CreateValidationMessagesAccessor(emailContainer); + var submitButton = appElement.FindElement(By.TagName("button")); + + // Doesn't show messages for other fields + submitButton.Click(); + WaitAssert.Empty(emailMessagesAccessor); + + // Updates on edit + emailInput.SendKeys("abc\t"); + WaitAssert.Equal(new[] { "That doesn't look like a real email address" }, emailMessagesAccessor); + + // Can show more than one message + emailInput.SendKeys("too long too long too long\t"); + WaitAssert.Equal(new[] { "That doesn't look like a real email address", "We only accept very short email addresses (max 10 chars)" }, emailMessagesAccessor); + + // Can become valid + emailInput.Clear(); + emailInput.SendKeys("a@b.com\t"); + WaitAssert.Empty(emailMessagesAccessor); + } + + private Func CreateValidationMessagesAccessor(IWebElement appElement) + { + return () => appElement.FindElements(By.ClassName("validation-message")) + .Select(x => x.Text) + .OrderBy(x => x) + .ToArray(); + } + } +} diff --git a/src/Components/test/testassets/BasicTestApp/BasicTestApp.csproj b/src/Components/test/testassets/BasicTestApp/BasicTestApp.csproj index 7524ef0c18..7de6b2d63a 100644 --- a/src/Components/test/testassets/BasicTestApp/BasicTestApp.csproj +++ b/src/Components/test/testassets/BasicTestApp/BasicTestApp.csproj @@ -1,4 +1,4 @@ - + netstandard2.0 @@ -9,6 +9,7 @@ + diff --git a/src/Components/test/testassets/BasicTestApp/FormsTest/NotifyPropertyChangedValidationComponent.cshtml b/src/Components/test/testassets/BasicTestApp/FormsTest/NotifyPropertyChangedValidationComponent.cshtml new file mode 100644 index 0000000000..e921bc58aa --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/FormsTest/NotifyPropertyChangedValidationComponent.cshtml @@ -0,0 +1,105 @@ +@using System.ComponentModel +@using System.ComponentModel.DataAnnotations +@using System.Runtime.CompilerServices; +@using Microsoft.AspNetCore.Components.Forms + +

    + There's no requirement for models to implement INotifyPropertyChanged, but if they do, + you can easily wire that up to the EditContext. Then you have no need to use the built-in + Input* components - you can instead bind to regular HTML elements and still get modification + notifications. This provides more flexibility in how the UI is rendered, at the cost of + more complexity and boilerplate in your model classes. +

    +

    + This example also shows that you don't strictly have to use EditForm. You can manually + cascade an EditContext to the components that integrate with it. +

    + +
    +

    + User name: + +

    +

    + Accept terms: + +

    + + + + + + +
    + +
    @submissionStatus
    + +@functions { + MyModel person = new MyModel(); + EditContext editContext; + string submissionStatus; + + protected override void OnInit() + { + editContext = new EditContext(person).AddDataAnnotationsValidation(); + + // Wire up INotifyPropertyChanged to the EditContext + person.PropertyChanged += (sender, eventArgs) => + { + var fieldIdentifier = new FieldIdentifier(sender, eventArgs.PropertyName); + editContext.NotifyFieldChanged(fieldIdentifier); + }; + } + + void HandleSubmit() + { + if (editContext.Validate()) + { + submissionStatus = $"Submitted at {DateTime.Now.ToLongTimeString()}"; + editContext.MarkAsUnmodified(); + } + } + + class MyModel : INotifyPropertyChanged + { + string _userName; + bool _acceptsTerms; + + [Required, StringLength(10, ErrorMessage = "That name is too long")] + public string UserName + { + get => _userName; + set => SetProperty(ref _userName, value); + } + + [Range(typeof(bool), "true", "true", ErrorMessage = "You must accept the terms")] + public bool AcceptsTerms + { + get => _acceptsTerms; + set => SetProperty(ref _acceptsTerms, value); + } + + #region INotifyPropertyChanged boilerplate + + public event PropertyChangedEventHandler PropertyChanged; + + void OnPropertyChanged([CallerMemberName] string propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + + bool SetProperty(ref T storage, T value, [CallerMemberName] string propertyName = null) + { + if (Equals(storage, value)) + { + return false; + } + + storage = value; + OnPropertyChanged(propertyName); + return true; + } + + #endregion + } +} diff --git a/src/Components/test/testassets/BasicTestApp/FormsTest/SimpleValidationComponent.cshtml b/src/Components/test/testassets/BasicTestApp/FormsTest/SimpleValidationComponent.cshtml new file mode 100644 index 0000000000..faca63ad32 --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/FormsTest/SimpleValidationComponent.cshtml @@ -0,0 +1,50 @@ +@using System.ComponentModel.DataAnnotations +@using Microsoft.AspNetCore.Components.Forms + + + + +

    + User name: +

    +

    + Accept terms: +

    + + + + @* Could use instead, but this shows it can be done manually *@ +
      + @foreach (var message in context.GetValidationMessages()) + { +
    • @message
    • + } +
    + +
    + +@if (lastCallback != null) +{ + @lastCallback +} + +@functions { + string lastCallback; + + [Required(ErrorMessage = "Please choose a username")] + public string UserName { get; set; } + + [Required] + [Range(typeof(bool), "true", "true", ErrorMessage = "You must accept the terms")] + public bool AcceptsTerms { get; set; } + + void HandleValidSubmit() + { + lastCallback = "OnValidSubmit"; + } + + void HandleInvalidSubmit() + { + lastCallback = "OnInvalidSubmit"; + } +} diff --git a/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.cshtml b/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.cshtml new file mode 100644 index 0000000000..4ee4472b6e --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.cshtml @@ -0,0 +1,89 @@ +@using System.ComponentModel.DataAnnotations +@using Microsoft.AspNetCore.Components.Forms + + + + +

    + Name: +

    + +

    + Age (years): +

    +

    + Height (optional): +

    +

    + Description: +

    +

    + Renewal date: +

    +

    + Expiry date (optional): +

    +

    + Ticket class: + + + + + + +

    +

    + Accepts terms: +

    + + + + +
    + +
      @foreach (var entry in submissionLog) {
    • @entry
    • }
    + +@functions { + Person person = new Person(); + + // Usually this would be in a different file + class Person + { + [Required(ErrorMessage = "Enter a name"), StringLength(10, ErrorMessage = "That name is too long")] + public string Name { get; set; } + + [EmailAddress(ErrorMessage = "That doesn't look like a real email address")] + [StringLength(10, ErrorMessage = "We only accept very short email addresses (max 10 chars)")] + public string Email { get; set; } + + [Range(0, 200, ErrorMessage = "Nobody is that old")] + public int AgeInYears { get; set; } + + public float? OptionalHeight { get; set; } + + public DateTime RenewalDate { get; set; } = DateTime.Now; + + public DateTimeOffset? OptionalExpiryDate { get; set; } + + [Required, Range(typeof(bool), "true", "true", ErrorMessage = "Must accept terms")] + public bool AcceptsTerms { get; set; } + + [Required, StringLength(20, ErrorMessage = "Description is max 20 chars")] + public string Description { get; set; } + + [Required, EnumDataType(typeof(TicketClass))] + public TicketClass TicketClass { get; set; } + } + + enum TicketClass { Economy, Premium, First } + + List submissionLog = new List(); // So we can assert about the callbacks + + void HandleValidSubmit() + { + submissionLog.Add("OnValidSubmit"); + } +} diff --git a/src/Components/test/testassets/BasicTestApp/Index.cshtml b/src/Components/test/testassets/BasicTestApp/Index.cshtml index 2bf6ccf600..4b5a04c54a 100644 --- a/src/Components/test/testassets/BasicTestApp/Index.cshtml +++ b/src/Components/test/testassets/BasicTestApp/Index.cshtml @@ -46,6 +46,9 @@ + + + @if (SelectedComponentType != null) diff --git a/src/Components/test/testassets/BasicTestApp/wwwroot/index.html b/src/Components/test/testassets/BasicTestApp/wwwroot/index.html index bf671c987a..edd3f41214 100644 --- a/src/Components/test/testassets/BasicTestApp/wwwroot/index.html +++ b/src/Components/test/testassets/BasicTestApp/wwwroot/index.html @@ -3,7 +3,8 @@ Basic test app - + + Loading... diff --git a/src/Components/test/testassets/BasicTestApp/wwwroot/style.css b/src/Components/test/testassets/BasicTestApp/wwwroot/style.css new file mode 100644 index 0000000000..740eb993c6 --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/wwwroot/style.css @@ -0,0 +1,11 @@ +.modified.valid { + box-shadow: 0px 0px 0px 2px rgb(78, 203, 37); +} + +.invalid { + box-shadow: 0px 0px 0px 2px rgb(255, 0, 0); +} + +.validation-message { + color: red; +}