Components: Forms and validation (#7614)
This commit is contained in:
parent
4e44025a52
commit
7a2dfd3200
|
|
@ -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)" />
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@
|
|||
-->
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
|
||||
<_CompilationOnlyReference Include="System.Buffers" />
|
||||
<_CompilationOnlyReference Include="System.ComponentModel.Annotations" />
|
||||
</ItemGroup>
|
||||
|
||||
<!--
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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. -->
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)}'.");
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)}'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@
|
|||
|
||||
<ItemGroup>
|
||||
<Reference Include="Microsoft.JSInterop" />
|
||||
<Reference Include="System.ComponentModel.Annotations" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in New Issue