From 7d13f52f63a80b534bebf39032edf8711a4fd197 Mon Sep 17 00:00:00 2001
From: Steve Sanderson
Date: Tue, 12 Feb 2019 11:04:40 +0000
Subject: [PATCH 01/70] Add FieldIdentifier
---
.../Components/src/Forms/FieldIdentifier.cs | 58 ++++++++++
.../test/Forms/FieldIdentifierTest.cs | 106 ++++++++++++++++++
2 files changed, 164 insertions(+)
create mode 100644 src/Components/Components/src/Forms/FieldIdentifier.cs
create mode 100644 src/Components/Components/test/Forms/FieldIdentifierTest.cs
diff --git a/src/Components/Components/src/Forms/FieldIdentifier.cs b/src/Components/Components/src/Forms/FieldIdentifier.cs
new file mode 100644
index 000000000000..4fd5f60012fc
--- /dev/null
+++ b/src/Components/Components/src/Forms/FieldIdentifier.cs
@@ -0,0 +1,58 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+
+namespace Microsoft.AspNetCore.Components.Forms
+{
+ ///
+ /// Uniquely identifies a single field that can be edited. This may correspond to a property on a
+ /// model object, or can be any other named value.
+ ///
+ public struct FieldIdentifier
+ {
+ ///
+ /// Initializes a new instance of the structure.
+ ///
+ /// The object that owns the field.
+ /// The name of the editable field.
+ public FieldIdentifier(object model, string fieldName)
+ {
+ if (model == null)
+ {
+ throw new ArgumentNullException(nameof(model));
+ }
+
+ if (model.GetType().IsValueType)
+ {
+ throw new ArgumentException("The model must be a reference-typed object.", nameof(model));
+ }
+
+ Model = model;
+
+ // Note that we do allow an empty string. This is used by some validation systems
+ // as a place to store object-level (not per-property) messages.
+ FieldName = fieldName ?? throw new ArgumentNullException(nameof(fieldName));
+ }
+
+ ///
+ /// Gets the object that owns the editable field.
+ ///
+ public object Model { get; }
+
+ ///
+ /// Gets the name of the editable field.
+ ///
+ public string FieldName { get; }
+
+ ///
+ public override int GetHashCode()
+ => (Model, FieldName).GetHashCode();
+
+ ///
+ public override bool Equals(object obj)
+ => obj is FieldIdentifier otherIdentifier
+ && otherIdentifier.Model == Model
+ && string.Equals(otherIdentifier.FieldName, FieldName, StringComparison.Ordinal);
+ }
+}
diff --git a/src/Components/Components/test/Forms/FieldIdentifierTest.cs b/src/Components/Components/test/Forms/FieldIdentifierTest.cs
new file mode 100644
index 000000000000..1d92b00a3d12
--- /dev/null
+++ b/src/Components/Components/test/Forms/FieldIdentifierTest.cs
@@ -0,0 +1,106 @@
+// 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.Forms;
+using System;
+using Xunit;
+
+namespace Microsoft.AspNetCore.Components.Tests.Forms
+{
+ public class FieldIdentifierTest
+ {
+ [Fact]
+ public void CannotUseNullModel()
+ {
+ var ex = Assert.Throws(() => new FieldIdentifier(null, "somefield"));
+ Assert.Equal("model", ex.ParamName);
+ }
+
+ [Fact]
+ public void CannotUseValueTypeModel()
+ {
+ var ex = Assert.Throws(() => new FieldIdentifier(DateTime.Now, "somefield"));
+ Assert.Equal("model", ex.ParamName);
+ Assert.StartsWith("The model must be a reference-typed object.", ex.Message);
+ }
+
+ [Fact]
+ public void CannotUseNullFieldName()
+ {
+ var ex = Assert.Throws(() => new FieldIdentifier(new object(), null));
+ Assert.Equal("fieldName", ex.ParamName);
+ }
+
+ [Fact]
+ public void CanUseEmptyFieldName()
+ {
+ var fieldIdentifier = new FieldIdentifier(new object(), string.Empty);
+ Assert.Equal(string.Empty, fieldIdentifier.FieldName);
+ }
+
+ [Fact]
+ public void CanGetModelAndFieldName()
+ {
+ // Arrange/Act
+ var model = new object();
+ var fieldIdentifier = new FieldIdentifier(model, "someField");
+
+ // Assert
+ Assert.Same(model, fieldIdentifier.Model);
+ Assert.Equal("someField", fieldIdentifier.FieldName);
+ }
+
+ [Fact]
+ public void DistinctModelsProduceDistinctHashCodesAndNonEquality()
+ {
+ // Arrange
+ var fieldIdentifier1 = new FieldIdentifier(new object(), "field");
+ var fieldIdentifier2 = new FieldIdentifier(new object(), "field");
+
+ // Act/Assert
+ Assert.NotEqual(fieldIdentifier1.GetHashCode(), fieldIdentifier2.GetHashCode());
+ Assert.False(fieldIdentifier1.Equals(fieldIdentifier2));
+ }
+
+ [Fact]
+ public void DistinctFieldNamesProduceDistinctHashCodesAndNonEquality()
+ {
+ // Arrange
+ var model = new object();
+ var fieldIdentifier1 = new FieldIdentifier(model, "field1");
+ var fieldIdentifier2 = new FieldIdentifier(model, "field2");
+
+ // Act/Assert
+ Assert.NotEqual(fieldIdentifier1.GetHashCode(), fieldIdentifier2.GetHashCode());
+ Assert.False(fieldIdentifier1.Equals(fieldIdentifier2));
+ }
+
+ [Fact]
+ public void SameContentsProduceSameHashCodesAndEquality()
+ {
+ // Arrange
+ var model = new object();
+ var fieldIdentifier1 = new FieldIdentifier(model, "field");
+ var fieldIdentifier2 = new FieldIdentifier(model, "field");
+
+ // Act/Assert
+ Assert.Equal(fieldIdentifier1.GetHashCode(), fieldIdentifier2.GetHashCode());
+ Assert.True(fieldIdentifier1.Equals(fieldIdentifier2));
+ }
+
+ [Fact]
+ public void FieldNamesAreCaseSensitive()
+ {
+ // Arrange
+ var model = new object();
+ var fieldIdentifierLower = new FieldIdentifier(model, "field");
+ var fieldIdentifierPascal = new FieldIdentifier(model, "Field");
+
+ // Act/Assert
+ Assert.Equal("field", fieldIdentifierLower.FieldName);
+ Assert.Equal("Field", fieldIdentifierPascal.FieldName);
+ Assert.NotEqual(fieldIdentifierLower.GetHashCode(), fieldIdentifierPascal.GetHashCode());
+ Assert.False(fieldIdentifierLower.Equals(fieldIdentifierPascal));
+ }
+ }
+}
From 2a06bb1462921d81f21d85427b899c2b7d814c75 Mon Sep 17 00:00:00 2001
From: Steve Sanderson
Date: Tue, 12 Feb 2019 11:26:01 +0000
Subject: [PATCH 02/70] Beginning EditContext
---
.../Components/src/Forms/EditContext.cs | 66 +++++++++++++++++++
.../Components/test/Forms/EditContextTest.cs | 64 ++++++++++++++++++
2 files changed, 130 insertions(+)
create mode 100644 src/Components/Components/src/Forms/EditContext.cs
create mode 100644 src/Components/Components/test/Forms/EditContextTest.cs
diff --git a/src/Components/Components/src/Forms/EditContext.cs b/src/Components/Components/src/Forms/EditContext.cs
new file mode 100644
index 000000000000..0850e7977250
--- /dev/null
+++ b/src/Components/Components/src/Forms/EditContext.cs
@@ -0,0 +1,66 @@
+// 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
+{
+ ///
+ /// Holds state related to a data editing process.
+ ///
+ public class EditContext
+ {
+ ///
+ /// Constructs an instance of .
+ ///
+ /// The model object for the . This object should hold the data being edited, for example as a set of properties.
+ public EditContext(object model)
+ {
+ // The only reason we disallow null is because you'd almost always want one, and if you
+ // really don't, you can pass an empty object then ignore it. Ensuring it's nonnull
+ // simplifies things for all consumers of EditContext.
+ Model = model ?? throw new ArgumentNullException(nameof(model));
+ }
+
+ ///
+ /// Supplies a corresponding to a specified field name
+ /// on this 's .
+ ///
+ /// The name of the editable field.
+ /// A corresponding to a specified field name on this 's .
+ public FieldIdentifier Field(string fieldName)
+ => new FieldIdentifier(Model, fieldName);
+
+ ///
+ /// Gets the model object for this .
+ ///
+ public object Model { get; }
+
+ ///
+ /// Signals that the specified field within this has been changed.
+ ///
+ /// Identifies the field whose value has been changed.
+ public void NotifyFieldChanged(FieldIdentifier fieldIdentifier)
+ {
+ throw new NotImplementedException();
+ }
+
+ ///
+ /// Determines whether any of the fields in this have been modified.
+ ///
+ /// True if any of the fields in this have been modified; otherwise false.
+ public bool IsModified()
+ {
+ return false;
+ }
+
+ ///
+ /// Determines whether the specified fields in this has been modified.
+ ///
+ /// True if the field has been modified; otherwise false.
+ public bool IsModified(FieldIdentifier fieldIdentifier)
+ {
+ return false;
+ }
+ }
+}
diff --git a/src/Components/Components/test/Forms/EditContextTest.cs b/src/Components/Components/test/Forms/EditContextTest.cs
new file mode 100644
index 000000000000..cc3e04225165
--- /dev/null
+++ b/src/Components/Components/test/Forms/EditContextTest.cs
@@ -0,0 +1,64 @@
+// 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.Forms;
+using System;
+using Xunit;
+
+namespace Microsoft.AspNetCore.Components.Tests.Forms
+{
+ public class EditContextTest
+ {
+ [Fact]
+ public void CannotUseNullModel()
+ {
+ var ex = Assert.Throws(() => new EditContext(null));
+ Assert.Equal("model", ex.ParamName);
+ }
+
+ [Fact]
+ public void CanGetModel()
+ {
+ var model = new object();
+ var editContext = new EditContext(model);
+ Assert.Same(model, editContext.Model);
+ }
+
+ [Fact]
+ public void CanConstructFieldIdentifiersForRootModel()
+ {
+ // Arrange/Act
+ var model = new object();
+ var editContext = new EditContext(model);
+ var fieldIdentifier = editContext.Field("testFieldName");
+
+ // Assert
+ Assert.Same(model, fieldIdentifier.Model);
+ Assert.Equal("testFieldName", fieldIdentifier.FieldName);
+ }
+
+ [Fact]
+ public void IsInitiallyUnmodified()
+ {
+ var editContext = new EditContext(new object());
+ Assert.False(editContext.IsModified());
+ }
+
+ [Fact]
+ public void TracksFieldsAsModifiedWhenChanged()
+ {
+ // Arrange
+ var editContext = new EditContext(new object());
+ var field1 = editContext.Field("field1");
+ var field2 = editContext.Field("field2");
+
+ // Act
+ editContext.NotifyFieldChanged(field1);
+
+ // Assert
+ Assert.True(editContext.IsModified());
+ Assert.True(editContext.IsModified(field1));
+ Assert.False(editContext.IsModified(field2));
+ }
+ }
+}
From ef07f6eed70aeb8dd7d99a60ecc233e4afe48267 Mon Sep 17 00:00:00 2001
From: Steve Sanderson
Date: Tue, 12 Feb 2019 11:58:07 +0000
Subject: [PATCH 03/70] Tracks modifications and notifies about field value
changes
---
.../Components/src/Forms/EditContext.cs | 57 +++++++++++++++--
.../Components/src/Forms/FieldIdentifier.cs | 2 +-
.../Components/src/Forms/FieldState.cs | 10 +++
.../Components/test/Forms/EditContextTest.cs | 61 ++++++++++++++++++-
4 files changed, 122 insertions(+), 8 deletions(-)
create mode 100644 src/Components/Components/src/Forms/FieldState.cs
diff --git a/src/Components/Components/src/Forms/EditContext.cs b/src/Components/Components/src/Forms/EditContext.cs
index 0850e7977250..b095822a2d10 100644
--- a/src/Components/Components/src/Forms/EditContext.cs
+++ b/src/Components/Components/src/Forms/EditContext.cs
@@ -2,6 +2,8 @@
// 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
{
@@ -10,6 +12,8 @@ namespace Microsoft.AspNetCore.Components.Forms
///
public class EditContext
{
+ private Dictionary _fieldStates = new Dictionary();
+
///
/// Constructs an instance of .
///
@@ -22,6 +26,11 @@ public EditContext(object model)
Model = model ?? throw new ArgumentNullException(nameof(model));
}
+ ///
+ /// An event that is raised when a field value changes.
+ ///
+ public event EventHandler OnFieldChanged;
+
///
/// Supplies a corresponding to a specified field name
/// on this 's .
@@ -37,12 +46,37 @@ public FieldIdentifier Field(string fieldName)
public object Model { get; }
///
- /// Signals that the specified field within this has been changed.
+ /// Signals that the value for the specified field has changed.
///
/// Identifies the field whose value has been changed.
public void NotifyFieldChanged(FieldIdentifier fieldIdentifier)
{
- throw new NotImplementedException();
+ var state = GetOrCreateFieldState(fieldIdentifier);
+ state.IsModified = true;
+ OnFieldChanged?.Invoke(this, fieldIdentifier);
+ }
+
+ ///
+ /// Clears any modification flag that may be tracked for the specified field.
+ ///
+ /// Identifies the field whose modification flag (if any) should be cleared.
+ public void MarkAsUnmodified(FieldIdentifier fieldIdentifier)
+ {
+ if (_fieldStates.TryGetValue(fieldIdentifier, out var state))
+ {
+ state.IsModified = false;
+ }
+ }
+
+ ///
+ /// Clears all modification flags within this .
+ ///
+ public void MarkAsUnmodified()
+ {
+ foreach (var state in _fieldStates.Values)
+ {
+ state.IsModified = false;
+ }
}
///
@@ -50,17 +84,28 @@ public void NotifyFieldChanged(FieldIdentifier fieldIdentifier)
///
/// True if any of the fields in this have been modified; otherwise false.
public bool IsModified()
- {
- return false;
- }
+ // If necessary, we could consider caching the overall "is modified" state and only recomputing
+ // when there's a call to NotifyFieldModified/NotifyFieldUnmodified
+ => _fieldStates.Values.Any(state => state.IsModified);
///
/// Determines whether the specified fields in this has been modified.
///
/// True if the field has been modified; otherwise false.
public bool IsModified(FieldIdentifier fieldIdentifier)
+ => _fieldStates.TryGetValue(fieldIdentifier, out var state)
+ ? state.IsModified
+ : false;
+
+ private FieldState GetOrCreateFieldState(FieldIdentifier fieldIdentifier)
{
- return false;
+ if (!_fieldStates.TryGetValue(fieldIdentifier, out var state))
+ {
+ state = new FieldState();
+ _fieldStates.Add(fieldIdentifier, state);
+ }
+
+ return state;
}
}
}
diff --git a/src/Components/Components/src/Forms/FieldIdentifier.cs b/src/Components/Components/src/Forms/FieldIdentifier.cs
index 4fd5f60012fc..d4def4604867 100644
--- a/src/Components/Components/src/Forms/FieldIdentifier.cs
+++ b/src/Components/Components/src/Forms/FieldIdentifier.cs
@@ -9,7 +9,7 @@ namespace Microsoft.AspNetCore.Components.Forms
/// Uniquely identifies a single field that can be edited. This may correspond to a property on a
/// model object, or can be any other named value.
///
- public struct FieldIdentifier
+ public readonly struct FieldIdentifier
{
///
/// Initializes a new instance of the structure.
diff --git a/src/Components/Components/src/Forms/FieldState.cs b/src/Components/Components/src/Forms/FieldState.cs
new file mode 100644
index 000000000000..bd35e528ec00
--- /dev/null
+++ b/src/Components/Components/src/Forms/FieldState.cs
@@ -0,0 +1,10 @@
+// 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
+{
+ internal class FieldState
+ {
+ public bool IsModified { get; set; }
+ }
+}
diff --git a/src/Components/Components/test/Forms/EditContextTest.cs b/src/Components/Components/test/Forms/EditContextTest.cs
index cc3e04225165..5607b583b9c2 100644
--- a/src/Components/Components/test/Forms/EditContextTest.cs
+++ b/src/Components/Components/test/Forms/EditContextTest.cs
@@ -45,7 +45,7 @@ public void IsInitiallyUnmodified()
}
[Fact]
- public void TracksFieldsAsModifiedWhenChanged()
+ public void TracksFieldsAsModifiedWhenValueChanged()
{
// Arrange
var editContext = new EditContext(new object());
@@ -60,5 +60,64 @@ public void TracksFieldsAsModifiedWhenChanged()
Assert.True(editContext.IsModified(field1));
Assert.False(editContext.IsModified(field2));
}
+
+ [Fact]
+ public void CanClearIndividualModifications()
+ {
+ // 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(field1);
+
+ // Assert
+ Assert.True(editContext.IsModified());
+ Assert.False(editContext.IsModified(field1));
+ Assert.True(editContext.IsModified(field2));
+ }
+
+ [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 = editContext.Field("field1");
+ var didReceiveNotification = false;
+ editContext.OnFieldChanged += (sender, changedFieldIdentifier) =>
+ {
+ Assert.Same(editContext, sender);
+ Assert.Equal(field1, changedFieldIdentifier);
+ didReceiveNotification = true;
+ };
+
+ // Act
+ editContext.NotifyFieldChanged(field1);
+
+ // Assert
+ Assert.True(didReceiveNotification);
+ }
}
}
From ea75b9f1010cb336829d02f28e73278c4c073a55 Mon Sep 17 00:00:00 2001
From: Steve Sanderson
Date: Tue, 12 Feb 2019 12:02:28 +0000
Subject: [PATCH 04/70] Improve XML doc
---
src/Components/Components/src/Forms/EditContext.cs | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/src/Components/Components/src/Forms/EditContext.cs b/src/Components/Components/src/Forms/EditContext.cs
index b095822a2d10..2669fc13d44e 100644
--- a/src/Components/Components/src/Forms/EditContext.cs
+++ b/src/Components/Components/src/Forms/EditContext.cs
@@ -8,7 +8,8 @@
namespace Microsoft.AspNetCore.Components.Forms
{
///
- /// Holds state related to a data editing process.
+ /// Holds metadata related to a data editing process, such as flags to indicate which
+ /// fields have been modified and the current set of validation messages.
///
public class EditContext
{
From a06bb3a86ce1f8a3ec59e4f7623ecaca3bf986e0 Mon Sep 17 00:00:00 2001
From: Steve Sanderson
Date: Tue, 12 Feb 2019 12:10:28 +0000
Subject: [PATCH 05/70] Extend tests to show fields don't have to be on the
EditContext model
---
.../Components/test/Forms/EditContextTest.cs | 32 +++++++++++--------
1 file changed, 19 insertions(+), 13 deletions(-)
diff --git a/src/Components/Components/test/Forms/EditContextTest.cs b/src/Components/Components/test/Forms/EditContextTest.cs
index 5607b583b9c2..a3a08a56ae30 100644
--- a/src/Components/Components/test/Forms/EditContextTest.cs
+++ b/src/Components/Components/test/Forms/EditContextTest.cs
@@ -49,16 +49,19 @@ public void TracksFieldsAsModifiedWhenValueChanged()
{
// Arrange
var editContext = new EditContext(new object());
- var field1 = editContext.Field("field1");
- var field2 = editContext.Field("field2");
+ var fieldOnThisModel1 = editContext.Field("field1");
+ var fieldOnThisModel2 = editContext.Field("field2");
+ var fieldOnOtherModel = new FieldIdentifier(new object(), "field on other model");
// Act
- editContext.NotifyFieldChanged(field1);
+ editContext.NotifyFieldChanged(fieldOnThisModel1);
+ editContext.NotifyFieldChanged(fieldOnOtherModel);
// Assert
Assert.True(editContext.IsModified());
- Assert.True(editContext.IsModified(field1));
- Assert.False(editContext.IsModified(field2));
+ Assert.True(editContext.IsModified(fieldOnThisModel1));
+ Assert.False(editContext.IsModified(fieldOnThisModel2));
+ Assert.True(editContext.IsModified(fieldOnOtherModel));
}
[Fact]
@@ -66,18 +69,21 @@ public void CanClearIndividualModifications()
{
// Arrange
var editContext = new EditContext(new object());
- var field1 = editContext.Field("field1");
- var field2 = editContext.Field("field2");
- editContext.NotifyFieldChanged(field1);
- editContext.NotifyFieldChanged(field2);
+ 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(field1);
+ editContext.MarkAsUnmodified(fieldThatWasModified);
+ editContext.MarkAsUnmodified(fieldThatWasNeverModified);
// Assert
Assert.True(editContext.IsModified());
- Assert.False(editContext.IsModified(field1));
- Assert.True(editContext.IsModified(field2));
+ Assert.False(editContext.IsModified(fieldThatWasModified));
+ Assert.True(editContext.IsModified(fieldThatRemainsModified));
+ Assert.False(editContext.IsModified(fieldThatWasNeverModified));
}
[Fact]
@@ -104,7 +110,7 @@ public void RaisesEventWhenFieldIsChanged()
{
// Arrange
var editContext = new EditContext(new object());
- var field1 = editContext.Field("field1");
+ var field1 = new FieldIdentifier(new object(), "fieldname"); // Shows it can be on a different model
var didReceiveNotification = false;
editContext.OnFieldChanged += (sender, changedFieldIdentifier) =>
{
From 834dd3f3d489334c4ddb704c4096fc20274cbe2b Mon Sep 17 00:00:00 2001
From: Steve Sanderson
Date: Tue, 12 Feb 2019 13:38:45 +0000
Subject: [PATCH 06/70] Begin ValidationMessageStore
---
.../Components/src/Forms/EditContext.cs | 2 +-
.../src/Forms/ValidationMessageStore.cs | 77 +++++++++++++++
.../test/Forms/ValidationMessageStoreTest.cs | 97 +++++++++++++++++++
3 files changed, 175 insertions(+), 1 deletion(-)
create mode 100644 src/Components/Components/src/Forms/ValidationMessageStore.cs
create mode 100644 src/Components/Components/test/Forms/ValidationMessageStoreTest.cs
diff --git a/src/Components/Components/src/Forms/EditContext.cs b/src/Components/Components/src/Forms/EditContext.cs
index 2669fc13d44e..9212a6bc6c50 100644
--- a/src/Components/Components/src/Forms/EditContext.cs
+++ b/src/Components/Components/src/Forms/EditContext.cs
@@ -13,7 +13,7 @@ namespace Microsoft.AspNetCore.Components.Forms
///
public class EditContext
{
- private Dictionary _fieldStates = new Dictionary();
+ private readonly Dictionary _fieldStates = new Dictionary();
///
/// Constructs an instance of .
diff --git a/src/Components/Components/src/Forms/ValidationMessageStore.cs b/src/Components/Components/src/Forms/ValidationMessageStore.cs
new file mode 100644
index 000000000000..795b49f0b137
--- /dev/null
+++ b/src/Components/Components/src/Forms/ValidationMessageStore.cs
@@ -0,0 +1,77 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Microsoft.AspNetCore.Components.Forms
+{
+ ///
+ /// Holds validation messages for an .
+ ///
+ public class ValidationMessageStore
+ {
+ private readonly EditContext _editContext;
+ private readonly Dictionary> _messages = new Dictionary>();
+
+ ///
+ /// Creates an instance of .
+ ///
+ /// The with which this store should be associated.
+ public ValidationMessageStore(EditContext editContext)
+ {
+ _editContext = editContext ?? throw new ArgumentNullException(nameof(editContext));
+ }
+
+ ///
+ /// Adds a validation message for the specified field.
+ ///
+ /// The identifier for the field.
+ /// The validation message.
+ public void Add(FieldIdentifier fieldIdentifier, string message)
+ => GetOrCreateMessagesListForField(fieldIdentifier).Add(message);
+
+ ///
+ /// Adds the messages from the specified collection for the specified field.
+ ///
+ /// The identifier for the field.
+ /// The validation messages to be added.
+ public void AddRange(FieldIdentifier fieldIdentifier, IEnumerable messages)
+ => GetOrCreateMessagesListForField(fieldIdentifier).AddRange(messages);
+
+ ///
+ /// Gets the validation messages within this for the specified field.
+ ///
+ /// The identifier for the field.
+ /// The validation messages for the specified field within this .
+ public IEnumerable this[FieldIdentifier fieldIdentifier]
+ {
+ get => _messages.TryGetValue(fieldIdentifier, out var messages) ? messages : Enumerable.Empty();
+ }
+
+ ///
+ /// Removes all messages within this .
+ ///
+ public void Clear()
+ => _messages.Clear();
+
+ ///
+ /// Removes all messages within this for the specified field.
+ ///
+ /// The identifier for the field.
+ public void Clear(FieldIdentifier fieldIdentifier)
+ => _messages.Remove(fieldIdentifier);
+
+ private List GetOrCreateMessagesListForField(FieldIdentifier fieldIdentifier)
+ {
+ if (!_messages.TryGetValue(fieldIdentifier, out var messagesForField))
+ {
+ messagesForField = new List();
+ _messages.Add(fieldIdentifier, messagesForField);
+ }
+
+ return messagesForField;
+ }
+ }
+}
diff --git a/src/Components/Components/test/Forms/ValidationMessageStoreTest.cs b/src/Components/Components/test/Forms/ValidationMessageStoreTest.cs
new file mode 100644
index 000000000000..f3822ab89941
--- /dev/null
+++ b/src/Components/Components/test/Forms/ValidationMessageStoreTest.cs
@@ -0,0 +1,97 @@
+// 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.Forms;
+using System;
+using Xunit;
+
+namespace Microsoft.AspNetCore.Components.Tests.Forms
+{
+ public class ValidationMessageStoreTest
+ {
+ [Fact]
+ public void CannotUseNullEditContext()
+ {
+ var ex = Assert.Throws(() => new ValidationMessageStore(null));
+ Assert.Equal("editContext", ex.ParamName);
+ }
+
+ [Fact]
+ public void CanCreateForEditContext()
+ {
+ new ValidationMessageStore(new EditContext(new object()));
+ }
+
+ [Fact]
+ public void CanAddMessages()
+ {
+ // Arrange
+ var messages = new ValidationMessageStore(new EditContext(new object()));
+ var field1 = new FieldIdentifier(new object(), "field1");
+ var field2 = new FieldIdentifier(new object(), "field2");
+ var field3 = new FieldIdentifier(new object(), "field3");
+
+ // Act
+ messages.Add(field1, "Field 1 message 1");
+ messages.Add(field1, "Field 1 message 2");
+ messages.Add(field2, "Field 2 message 1");
+
+ // Assert
+ Assert.Equal(new[] { "Field 1 message 1", "Field 1 message 2" }, messages[field1]);
+ Assert.Equal(new[] { "Field 2 message 1" }, messages[field2]);
+ Assert.Empty(messages[field3]);
+ }
+
+ [Fact]
+ public void CanAddMessagesByRange()
+ {
+ // Arrange
+ var messages = new ValidationMessageStore(new EditContext(new object()));
+ var field1 = new FieldIdentifier(new object(), "field1");
+ var entries = new[] { "A", "B", "C" };
+
+ // Act
+ messages.AddRange(field1, entries);
+
+ // Assert
+ Assert.Equal(entries, messages[field1]);
+ }
+
+ [Fact]
+ public void CanClearMessagesForSingleField()
+ {
+ // Arrange
+ var messages = new ValidationMessageStore(new EditContext(new object()));
+ var field1 = new FieldIdentifier(new object(), "field1");
+ var field2 = new FieldIdentifier(new object(), "field2");
+ messages.Add(field1, "Field 1 message 1");
+ messages.Add(field1, "Field 1 message 2");
+ messages.Add(field2, "Field 2 message 1");
+
+ // Act
+ messages.Clear(field1);
+
+ // Assert
+ Assert.Empty(messages[field1]);
+ Assert.Equal(new[] { "Field 2 message 1" }, messages[field2]);
+ }
+
+ [Fact]
+ public void CanClearMessagesForAllFields()
+ {
+ // Arrange
+ var messages = new ValidationMessageStore(new EditContext(new object()));
+ var field1 = new FieldIdentifier(new object(), "field1");
+ var field2 = new FieldIdentifier(new object(), "field2");
+ messages.Add(field1, "Field 1 message 1");
+ messages.Add(field2, "Field 2 message 1");
+
+ // Act
+ messages.Clear();
+
+ // Assert
+ Assert.Empty(messages[field1]);
+ Assert.Empty(messages[field2]);
+ }
+ }
+}
From 2705a378cf2bc720dadad7612a36abf3dd71b5ac Mon Sep 17 00:00:00 2001
From: Steve Sanderson
Date: Tue, 12 Feb 2019 14:16:39 +0000
Subject: [PATCH 07/70] Efficiently list validation messages across stores
---
.../Components/src/Forms/EditContext.cs | 30 ++++++++--
.../Components/src/Forms/FieldState.cs | 32 ++++++++++
.../src/Forms/ValidationMessageStore.cs | 23 ++++++-
.../test/Forms/ValidationMessageStoreTest.cs | 60 +++++++++++++++++++
4 files changed, 138 insertions(+), 7 deletions(-)
diff --git a/src/Components/Components/src/Forms/EditContext.cs b/src/Components/Components/src/Forms/EditContext.cs
index 9212a6bc6c50..78da445c5e1d 100644
--- a/src/Components/Components/src/Forms/EditContext.cs
+++ b/src/Components/Components/src/Forms/EditContext.cs
@@ -52,8 +52,7 @@ public FieldIdentifier Field(string fieldName)
/// Identifies the field whose value has been changed.
public void NotifyFieldChanged(FieldIdentifier fieldIdentifier)
{
- var state = GetOrCreateFieldState(fieldIdentifier);
- state.IsModified = true;
+ GetFieldState(fieldIdentifier, ensureExists: true).IsModified = true;
OnFieldChanged?.Invoke(this, fieldIdentifier);
}
@@ -89,6 +88,27 @@ public bool IsModified()
// when there's a call to NotifyFieldModified/NotifyFieldUnmodified
=> _fieldStates.Values.Any(state => state.IsModified);
+ ///
+ /// Gets the current validation messages across all fields.
+ ///
+ /// This method does not perform validation itself. It only returns messages determined by previous validation actions.
+ ///
+ /// The current validation messages.
+ public IEnumerable GetValidationMessages()
+ // Since we're only enumerating the fields for which we have a non-null state, the cost of this grows
+ // based on how many fields have been modified or have associated validation messages
+ => _fieldStates.Values.SelectMany(state => state.GetValidationMessages());
+
+ ///
+ /// Gets the current validation messages for the specified field.
+ ///
+ /// This method does not perform validation itself. It only returns messages determined by previous validation actions.
+ ///
+ /// Identifies the field whose current validation messages should be returned.
+ /// The current validation messages for the specified field.
+ public IEnumerable GetValidationMessages(FieldIdentifier fieldIdentifier)
+ => _fieldStates.TryGetValue(fieldIdentifier, out var state) ? state.GetValidationMessages() : Enumerable.Empty();
+
///
/// Determines whether the specified fields in this has been modified.
///
@@ -98,11 +118,11 @@ public bool IsModified(FieldIdentifier fieldIdentifier)
? state.IsModified
: false;
- private FieldState GetOrCreateFieldState(FieldIdentifier fieldIdentifier)
+ internal FieldState GetFieldState(FieldIdentifier fieldIdentifier, bool ensureExists)
{
- if (!_fieldStates.TryGetValue(fieldIdentifier, out var state))
+ if (!_fieldStates.TryGetValue(fieldIdentifier, out var state) && ensureExists)
{
- state = new FieldState();
+ state = new FieldState(fieldIdentifier);
_fieldStates.Add(fieldIdentifier, state);
}
diff --git a/src/Components/Components/src/Forms/FieldState.cs b/src/Components/Components/src/Forms/FieldState.cs
index bd35e528ec00..2497952c4ca5 100644
--- a/src/Components/Components/src/Forms/FieldState.cs
+++ b/src/Components/Components/src/Forms/FieldState.cs
@@ -1,10 +1,42 @@
// 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;
+using System.Linq;
+
namespace Microsoft.AspNetCore.Components.Forms
{
internal class FieldState
{
+ private readonly FieldIdentifier _fieldIdentifier;
+
+ // We track which ValidationMessageStore instances have a nonempty set of messages for this field so that
+ // we can quickly evaluate the list of messages for the field without having to query all stores. This is
+ // relevant because each validation component may define its own message store, so there might be as many
+ // stores are there are fields or UI elements.
+ private HashSet _validationMessageStores;
+
+ public FieldState(FieldIdentifier fieldIdentifier)
+ {
+ _fieldIdentifier = fieldIdentifier;
+ }
+
public bool IsModified { get; set; }
+
+ public IEnumerable GetValidationMessages()
+ => _validationMessageStores == null ? Enumerable.Empty() : _validationMessageStores.SelectMany(store => store[_fieldIdentifier]);
+
+ public void AssociateWithValidationMessageStore(ValidationMessageStore validationMessageStore)
+ {
+ if (_validationMessageStores == null)
+ {
+ _validationMessageStores = new HashSet();
+ }
+
+ _validationMessageStores.Add(validationMessageStore);
+ }
+
+ public void DissociateFromValidationMessageStore(ValidationMessageStore validationMessageStore)
+ => _validationMessageStores?.Remove(validationMessageStore);
}
}
diff --git a/src/Components/Components/src/Forms/ValidationMessageStore.cs b/src/Components/Components/src/Forms/ValidationMessageStore.cs
index 795b49f0b137..ec242a4fbfb1 100644
--- a/src/Components/Components/src/Forms/ValidationMessageStore.cs
+++ b/src/Components/Components/src/Forms/ValidationMessageStore.cs
@@ -42,6 +42,8 @@ public void AddRange(FieldIdentifier fieldIdentifier, IEnumerable messag
///
/// Gets the validation messages within this for the specified field.
+ ///
+ /// To get the validation messages across all validation message stores, use instead
///
/// The identifier for the field.
/// The validation messages for the specified field within this .
@@ -54,14 +56,24 @@ public IEnumerable this[FieldIdentifier fieldIdentifier]
/// Removes all messages within this .
///
public void Clear()
- => _messages.Clear();
+ {
+ foreach (var fieldIdentifier in _messages.Keys)
+ {
+ DissociateFromField(fieldIdentifier);
+ }
+
+ _messages.Clear();
+ }
///
/// Removes all messages within this for the specified field.
///
/// The identifier for the field.
public void Clear(FieldIdentifier fieldIdentifier)
- => _messages.Remove(fieldIdentifier);
+ {
+ DissociateFromField(fieldIdentifier);
+ _messages.Remove(fieldIdentifier);
+ }
private List GetOrCreateMessagesListForField(FieldIdentifier fieldIdentifier)
{
@@ -69,9 +81,16 @@ private List GetOrCreateMessagesListForField(FieldIdentifier fieldIdenti
{
messagesForField = new List();
_messages.Add(fieldIdentifier, messagesForField);
+ AssociateWithField(fieldIdentifier);
}
return messagesForField;
}
+
+ private void AssociateWithField(FieldIdentifier fieldIdentifier)
+ => _editContext.GetFieldState(fieldIdentifier, ensureExists: true).AssociateWithValidationMessageStore(this);
+
+ private void DissociateFromField(FieldIdentifier fieldIdentifier)
+ => _editContext.GetFieldState(fieldIdentifier, ensureExists: false)?.DissociateFromValidationMessageStore(this);
}
}
diff --git a/src/Components/Components/test/Forms/ValidationMessageStoreTest.cs b/src/Components/Components/test/Forms/ValidationMessageStoreTest.cs
index f3822ab89941..21ce92b8033d 100644
--- a/src/Components/Components/test/Forms/ValidationMessageStoreTest.cs
+++ b/src/Components/Components/test/Forms/ValidationMessageStoreTest.cs
@@ -3,6 +3,7 @@
using Microsoft.AspNetCore.Components.Forms;
using System;
+using System.Linq;
using Xunit;
namespace Microsoft.AspNetCore.Components.Tests.Forms
@@ -93,5 +94,64 @@ public void CanClearMessagesForAllFields()
Assert.Empty(messages[field1]);
Assert.Empty(messages[field2]);
}
+
+ [Fact]
+ public void CanEnumerateMessagesAcrossAllStoresForSingleField()
+ {
+ // 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 CanEnumerateMessagesAcrossAllStoresForAllFields()
+ {
+ // 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());
+
+ }
}
}
From 9a1ea5014e1f69dabd198ee09f67f8fe15bdf13a Mon Sep 17 00:00:00 2001
From: Steve Sanderson
Date: Tue, 12 Feb 2019 14:21:57 +0000
Subject: [PATCH 08/70] Move unit tests to better place
---
.../Components/test/Forms/EditContextTest.cs | 59 ++++++++++++++++++
.../test/Forms/ValidationMessageStoreTest.cs | 60 -------------------
2 files changed, 59 insertions(+), 60 deletions(-)
diff --git a/src/Components/Components/test/Forms/EditContextTest.cs b/src/Components/Components/test/Forms/EditContextTest.cs
index a3a08a56ae30..1e8461b9309b 100644
--- a/src/Components/Components/test/Forms/EditContextTest.cs
+++ b/src/Components/Components/test/Forms/EditContextTest.cs
@@ -3,6 +3,7 @@
using Microsoft.AspNetCore.Components.Forms;
using System;
+using System.Linq;
using Xunit;
namespace Microsoft.AspNetCore.Components.Tests.Forms
@@ -125,5 +126,63 @@ public void RaisesEventWhenFieldIsChanged()
// 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());
+ }
}
}
diff --git a/src/Components/Components/test/Forms/ValidationMessageStoreTest.cs b/src/Components/Components/test/Forms/ValidationMessageStoreTest.cs
index 21ce92b8033d..f3822ab89941 100644
--- a/src/Components/Components/test/Forms/ValidationMessageStoreTest.cs
+++ b/src/Components/Components/test/Forms/ValidationMessageStoreTest.cs
@@ -3,7 +3,6 @@
using Microsoft.AspNetCore.Components.Forms;
using System;
-using System.Linq;
using Xunit;
namespace Microsoft.AspNetCore.Components.Tests.Forms
@@ -94,64 +93,5 @@ public void CanClearMessagesForAllFields()
Assert.Empty(messages[field1]);
Assert.Empty(messages[field2]);
}
-
- [Fact]
- public void CanEnumerateMessagesAcrossAllStoresForSingleField()
- {
- // 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 CanEnumerateMessagesAcrossAllStoresForAllFields()
- {
- // 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());
-
- }
}
}
From 28b7c41d24f8fb958da4e0f6161f3d8b5e195fb7 Mon Sep 17 00:00:00 2001
From: Steve Sanderson
Date: Wed, 13 Feb 2019 09:26:13 +0000
Subject: [PATCH 09/70] Add notes about async validation plan
---
.../Components/src/Forms/EditContext.cs | 25 +++++++++++++++++++
1 file changed, 25 insertions(+)
diff --git a/src/Components/Components/src/Forms/EditContext.cs b/src/Components/Components/src/Forms/EditContext.cs
index 78da445c5e1d..5b772b42c259 100644
--- a/src/Components/Components/src/Forms/EditContext.cs
+++ b/src/Components/Components/src/Forms/EditContext.cs
@@ -7,6 +7,31 @@
namespace Microsoft.AspNetCore.Components.Forms
{
+ /* Async validation plan
+ * =====================
+ * - Add method: editContext.AddValidationTask(FieldIdentifier f, Task t)
+ * It adds the task to a HashSet on both the FieldState and the EditContext,
+ * so we can easily get all the tasks for a given field and across the whole EditContext
+ * Also it awaits the task completion, and then regardless of outcome (success/fail/cancel),
+ * it removes the task from those hashsets.
+ * - Add method: editContext.WhenAllValidationTasks()
+ * Add method: editContext.WhenAllValidationTasks(FieldIdentifier f)
+ * These return Task.WhenAll(hashSet.Values), or Task.Completed if there are none
+ * - Optionally also add editContext.HasPendingValidationTasks()
+ * - Add method: editContext.ValidateAsync() that awaits all the validation tasks then
+ * returns true if there are no validation messages, false otherwise
+ * - Now a validation library can register tasks whenever it starts an async validation process,
+ * can cancel them if it wants, and can still issue ValidationResultsChanged notifications when
+ * each task completes. So a UI can determine whether to show "pending" state on a per-field
+ * and per-form basis, and will re-render as each field's results arrive.
+ * - Note: it's unclear why we'd need WhenAllValidationTasks(FieldIdentifier) (i.e., per-field),
+ * since you wouldn't "await" this to get per-field updates (rather, you'd use ValidationResultsChanged).
+ * Maybe WhenAllValidationTasks can be private, and only called by ValidateAsync. We just expose
+ * public HasPendingValidationTasks (per-field and per-edit-context).
+ * Will implement this shortly after getting more of the system in place, assuming it still
+ * appears to be the correct design.
+ */
+
///
/// 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.
From 4b14bb9adeb4096ee2ef8981db0a0c8c10101850 Mon Sep 17 00:00:00 2001
From: Steve Sanderson
Date: Wed, 13 Feb 2019 09:41:21 +0000
Subject: [PATCH 10/70] Very basic synchronous Validate method
---
.../Components/src/Forms/EditContext.cs | 15 ++++++
.../Components/test/Forms/EditContextTest.cs | 53 +++++++++++++++++++
2 files changed, 68 insertions(+)
diff --git a/src/Components/Components/src/Forms/EditContext.cs b/src/Components/Components/src/Forms/EditContext.cs
index 5b772b42c259..22fd4be679b0 100644
--- a/src/Components/Components/src/Forms/EditContext.cs
+++ b/src/Components/Components/src/Forms/EditContext.cs
@@ -57,6 +57,11 @@ public EditContext(object model)
///
public event EventHandler OnFieldChanged;
+ ///
+ /// An event that is raised when validation is requested.
+ ///
+ public event EventHandler OnValidationRequested;
+
///
/// Supplies a corresponding to a specified field name
/// on this 's .
@@ -143,6 +148,16 @@ public bool IsModified(FieldIdentifier fieldIdentifier)
? state.IsModified
: false;
+ ///
+ /// Validates this .
+ ///
+ /// True if there are no validation messages after validation; otherwise false.
+ public bool Validate()
+ {
+ OnValidationRequested?.Invoke(this, null);
+ return !GetValidationMessages().Any();
+ }
+
internal FieldState GetFieldState(FieldIdentifier fieldIdentifier, bool ensureExists)
{
if (!_fieldStates.TryGetValue(fieldIdentifier, out var state) && ensureExists)
diff --git a/src/Components/Components/test/Forms/EditContextTest.cs b/src/Components/Components/test/Forms/EditContextTest.cs
index 1e8461b9309b..f1daf6fad5e1 100644
--- a/src/Components/Components/test/Forms/EditContextTest.cs
+++ b/src/Components/Components/test/Forms/EditContextTest.cs
@@ -184,5 +184,58 @@ public void CanEnumerateValidationMessagesAcrossAllStoresForAllFields()
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.Null(eventArgs); // Not currently used
+ 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());
+ }
}
}
From 65de11a033f78900c3e33a5e652944f6ebbf5c77 Mon Sep 17 00:00:00 2001
From: Steve Sanderson
Date: Wed, 13 Feb 2019 10:11:18 +0000
Subject: [PATCH 11/70] Add DataAnnotations validation support
---
.../EditContextDataAnnotationsExtensions.cs | 53 ++++++++++++
.../Microsoft.AspNetCore.Components.csproj | 1 +
...ditContextDataAnnotationsExtensionsTest.cs | 81 +++++++++++++++++++
3 files changed, 135 insertions(+)
create mode 100644 src/Components/Components/src/Forms/EditContextDataAnnotationsExtensions.cs
create mode 100644 src/Components/Components/test/Forms/EditContextDataAnnotationsExtensionsTest.cs
diff --git a/src/Components/Components/src/Forms/EditContextDataAnnotationsExtensions.cs b/src/Components/Components/src/Forms/EditContextDataAnnotationsExtensions.cs
new file mode 100644
index 000000000000..f55feeb6ccd7
--- /dev/null
+++ b/src/Components/Components/src/Forms/EditContextDataAnnotationsExtensions.cs
@@ -0,0 +1,53 @@
+// 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.ComponentModel.DataAnnotations;
+
+namespace Microsoft.AspNetCore.Components.Forms
+{
+ ///
+ /// Extension methods to add DataAnnotations validation to an .
+ ///
+ public static class EditContextDataAnnotationsExtensions
+ {
+ ///
+ /// Adds DataAnnotations validation support to the .
+ ///
+ /// The .
+ public static EditContext AddDataAnnotationsValidation(this EditContext editContext)
+ {
+ if (editContext == null)
+ {
+ throw new ArgumentNullException(nameof(editContext));
+ }
+
+ var messages = new ValidationMessageStore(editContext);
+
+ editContext.OnValidationRequested += (object sender, EventArgs e) =>
+ {
+ ValidateModel((EditContext)sender, messages);
+ };
+
+ return editContext;
+ }
+
+ private static void ValidateModel(EditContext editContext, ValidationMessageStore messages)
+ {
+ var validationContext = new ValidationContext(editContext.Model);
+ var validationResults = new List();
+ Validator.TryValidateObject(editContext.Model, validationContext, validationResults, true);
+
+ // Transfer results to the ValidationMessageStore
+ messages.Clear();
+ foreach (var validationResult in validationResults)
+ {
+ foreach (var memberName in validationResult.MemberNames)
+ {
+ messages.Add(editContext.Field(memberName), validationResult.ErrorMessage);
+ }
+ }
+ }
+ }
+}
diff --git a/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj b/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj
index b9026cff0960..0974eb1c359a 100644
--- a/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj
+++ b/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj
@@ -10,6 +10,7 @@
+
diff --git a/src/Components/Components/test/Forms/EditContextDataAnnotationsExtensionsTest.cs b/src/Components/Components/test/Forms/EditContextDataAnnotationsExtensionsTest.cs
new file mode 100644
index 000000000000..e58da2f9b6b0
--- /dev/null
+++ b/src/Components/Components/test/Forms/EditContextDataAnnotationsExtensionsTest.cs
@@ -0,0 +1,81 @@
+// 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.Forms;
+using System;
+using System.ComponentModel.DataAnnotations;
+using Xunit;
+
+namespace Microsoft.AspNetCore.Components.Tests.Forms
+{
+ public class EditContextDataAnnotationsExtensionsTest
+ {
+ [Fact]
+ public void CannotUseNullEditContext()
+ {
+ var editContext = (EditContext)null;
+ var ex = Assert.Throws(() => editContext.AddDataAnnotationsValidation());
+ Assert.Equal("editContext", ex.ParamName);
+ }
+
+ [Fact]
+ public void ReturnsEditContextForChaining()
+ {
+ var editContext = new EditContext(new object());
+ var returnValue = editContext.AddDataAnnotationsValidation();
+ Assert.Same(editContext, returnValue);
+ }
+
+ [Fact]
+ public void GetsValidationMessagesFromDataAnnotations()
+ {
+ // Arrange
+ var model = new TestModel { IntFrom1To100 = 101 };
+ var editContext = new EditContext(model).AddDataAnnotationsValidation();
+
+ // Act
+ var isValid = editContext.Validate();
+
+ // Assert
+ Assert.False(isValid);
+
+ Assert.Equal(new string[]
+ {
+ "The RequiredString field is required.",
+ "The field IntFrom1To100 must be between 1 and 100."
+ },
+ editContext.GetValidationMessages());
+
+ Assert.Equal(new string[] { "The RequiredString field is 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[] { "The field IntFrom1To100 must be between 1 and 100." },
+ 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());
+ }
+
+ class TestModel
+ {
+ [Required] public string RequiredString { get; set; }
+
+ [Range(1, 100)] public int IntFrom1To100 { get; set; }
+ }
+ }
+}
From 945e2f0b3ccc0f5f001105bd2f414f3bf46a55d4 Mon Sep 17 00:00:00 2001
From: Steve Sanderson
Date: Wed, 13 Feb 2019 12:02:41 +0000
Subject: [PATCH 12/70] Add EditForm and DataAnnotationsValidator component
---
.../src/Forms/DataAnnotationsValidator.cs | 28 ++++
.../Components/src/Forms/EditForm.cs | 154 ++++++++++++++++++
.../Forms/TemporaryEventRoutingWorkaround.cs | 31 ++++
.../SimpleValidationComponent.cshtml | 58 +++++++
.../test/testassets/BasicTestApp/Index.cshtml | 1 +
5 files changed, 272 insertions(+)
create mode 100644 src/Components/Components/src/Forms/DataAnnotationsValidator.cs
create mode 100644 src/Components/Components/src/Forms/EditForm.cs
create mode 100644 src/Components/Components/src/Forms/TemporaryEventRoutingWorkaround.cs
create mode 100644 src/Components/test/testassets/BasicTestApp/FormsTest/SimpleValidationComponent.cshtml
diff --git a/src/Components/Components/src/Forms/DataAnnotationsValidator.cs b/src/Components/Components/src/Forms/DataAnnotationsValidator.cs
new file mode 100644
index 000000000000..e0a803b64439
--- /dev/null
+++ b/src/Components/Components/src/Forms/DataAnnotationsValidator.cs
@@ -0,0 +1,28 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+
+namespace Microsoft.AspNetCore.Components.Forms
+{
+ ///
+ /// Adds Data Annotations validation support to an .
+ ///
+ public class DataAnnotationsValidator : ComponentBase
+ {
+ [CascadingParameter] EditContext EditContext { get; set; }
+
+ ///
+ protected override void OnInit()
+ {
+ if (EditContext == 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)}.");
+ }
+
+ EditContext.AddDataAnnotationsValidation();
+ }
+ }
+}
diff --git a/src/Components/Components/src/Forms/EditForm.cs b/src/Components/Components/src/Forms/EditForm.cs
new file mode 100644
index 000000000000..3f11acdfad97
--- /dev/null
+++ b/src/Components/Components/src/Forms/EditForm.cs
@@ -0,0 +1,154 @@
+// 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;
+using System;
+using System.Threading.Tasks;
+
+namespace Microsoft.AspNetCore.Components.Forms
+{
+ ///
+ /// Renders a form element that cascades an to descendants.
+ ///
+ public class EditForm : ComponentBase
+ {
+ private readonly Func _handleSubmitDelegate; // Cache to avoid per-render allocations
+
+ private EditContext _fixedEditContext;
+
+ ///
+ /// Constructs an instance of .
+ ///
+ public EditForm()
+ {
+ _handleSubmitDelegate = HandleSubmitAsync;
+ }
+
+ ///
+ /// Supplies the edit context explicitly. If using this parameter, do not
+ /// also supply , since the model value will be taken
+ /// from the property.
+ ///
+ [Parameter] EditContext EditContext { get; set; }
+
+ ///
+ /// Specifies the top-level model object for the form. An edit context will
+ /// be constructed for this model. If using this parameter, do not also supply
+ /// a value for .
+ ///
+ [Parameter] object Model { get; set; }
+
+ ///
+ /// Specifies the content to be rendered inside this .
+ ///
+ [Parameter] RenderFragment ChildContent { get; set; }
+
+ ///
+ /// A callback that will be invoked when the form is submitted.
+ ///
+ /// If using this parameter, you are responsible for triggering any validation
+ /// manually, e.g., by calling .
+ ///
+ [Parameter] Func OnSubmit { get; set; }
+
+ ///
+ /// A callback that will be invoked when the form is submitted and the
+ /// is determined to be valid.
+ ///
+ [Parameter] Func OnValidSubmit { get; set; }
+
+ ///
+ /// A callback that will be invoked when the form is submitted and the
+ /// is determined to be invalid.
+ ///
+ [Parameter] Func OnInvalidSubmit { get; set; }
+
+ ///
+ protected override void OnParametersSet()
+ {
+ if ((EditContext == null) == (Model == null))
+ {
+ throw new InvalidOperationException($"{nameof(EditForm)} requires a {nameof(Model)} " +
+ $"parameter, or an {nameof(EditContext)} parameter, but not both.");
+ }
+
+ // If you're using OnSubmit, it becomes your responsibility to trigger validation manually
+ // (e.g., so you can display a "pending" state in the UI). In that case you don't want the
+ // system to trigger a second validation implicitly, so don't combine it with the simplified
+ // OnValidSubmit/OnInvalidSubmit handlers.
+ if (OnSubmit != null && (OnValidSubmit != null || OnInvalidSubmit != null))
+ {
+ throw new InvalidOperationException($"When supplying an {nameof(OnSubmit)} parameter to " +
+ $"{nameof(EditForm)}, do not also supply {nameof(OnValidSubmit)} or {nameof(OnInvalidSubmit)}.");
+ }
+
+ // Update _fixedEditContext if we don't have one yet, or if they are supplying a
+ // potentially new EditContext, or if they are supplying a different Model
+ if (_fixedEditContext == null || EditContext != null || Model != _fixedEditContext.Model)
+ {
+ _fixedEditContext = EditContext ?? new EditContext(Model);
+ }
+ }
+
+ ///
+ protected override void BuildRenderTree(RenderTreeBuilder builder)
+ {
+ base.BuildRenderTree(builder);
+
+ // If _fixedEditContext changes, tear down and recreate all descendants.
+ // This is so we can safely use the IsFixed optimization on CascadingValue,
+ // optimizing for the common case where _fixedEditContext never changes.
+ builder.OpenRegion(_fixedEditContext.GetHashCode());
+
+ builder.OpenElement(0, "form");
+ builder.AddAttribute(1, "onsubmit", _handleSubmitDelegate);
+ builder.OpenComponent>(2);
+ builder.AddAttribute(3, "IsFixed", true);
+ builder.AddAttribute(4, "Value", _fixedEditContext);
+
+ // TODO: Once the event routing bug is fixed, replace the following with
+ // builder.AddAttribute(5, RenderTreeBuilder.ChildContent, ChildContent?.Invoke(_fixedEditContext));
+ builder.AddAttribute(5, RenderTreeBuilder.ChildContent, (RenderFragment)RenderChildContentInWorkaround);
+
+ builder.CloseComponent();
+ builder.CloseElement();
+
+ builder.CloseRegion();
+ }
+
+ private void RenderChildContentInWorkaround(RenderTreeBuilder builder)
+ {
+ builder.OpenComponent(0);
+ builder.AddAttribute(1, RenderTreeBuilder.ChildContent, ChildContent?.Invoke(_fixedEditContext));
+ builder.CloseComponent();
+ }
+
+ private async Task HandleSubmitAsync()
+ {
+ if (OnSubmit != null)
+ {
+ // When using OnSubmit, the developer takes control of the validation lifecycle
+ await OnSubmit(_fixedEditContext);
+ }
+ else
+ {
+ // Otherwise, the system implicitly runs validation on form submission
+ var isValid = _fixedEditContext.Validate(); // This will likely become ValidateAsync later
+
+ var task = isValid
+ ? OnValidSubmit?.Invoke(_fixedEditContext)
+ : OnInvalidSubmit?.Invoke(_fixedEditContext);
+
+ if (isValid && OnValidSubmit != null)
+ {
+ await OnValidSubmit(_fixedEditContext);
+ }
+
+ if (!isValid && OnInvalidSubmit != null)
+ {
+ await OnInvalidSubmit(_fixedEditContext);
+ }
+ }
+ }
+ }
+}
diff --git a/src/Components/Components/src/Forms/TemporaryEventRoutingWorkaround.cs b/src/Components/Components/src/Forms/TemporaryEventRoutingWorkaround.cs
new file mode 100644
index 000000000000..54940e38c36c
--- /dev/null
+++ b/src/Components/Components/src/Forms/TemporaryEventRoutingWorkaround.cs
@@ -0,0 +1,31 @@
+// 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
+{
+ /*
+ * Currently, anything directly inside a can't receive events, because
+ * CascadingValue doesn't implement IHandleEvent. This is a manifestation of the event
+ * routing bug - the event should really be routed to the component whose markup contains
+ * the ChildContent we passed to CascadingValue.
+ *
+ * This workaround is semi-effective. It avoids the "cannot handle events" exception, but
+ * doesn't cause the correct target component to re-render, so the target still has to
+ * call StateHasChanged manually when it shouldn't have to.
+ *
+ * TODO: Once the underlying issue is fixed, remove this class and its usage entirely.
+ */
+
+ internal class TemporaryEventRoutingWorkaround : ComponentBase
+ {
+ [Parameter] RenderFragment ChildContent { get; set; }
+
+ protected override void BuildRenderTree(RenderTreeBuilder builder)
+ {
+ base.BuildRenderTree(builder);
+ builder.AddContent(0, ChildContent);
+ }
+ }
+}
diff --git a/src/Components/test/testassets/BasicTestApp/FormsTest/SimpleValidationComponent.cshtml b/src/Components/test/testassets/BasicTestApp/FormsTest/SimpleValidationComponent.cshtml
new file mode 100644
index 000000000000..55c0a0f1059d
--- /dev/null
+++ b/src/Components/test/testassets/BasicTestApp/FormsTest/SimpleValidationComponent.cshtml
@@ -0,0 +1,58 @@
+@using System.ComponentModel.DataAnnotations
+@using Microsoft.AspNetCore.Components.Forms
+
+
+
+
+
+ User name:
+
+
+ Accept terms:
+
+
+
+
+ @* Could use instead, but this shows it can be done manually *@
+
+ @foreach (var message in context.GetValidationMessages())
+ {
+
@message
+ }
+
+
+
+
+@if (lastCallback != null)
+{
+ @lastCallback
+}
+
+@functions {
+ string lastCallback;
+
+ [Required]
+ public string UserName { get; set; }
+
+ [Required]
+ [Range(typeof(bool), "true", "true", ErrorMessage = "You must accept the terms")]
+ public bool AcceptsTerms { get; set; }
+
+ Task HandleValidSubmitAsync(EditContext editContext)
+ {
+ lastCallback = "OnValidSubmit";
+
+ StateHasChanged(); // This is only needed as a temporary workaround to the event routing issue
+
+ return Task.CompletedTask;
+ }
+
+ Task HandleInvalidSubmitAsync(EditContext editContext)
+ {
+ lastCallback = "OnInvalidSubmit";
+
+ StateHasChanged(); // This is only needed as a temporary workaround to the event routing issue
+
+ return Task.CompletedTask;
+ }
+}
diff --git a/src/Components/test/testassets/BasicTestApp/Index.cshtml b/src/Components/test/testassets/BasicTestApp/Index.cshtml
index 2bf6ccf60038..0db1239227da 100644
--- a/src/Components/test/testassets/BasicTestApp/Index.cshtml
+++ b/src/Components/test/testassets/BasicTestApp/Index.cshtml
@@ -46,6 +46,7 @@
+
@if (SelectedComponentType != null)
From 937e617af0c9b843e60155af73b50fbd911d287d Mon Sep 17 00:00:00 2001
From: Steve Sanderson
Date: Wed, 13 Feb 2019 12:14:05 +0000
Subject: [PATCH 13/70] Begin TypicalValidationComponent for E2E tests
---
.../Components/src/Forms/EditForm.cs | 4 --
.../TypicalValidationComponent.cshtml | 39 +++++++++++++++++++
.../test/testassets/BasicTestApp/Index.cshtml | 1 +
3 files changed, 40 insertions(+), 4 deletions(-)
create mode 100644 src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.cshtml
diff --git a/src/Components/Components/src/Forms/EditForm.cs b/src/Components/Components/src/Forms/EditForm.cs
index 3f11acdfad97..6894f617127b 100644
--- a/src/Components/Components/src/Forms/EditForm.cs
+++ b/src/Components/Components/src/Forms/EditForm.cs
@@ -135,10 +135,6 @@ private async Task HandleSubmitAsync()
// Otherwise, the system implicitly runs validation on form submission
var isValid = _fixedEditContext.Validate(); // This will likely become ValidateAsync later
- var task = isValid
- ? OnValidSubmit?.Invoke(_fixedEditContext)
- : OnInvalidSubmit?.Invoke(_fixedEditContext);
-
if (isValid && OnValidSubmit != null)
{
await OnValidSubmit(_fixedEditContext);
diff --git a/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.cshtml b/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.cshtml
new file mode 100644
index 000000000000..b881d244677b
--- /dev/null
+++ b/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.cshtml
@@ -0,0 +1,39 @@
+@using System.ComponentModel.DataAnnotations
+@using Microsoft.AspNetCore.Components.Forms
+
+
+
+
+
+ Name:
+
+
+ Age (years):
+
+
+
+
+
+
+
+
@foreach (var entry in submissionLog) {
@entry
}
+
+@functions {
+ // Usually this would be in a different file
+ class Person
+ {
+ [Required] public string Name { get; set; }
+
+ [Required, Range(0, 200)] public int AgeInYears { get; set; }
+ }
+
+ Person person = new Person();
+ List submissionLog = new List(); // So we can assert about the callbacks
+
+ Task HandleValidSubmitAsync(EditContext editContext)
+ {
+ submissionLog.Add("OnValidSubmit");
+ StateHasChanged(); // Temporary workaround for event routing bug
+ return Task.CompletedTask;
+ }
+}
diff --git a/src/Components/test/testassets/BasicTestApp/Index.cshtml b/src/Components/test/testassets/BasicTestApp/Index.cshtml
index 0db1239227da..825ec4c4631f 100644
--- a/src/Components/test/testassets/BasicTestApp/Index.cshtml
+++ b/src/Components/test/testassets/BasicTestApp/Index.cshtml
@@ -47,6 +47,7 @@
+
@if (SelectedComponentType != null)
From a06b059caa714ab220dbf707286dbf66288e8dba Mon Sep 17 00:00:00 2001
From: Steve Sanderson
Date: Wed, 13 Feb 2019 12:52:18 +0000
Subject: [PATCH 14/70] Implement ValidationSummary and notifications of
validation state change
---
.../src/Forms/DataAnnotationsValidator.cs | 6 +-
.../Components/src/Forms/EditContext.cs | 13 +++
.../EditContextDataAnnotationsExtensions.cs | 2 +
.../Components/src/Forms/ValidationSummary.cs | 95 +++++++++++++++++++
...ditContextDataAnnotationsExtensionsTest.cs | 26 +++++
5 files changed, 139 insertions(+), 3 deletions(-)
create mode 100644 src/Components/Components/src/Forms/ValidationSummary.cs
diff --git a/src/Components/Components/src/Forms/DataAnnotationsValidator.cs b/src/Components/Components/src/Forms/DataAnnotationsValidator.cs
index e0a803b64439..1cf02723bb92 100644
--- a/src/Components/Components/src/Forms/DataAnnotationsValidator.cs
+++ b/src/Components/Components/src/Forms/DataAnnotationsValidator.cs
@@ -10,19 +10,19 @@ namespace Microsoft.AspNetCore.Components.Forms
///
public class DataAnnotationsValidator : ComponentBase
{
- [CascadingParameter] EditContext EditContext { get; set; }
+ [CascadingParameter] EditContext CurrentEditContext { get; set; }
///
protected override void OnInit()
{
- if (EditContext == null)
+ 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)}.");
}
- EditContext.AddDataAnnotationsValidation();
+ CurrentEditContext.AddDataAnnotationsValidation();
}
}
}
diff --git a/src/Components/Components/src/Forms/EditContext.cs b/src/Components/Components/src/Forms/EditContext.cs
index 22fd4be679b0..f44ad8fd1420 100644
--- a/src/Components/Components/src/Forms/EditContext.cs
+++ b/src/Components/Components/src/Forms/EditContext.cs
@@ -62,6 +62,11 @@ public EditContext(object model)
///
public event EventHandler OnValidationRequested;
+ ///
+ /// An event that is raised when validation state has changed.
+ ///
+ public event EventHandler OnValidationStateChanged;
+
///
/// Supplies a corresponding to a specified field name
/// on this 's .
@@ -86,6 +91,14 @@ public void NotifyFieldChanged(FieldIdentifier fieldIdentifier)
OnFieldChanged?.Invoke(this, fieldIdentifier);
}
+ ///
+ /// Signals that some aspect of validation state has changed.
+ ///
+ public void NotifyValidationStateChanged()
+ {
+ OnValidationStateChanged?.Invoke(this, null);
+ }
+
///
/// Clears any modification flag that may be tracked for the specified field.
///
diff --git a/src/Components/Components/src/Forms/EditContextDataAnnotationsExtensions.cs b/src/Components/Components/src/Forms/EditContextDataAnnotationsExtensions.cs
index f55feeb6ccd7..3b7e3380b806 100644
--- a/src/Components/Components/src/Forms/EditContextDataAnnotationsExtensions.cs
+++ b/src/Components/Components/src/Forms/EditContextDataAnnotationsExtensions.cs
@@ -48,6 +48,8 @@ private static void ValidateModel(EditContext editContext, ValidationMessageStor
messages.Add(editContext.Field(memberName), validationResult.ErrorMessage);
}
}
+
+ editContext.NotifyValidationStateChanged();
}
}
}
diff --git a/src/Components/Components/src/Forms/ValidationSummary.cs b/src/Components/Components/src/Forms/ValidationSummary.cs
new file mode 100644
index 000000000000..1165af3336fc
--- /dev/null
+++ b/src/Components/Components/src/Forms/ValidationSummary.cs
@@ -0,0 +1,95 @@
+// 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;
+using System;
+
+namespace Microsoft.AspNetCore.Components.Forms
+{
+ /*
+ * Note: there's no reason why developers strictly need to use this. It's equally valid to
+ * put a @foreach(var message in context.GetValidationMessages()) { ... } inside a form.
+ * This component is for convenience only, plus it implements a few small perf optimizations.
+ */
+
+ ///
+ /// Displays a list of validation messages from a cascaded .
+ ///
+ public class ValidationSummary : ComponentBase, IDisposable
+ {
+ private EditContext _previousEditContext;
+ private readonly EventHandler _validationStateChangedHandler;
+
+ [CascadingParameter] EditContext CurrentEditContext { get; set; }
+
+ /// `
+ /// Constructs an instance of .
+ ///
+ public ValidationSummary()
+ {
+ _validationStateChangedHandler = (sender, eventArgs) => StateHasChanged();
+ }
+
+ ///
+ protected override void OnParametersSet()
+ {
+ if (CurrentEditContext == null)
+ {
+ throw new InvalidOperationException($"{nameof(ValidationSummary)} requires a cascading parameter " +
+ $"of type {nameof(EditContext)}. For example, you can use {nameof(ValidationSummary)} inside " +
+ $"an {nameof(EditForm)}.");
+ }
+
+ if (CurrentEditContext != _previousEditContext)
+ {
+ DetachValidationStateChangedListener();
+ CurrentEditContext.OnValidationStateChanged += _validationStateChangedHandler;
+ _previousEditContext = CurrentEditContext;
+ }
+ }
+
+ ///
+ protected override void BuildRenderTree(RenderTreeBuilder builder)
+ {
+ base.BuildRenderTree(builder);
+
+ // As an optimization, only evaluate the messages enumerable once, and
+ // only produce the enclosing
if there's at least one message
+ var messagesEnumerator = CurrentEditContext.GetValidationMessages().GetEnumerator();
+ if (messagesEnumerator.MoveNext())
+ {
+ builder.OpenElement(0, "ul");
+ builder.AddAttribute(1, "class", "validation-errors");
+
+ do
+ {
+ builder.OpenElement(2, "li");
+ builder.AddAttribute(3, "class", "validation-message");
+ builder.AddContent(4, messagesEnumerator.Current);
+ builder.CloseElement();
+ }
+ while (messagesEnumerator.MoveNext());
+
+ builder.CloseElement();
+ }
+ }
+
+ private void HandleValidationStateChanged(object sender, EventArgs eventArgs)
+ {
+ StateHasChanged();
+ }
+
+ void IDisposable.Dispose()
+ {
+ DetachValidationStateChangedListener();
+ }
+
+ private void DetachValidationStateChangedListener()
+ {
+ if (_previousEditContext != null)
+ {
+ _previousEditContext.OnValidationStateChanged -= _validationStateChangedHandler;
+ }
+ }
+ }
+}
diff --git a/src/Components/Components/test/Forms/EditContextDataAnnotationsExtensionsTest.cs b/src/Components/Components/test/Forms/EditContextDataAnnotationsExtensionsTest.cs
index e58da2f9b6b0..19c751b22375 100644
--- a/src/Components/Components/test/Forms/EditContextDataAnnotationsExtensionsTest.cs
+++ b/src/Components/Components/test/Forms/EditContextDataAnnotationsExtensionsTest.cs
@@ -71,6 +71,32 @@ public void ClearsExistingValidationMessagesOnFurtherRuns()
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);
+ }
+
class TestModel
{
[Required] public string RequiredString { get; set; }
From cd3547afa13e727fc90cda063fcab61a3cc9e4d1 Mon Sep 17 00:00:00 2001
From: Steve Sanderson
Date: Thu, 14 Feb 2019 11:03:20 +0000
Subject: [PATCH 15/70] Support per-field validation for DataAnnotations
---
.../EditContextDataAnnotationsExtensions.cs | 53 +++++++++++++--
...ditContextDataAnnotationsExtensionsTest.cs | 64 +++++++++++++++++++
2 files changed, 113 insertions(+), 4 deletions(-)
diff --git a/src/Components/Components/src/Forms/EditContextDataAnnotationsExtensions.cs b/src/Components/Components/src/Forms/EditContextDataAnnotationsExtensions.cs
index 3b7e3380b806..1f559a686fee 100644
--- a/src/Components/Components/src/Forms/EditContextDataAnnotationsExtensions.cs
+++ b/src/Components/Components/src/Forms/EditContextDataAnnotationsExtensions.cs
@@ -2,8 +2,11 @@
// 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
{
@@ -12,6 +15,9 @@ namespace Microsoft.AspNetCore.Components.Forms
///
public static class EditContextDataAnnotationsExtensions
{
+ private static ConcurrentDictionary _propertyInfoCache
+ = new ConcurrentDictionary();
+
///
/// Adds DataAnnotations validation support to the .
///
@@ -25,10 +31,13 @@ public static EditContext AddDataAnnotationsValidation(this EditContext editCont
var messages = new ValidationMessageStore(editContext);
- editContext.OnValidationRequested += (object sender, EventArgs e) =>
- {
- ValidateModel((EditContext)sender, messages);
- };
+ // 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, fieldIdentifier) => ValidateField(editContext, messages, fieldIdentifier);
return editContext;
}
@@ -51,5 +60,41 @@ private static void ValidateModel(EditContext editContext, ValidationMessageStor
editContext.NotifyValidationStateChanged();
}
+
+ private static void ValidateField(EditContext editContext, ValidationMessageStore messages, FieldIdentifier fieldIdentifier)
+ {
+ if (TryGetValidatableProperty(fieldIdentifier, out var propertyInfo))
+ {
+ var propertyValue = propertyInfo.GetValue(fieldIdentifier.Model);
+ var validationContext = new ValidationContext(fieldIdentifier.Model)
+ {
+ MemberName = propertyInfo.Name
+ };
+ var results = new List();
+
+ Validator.TryValidateProperty(propertyValue, validationContext, results);
+ messages.Clear(fieldIdentifier);
+ messages.AddRange(fieldIdentifier, results.Select(result => result.ErrorMessage));
+
+ // We have to notify even if there were no messages before and are still no messages now,
+ // because the "state" that changed might be the completion of some async validation task
+ editContext.NotifyValidationStateChanged();
+ }
+ }
+
+ private static bool TryGetValidatableProperty(FieldIdentifier fieldIdentifier, out PropertyInfo propertyInfo)
+ {
+ if (!_propertyInfoCache.TryGetValue(fieldIdentifier, 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 = fieldIdentifier.Model.GetType().GetProperty(fieldIdentifier.FieldName);
+
+ // No need to lock, because it doesn't matter if we write the same value twice
+ _propertyInfoCache[fieldIdentifier] = propertyInfo;
+ }
+
+ return propertyInfo != null;
+ }
}
}
diff --git a/src/Components/Components/test/Forms/EditContextDataAnnotationsExtensionsTest.cs b/src/Components/Components/test/Forms/EditContextDataAnnotationsExtensionsTest.cs
index 19c751b22375..1cd2ef0bdd25 100644
--- a/src/Components/Components/test/Forms/EditContextDataAnnotationsExtensionsTest.cs
+++ b/src/Components/Components/test/Forms/EditContextDataAnnotationsExtensionsTest.cs
@@ -97,11 +97,75 @@ public void NotifiesValidationStateChangedAfterObjectValidation()
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[] { "The RequiredString field is 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[]
+ {
+ "The RequiredString field is required.",
+ "The field IntFrom1To100 must be between 1 and 100."
+ },
+ editContext.GetValidationMessages());
+
+ // Act/Assert 3: Notify about RequiredString
+ editContext.NotifyFieldChanged(requiredStringIdentifier);
+ Assert.Equal(3, onValidationStateChangedCount);
+ Assert.Equal(new[] { "The field IntFrom1To100 must be between 1 and 100." }, 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] public string RequiredString { get; set; }
[Range(1, 100)] 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
}
}
}
From f0a26503c8cad21ade8a12e1d64a96acf48a0936 Mon Sep 17 00:00:00 2001
From: Steve Sanderson
Date: Thu, 14 Feb 2019 12:03:12 +0000
Subject: [PATCH 16/70] Support for converting Expression> to
FieldIdentifier
---
.../Components/src/Forms/FieldIdentifier.cs | 57 +++++++++++++++++++
.../test/Forms/FieldIdentifierTest.cs | 50 ++++++++++++++++
2 files changed, 107 insertions(+)
diff --git a/src/Components/Components/src/Forms/FieldIdentifier.cs b/src/Components/Components/src/Forms/FieldIdentifier.cs
index d4def4604867..95da3b0070e3 100644
--- a/src/Components/Components/src/Forms/FieldIdentifier.cs
+++ b/src/Components/Components/src/Forms/FieldIdentifier.cs
@@ -2,6 +2,8 @@
// 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 System.Reflection;
namespace Microsoft.AspNetCore.Components.Forms
{
@@ -35,6 +37,19 @@ public FieldIdentifier(object model, string fieldName)
FieldName = fieldName ?? throw new ArgumentNullException(nameof(fieldName));
}
+ ///
+ /// Initializes a new instance of the structure.
+ ///
+ /// An expression that identifies an object member.
+ public FieldIdentifier(Expression> accessor)
+ {
+ ParseAccessor(accessor, out var model, out var fieldName);
+
+ // Copy to self, enforcing the same invariants as the other constructor
+ var result = new FieldIdentifier(model, fieldName);
+ (Model, FieldName) = (result.Model, result.FieldName);
+ }
+
///
/// Gets the object that owns the editable field.
///
@@ -54,5 +69,47 @@ public override bool Equals(object obj)
=> obj is FieldIdentifier otherIdentifier
&& otherIdentifier.Model == Model
&& string.Equals(otherIdentifier.FieldName, FieldName, StringComparison.Ordinal);
+
+ private static void ParseAccessor(Expression> accessor, out object model, out string fieldName)
+ {
+ var accessorBody = accessor.Body;
+
+ // Unwrap casts to object
+ if (accessorBody is UnaryExpression unaryExpression
+ && unaryExpression.NodeType == ExpressionType.Convert
+ && unaryExpression.Type == typeof(object))
+ {
+ accessorBody = unaryExpression.Operand;
+ }
+
+ if (!(accessorBody is MemberExpression memberExpression))
+ {
+ throw new ArgumentException("The accessor is not supported because its body is not a MemberExpression");
+ }
+
+ // 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;
+ case MemberExpression nestedMemberExpression:
+ // 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
-@submissionStatus
+
@submissionStatus
@functions {
MyModel person = new MyModel();
@@ -65,7 +65,7 @@
string _userName;
bool _acceptsTerms;
- [Required]
+ [Required, StringLength(10, ErrorMessage = "That name is too long")]
public string UserName
{
get => _userName;
From d63f817de7443f120af36a1773a29c419303aaa8 Mon Sep 17 00:00:00 2001
From: Steve Sanderson
Date: Mon, 18 Feb 2019 17:34:17 +0000
Subject: [PATCH 65/70] Add ValidationMessage component
---
.../Components/src/Forms/ValidationMessage.cs | 96 +++++++++++++++++++
.../TypicalValidationComponent.cshtml | 8 ++
.../testassets/BasicTestApp/wwwroot/style.css | 2 +-
3 files changed, 105 insertions(+), 1 deletion(-)
create mode 100644 src/Components/Components/src/Forms/ValidationMessage.cs
diff --git a/src/Components/Components/src/Forms/ValidationMessage.cs b/src/Components/Components/src/Forms/ValidationMessage.cs
new file mode 100644
index 000000000000..176824931da9
--- /dev/null
+++ b/src/Components/Components/src/Forms/ValidationMessage.cs
@@ -0,0 +1,96 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Linq.Expressions;
+using Microsoft.AspNetCore.Components.RenderTree;
+
+namespace Microsoft.AspNetCore.Components.Forms
+{
+ ///
+ /// Displays a list of validation messages for a specified field within a cascaded .
+ ///
+ public class ValidationMessage : ComponentBase, IDisposable
+ {
+ private EditContext _previousEditContext;
+ private Expression> _previousFieldAccessor;
+ private readonly EventHandler _validationStateChangedHandler;
+ private FieldIdentifier _fieldIdentifier;
+
+ [CascadingParameter] EditContext CurrentEditContext { get; set; }
+
+ ///
+ /// Specifies the field for which validation messages should be displayed.
+ ///
+ [Parameter] Expression> For { get; set; }
+
+ /// `
+ /// Constructs an instance of .
+ ///
+ public ValidationMessage()
+ {
+ _validationStateChangedHandler = (sender, eventArgs) => StateHasChanged();
+ }
+
+ ///
+ protected override void OnParametersSet()
+ {
+ if (CurrentEditContext == null)
+ {
+ throw new InvalidOperationException($"{GetType()} requires a cascading parameter " +
+ $"of type {nameof(EditContext)}. For example, you can use {GetType()} inside " +
+ $"an {nameof(EditForm)}.");
+ }
+
+ if (For == null) // Not possible except if you manually specify T
+ {
+ throw new InvalidOperationException($"{GetType()} requires a value for the " +
+ $"{nameof(For)} parameter.");
+ }
+ else if (For != _previousFieldAccessor)
+ {
+ _fieldIdentifier = FieldIdentifier.Create(For);
+ _previousFieldAccessor = For;
+ }
+
+ if (CurrentEditContext != _previousEditContext)
+ {
+ DetachValidationStateChangedListener();
+ CurrentEditContext.OnValidationStateChanged += _validationStateChangedHandler;
+ _previousEditContext = CurrentEditContext;
+ }
+ }
+
+ ///
+ protected override void BuildRenderTree(RenderTreeBuilder builder)
+ {
+ base.BuildRenderTree(builder);
+
+ foreach (var message in CurrentEditContext.GetValidationMessages(_fieldIdentifier))
+ {
+ builder.OpenElement(0, "div");
+ builder.AddAttribute(1, "class", "validation-message");
+ builder.AddContent(2, message);
+ builder.CloseElement();
+ }
+ }
+
+ private void HandleValidationStateChanged(object sender, ValidationStateChangedEventArgs eventArgs)
+ {
+ StateHasChanged();
+ }
+
+ void IDisposable.Dispose()
+ {
+ DetachValidationStateChangedListener();
+ }
+
+ private void DetachValidationStateChangedListener()
+ {
+ if (_previousEditContext != null)
+ {
+ _previousEditContext.OnValidationStateChanged -= _validationStateChangedHandler;
+ }
+ }
+ }
+}
diff --git a/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.cshtml b/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.cshtml
index a2ce4977782f..4ee4472b6e45 100644
--- a/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.cshtml
+++ b/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.cshtml
@@ -7,6 +7,10 @@
Name:
+
+ Email:
+
+
Age (years):
@@ -51,6 +55,10 @@
[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; }
diff --git a/src/Components/test/testassets/BasicTestApp/wwwroot/style.css b/src/Components/test/testassets/BasicTestApp/wwwroot/style.css
index c865a790a564..740eb993c650 100644
--- a/src/Components/test/testassets/BasicTestApp/wwwroot/style.css
+++ b/src/Components/test/testassets/BasicTestApp/wwwroot/style.css
@@ -6,6 +6,6 @@
box-shadow: 0px 0px 0px 2px rgb(255, 0, 0);
}
-.validation-errors {
+.validation-message {
color: red;
}
From 2658d7ea306007d670037f92985f27e0c8f2bc4a Mon Sep 17 00:00:00 2001
From: Steve Sanderson
Date: Tue, 19 Feb 2019 09:48:47 +0000
Subject: [PATCH 66/70] E2E test for ValidationMessage
---
.../test/E2ETest/Tests/FormsTest.cs | 27 +++++++++++++++++++
1 file changed, 27 insertions(+)
diff --git a/src/Components/test/E2ETest/Tests/FormsTest.cs b/src/Components/test/E2ETest/Tests/FormsTest.cs
index 9e03a56b2388..f7046bc25675 100644
--- a/src/Components/test/E2ETest/Tests/FormsTest.cs
+++ b/src/Components/test/E2ETest/Tests/FormsTest.cs
@@ -289,6 +289,33 @@ public void CanWireUpINotifyPropertyChangedToEditContext()
WaitAssert.Equal("valid", () => acceptsTermsInput.GetAttribute("class"));
}
+ [Fact]
+ public void ValidationMessageDisplaysMessagesForField()
+ {
+ var appElement = MountTestComponent();
+ var emailContainer = appElement.FindElement(By.ClassName("email"));
+ var emailInput = emailContainer.FindElement(By.TagName("input"));
+ var emailMessagesAccessor = CreateValidationMessagesAccessor(emailContainer);
+ var submitButton = appElement.FindElement(By.TagName("button"));
+
+ // Doesn't show messages for other fields
+ submitButton.Click();
+ WaitAssert.Empty(emailMessagesAccessor);
+
+ // Updates on edit
+ emailInput.SendKeys("abc\t");
+ WaitAssert.Equal(new[] { "That doesn't look like a real email address" }, emailMessagesAccessor);
+
+ // Can show more than one message
+ emailInput.SendKeys("too long too long too long\t");
+ WaitAssert.Equal(new[] { "That doesn't look like a real email address", "We only accept very short email addresses (max 10 chars)" }, emailMessagesAccessor);
+
+ // Can become valid
+ emailInput.Clear();
+ emailInput.SendKeys("a@b.com\t");
+ WaitAssert.Empty(emailMessagesAccessor);
+ }
+
private Func CreateValidationMessagesAccessor(IWebElement appElement)
{
return () => appElement.FindElements(By.ClassName("validation-message"))
From 255d0e804658961f905ff5702600051d885ecf2b Mon Sep 17 00:00:00 2001
From: Steve Sanderson
Date: Tue, 19 Feb 2019 16:46:38 +0000
Subject: [PATCH 67/70] Update build config
---
eng/SharedFramework.External.props | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/eng/SharedFramework.External.props b/eng/SharedFramework.External.props
index 6d927bd2f2b9..83705b2145f0 100644
--- a/eng/SharedFramework.External.props
+++ b/eng/SharedFramework.External.props
@@ -51,7 +51,6 @@
-
@@ -91,6 +90,7 @@
-->
<_CompilationOnlyReference Include="System.Buffers" />
+ <_CompilationOnlyReference Include="System.ComponentModel.Annotations" />
4.5.04.4.0
+ 4.3.04.3.24.5.2
From 4f7bdc0cebe95769a4efc170c6fdbed634d086c0 Mon Sep 17 00:00:00 2001
From: Steve Sanderson
Date: Tue, 19 Feb 2019 21:35:01 +0000
Subject: [PATCH 69/70] FieldIdentifier enhancements as discussed
---
.../Components/src/Forms/FieldIdentifier.cs | 9 ++--
.../test/Forms/FieldIdentifierTest.cs | 41 +++++++++++++++++++
2 files changed, 44 insertions(+), 6 deletions(-)
diff --git a/src/Components/Components/src/Forms/FieldIdentifier.cs b/src/Components/Components/src/Forms/FieldIdentifier.cs
index c7832f8209e1..a113cccbe78c 100644
--- a/src/Components/Components/src/Forms/FieldIdentifier.cs
+++ b/src/Components/Components/src/Forms/FieldIdentifier.cs
@@ -85,7 +85,7 @@ private static void ParseAccessor(Expression> accessor, out object mo
if (!(accessorBody is MemberExpression memberExpression))
{
- throw new ArgumentException("The accessor is not supported because its body is not a 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.
@@ -98,18 +98,15 @@ private static void ParseAccessor(Expression> accessor, out object mo
case ConstantExpression constantExpression:
model = constantExpression.Value;
break;
- case MemberExpression nestedMemberExpression:
+ default:
// It would be great to cache this somehow, but it's unclear there's a reasonable way to do
// so, given that it embeds captured values such as "this". We could consider special-casing
// for "() => something.Member" and building a cache keyed by "something.GetType()" with values
// of type Func so we can cheaply map from "something" to "something.Member".
- var modelLambda = Expression.Lambda(nestedMemberExpression);
+ var modelLambda = Expression.Lambda(memberExpression.Expression);
var modelLambdaCompiled = (Func)modelLambda.Compile();
model = modelLambdaCompiled();
break;
- default:
- // An error message that might help us work out what extra expression types need to be supported
- throw new InvalidOperationException($"The accessor is not supported because the model value cannot be parsed from it. Expression: '{memberExpression.Expression}', type: '{memberExpression.Expression.GetType().FullName}'");
}
}
}
diff --git a/src/Components/Components/test/Forms/FieldIdentifierTest.cs b/src/Components/Components/test/Forms/FieldIdentifierTest.cs
index 1bb5f34c914f..f19751a45d28 100644
--- a/src/Components/Components/test/Forms/FieldIdentifierTest.cs
+++ b/src/Components/Components/test/Forms/FieldIdentifierTest.cs
@@ -2,6 +2,7 @@
// 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;
@@ -112,6 +113,14 @@ public void CanCreateFromExpression_Property()
Assert.Equal(nameof(model.StringProperty), fieldIdentifier.FieldName);
}
+ [Fact]
+ public void CannotCreateFromExpression_NonMember()
+ {
+ var ex = Assert.Throws(() =>
+ FieldIdentifier.Create(() => new TestModel()));
+ Assert.Equal($"The provided expression contains a NewExpression which is not supported. {nameof(FieldIdentifier)} only supports simple member accessors (fields, properties) of an object.", ex.Message);
+ }
+
[Fact]
public void CanCreateFromExpression_Field()
{
@@ -141,6 +150,33 @@ public void CanCreateFromExpression_MemberOfConstantExpression()
Assert.Equal(nameof(StringPropertyOnThisClass), fieldIdentifier.FieldName);
}
+ [Fact]
+ public void CanCreateFromExpression_MemberOfChildObject()
+ {
+ var parentModel = new ParentModel { Child = new TestModel() };
+ var fieldIdentifier = FieldIdentifier.Create(() => parentModel.Child.StringField);
+ Assert.Same(parentModel.Child, fieldIdentifier.Model);
+ Assert.Equal(nameof(TestModel.StringField), fieldIdentifier.FieldName);
+ }
+
+ [Fact]
+ public void CanCreateFromExpression_MemberOfIndexedCollectionEntry()
+ {
+ var models = new List() { null, new TestModel() };
+ var fieldIdentifier = FieldIdentifier.Create(() => models[1].StringField);
+ Assert.Same(models[1], fieldIdentifier.Model);
+ Assert.Equal(nameof(TestModel.StringField), fieldIdentifier.FieldName);
+ }
+
+ [Fact]
+ public void CanCreateFromExpression_MemberOfObjectWithCast()
+ {
+ var model = new TestModel();
+ var fieldIdentifier = FieldIdentifier.Create(() => ((TestModel)(object)model).StringField);
+ Assert.Same(model, fieldIdentifier.Model);
+ Assert.Equal(nameof(TestModel.StringField), fieldIdentifier.FieldName);
+ }
+
string StringPropertyOnThisClass { get; set; }
class TestModel
@@ -153,5 +189,10 @@ class TestModel
public string StringField;
#pragma warning restore 649
}
+
+ class ParentModel
+ {
+ public TestModel Child { get; set; }
+ }
}
}
From dd0929b5814d92229ad79606e8f105a80ef955c8 Mon Sep 17 00:00:00 2001
From: Steve Sanderson
Date: Tue, 19 Feb 2019 23:21:11 +0000
Subject: [PATCH 70/70] Empty commit to trigger CI