Components: Forms and validation (#7614)

This commit is contained in:
Steve Sanderson 2019-02-20 09:56:32 +00:00 committed by GitHub
parent 4e44025a52
commit 7a2dfd3200
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
42 changed files with 3540 additions and 2 deletions

View File

@ -134,6 +134,8 @@ and are generated based on the last package release.
<LatestPackageReference Include="StackExchange.Redis" Version="$(StackExchangeRedisPackageVersion)" />
<LatestPackageReference Include="System.Buffers" Version="$(SystemBuffersPackageVersion)" />
<LatestPackageReference Include="System.CodeDom" Version="$(SystemCodeDomPackageVersion)" />
<LatestPackageReference Include="System.ComponentModel" Version="$(SystemComponentModelPackageVersion)" />
<LatestPackageReference Include="System.ComponentModel.Annotations" Version="$(SystemComponentModelAnnotationsPackageVersion)" />
<LatestPackageReference Include="System.Data.SqlClient" Version="$(SystemDataSqlClientPackageVersion)" />
<LatestPackageReference Include="System.Diagnostics.EventLog" Version="$(SystemDiagnosticsEventLogPackageVersion)" />
<LatestPackageReference Include="System.IdentityModel.Tokens.Jwt" Version="$(SystemIdentityModelTokensJwtPackageVersion)" />

View File

@ -90,6 +90,7 @@
-->
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
<_CompilationOnlyReference Include="System.Buffers" />
<_CompilationOnlyReference Include="System.ComponentModel.Annotations" />
</ItemGroup>
<!--

View File

@ -293,6 +293,10 @@
<Uri>https://github.com/dotnet/corefx</Uri>
<Sha>0abec4390b30fdda97dc496594f9b1f9c9b20e17</Sha>
</Dependency>
<Dependency Name="System.ComponentModel.Annotations" Version="4.6.0-preview.19109.6">
<Uri>https://github.com/dotnet/corefx</Uri>
<Sha>0abec4390b30fdda97dc496594f9b1f9c9b20e17</Sha>
</Dependency>
<Dependency Name="System.Data.SqlClient" Version="4.7.0-preview.19109.6">
<Uri>https://github.com/dotnet/corefx</Uri>
<Sha>0abec4390b30fdda97dc496594f9b1f9c9b20e17</Sha>

View File

@ -25,6 +25,7 @@
<MicrosoftBclJsonSourcesPackageVersion>4.6.0-preview.19109.6</MicrosoftBclJsonSourcesPackageVersion>
<MicrosoftCSharpPackageVersion>4.6.0-preview.19109.6</MicrosoftCSharpPackageVersion>
<MicrosoftWin32RegistryPackageVersion>4.6.0-preview.19109.6</MicrosoftWin32RegistryPackageVersion>
<SystemComponentModelAnnotationsPackageVersion>4.6.0-preview.19109.6</SystemComponentModelAnnotationsPackageVersion>
<SystemDataSqlClientPackageVersion>4.7.0-preview.19109.6</SystemDataSqlClientPackageVersion>
<SystemDiagnosticsEventLogPackageVersion>4.6.0-preview.19109.6</SystemDiagnosticsEventLogPackageVersion>
<SystemIOPipelinesPackageVersion>4.6.0-preview.19109.6</SystemIOPipelinesPackageVersion>
@ -134,6 +135,7 @@
<!-- Stable dotnet/corefx packages no longer updated for .NET Core 3 -->
<SystemBuffersPackageVersion>4.5.0</SystemBuffersPackageVersion>
<SystemCodeDomPackageVersion>4.4.0</SystemCodeDomPackageVersion>
<SystemComponentModelPackageVersion>4.3.0</SystemComponentModelPackageVersion>
<SystemNetHttpPackageVersion>4.3.2</SystemNetHttpPackageVersion>
<SystemThreadingTasksExtensionsPackageVersion>4.5.2</SystemThreadingTasksExtensionsPackageVersion>
<!-- Packages developed by @aspnet, but manually updated as necessary. -->

View File

@ -9,4 +9,9 @@
to implement timers. Fixes https://github.com/aspnet/Blazor/issues/239 -->
<type fullname="System.Threading.WasmRuntime" />
</assembly>
<assembly fullname="System">
<!-- Without this, [Required(typeof(bool), "true", "true", ErrorMessage = "...")] fails -->
<type fullname="System.ComponentModel.BooleanConverter" />
</assembly>
</linker>

View File

@ -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",

View File

@ -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
{
/// <summary>
/// Adds Data Annotations validation support to an <see cref="EditContext"/>.
/// </summary>
public class DataAnnotationsValidator : ComponentBase
{
[CascadingParameter] EditContext CurrentEditContext { get; set; }
/// <inheritdoc />
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();
}
}
}

View File

