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="StackExchange.Redis" Version="$(StackExchangeRedisPackageVersion)" />
|
||||||
<LatestPackageReference Include="System.Buffers" Version="$(SystemBuffersPackageVersion)" />
|
<LatestPackageReference Include="System.Buffers" Version="$(SystemBuffersPackageVersion)" />
|
||||||
<LatestPackageReference Include="System.CodeDom" Version="$(SystemCodeDomPackageVersion)" />
|
<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.Data.SqlClient" Version="$(SystemDataSqlClientPackageVersion)" />
|
||||||
<LatestPackageReference Include="System.Diagnostics.EventLog" Version="$(SystemDiagnosticsEventLogPackageVersion)" />
|
<LatestPackageReference Include="System.Diagnostics.EventLog" Version="$(SystemDiagnosticsEventLogPackageVersion)" />
|
||||||
<LatestPackageReference Include="System.IdentityModel.Tokens.Jwt" Version="$(SystemIdentityModelTokensJwtPackageVersion)" />
|
<LatestPackageReference Include="System.IdentityModel.Tokens.Jwt" Version="$(SystemIdentityModelTokensJwtPackageVersion)" />
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,7 @@
|
||||||
-->
|
-->
|
||||||
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
|
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
|
||||||
<_CompilationOnlyReference Include="System.Buffers" />
|
<_CompilationOnlyReference Include="System.Buffers" />
|
||||||
|
<_CompilationOnlyReference Include="System.ComponentModel.Annotations" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
|
|
|
||||||
|
|
@ -293,6 +293,10 @@
|
||||||
<Uri>https://github.com/dotnet/corefx</Uri>
|
<Uri>https://github.com/dotnet/corefx</Uri>
|
||||||
<Sha>0abec4390b30fdda97dc496594f9b1f9c9b20e17</Sha>
|
<Sha>0abec4390b30fdda97dc496594f9b1f9c9b20e17</Sha>
|
||||||
</Dependency>
|
</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">
|
<Dependency Name="System.Data.SqlClient" Version="4.7.0-preview.19109.6">
|
||||||
<Uri>https://github.com/dotnet/corefx</Uri>
|
<Uri>https://github.com/dotnet/corefx</Uri>
|
||||||
<Sha>0abec4390b30fdda97dc496594f9b1f9c9b20e17</Sha>
|
<Sha>0abec4390b30fdda97dc496594f9b1f9c9b20e17</Sha>
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@
|
||||||
<MicrosoftBclJsonSourcesPackageVersion>4.6.0-preview.19109.6</MicrosoftBclJsonSourcesPackageVersion>
|
<MicrosoftBclJsonSourcesPackageVersion>4.6.0-preview.19109.6</MicrosoftBclJsonSourcesPackageVersion>
|
||||||
<MicrosoftCSharpPackageVersion>4.6.0-preview.19109.6</MicrosoftCSharpPackageVersion>
|
<MicrosoftCSharpPackageVersion>4.6.0-preview.19109.6</MicrosoftCSharpPackageVersion>
|
||||||
<MicrosoftWin32RegistryPackageVersion>4.6.0-preview.19109.6</MicrosoftWin32RegistryPackageVersion>
|
<MicrosoftWin32RegistryPackageVersion>4.6.0-preview.19109.6</MicrosoftWin32RegistryPackageVersion>
|
||||||
|
<SystemComponentModelAnnotationsPackageVersion>4.6.0-preview.19109.6</SystemComponentModelAnnotationsPackageVersion>
|
||||||
<SystemDataSqlClientPackageVersion>4.7.0-preview.19109.6</SystemDataSqlClientPackageVersion>
|
<SystemDataSqlClientPackageVersion>4.7.0-preview.19109.6</SystemDataSqlClientPackageVersion>
|
||||||
<SystemDiagnosticsEventLogPackageVersion>4.6.0-preview.19109.6</SystemDiagnosticsEventLogPackageVersion>
|
<SystemDiagnosticsEventLogPackageVersion>4.6.0-preview.19109.6</SystemDiagnosticsEventLogPackageVersion>
|
||||||
<SystemIOPipelinesPackageVersion>4.6.0-preview.19109.6</SystemIOPipelinesPackageVersion>
|
<SystemIOPipelinesPackageVersion>4.6.0-preview.19109.6</SystemIOPipelinesPackageVersion>
|
||||||
|
|
@ -134,6 +135,7 @@
|
||||||
<!-- Stable dotnet/corefx packages no longer updated for .NET Core 3 -->
|
<!-- Stable dotnet/corefx packages no longer updated for .NET Core 3 -->
|
||||||
<SystemBuffersPackageVersion>4.5.0</SystemBuffersPackageVersion>
|
<SystemBuffersPackageVersion>4.5.0</SystemBuffersPackageVersion>
|
||||||
<SystemCodeDomPackageVersion>4.4.0</SystemCodeDomPackageVersion>
|
<SystemCodeDomPackageVersion>4.4.0</SystemCodeDomPackageVersion>
|
||||||
|
<SystemComponentModelPackageVersion>4.3.0</SystemComponentModelPackageVersion>
|
||||||
<SystemNetHttpPackageVersion>4.3.2</SystemNetHttpPackageVersion>
|
<SystemNetHttpPackageVersion>4.3.2</SystemNetHttpPackageVersion>
|
||||||
<SystemThreadingTasksExtensionsPackageVersion>4.5.2</SystemThreadingTasksExtensionsPackageVersion>
|
<SystemThreadingTasksExtensionsPackageVersion>4.5.2</SystemThreadingTasksExtensionsPackageVersion>
|
||||||
<!-- Packages developed by @aspnet, but manually updated as necessary. -->
|
<!-- Packages developed by @aspnet, but manually updated as necessary. -->
|
||||||
|
|
|
||||||
|
|
@ -9,4 +9,9 @@
|
||||||
to implement timers. Fixes https://github.com/aspnet/Blazor/issues/239 -->
|
to implement timers. Fixes https://github.com/aspnet/Blazor/issues/239 -->
|
||||||
<type fullname="System.Threading.WasmRuntime" />
|
<type fullname="System.Threading.WasmRuntime" />
|
||||||
</assembly>
|
</assembly>
|
||||||
|
|
||||||
|
<assembly fullname="System">
|
||||||
|
<!-- Without this, [Required(typeof(bool), "true", "true", ErrorMessage = "...")] fails -->
|
||||||
|
<type fullname="System.ComponentModel.BooleanConverter" />
|
||||||
|
</assembly>
|
||||||
</linker>
|
</linker>
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,8 @@ namespace Microsoft.AspNetCore.Blazor.Build.Test
|
||||||
"System.Collections.dll",
|
"System.Collections.dll",
|
||||||
"System.ComponentModel.Composition.dll",
|
"System.ComponentModel.Composition.dll",
|
||||||
"System.ComponentModel.dll",
|
"System.ComponentModel.dll",
|
||||||
|
"System.ComponentModel.Annotations.dll",
|
||||||
|
"System.ComponentModel.DataAnnotations.dll",
|
||||||
"System.Core.dll",
|
"System.Core.dll",
|
||||||
"System.Data.dll",
|
"System.Data.dll",
|
||||||
"System.Diagnostics.Debug.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>
|
<ItemGroup>
|
||||||
<Reference Include="Microsoft.JSInterop" />
|
<Reference Include="Microsoft.JSInterop" />
|
||||||
|
<Reference Include="System.ComponentModel.Annotations" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</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>
|
<PropertyGroup>
|
||||||
<TargetFramework>netstandard2.0</TargetFramework>
|
<TargetFramework>netstandard2.0</TargetFramework>
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<Reference Include="System.ComponentModel" />
|
||||||
<Reference Include="Microsoft.AspNetCore.Blazor" />
|
<Reference Include="Microsoft.AspNetCore.Blazor" />
|
||||||
<Reference Include="Microsoft.NET.Sdk.Razor" PrivateAssets="All" />
|
<Reference Include="Microsoft.NET.Sdk.Razor" PrivateAssets="All" />
|
||||||
</ItemGroup>
|
</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.ConcurrentRenderParent">Concurrent rendering</option>
|
||||||
<option value="BasicTestApp.DispatchingComponent">Dispatching to sync context</option>
|
<option value="BasicTestApp.DispatchingComponent">Dispatching to sync context</option>
|
||||||
<option value="BasicTestApp.EventCallbackTest.EventCallbackCases">EventCallback</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>
|
</select>
|
||||||
|
|
||||||
@if (SelectedComponentType != null)
|
@if (SelectedComponentType != null)
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<title>Basic test app</title>
|
<title>Basic test app</title>
|
||||||
<base href="/subdir/" />
|
<base href="/subdir/" />
|
||||||
|
<link href="style.css" rel="stylesheet" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<root>Loading...</root>
|
<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