@ -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
{
/// <summary>
/// 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.
/// </summary>
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<FieldIdentifier, FieldState> _fieldStates = new Dictionary<FieldIdentifier, FieldState>();
/// <summary>
/// Constructs an instance of <see cref="EditContext"/>.
/// </summary>
/// <param name="model">The model object for the <see cref="EditContext"/>. This object should hold the data being edited, for example as a set of properties.</param>
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));
}
/// <summary>
/// An event that is raised when a field value changes.
/// </summary>
public event EventHandler<FieldChangedEventArgs> OnFieldChanged;
/// <summary>
/// An event that is raised when validation is requested.
/// </summary>
public event EventHandler<ValidationRequestedEventArgs> OnValidationRequested;
/// <summary>
/// An event that is raised when validation state has changed.
/// </summary>
public event EventHandler<ValidationStateChangedEventArgs> OnValidationStateChanged;
/// <summary>
/// Supplies a <see cref="FieldIdentifier"/> corresponding to a specified field name
/// on this <see cref="EditContext"/>'s <see cref="Model"/>.
/// </summary>
/// <param name="fieldName">The name of the editable field.</param>
/// <returns>A <see cref="FieldIdentifier"/> corresponding to a specified field name on this <see cref="EditContext"/>'s <see cref="Model"/>.</returns>
public FieldIdentifier Field(string fieldName)
=> new FieldIdentifier(Model, fieldName);
/// <summary>
/// Gets the model object for this <see cref="EditContext"/>.
/// </summary>
public object Model { get; }
/// <summary>
/// Signals that the value for the specified field has changed.
/// </summary>
/// <param name="fieldIdentifier">Identifies the field whose value has been changed.</param>
public void NotifyFieldChanged(in FieldIdentifier fieldIdentifier)
{
GetFieldState(fieldIdentifier, ensureExists: true).IsModified = true;
OnFieldChanged?.Invoke(this, new FieldChangedEventArgs(fieldIdentifier));
}
/// <summary>
/// Signals that some aspect of validation state has changed.
/// </summary>
public void NotifyValidationStateChanged()
{
OnValidationStateChanged?.Invoke(this, ValidationStateChangedEventArgs.Empty);
}
/// <summary>
/// Clears any modification flag that may be tracked for the specified field.
/// </summary>
/// <param name="fieldIdentifier">Identifies the field whose modification flag (if any) should be cleared.</param>
public void MarkAsUnmodified(in FieldIdentifier fieldIdentifier)
{
if (_fieldStates.TryGetValue(fieldIdentifier, out var state))
{
state.IsModified = false;
}
}
/// <summary>
/// Clears all modification flags within this <see cref="EditContext"/>.
/// </summary>
public void MarkAsUnmodified()
{
foreach (var state in _fieldStates)
{
state.Value.IsModified = false;
}
}
/// <summary>
/// Determines whether any of the fields in this <see cref="EditContext"/> have been modified.
/// </summary>
/// <returns>True if any of the fields in this <see cref="EditContext"/> have been modified; otherwise false.</returns>
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;
}
/// <summary>
/// Gets the current validation messages across all fields.
///
/// This method does not perform validation itself. It only returns messages determined by previous validation actions.
/// </summary>
/// <returns>The current validation messages.</returns>
public IEnumerable<string> 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;
}
}
}
/// <summary>
/// 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.
/// </summary>
/// <param name="fieldIdentifier">Identifies the field whose current validation messages should be returned.</param>
/// <returns>The current validation messages for the specified field.</returns>
public IEnumerable<string> GetValidationMessages(FieldIdentifier fieldIdentifier)
{
if (_fieldStates.TryGetValue(fieldIdentifier, out var state))
{
foreach (var message in state.GetValidationMessages())
{
yield return message;
}
}
}
/// <summary>
/// Determines whether the specified fields in this <see cref="EditContext"/> has been modified.
/// </summary>
/// <returns>True if the field has been modified; otherwise false.</returns>
public bool IsModified(in FieldIdentifier fieldIdentifier)
=> _fieldStates.TryGetValue(fieldIdentifier, out var state)
? state.IsModified
: false;
/// <summary>
/// Validates this <see cref="EditContext"/>.
/// </summary>
/// <returns>True if there are no validation messages after validation; otherwise false.</returns>
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;
}
}
}

View File

@ -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
{
/// <summary>
/// Extension methods to add DataAnnotations validation to an <see cref="EditContext"/>.
/// </summary>
public static class EditContextDataAnnotationsExtensions
{
private static ConcurrentDictionary<(Type ModelType, string FieldName), PropertyInfo> _propertyInfoCache
= new ConcurrentDictionary<(Type, string), PropertyInfo>();
/// <summary>
/// Adds DataAnnotations validation support to the <see cref="EditContext"/>.
/// </summary>
/// <param name="editContext">The <see cref="EditContext"/>.</param>
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<ValidationResult>();
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<ValidationResult>();
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;
}
}
}

View File

@ -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
{
/// <summary>
/// Provides extension methods to simplify using <see cref="EditContext"/> with expressions.
/// </summary>
public static class EditContextExpressionExtensions
{
/// <summary>
/// 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.
/// </summary>
/// <param name="editContext">The <see cref="EditContext"/>.</param>
/// <param name="accessor">Identifies the field whose current validation messages should be returned.</param>
/// <returns>The current validation messages for the specified field.</returns>
public static IEnumerable<string> GetValidationMessages(this EditContext editContext, Expression<Func<object>> accessor)
=> editContext.GetValidationMessages(FieldIdentifier.Create(accessor));
/// <summary>
/// Determines whether the specified fields in this <see cref="EditContext"/> has been modified.
/// </summary>
/// <param name="editContext">The <see cref="EditContext"/>.</param>
/// <param name="accessor">Identifies the field whose current validation messages should be returned.</param>
/// <returns>True if the field has been modified; otherwise false.</returns>
public static bool IsModified(this EditContext editContext, Expression<Func<object>> accessor)
=> editContext.IsModified(FieldIdentifier.Create(accessor));
}
}

View File

@ -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
{
/// <summary>
/// Provides extension methods to describe the state of <see cref="EditContext"/>
/// fields as CSS class names.
/// </summary>
public static class EditContextFieldClassExtensions
{
/// <summary>
/// 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.
/// </summary>
/// <param name="editContext">The <see cref="EditContext"/>.</param>
/// <param name="accessor">An identifier for the field.</param>
/// <returns>A string that indicates the status of the field.</returns>
public static string FieldClass<TField>(this EditContext editContext, Expression<Func<TField>> accessor)
=> FieldClass(editContext, FieldIdentifier.Create(accessor));
/// <summary>
/// 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.
/// </summary>
/// <param name="editContext">The <see cref="EditContext"/>.</param>
/// <param name="fieldIdentifier">An identifier for the field.</param>
/// <returns>A string that indicates the status of the field.</returns>
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";
}
}
}
}

View File

@ -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
{
/// <summary>
/// Renders a form element that cascades an <see cref="EditContext"/> to descendants.
/// </summary>
public class EditForm : ComponentBase
{
private readonly Func<Task> _handleSubmitDelegate; // Cache to avoid per-render allocations
private EditContext _fixedEditContext;
/// <summary>
/// Constructs an instance of <see cref="EditForm"/>.
/// </summary>
public EditForm()
{
_handleSubmitDelegate = HandleSubmitAsync;
}
/// <summary>
/// Supplies the edit context explicitly. If using this parameter, do not
/// also supply <see cref="Model"/>, since the model value will be taken
/// from the <see cref="EditContext.Model"/> property.
/// </summary>
[Parameter] EditContext EditContext { get; set; }
/// <summary>
/// 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 <see cref="EditContext"/>.
/// </summary>
[Parameter] object Model { get; set; }
/// <summary>
/// Specifies the content to be rendered inside this <see cref="EditForm"/>.
/// </summary>
[Parameter] RenderFragment<EditContext> ChildContent { get; set; }
/// <summary>
/// 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 <see cref="EditContext.Validate"/>.
/// </summary>
[Parameter] EventCallback<EditContext> OnSubmit { get; set; }
/// <summary>
/// A callback that will be invoked when the form is submitted and the
/// <see cref="EditContext"/> is determined to be valid.
/// </summary>
[Parameter] EventCallback<EditContext> OnValidSubmit { get; set; }
/// <summary>
/// A callback that will be invoked when the form is submitted and the
/// <see cref="EditContext"/> is determined to be invalid.
/// </summary>
[Parameter] EventCallback<EditContext> OnInvalidSubmit { get; set; }
/// <inheritdoc />
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);
}
}
/// <inheritdoc />
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<CascadingValue<EditContext>>(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);
}
}
}
}
}

View File

@ -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
{
/// <summary>
/// Provides information about the <see cref="EditContext.OnFieldChanged"/> event.
/// </summary>
public sealed class FieldChangedEventArgs
{
/// <summary>
/// Identifies the field whose value has changed.
/// </summary>
public FieldIdentifier FieldIdentifier { get; }
internal FieldChangedEventArgs(in FieldIdentifier fieldIdentifier)
{
FieldIdentifier = fieldIdentifier;
}
}
}

View File

@ -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
{
/// <summary>
/// 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.
/// </summary>
public readonly struct FieldIdentifier
{
/// <summary>
/// Initializes a new instance of the <see cref="FieldIdentifier"/> structure.
/// </summary>
/// <param name="accessor">An expression that identifies an object member.</param>
public static FieldIdentifier Create<T>(Expression<Func<T>> accessor)
{
if (accessor == null)
{
throw new ArgumentNullException(nameof(accessor));
}
ParseAccessor(accessor, out var model, out var fieldName);
return new FieldIdentifier(model, fieldName);
}
/// <summary>
/// Initializes a new instance of the <see cref="FieldIdentifier"/> structure.
/// </summary>
/// <param name="model">The object that owns the field.</param>
/// <param name="fieldName">The name of the editable field.</param>
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));
}
/// <summary>
/// Gets the object that owns the editable field.
/// </summary>
public object Model { get; }
/// <summary>
/// Gets the name of the editable field.
/// </summary>
public string FieldName { get; }
/// <inheritdoc />
public override int GetHashCode()
=> (Model, FieldName).GetHashCode();
/// <inheritdoc />
public override bool Equals(object obj)
=> obj is FieldIdentifier otherIdentifier
&& otherIdentifier.Model == Model
&& string.Equals(otherIdentifier.FieldName, FieldName, StringComparison.Ordinal);
private static void ParseAccessor<T>(Expression<Func<T>> 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<object, object> so we can cheaply map from "something" to "something.Member".
var modelLambda = Expression.Lambda(memberExpression.Expression);
var modelLambdaCompiled = (Func<object>)modelLambda.Compile();
model = modelLambdaCompiled();
break;
}
}
}
}

View File

@ -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<ValidationMessageStore> _validationMessageStores;
public FieldState(FieldIdentifier fieldIdentifier)
{
_fieldIdentifier = fieldIdentifier;
}
public bool IsModified { get; set; }
public IEnumerable<string> 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<ValidationMessageStore>();
}
_validationMessageStores.Add(validationMessageStore);
}
public void DissociateFromValidationMessageStore(ValidationMessageStore validationMessageStore)
=> _validationMessageStores?.Remove(validationMessageStore);
}
}

View File

@ -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
{
/// <summary>
/// A base class for form input components. This base class automatically
/// integrates with an <see cref="Forms.EditContext"/>, which must be supplied
/// as a cascading parameter.
/// </summary>
public abstract class InputBase<T> : ComponentBase
{
private bool _previousParsingAttemptFailed;
private ValidationMessageStore _parsingValidationMessages;
private Type _nullableUnderlyingType;
[CascadingParameter] EditContext CascadedEditContext { get; set; }
/// <summary>
/// Gets a value for the component's 'id' attribute.
/// </summary>
[Parameter] protected string Id { get; private set; }
/// <summary>
/// Gets a value for the component's 'class' attribute.
/// </summary>
[Parameter] protected string Class { get; private set; }
/// <summary>
/// Gets or sets the value of the input. This should be used with two-way binding.
/// </summary>
/// <example>
/// bind-Value="@model.PropertyName"
/// </example>
[Parameter] T Value { get; set; }
/// <summary>
/// Gets or sets a callback that updates the bound value.
/// </summary>
[Parameter] Action<T> ValueChanged { get; set; }
/// <summary>
/// Gets or sets an expression that identifies the bound value.
/// </summary>
[Parameter] Expression<Func<T>> ValueExpression { get; set; }
/// <summary>
/// Gets the associated <see cref="Microsoft.AspNetCore.Components.Forms.EditContext"/>.
/// </summary>
protected EditContext EditContext { get; private set; }
/// <summary>
/// Gets the <see cref="FieldIdentifier"/> for the bound value.
/// </summary>
protected FieldIdentifier FieldIdentifier { get; private set; }
/// <summary>
/// Gets or sets the current value of the input.
/// </summary>
protected T CurrentValue
{
get => Value;
set
{
var hasChanged = !EqualityComparer<T>.Default.Equals(value, Value);
if (hasChanged)
{
Value = value;
ValueChanged?.Invoke(value);
EditContext.NotifyFieldChanged(FieldIdentifier);
}
}
}
/// <summary>
/// Gets or sets the current value of the input, represented as a string.
/// </summary>
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<T> 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;
}
}
}
/// <summary>
/// Formats the value as a string. Derived classes can override this to determine the formating used for <see cref="CurrentValueAsString"/>.
/// </summary>
/// <param name="value">The value to format.</param>
/// <returns>A string representation of the value.</returns>
protected virtual string FormatValueAsString(T value)
=> value?.ToString();
/// <summary>
/// Parses a string to create an instance of <typeparamref name="T"/>. Derived classes can override this to change how
/// <see cref="CurrentValueAsString"/> interprets incoming values.
/// </summary>
/// <param name="value">The string value to be parsed.</param>
/// <param name="result">An instance of <typeparamref name="T"/>.</param>
/// <param name="validationErrorMessage">If the value could not be parsed, provides a validation error message.</param>
/// <returns>True if the value could be parsed; otherwise false.</returns>
protected abstract bool TryParseValueFromString(string value, out T result, out string validationErrorMessage);
/// <summary>
/// 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.
/// </summary>
protected string FieldClass
=> EditContext.FieldClass(FieldIdentifier);
/// <summary>
/// Gets a CSS class string that combines the <see cref="Class"/> and <see cref="FieldClass"/>
/// properties. Derived components should typically use this value for the primary HTML element's
/// 'class' attribute.
/// </summary>
protected string CssClass
=> string.IsNullOrEmpty(Class)
? FieldClass // Never null or empty
: $"{Class} {FieldClass}";
/// <inheritdoc />
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);
}
}
}

View File

@ -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<bool>
* <input type="checkbox" bind="@CurrentValue" id="@Id" class="@CssClass" />
*
* 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.
*/
/// <summary>
/// An input component for editing <see cref="bool"/> values.
/// </summary>
public class InputCheckbox : InputBase<bool>
{
/// <inheritdoc />
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();
}
/// <inheritdoc />
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)}'.");
}
}

View File

@ -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
{
/// <summary>
/// An input component for editing date values.
/// Supported types are <see cref="DateTime"/> and <see cref="DateTimeOffset"/>.
/// </summary>
public class InputDate<T> : InputBase<T>
{
const string dateFormat = "yyyy-MM-dd"; // Compatible with HTML date inputs
[Parameter] string ParsingErrorMessage { get; set; } = "The {0} field must be a date.";
/// <inheritdoc />
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();
}
/// <inheritdoc />
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<DateTime>, etc.
}
}
/// <inheritdoc />
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;
}
}
}
}

View File

@ -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
{
/// <summary>
/// An input component for editing numeric values.
/// Supported numeric types are <see cref="int"/>, <see cref="long"/>, <see cref="float"/>, <see cref="double"/>, <see cref="decimal"/>.
/// </summary>
public class InputNumber<T> : InputBase<T>
{
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<T>, 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.";
/// <inheritdoc />
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();
}
/// <inheritdoc />
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;
}
}
}
}

View File

@ -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
{
/// <summary>
/// A dropdown selection component.
/// </summary>
public class InputSelect<T> : InputBase<T>
{
[Parameter] RenderFragment ChildContent { get; set; }
/// <inheritdoc />
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();
}
/// <inheritdoc />
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)}'.");
}
}
}

View File

@ -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<string>
* <input bind="@CurrentValue" id="@Id" class="@CssClass" />
*
* 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.
*/
/// <summary>
/// An input component for editing <see cref="string"/> values.
/// </summary>
public class InputText : InputBase<string>
{
/// <inheritdoc />
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();
}
/// <inheritdoc />
protected override bool TryParseValueFromString(string value, out string result, out string validationErrorMessage)
{
result = value;
validationErrorMessage = null;
return true;
}
}
}

View File

@ -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<string>
* <textarea bind="@CurrentValue" id="@Id" class="@CssClass"></textarea>
*
* 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.
*/
/// <summary>
/// A multiline input component for editing <see cref="string"/> values.
/// </summary>
public class InputTextArea : InputBase<string>
{
/// <inheritdoc />
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();
}
/// <inheritdoc />
protected override bool TryParseValueFromString(string value, out string result, out string validationErrorMessage)
{
result = value;
validationErrorMessage = null;
return true;
}
}
}

View File

@ -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
{
/// <summary>
/// Displays a list of validation messages for a specified field within a cascaded <see cref="EditContext"/>.
/// </summary>
public class ValidationMessage<T> : ComponentBase, IDisposable
{
private EditContext _previousEditContext;
private Expression<Func<T>> _previousFieldAccessor;
private readonly EventHandler<ValidationStateChangedEventArgs> _validationStateChangedHandler;
private FieldIdentifier _fieldIdentifier;
[CascadingParameter] EditContext CurrentEditContext { get; set; }
/// <summary>
/// Specifies the field for which validation messages should be displayed.
/// </summary>
[Parameter] Expression<Func<T>> For { get; set; }
/// <summary>`
/// Constructs an instance of <see cref="ValidationSummary"/>.
/// </summary>
public ValidationMessage()
{
_validationStateChangedHandler = (sender, eventArgs) => StateHasChanged();
}
/// <inheritdoc />
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;
}
}
/// <inheritdoc />
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;
}
}
}
}

View File

@ -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
{
/// <summary>
/// Holds validation messages for an <see cref="EditContext"/>.
/// </summary>
public sealed class ValidationMessageStore
{
private readonly EditContext _editContext;
private readonly Dictionary<FieldIdentifier, List<string>> _messages = new Dictionary<FieldIdentifier, List<string>>();
/// <summary>
/// Creates an instance of <see cref="ValidationMessageStore"/>.
/// </summary>
/// <param name="editContext">The <see cref="EditContext"/> with which this store should be associated.</param>
public ValidationMessageStore(EditContext editContext)
{
_editContext = editContext ?? throw new ArgumentNullException(nameof(editContext));
}
/// <summary>
/// Adds a validation message for the specified field.
/// </summary>
/// <param name="fieldIdentifier">The identifier for the field.</param>
/// <param name="message">The validation message.</param>
public void Add(in FieldIdentifier fieldIdentifier, string message)
=> GetOrCreateMessagesListForField(fieldIdentifier).Add(message);
/// <summary>
/// Adds the messages from the specified collection for the specified field.
/// </summary>
/// <param name="fieldIdentifier">The identifier for the field.</param>
/// <param name="messages">The validation messages to be added.</param>
public void AddRange(in FieldIdentifier fieldIdentifier, IEnumerable<string> messages)
=> GetOrCreateMessagesListForField(fieldIdentifier).AddRange(messages);
/// <summary>
/// Gets the validation messages within this <see cref="ValidationMessageStore"/> for the specified field.
///
/// To get the validation messages across all validation message stores, use <see cref="EditContext.GetValidationMessages(FieldIdentifier)"/> instead
/// </summary>
/// <param name="fieldIdentifier">The identifier for the field.</param>
/// <returns>The validation messages for the specified field within this <see cref="ValidationMessageStore"/>.</returns>
public IEnumerable<string> this[FieldIdentifier fieldIdentifier]
=> _messages.TryGetValue(fieldIdentifier, out var messages) ? messages : Enumerable.Empty<string>();
/// <summary>
/// Gets the validation messages within this <see cref="ValidationMessageStore"/> for the specified field.
///
/// To get the validation messages across all validation message stores, use <see cref="EditContext.GetValidationMessages(FieldIdentifier)"/> instead
/// </summary>
/// <param name="accessor">The identifier for the field.</param>
/// <returns>The validation messages for the specified field within this <see cref="ValidationMessageStore"/>.</returns>
public IEnumerable<string> this[Expression<Func<object>> accessor]
=> this[FieldIdentifier.Create(accessor)];
/// <summary>
/// Removes all messages within this <see cref="ValidationMessageStore"/>.
/// </summary>
public void Clear()
{
foreach (var fieldIdentifier in _messages.Keys)
{
DissociateFromField(fieldIdentifier);
}
_messages.Clear();
}
/// <summary>
/// Removes all messages within this <see cref="ValidationMessageStore"/> for the specified field.
/// </summary>
/// <param name="fieldIdentifier">The identifier for the field.</param>
public void Clear(in FieldIdentifier fieldIdentifier)
{
DissociateFromField(fieldIdentifier);
_messages.Remove(fieldIdentifier);
}
private List<string> GetOrCreateMessagesListForField(in FieldIdentifier fieldIdentifier)
{
if (!_messages.TryGetValue(fieldIdentifier, out var messagesForField))
{
messagesForField = new List<string>();
_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);
}
}

View File

@ -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
{
/// <summary>
/// Provides extension methods to simplify using <see cref="ValidationMessageStore"/> with expressions.
/// </summary>
public static class ValidationMessageStoreExpressionExtensions
{
/// <summary>
/// Adds a validation message for the specified field.
/// </summary>
/// <param name="store">The <see cref="ValidationMessageStore"/>.</param>
/// <param name="accessor">Identifies the field for which to add the message.</param>
/// <param name="message">The validation message.</param>
public static void Add(this ValidationMessageStore store, Expression<Func<object>> accessor, string message)
=> store.Add(FieldIdentifier.Create(accessor), message);
/// <summary>
/// Adds the messages from the specified collection for the specified field.
/// </summary>
/// <param name="store">The <see cref="ValidationMessageStore"/>.</param>
/// <param name="accessor">Identifies the field for which to add the messages.</param>
/// <param name="messages">The validation messages to be added.</param>
public static void AddRange(this ValidationMessageStore store, Expression<Func<object>> accessor, IEnumerable<string> messages)
=> store.AddRange(FieldIdentifier.Create(accessor), messages);
/// <summary>
/// Removes all messages within this <see cref="ValidationMessageStore"/> for the specified field.
/// </summary>
/// <param name="store">The <see cref="ValidationMessageStore"/>.</param>
/// <param name="accessor">Identifies the field for which to remove the messages.</param>
public static void Clear(this ValidationMessageStore store, Expression<Func<object>> accessor)
=> store.Clear(FieldIdentifier.Create(accessor));
}
}

View File

@ -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
{
/// <summary>
/// Provides information about the <see cref="EditContext.OnValidationRequested"/> event.
/// </summary>
public sealed class ValidationRequestedEventArgs
{
internal static readonly ValidationRequestedEventArgs Empty = new ValidationRequestedEventArgs();
internal ValidationRequestedEventArgs()
{
}
}
}

View File

@ -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
{
/// <summary>
/// Provides information about the <see cref="EditContext.OnValidationStateChanged"/> event.
/// </summary>
public sealed class ValidationStateChangedEventArgs
{
internal static readonly ValidationStateChangedEventArgs Empty = new ValidationStateChangedEventArgs();
internal ValidationStateChangedEventArgs()
{
}
}
}

View File

@ -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.
/// <summary>
/// Displays a list of validation messages from a cascaded <see cref="EditContext"/>.
/// </summary>
public class ValidationSummary : ComponentBase, IDisposable
{
private EditContext _previousEditContext;
private readonly EventHandler<ValidationStateChangedEventArgs> _validationStateChangedHandler;
[CascadingParameter] EditContext CurrentEditContext { get; set; }
/// <summary>`
/// Constructs an instance of <see cref="ValidationSummary"/>.
/// </summary>
public ValidationSummary()
{
_validationStateChangedHandler = (sender, eventArgs) => StateHasChanged();
}
/// <inheritdoc />
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;
}
}
/// <inheritdoc />
protected override void BuildRenderTree(RenderTreeBuilder builder)
{
base.BuildRenderTree(builder);
// As an optimization, only evaluate the messages enumerable once, and
// only produce the enclosing <ul> 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;
}
}
}
}

View File

@ -10,6 +10,7 @@
<ItemGroup>
<Reference Include="Microsoft.JSInterop" />
<Reference Include="System.ComponentModel.Annotations" />
</ItemGroup>
</Project>

View File

@ -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<ArgumentNullException>(() => 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
}
}
}

View File

@ -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<ArgumentNullException>(() => 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());
}
}
}

View File

@ -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<ArgumentNullException>(() => new FieldIdentifier(null, "somefield"));
Assert.Equal("model", ex.ParamName);
}
[Fact]
public void CannotUseValueTypeModel()
{
var ex = Assert.Throws<ArgumentException>(() => 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<ArgumentNullException>(() => 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<ArgumentException>(() =>
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<Func<object>>, then any value types will be implicitly cast
var model = new TestModel();
Expression<Func<object>> 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<TestModel>() { 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; }
}
}
}

View File

@ -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<string>();
var testRenderer = new TestRenderer();
var componentId = testRenderer.AssignRootComponentId(inputComponent);
// Act/Assert
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => testRenderer.RenderRootComponentAsync(componentId));
Assert.StartsWith($"{typeof(TestInputComponent<string>)} requires a cascading parameter of type {nameof(EditContext)}", ex.Message);
}
[Fact]
public async Task ThrowsIfEditContextChanges()
{
// Arrange
var model = new TestModel();
var rootComponent = new TestInputHostComponent<string, TestInputComponent<string>> { EditContext = new EditContext(model), ValueExpression = () => model.StringProperty };
await RenderAndGetTestInputComponentAsync(rootComponent);
// Act/Assert
rootComponent.EditContext = new EditContext(model);
var ex = Assert.Throws<InvalidOperationException>(() => rootComponent.TriggerRender());
Assert.StartsWith($"{typeof(TestInputComponent<string>)} does not support changing the EditContext dynamically", ex.Message);
}
[Fact]
public async Task ThrowsIfNoValueExpressionIsSupplied()
{
// Arrange
var model = new TestModel();
var rootComponent = new TestInputHostComponent<string, TestInputComponent<string>> { EditContext = new EditContext(model) };
// Act/Assert
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => RenderAndGetTestInputComponentAsync(rootComponent));
Assert.Contains($"{typeof(TestInputComponent<string>)} 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<string, TestInputComponent<string>>
{
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<string, TestInputComponent<string>>
{
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<string, TestInputComponent<string>>
{
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<string, TestInputComponent<string>>
{
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<string, TestInputComponent<string>>
{
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<string>();
var rootComponent = new TestInputHostComponent<string, TestInputComponent<string>>
{
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<string>();
var rootComponent = new TestInputHostComponent<string, TestInputComponent<string>>
{
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<string, TestInputComponent<string>>
{
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<string, TestInputComponent<string>>
{
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<string, TestInputComponent<string>>
{
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<DateTime, TestDateInputComponent>
{
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<DateTime>();
var rootComponent = new TestInputHostComponent<DateTime, TestDateInputComponent>
{
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<DateTime>();
var rootComponent = new TestInputHostComponent<DateTime, TestDateInputComponent>
{
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<TComponent>(CapturedBatch batch)
=> batch.ReferenceFrames
.Where(f => f.FrameType == RenderTreeFrameType.Component)
.Select(f => f.Component)
.OfType<TComponent>()
.Single();
private static async Task<TComponent> RenderAndGetTestInputComponentAsync<TValue, TComponent>(TestInputHostComponent<TValue, TComponent> hostComponent) where TComponent: TestInputComponent<TValue>
{
var testRenderer = new TestRenderer();
var componentId = testRenderer.AssignRootComponentId(hostComponent);
await testRenderer.RenderRootComponentAsync(componentId);
return FindComponent<TComponent>(testRenderer.Batches.Single());
}
class TestModel
{
public string StringProperty { get; set; }
public DateTime DateProperty { get; set; }
}
class TestInputComponent<T> : InputBase<T>
{
// 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<DateTime>
{
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<TValue, TComponent> : AutoRenderComponent where TComponent: TestInputComponent<TValue>
{
public string Id { get; set; }
public string Class { get; set; }
public EditContext EditContext { get; set; }
public TValue Value { get; set; }
public Action<TValue> ValueChanged { get; set; }
public Expression<Func<TValue>> ValueExpression { get; set; }
protected override void BuildRenderTree(RenderTreeBuilder builder)
{
builder.OpenComponent<CascadingValue<EditContext>>(0);
builder.AddAttribute(1, "Value", EditContext);
builder.AddAttribute(2, RenderTreeBuilder.ChildContent, new RenderFragment(childBuilder =>
{
childBuilder.OpenComponent<TComponent>(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();
}
}
}
}

View File

@ -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<ArgumentNullException>(() => 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]);
}
}
}

View File

@ -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<Program> 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<SimpleValidationComponent>();
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<TypicalValidationComponent>();
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<TypicalValidationComponent>();
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<TypicalValidationComponent>();
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<TypicalValidationComponent>();
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<TypicalValidationComponent>();
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<TypicalValidationComponent>();
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<TypicalValidationComponent>();
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<TypicalValidationComponent>();
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<NotifyPropertyChangedValidationComponent>();
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<TypicalValidationComponent>();
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<string[]> CreateValidationMessagesAccessor(IWebElement appElement)
{
return () => appElement.FindElements(By.ClassName("validation-message"))
.Select(x => x.Text)
.OrderBy(x => x)
.ToArray();
}
}
}

View File

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
@ -9,6 +9,7 @@
</PropertyGroup>
<ItemGroup>
<Reference Include="System.ComponentModel" />
<Reference Include="Microsoft.AspNetCore.Blazor" />
<Reference Include="Microsoft.NET.Sdk.Razor" PrivateAssets="All" />
</ItemGroup>

View File

@ -0,0 +1,105 @@
@using System.ComponentModel
@using System.ComponentModel.DataAnnotations
@using System.Runtime.CompilerServices;
@using Microsoft.AspNetCore.Components.Forms
<p>
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.
</p>
<p>
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.
</p>
<form onsubmit="@HandleSubmit">
<p class="user-name">
User name:
<input bind="@person.UserName" class="@editContext.FieldClass(() => person.UserName)" />
</p>
<p class="accepts-terms">
Accept terms:
<input type="checkbox" bind="@person.AcceptsTerms" class="@editContext.FieldClass(() => person.AcceptsTerms)" />
</p>
<button type="submit">Submit</button>
<CascadingValue Value="@editContext" IsFixed="true">
<ValidationSummary />
</CascadingValue>
</form>
<div id="submission-status">@submissionStatus</div>
@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<T>(ref T storage, T value, [CallerMemberName] string propertyName = null)
{
if (Equals(storage, value))
{
return false;
}
storage = value;
OnPropertyChanged(propertyName);
return true;
}
#endregion
}
}

View File

@ -0,0 +1,50 @@
@using System.ComponentModel.DataAnnotations
@using Microsoft.AspNetCore.Components.Forms
<EditForm Model="@this" OnValidSubmit="@(EventCallback.Factory.Create<EditContext>(this, HandleValidSubmit))" OnInvalidSubmit="@(EventCallback.Factory.Create<EditContext>(this, HandleInvalidSubmit))">
<DataAnnotationsValidator />
<p class="user-name">
User name: <input bind="@UserName" class="@context.FieldClass(() => UserName)" />
</p>
<p class="accepts-terms">
Accept terms: <input type="checkbox" bind="@AcceptsTerms" class="@context.FieldClass(() => AcceptsTerms)" />
</p>
<button type="submit">Submit</button>
@* Could use <ValidationSummary /> instead, but this shows it can be done manually *@
<ul class="validation-errors">
@foreach (var message in context.GetValidationMessages())
{
<li class="validation-message">@message</li>
}
</ul>
</EditForm>
@if (lastCallback != null)
{
<span id="last-callback">@lastCallback</span>
}
@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";
}
}

View File

@ -0,0 +1,89 @@
@using System.ComponentModel.DataAnnotations
@using Microsoft.AspNetCore.Components.Forms
<EditForm Model="@person" OnValidSubmit="@(EventCallback.Factory.Create<EditContext>(this, HandleValidSubmit))">
<DataAnnotationsValidator />
<p class="name">
Name: <InputText bind-Value="@person.Name" ValueExpression="@(() => person.Name)" />
</p>
<p class="email">
Email: <InputText bind-Value="@person.Email" ValueExpression="@(() => person.Email)" />
<ValidationMessage For="@(() => person.Email)" />
</p>
<p class="age">
Age (years): <InputNumber bind-Value="@person.AgeInYears" ValueExpression="@(() => person.AgeInYears)" />
</p>
<p class="height">
Height (optional): <InputNumber bind-Value="@person.OptionalHeight" ValueExpression="@(() => person.OptionalHeight)" />
</p>
<p class="description">
Description: <InputTextArea bind-Value="@person.Description" ValueExpression="@(() => person.Description)" />
</p>
<p class="renewal-date">
Renewal date: <InputDate bind-Value="@person.RenewalDate" ValueExpression="@(() => person.RenewalDate)" />
</p>
<p class="expiry-date">
Expiry date (optional): <InputDate bind-Value="@person.OptionalExpiryDate" ValueExpression="@(() => person.OptionalExpiryDate)" />
</p>
<p class="ticket-class">
Ticket class:
<InputSelect bind-Value="@person.TicketClass" ValueExpression="@(() => person.TicketClass)">
<option>(select)</option>
<option value="@TicketClass.Economy">Economy class</option>
<option value="@TicketClass.Premium">Premium class</option>
<option value="@TicketClass.First">First class</option>
</InputSelect>
</p>
<p class="accepts-terms">
Accepts terms: <InputCheckbox bind-Value="@person.AcceptsTerms" ValueExpression="@(() => person.AcceptsTerms)" />
</p>
<button type="submit">Submit</button>
<ValidationSummary />
</EditForm>
<ul>@foreach (var entry in submissionLog) { <li>@entry</li> }</ul>
@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<string> submissionLog = new List<string>(); // So we can assert about the callbacks
void HandleValidSubmit()
{
submissionLog.Add("OnValidSubmit");
}
}

View File

@ -46,6 +46,9 @@
<option value="BasicTestApp.ConcurrentRenderParent">Concurrent rendering</option>
<option value="BasicTestApp.DispatchingComponent">Dispatching to sync context</option>
<option value="BasicTestApp.EventCallbackTest.EventCallbackCases">EventCallback</option>
<option value="BasicTestApp.FormsTest.SimpleValidationComponent">Simple validation</option>
<option value="BasicTestApp.FormsTest.TypicalValidationComponent">Typical validation</option>
<option value="BasicTestApp.FormsTest.NotifyPropertyChangedValidationComponent">INotifyPropertyChanged validation</option>
</select>
@if (SelectedComponentType != null)

View File

@ -3,7 +3,8 @@
<head>
<meta charset="utf-8" />
<title>Basic test app</title>
<base href="/subdir/" />
<base href="/subdir/" />
<link href="style.css" rel="stylesheet" />
</head>
<body>
<root>Loading...</root>

View File

@ -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;
}