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 so we can cheaply map from "something" to "something.Member". + var modelLambda = Expression.Lambda(nestedMemberExpression); + 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 1d92b00a3d12..80eecbdb2a78 100644 --- a/src/Components/Components/test/Forms/FieldIdentifierTest.cs +++ b/src/Components/Components/test/Forms/FieldIdentifierTest.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Components.Forms; using System; +using System.Linq.Expressions; using Xunit; namespace Microsoft.AspNetCore.Components.Tests.Forms @@ -102,5 +103,54 @@ public void FieldNamesAreCaseSensitive() Assert.NotEqual(fieldIdentifierLower.GetHashCode(), fieldIdentifierPascal.GetHashCode()); Assert.False(fieldIdentifierLower.Equals(fieldIdentifierPascal)); } + + [Fact] + public void CanConstructFromExpression_Property() + { + var model = new TestModel(); + var fieldIdentifier = new FieldIdentifier(() => model.StringProperty); + Assert.Same(model, fieldIdentifier.Model); + Assert.Equal(nameof(model.StringProperty), fieldIdentifier.FieldName); + } + + [Fact] + public void CanConstructFromExpression_Field() + { + var model = new TestModel(); + var fieldIdentifier = new FieldIdentifier(() => model.StringField); + Assert.Same(model, fieldIdentifier.Model); + Assert.Equal(nameof(model.StringField), fieldIdentifier.FieldName); + } + + [Fact] + public void CanConstructFromExpression_WithCastToObject() + { + // This case is needed because value types will implicitly be cast to object + var model = new TestModel(); + var fieldIdentifier = new FieldIdentifier(() => model.IntProperty); + Assert.Same(model, fieldIdentifier.Model); + Assert.Equal(nameof(model.IntProperty), fieldIdentifier.FieldName); + } + + [Fact] + public void CanConstructFromExpression_MemberOfConstantExpression() + { + var fieldIdentifier = new FieldIdentifier(() => StringPropertyOnThisClass); + Assert.Same(this, fieldIdentifier.Model); + Assert.Equal(nameof(StringPropertyOnThisClass), fieldIdentifier.FieldName); + } + + string StringPropertyOnThisClass { get; set; } + + class TestModel + { + public string StringProperty { get; set; } + + public int IntProperty { get; set; } + +#pragma warning disable 649 + public string StringField; +#pragma warning restore 649 + } } } From 9547035b4f218cab181891d77f7fc46fbce6c1b1 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 14 Feb 2019 12:16:24 +0000 Subject: [PATCH 17/70] Extension methods for convenience when using accessors --- .../Forms/EditContextExpressionExtensions.cs | 35 ++++++++++++++++ .../src/Forms/ValidationMessageStore.cs | 15 +++++-- ...idationMessageStoreExpressionExtensions.cs | 41 +++++++++++++++++++ 3 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 src/Components/Components/src/Forms/EditContextExpressionExtensions.cs create mode 100644 src/Components/Components/src/Forms/ValidationMessageStoreExpressionExtensions.cs diff --git a/src/Components/Components/src/Forms/EditContextExpressionExtensions.cs b/src/Components/Components/src/Forms/EditContextExpressionExtensions.cs new file mode 100644 index 000000000000..633ecb1b0a7b --- /dev/null +++ b/src/Components/Components/src/Forms/EditContextExpressionExtensions.cs @@ -0,0 +1,35 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq.Expressions; + +namespace Microsoft.AspNetCore.Components.Forms +{ + /// + /// Provides extension methods to simplify using with expressions. + /// + public static class EditContextExpressionExtensions + { + /// + /// Gets the current validation messages for the specified field. + /// + /// This method does not perform validation itself. It only returns messages determined by previous validation actions. + /// + /// The . + /// Identifies the field whose current validation messages should be returned. + /// The current validation messages for the specified field. + public static IEnumerable GetValidationMessages(this EditContext editContext, Expression> accessor) + => editContext.GetValidationMessages(new FieldIdentifier(accessor)); + + /// + /// Determines whether the specified fields in this has been modified. + /// + /// The . + /// Identifies the field whose current validation messages should be returned. + /// True if the field has been modified; otherwise false. + public static bool IsModified(this EditContext editContext, Expression> accessor) + => editContext.IsModified(new FieldIdentifier(accessor)); + } +} diff --git a/src/Components/Components/src/Forms/ValidationMessageStore.cs b/src/Components/Components/src/Forms/ValidationMessageStore.cs index ec242a4fbfb1..858f413abd80 100644 --- a/src/Components/Components/src/Forms/ValidationMessageStore.cs +++ b/src/Components/Components/src/Forms/ValidationMessageStore.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Linq.Expressions; namespace Microsoft.AspNetCore.Components.Forms { @@ -48,9 +49,17 @@ public void AddRange(FieldIdentifier fieldIdentifier, IEnumerable messag /// 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(); - } + => _messages.TryGetValue(fieldIdentifier, out var messages) ? messages : Enumerable.Empty(); + + /// + /// Gets the validation messages within this for the specified field. + /// + /// To get the validation messages across all validation message stores, use instead + /// + /// The identifier for the field. + /// The validation messages for the specified field within this . + public IEnumerable this[Expression> accessor] + => this[new FieldIdentifier(accessor)]; /// /// Removes all messages within this . diff --git a/src/Components/Components/src/Forms/ValidationMessageStoreExpressionExtensions.cs b/src/Components/Components/src/Forms/ValidationMessageStoreExpressionExtensions.cs new file mode 100644 index 000000000000..8768992e729e --- /dev/null +++ b/src/Components/Components/src/Forms/ValidationMessageStoreExpressionExtensions.cs @@ -0,0 +1,41 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq.Expressions; + +namespace Microsoft.AspNetCore.Components.Forms +{ + /// + /// Provides extension methods to simplify using with expressions. + /// + public static class ValidationMessageStoreExpressionExtensions + { + /// + /// Adds a validation message for the specified field. + /// + /// The . + /// Identifies the field for which to add the message. + /// The validation message. + public static void Add(this ValidationMessageStore store, Expression> accessor, string message) + => store.Add(new FieldIdentifier(accessor), message); + + /// + /// Adds the messages from the specified collection for the specified field. + /// + /// The . + /// Identifies the field for which to add the messages. + /// The validation messages to be added. + public static void AddRange(this ValidationMessageStore store, Expression> accessor, IEnumerable messages) + => store.AddRange(new FieldIdentifier(accessor), messages); + + /// + /// Removes all messages within this for the specified field. + /// + /// The . + /// Identifies the field for which to remove the messages. + public static void Clear(this ValidationMessageStore store, Expression> accessor) + => store.Clear(new FieldIdentifier(accessor)); + } +} From dffd2ee2dd81b146061869f2f72a899a5a930805 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 14 Feb 2019 12:27:00 +0000 Subject: [PATCH 18/70] Support conversion to FieldIdentifier from any Expression> --- .../Forms/EditContextExpressionExtensions.cs | 4 +-- .../Components/src/Forms/FieldIdentifier.cs | 26 ++++++++----------- .../src/Forms/ValidationMessageStore.cs | 2 +- ...idationMessageStoreExpressionExtensions.cs | 6 ++--- .../test/Forms/FieldIdentifierTest.cs | 20 +++++++------- 5 files changed, 28 insertions(+), 30 deletions(-) diff --git a/src/Components/Components/src/Forms/EditContextExpressionExtensions.cs b/src/Components/Components/src/Forms/EditContextExpressionExtensions.cs index 633ecb1b0a7b..3d856e241f8e 100644 --- a/src/Components/Components/src/Forms/EditContextExpressionExtensions.cs +++ b/src/Components/Components/src/Forms/EditContextExpressionExtensions.cs @@ -21,7 +21,7 @@ public static class EditContextExpressionExtensions /// Identifies the field whose current validation messages should be returned. /// The current validation messages for the specified field. public static IEnumerable GetValidationMessages(this EditContext editContext, Expression> accessor) - => editContext.GetValidationMessages(new FieldIdentifier(accessor)); + => editContext.GetValidationMessages(FieldIdentifier.Create(accessor)); /// /// Determines whether the specified fields in this has been modified. @@ -30,6 +30,6 @@ public static IEnumerable GetValidationMessages(this EditContext editCon /// Identifies the field whose current validation messages should be returned. /// True if the field has been modified; otherwise false. public static bool IsModified(this EditContext editContext, Expression> accessor) - => editContext.IsModified(new FieldIdentifier(accessor)); + => editContext.IsModified(FieldIdentifier.Create(accessor)); } } diff --git a/src/Components/Components/src/Forms/FieldIdentifier.cs b/src/Components/Components/src/Forms/FieldIdentifier.cs index 95da3b0070e3..753a857a9bcc 100644 --- a/src/Components/Components/src/Forms/FieldIdentifier.cs +++ b/src/Components/Components/src/Forms/FieldIdentifier.cs @@ -3,7 +3,6 @@ using System; using System.Linq.Expressions; -using System.Reflection; namespace Microsoft.AspNetCore.Components.Forms { @@ -13,6 +12,16 @@ namespace Microsoft.AspNetCore.Components.Forms /// public readonly struct FieldIdentifier { + /// + /// Initializes a new instance of the structure. + /// + /// An expression that identifies an object member. + public static FieldIdentifier Create(Expression> accessor) + { + ParseAccessor(accessor, out var model, out var fieldName); + return new FieldIdentifier(model, fieldName); + } + /// /// Initializes a new instance of the structure. /// @@ -37,19 +46,6 @@ 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. /// @@ -70,7 +66,7 @@ public override bool Equals(object obj) && otherIdentifier.Model == Model && string.Equals(otherIdentifier.FieldName, FieldName, StringComparison.Ordinal); - private static void ParseAccessor(Expression> accessor, out object model, out string fieldName) + private static void ParseAccessor(Expression> accessor, out object model, out string fieldName) { var accessorBody = accessor.Body; diff --git a/src/Components/Components/src/Forms/ValidationMessageStore.cs b/src/Components/Components/src/Forms/ValidationMessageStore.cs index 858f413abd80..c493d75ebd4a 100644 --- a/src/Components/Components/src/Forms/ValidationMessageStore.cs +++ b/src/Components/Components/src/Forms/ValidationMessageStore.cs @@ -59,7 +59,7 @@ public IEnumerable this[FieldIdentifier fieldIdentifier] /// The identifier for the field. /// The validation messages for the specified field within this . public IEnumerable this[Expression> accessor] - => this[new FieldIdentifier(accessor)]; + => this[FieldIdentifier.Create(accessor)]; /// /// Removes all messages within this . diff --git a/src/Components/Components/src/Forms/ValidationMessageStoreExpressionExtensions.cs b/src/Components/Components/src/Forms/ValidationMessageStoreExpressionExtensions.cs index 8768992e729e..6304c6e2c3d4 100644 --- a/src/Components/Components/src/Forms/ValidationMessageStoreExpressionExtensions.cs +++ b/src/Components/Components/src/Forms/ValidationMessageStoreExpressionExtensions.cs @@ -19,7 +19,7 @@ public static class ValidationMessageStoreExpressionExtensions /// Identifies the field for which to add the message. /// The validation message. public static void Add(this ValidationMessageStore store, Expression> accessor, string message) - => store.Add(new FieldIdentifier(accessor), message); + => store.Add(FieldIdentifier.Create(accessor), message); /// /// Adds the messages from the specified collection for the specified field. @@ -28,7 +28,7 @@ public static void Add(this ValidationMessageStore store, ExpressionIdentifies the field for which to add the messages. /// The validation messages to be added. public static void AddRange(this ValidationMessageStore store, Expression> accessor, IEnumerable messages) - => store.AddRange(new FieldIdentifier(accessor), messages); + => store.AddRange(FieldIdentifier.Create(accessor), messages); /// /// Removes all messages within this for the specified field. @@ -36,6 +36,6 @@ public static void AddRange(this ValidationMessageStore store, ExpressionThe . /// Identifies the field for which to remove the messages. public static void Clear(this ValidationMessageStore store, Expression> accessor) - => store.Clear(new FieldIdentifier(accessor)); + => store.Clear(FieldIdentifier.Create(accessor)); } } diff --git a/src/Components/Components/test/Forms/FieldIdentifierTest.cs b/src/Components/Components/test/Forms/FieldIdentifierTest.cs index 80eecbdb2a78..5dfe4d3539e4 100644 --- a/src/Components/Components/test/Forms/FieldIdentifierTest.cs +++ b/src/Components/Components/test/Forms/FieldIdentifierTest.cs @@ -105,37 +105,39 @@ public void FieldNamesAreCaseSensitive() } [Fact] - public void CanConstructFromExpression_Property() + public void CanCreateFromExpression_Property() { var model = new TestModel(); - var fieldIdentifier = new FieldIdentifier(() => model.StringProperty); + var fieldIdentifier = FieldIdentifier.Create(() => model.StringProperty); Assert.Same(model, fieldIdentifier.Model); Assert.Equal(nameof(model.StringProperty), fieldIdentifier.FieldName); } [Fact] - public void CanConstructFromExpression_Field() + public void CanCreateFromExpression_Field() { var model = new TestModel(); - var fieldIdentifier = new FieldIdentifier(() => model.StringField); + var fieldIdentifier = FieldIdentifier.Create(() => model.StringField); Assert.Same(model, fieldIdentifier.Model); Assert.Equal(nameof(model.StringField), fieldIdentifier.FieldName); } [Fact] - public void CanConstructFromExpression_WithCastToObject() + public void CanCreateFromExpression_WithCastToObject() { - // This case is needed because value types will implicitly be cast to object + // This case is needed because, if a component is declared as receiving + // an Expression>, then any value types will be implicitly cast var model = new TestModel(); - var fieldIdentifier = new FieldIdentifier(() => model.IntProperty); + Expression> accessor = () => model.IntProperty; + var fieldIdentifier = FieldIdentifier.Create(accessor); Assert.Same(model, fieldIdentifier.Model); Assert.Equal(nameof(model.IntProperty), fieldIdentifier.FieldName); } [Fact] - public void CanConstructFromExpression_MemberOfConstantExpression() + public void CanCreateFromExpression_MemberOfConstantExpression() { - var fieldIdentifier = new FieldIdentifier(() => StringPropertyOnThisClass); + var fieldIdentifier = FieldIdentifier.Create(() => StringPropertyOnThisClass); Assert.Same(this, fieldIdentifier.Model); Assert.Equal(nameof(StringPropertyOnThisClass), fieldIdentifier.FieldName); } From b0f184c3724d77d6f20e4f01aee9dc3b4d86e903 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 14 Feb 2019 17:44:16 +0000 Subject: [PATCH 19/70] Beginning on InputBase --- .../Components/src/Forms/InputBase.cs | 78 ++++++ .../Components/test/Forms/InputBaseTest.cs | 227 ++++++++++++++++++ 2 files changed, 305 insertions(+) create mode 100644 src/Components/Components/src/Forms/InputBase.cs create mode 100644 src/Components/Components/test/Forms/InputBaseTest.cs diff --git a/src/Components/Components/src/Forms/InputBase.cs b/src/Components/Components/src/Forms/InputBase.cs new file mode 100644 index 000000000000..091ba95926c8 --- /dev/null +++ b/src/Components/Components/src/Forms/InputBase.cs @@ -0,0 +1,78 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq.Expressions; + +namespace Microsoft.AspNetCore.Components.Forms +{ + /// + /// A base class for form input components. This base class automatically + /// integrates with an , which must be supplied + /// as a cascading parameter. + /// + public abstract class InputBase : ComponentBase + { + private EditContext _fixedEditContext; + private FieldIdentifier _fieldIdentifier; + + [CascadingParameter] EditContext EditContext { get; set; } + + [Parameter] T Value { get; set; } + + [Parameter] Action ValueChanged { get; set; } + + [Parameter] Expression> ValueExpression { get; set; } + + /// + /// Gets or sets the current value of the input. + /// + protected T CurrentValue + { + get => Value; + set + { + var hasChanged = !EqualityComparer.Default.Equals(value, Value); + if (hasChanged) + { + Value = value; + ValueChanged?.Invoke(value); + } + } + } + + /// + protected override void OnInit() + { + if (EditContext == null) + { + throw new InvalidOperationException($"{GetType()} requires a cascading parameter " + + $"of type {nameof(Forms.EditContext)}. For example, you can use {GetType().FullName} inside " + + $"an {nameof(EditForm)}."); + } + + if (ValueExpression == null) + { + throw new InvalidOperationException($"{GetType()} requires a value for the 'ValueExpression' " + + $"parameter. Normally this is provided automatically when using 'bind-Value'."); + } + + _fixedEditContext = EditContext; + _fieldIdentifier = FieldIdentifier.Create(ValueExpression); + } + + /// + protected override void OnParametersSet() + { + if (EditContext != _fixedEditContext) + { + // We're not supporting it just because it's messy to be clearing up state and event + // handlers for the previous one, and there's no strong use case. If a strong use case + // emerges, we can consider changing this. + throw new InvalidOperationException($"{GetType()} does not support changing the " + + $"{nameof(Forms.EditContext)} dynamically."); + } + } + } +} diff --git a/src/Components/Components/test/Forms/InputBaseTest.cs b/src/Components/Components/test/Forms/InputBaseTest.cs new file mode 100644 index 000000000000..1b1f50fbe6ba --- /dev/null +++ b/src/Components/Components/test/Forms/InputBaseTest.cs @@ -0,0 +1,227 @@ +// 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 Microsoft.AspNetCore.Components.RenderTree; +using Microsoft.AspNetCore.Components.Test.Helpers; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.AspNetCore.Components.Tests.Forms +{ + public class InputBaseTest + { + [Fact] + public async Task ThrowsOnFirstRenderIfNoEditContextIsSupplied() + { + // Arrange + var inputComponent = new TestInputComponent(); + var testRenderer = new TestRenderer(); + var componentId = testRenderer.AssignRootComponentId(inputComponent); + + // Act/Assert + var ex = await Assert.ThrowsAsync( + () => testRenderer.RenderRootComponentAsync(componentId)); + Assert.StartsWith($"{typeof(TestInputComponent)} requires a cascading parameter of type {nameof(EditContext)}", ex.Message); + } + + [Fact] + public void ThrowsIfEditContextChanges() + { + // Arrange + var context = new TestRenderingContext>(); + var model = new TestModel(); + context.SupplyParameters(new EditContext(model), valueExpression: () => model.StringProperty); + + // Act/Assert + var ex = Assert.Throws(() => + { + context.SupplyParameters(new EditContext(model), valueExpression: () => model.StringProperty); + }); + Assert.StartsWith($"{typeof(TestInputComponent)} does not support changing the EditContext dynamically", ex.Message); + } + + [Fact] + public void ThrowsIfNoValueExpressionIsSupplied() + { + // Arrange + var context = new TestRenderingContext>(); + var model = new TestModel(); + + // Act/Assert + var ex = Assert.ThrowsAny(() => + { + context.SupplyParameters(new EditContext(model), valueExpression: null); + }); + Assert.Contains($"{typeof(TestInputComponent)} requires a value for the 'ValueExpression' parameter. Normally this is provided automatically when using 'bind-Value'.", ex.Message); + } + + [Fact] + public void GetsCurrentValueFromValueParameter() + { + // Arrange + var context = new TestRenderingContext>(); + var model = new TestModel(); + + // Act + context.SupplyParameters(new EditContext(model), value: "some value", valueExpression: () => model.StringProperty); + + // Assert + Assert.Equal("some value", context.Component.RenderedStates.Single().CurrentValue); + } + + [Fact] + public void CanReadBackChangesToCurrentValue() + { + // Arrange + var context = new TestRenderingContext>(); + var model = new TestModel(); + context.SupplyParameters(new EditContext(model), value: "some value", valueExpression: () => model.StringProperty); + Assert.Single(context.Component.RenderedStates); + + // Act + context.Component.CurrentValue = "new value"; + + // Assert + Assert.Equal("new value", context.Component.CurrentValue); + Assert.Single(context.Component.RenderedStates); // Writing to CurrentValue doesn't inherently trigger a render (though the fact that it invokes ValueChanged might) + } + + [Fact] + public void WritingToCurrentValueInvokesValueChangedIfDifferent() + { + // Arrange + var context = new TestRenderingContext>(); + var model = new TestModel(); + var valueChangedCallLog = new List(); + Action valueChanged = val => valueChangedCallLog.Add(val); + context.SupplyParameters(new EditContext(model), valueChanged: valueChanged, valueExpression: () => model.StringProperty); + Assert.Single(context.Component.RenderedStates); + + // Act + context.Component.CurrentValue = "new value"; + + // Assert + Assert.Single(valueChangedCallLog, "new value"); + } + + [Fact] + public void WritingToCurrentValueDoesNotInvokeValueChangedIfUnchanged() + { + // Arrange + var context = new TestRenderingContext>(); + var model = new TestModel(); + var valueChangedCallLog = new List(); + Action valueChanged = val => valueChangedCallLog.Add(val); + context.SupplyParameters(new EditContext(model), value: "initial value", valueChanged: valueChanged, valueExpression: () => model.StringProperty); + Assert.Single(context.Component.RenderedStates); + + // Act + context.Component.CurrentValue = "initial value"; + + // Assert + Assert.Empty(valueChangedCallLog); + } + + class TestModel + { + public string StringProperty { get; set; } + + public string AnotherStringProperty { get; set; } + } + + class TestInputComponent : InputBase where T: IEquatable + { + public List RenderedStates { get; } = new List(); + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + // No need to actually render anything. We just want to assert about what data is given to derived classes. + RenderedStates.Add(new StateWhenRendering { CurrentValue = CurrentValue }); + } + + public class StateWhenRendering + { + public T CurrentValue { get; set; } + } + + // Expose publicly for tests + public new T CurrentValue + { + get => base.CurrentValue; + set { base.CurrentValue = value; } + } + } + + class TestComponent : AutoRenderComponent + { + private readonly RenderFragment _renderFragment; + + public TestComponent(RenderFragment renderFragment) + { + _renderFragment = renderFragment; + } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + => _renderFragment(builder); + } + + class TestRenderingContext where TComponent: IComponent + { + private readonly TestRenderer _renderer = new TestRenderer(); + private readonly TestComponent _rootComponent; + private RenderFragment _renderFragment; + + public TestRenderingContext() + { + _rootComponent = new TestComponent(builder => builder.AddContent(0, _renderFragment)); + } + + public TComponent Component { get; private set; } + + public void SupplyParameters(EditContext editContext, T value = default, Action valueChanged = default, Expression> valueExpression = default) + { + _renderFragment = builder => + { + builder.OpenComponent>(0); + builder.AddAttribute(1, "Value", editContext); + builder.AddAttribute(2, RenderTreeBuilder.ChildContent, new RenderFragment(childBuilder => + { + childBuilder.OpenComponent(0); + childBuilder.AddAttribute(0, "Value", value); + childBuilder.AddAttribute(1, "ValueChanged", valueChanged); + childBuilder.AddAttribute(2, "ValueExpression", valueExpression); + childBuilder.CloseComponent(); + })); + builder.CloseComponent(); + }; + + if (Component == null) + { + var rootComponentId = _renderer.AssignRootComponentId(_rootComponent); + var renderTask = _renderer.RenderRootComponentAsync(rootComponentId); + if (renderTask.IsFaulted) + { + throw renderTask.Exception; + } + Assert.True(renderTask.IsCompletedSuccessfully); // Everything's synchronous here + + var batch = _renderer.Batches.Single(); + Component = batch.ReferenceFrames + .Where(f => f.FrameType == RenderTreeFrameType.Component) + .Select(f => f.Component) + .OfType() + .Single(); + } + else + { + _rootComponent.TriggerRender(); + } + } + } + } +} From ebe43a6d2dd60b111f5af98dbc635c91fc9606e8 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 14 Feb 2019 18:07:04 +0000 Subject: [PATCH 20/70] More InputBase --- .../Components/src/Forms/InputBase.cs | 18 ++++--- .../Components/test/Forms/InputBaseTest.cs | 51 +++++++++++++++++++ 2 files changed, 61 insertions(+), 8 deletions(-) diff --git a/src/Components/Components/src/Forms/InputBase.cs b/src/Components/Components/src/Forms/InputBase.cs index 091ba95926c8..19a73ac92ea4 100644 --- a/src/Components/Components/src/Forms/InputBase.cs +++ b/src/Components/Components/src/Forms/InputBase.cs @@ -14,10 +14,7 @@ namespace Microsoft.AspNetCore.Components.Forms /// public abstract class InputBase : ComponentBase { - private EditContext _fixedEditContext; - private FieldIdentifier _fieldIdentifier; - - [CascadingParameter] EditContext EditContext { get; set; } + [CascadingParameter] EditContext CascadedEditContext { get; set; } [Parameter] T Value { get; set; } @@ -25,6 +22,10 @@ public abstract class InputBase : ComponentBase [Parameter] Expression> ValueExpression { get; set; } + protected EditContext EditContext { get; private set; } + + protected FieldIdentifier FieldIdentifier { get; private set; } + /// /// Gets or sets the current value of the input. /// @@ -38,6 +39,7 @@ protected T CurrentValue { Value = value; ValueChanged?.Invoke(value); + EditContext.NotifyFieldChanged(FieldIdentifier); } } } @@ -45,7 +47,7 @@ protected T CurrentValue /// protected override void OnInit() { - if (EditContext == null) + if (CascadedEditContext == null) { throw new InvalidOperationException($"{GetType()} requires a cascading parameter " + $"of type {nameof(Forms.EditContext)}. For example, you can use {GetType().FullName} inside " + @@ -58,14 +60,14 @@ protected override void OnInit() $"parameter. Normally this is provided automatically when using 'bind-Value'."); } - _fixedEditContext = EditContext; - _fieldIdentifier = FieldIdentifier.Create(ValueExpression); + EditContext = CascadedEditContext; + FieldIdentifier = FieldIdentifier.Create(ValueExpression); } /// protected override void OnParametersSet() { - if (EditContext != _fixedEditContext) + if (CascadedEditContext != EditContext) { // We're not supporting it just because it's messy to be clearing up state and event // handlers for the previous one, and there's no strong use case. If a strong use case diff --git a/src/Components/Components/test/Forms/InputBaseTest.cs b/src/Components/Components/test/Forms/InputBaseTest.cs index 1b1f50fbe6ba..b020426d2fb9 100644 --- a/src/Components/Components/test/Forms/InputBaseTest.cs +++ b/src/Components/Components/test/Forms/InputBaseTest.cs @@ -74,6 +74,36 @@ public void GetsCurrentValueFromValueParameter() Assert.Equal("some value", context.Component.RenderedStates.Single().CurrentValue); } + [Fact] + public void ExposesEditContextToSubclass() + { + // Arrange + var context = new TestRenderingContext>(); + var model = new TestModel(); + var editContext = new EditContext(model); + + // Act + context.SupplyParameters(editContext, valueExpression: () => model.StringProperty); + + // Assert + Assert.Same(editContext, context.Component.EditContext); + } + + [Fact] + public void ExposesFieldIdentifierToSubclass() + { + // Arrange + var context = new TestRenderingContext>(); + var model = new TestModel(); + var editContext = new EditContext(model); + + // Act + context.SupplyParameters(editContext, valueExpression: () => model.StringProperty); + + // Assert + Assert.Equal(FieldIdentifier.Create(() => model.StringProperty), context.Component.FieldIdentifier); + } + [Fact] public void CanReadBackChangesToCurrentValue() { @@ -127,6 +157,23 @@ public void WritingToCurrentValueDoesNotInvokeValueChangedIfUnchanged() Assert.Empty(valueChangedCallLog); } + [Fact] + public void WritingToCurrentValueNotifiesEditContext() + { + // Arrange + var context = new TestRenderingContext>(); + var model = new TestModel(); + var editContext = new EditContext(model); + context.SupplyParameters(editContext, valueExpression: () => model.StringProperty); + Assert.False(editContext.IsModified(() => model.StringProperty)); + + // Act + context.Component.CurrentValue = "new value"; + + // Assert + Assert.True(editContext.IsModified(() => model.StringProperty)); + } + class TestModel { public string StringProperty { get; set; } @@ -155,6 +202,10 @@ public class StateWhenRendering get => base.CurrentValue; set { base.CurrentValue = value; } } + + public new EditContext EditContext => base.EditContext; + + public new FieldIdentifier FieldIdentifier => base.FieldIdentifier; } class TestComponent : AutoRenderComponent From 241af1c0a39f9f42f72116018a61dd3a3ddb491a Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 14 Feb 2019 18:11:14 +0000 Subject: [PATCH 21/70] Don't use OnInit --- .../Components/src/Forms/InputBase.cs | 41 ++++++++++--------- .../Components/test/Forms/InputBaseTest.cs | 5 +++ 2 files changed, 27 insertions(+), 19 deletions(-) diff --git a/src/Components/Components/src/Forms/InputBase.cs b/src/Components/Components/src/Forms/InputBase.cs index 19a73ac92ea4..1b2a355c6393 100644 --- a/src/Components/Components/src/Forms/InputBase.cs +++ b/src/Components/Components/src/Forms/InputBase.cs @@ -45,31 +45,34 @@ protected T CurrentValue } /// - protected override void OnInit() + protected override void OnParametersSet() { - if (CascadedEditContext == null) + if (EditContext == null) { - throw new InvalidOperationException($"{GetType()} requires a cascading parameter " + - $"of type {nameof(Forms.EditContext)}. For example, you can use {GetType().FullName} inside " + - $"an {nameof(EditForm)}."); - } + // This is the first render + // Could put this logic in OnInit, but its nice to avoid forcing people who override OnInit to call base.OnInit() - if (ValueExpression == null) - { - throw new InvalidOperationException($"{GetType()} requires a value for the 'ValueExpression' " + - $"parameter. Normally this is provided automatically when using 'bind-Value'."); - } + if (CascadedEditContext == null) + { + throw new InvalidOperationException($"{GetType()} requires a cascading parameter " + + $"of type {nameof(Forms.EditContext)}. For example, you can use {GetType().FullName} inside " + + $"an {nameof(EditForm)}."); + } - EditContext = CascadedEditContext; - FieldIdentifier = FieldIdentifier.Create(ValueExpression); - } + if (ValueExpression == null) + { + throw new InvalidOperationException($"{GetType()} requires a value for the 'ValueExpression' " + + $"parameter. Normally this is provided automatically when using 'bind-Value'."); + } - /// - protected override void OnParametersSet() - { - if (CascadedEditContext != EditContext) + EditContext = CascadedEditContext; + FieldIdentifier = FieldIdentifier.Create(ValueExpression); + } + else if (CascadedEditContext != EditContext) { - // We're not supporting it just because it's messy to be clearing up state and event + // Not the first render + + // We don't support changing EditContext because it's messy to be clearing up state and event // handlers for the previous one, and there's no strong use case. If a strong use case // emerges, we can consider changing this. throw new InvalidOperationException($"{GetType()} does not support changing the " + diff --git a/src/Components/Components/test/Forms/InputBaseTest.cs b/src/Components/Components/test/Forms/InputBaseTest.cs index b020426d2fb9..8a8147e44ca6 100644 --- a/src/Components/Components/test/Forms/InputBaseTest.cs +++ b/src/Components/Components/test/Forms/InputBaseTest.cs @@ -185,6 +185,11 @@ class TestInputComponent : InputBase where T: IEquatable { public List RenderedStates { get; } = new List(); + protected override void OnInit() + { + // Explicitly *not* calling base.OnInit() just to prove derived types don't have to + } + protected override void BuildRenderTree(RenderTreeBuilder builder) { // No need to actually render anything. We just want to assert about what data is given to derived classes. From 3dc0cb8c94b207ef8327bd32819786f3b45649af Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 14 Feb 2019 21:21:17 +0000 Subject: [PATCH 22/70] Add XML docs --- .../Components/src/Forms/InputBase.cs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/Components/Components/src/Forms/InputBase.cs b/src/Components/Components/src/Forms/InputBase.cs index 1b2a355c6393..117663a746fd 100644 --- a/src/Components/Components/src/Forms/InputBase.cs +++ b/src/Components/Components/src/Forms/InputBase.cs @@ -16,14 +16,32 @@ public abstract class InputBase : ComponentBase { [CascadingParameter] EditContext CascadedEditContext { get; set; } + /// + /// Gets or sets the value of the input. This should be used with two-way binding. + /// + /// + /// bind-Value="@model.PropertyName" + /// [Parameter] T Value { get; set; } + /// + /// Gets or sets a callback that updates the bound value. + /// [Parameter] Action ValueChanged { get; set; } + /// + /// Gets or sets an expression that identifies the bound value. + /// [Parameter] Expression> ValueExpression { get; set; } + /// + /// Gets the associated . + /// protected EditContext EditContext { get; private set; } + /// + /// Gets the for the bound value. + /// protected FieldIdentifier FieldIdentifier { get; private set; } /// From 0cf1637b25500d228354749f3751ceb996a0e4d5 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 14 Feb 2019 21:27:03 +0000 Subject: [PATCH 23/70] Move InputBase logic into SetParameters to avoid making lifecycle methods have to call base --- src/Components/Components/src/Forms/InputBase.cs | 12 +++++++++--- .../Components/test/Forms/InputBaseTest.cs | 5 ----- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/Components/Components/src/Forms/InputBase.cs b/src/Components/Components/src/Forms/InputBase.cs index 117663a746fd..5dbd422eb963 100644 --- a/src/Components/Components/src/Forms/InputBase.cs +++ b/src/Components/Components/src/Forms/InputBase.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Linq.Expressions; +using System.Threading.Tasks; namespace Microsoft.AspNetCore.Components.Forms { @@ -63,11 +64,13 @@ protected T CurrentValue } /// - protected override void OnParametersSet() + public override Task SetParametersAsync(ParameterCollection parameters) { + parameters.SetParameterProperties(this); + if (EditContext == null) { - // This is the first render + // This is the first run // Could put this logic in OnInit, but its nice to avoid forcing people who override OnInit to call base.OnInit() if (CascadedEditContext == null) @@ -88,7 +91,7 @@ protected override void OnParametersSet() } else if (CascadedEditContext != EditContext) { - // Not the first render + // Not the first run // We don't support changing EditContext because it's messy to be clearing up state and event // handlers for the previous one, and there's no strong use case. If a strong use case @@ -96,6 +99,9 @@ protected override void OnParametersSet() throw new InvalidOperationException($"{GetType()} does not support changing the " + $"{nameof(Forms.EditContext)} dynamically."); } + + // For derived components, retain the usual lifecycle with OnInit/OnParametersSet/etc. + return base.SetParametersAsync(ParameterCollection.Empty); } } } diff --git a/src/Components/Components/test/Forms/InputBaseTest.cs b/src/Components/Components/test/Forms/InputBaseTest.cs index 8a8147e44ca6..b020426d2fb9 100644 --- a/src/Components/Components/test/Forms/InputBaseTest.cs +++ b/src/Components/Components/test/Forms/InputBaseTest.cs @@ -185,11 +185,6 @@ class TestInputComponent : InputBase where T: IEquatable { public List RenderedStates { get; } = new List(); - protected override void OnInit() - { - // Explicitly *not* calling base.OnInit() just to prove derived types don't have to - } - protected override void BuildRenderTree(RenderTreeBuilder builder) { // No need to actually render anything. We just want to assert about what data is given to derived classes. From 78cf6e2268d7ed5cd408d635f1f93d757721a6aa Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 14 Feb 2019 22:31:00 +0000 Subject: [PATCH 24/70] Simplify tests --- .../Components/test/Forms/InputBaseTest.cs | 237 ++++++++---------- 1 file changed, 111 insertions(+), 126 deletions(-) diff --git a/src/Components/Components/test/Forms/InputBaseTest.cs b/src/Components/Components/test/Forms/InputBaseTest.cs index b020426d2fb9..d0a87e6d3031 100644 --- a/src/Components/Components/test/Forms/InputBaseTest.cs +++ b/src/Components/Components/test/Forms/InputBaseTest.cs @@ -30,172 +30,198 @@ public async Task ThrowsOnFirstRenderIfNoEditContextIsSupplied() } [Fact] - public void ThrowsIfEditContextChanges() + public async Task ThrowsIfEditContextChanges() { // Arrange - var context = new TestRenderingContext>(); var model = new TestModel(); - context.SupplyParameters(new EditContext(model), valueExpression: () => model.StringProperty); + var rootComponent = new TestInputHostComponent { EditContext = new EditContext(model), ValueExpression = () => model.StringProperty }; + await RenderAndGetTestInputComponentAsync(rootComponent); // Act/Assert - var ex = Assert.Throws(() => - { - context.SupplyParameters(new EditContext(model), valueExpression: () => model.StringProperty); - }); + rootComponent.EditContext = new EditContext(model); + var ex = Assert.Throws(() => rootComponent.TriggerRender()); Assert.StartsWith($"{typeof(TestInputComponent)} does not support changing the EditContext dynamically", ex.Message); } [Fact] - public void ThrowsIfNoValueExpressionIsSupplied() + public async Task ThrowsIfNoValueExpressionIsSupplied() { // Arrange - var context = new TestRenderingContext>(); var model = new TestModel(); + var rootComponent = new TestInputHostComponent { EditContext = new EditContext(model) }; // Act/Assert - var ex = Assert.ThrowsAny(() => - { - context.SupplyParameters(new EditContext(model), valueExpression: null); - }); + var ex = await Assert.ThrowsAsync(() => RenderAndGetTestInputComponentAsync(rootComponent)); Assert.Contains($"{typeof(TestInputComponent)} requires a value for the 'ValueExpression' parameter. Normally this is provided automatically when using 'bind-Value'.", ex.Message); } [Fact] - public void GetsCurrentValueFromValueParameter() + public async Task GetsCurrentValueFromValueParameter() { // Arrange - var context = new TestRenderingContext>(); var model = new TestModel(); + var rootComponent = new TestInputHostComponent + { + EditContext = new EditContext(model), + Value = "some value", + ValueExpression = () => model.StringProperty + }; // Act - context.SupplyParameters(new EditContext(model), value: "some value", valueExpression: () => model.StringProperty); + var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent); // Assert - Assert.Equal("some value", context.Component.RenderedStates.Single().CurrentValue); + Assert.Equal("some value", inputComponent.CurrentValue); } [Fact] - public void ExposesEditContextToSubclass() + public async Task ExposesEditContextToSubclass() { // Arrange - var context = new TestRenderingContext>(); var model = new TestModel(); - var editContext = new EditContext(model); + var rootComponent = new TestInputHostComponent + { + EditContext = new EditContext(model), + Value = "some value", + ValueExpression = () => model.StringProperty + }; // Act - context.SupplyParameters(editContext, valueExpression: () => model.StringProperty); + var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent); // Assert - Assert.Same(editContext, context.Component.EditContext); + Assert.Same(rootComponent.EditContext, inputComponent.EditContext); } [Fact] - public void ExposesFieldIdentifierToSubclass() + public async Task ExposesFieldIdentifierToSubclass() { // Arrange - var context = new TestRenderingContext>(); var model = new TestModel(); - var editContext = new EditContext(model); + var rootComponent = new TestInputHostComponent + { + EditContext = new EditContext(model), + Value = "some value", + ValueExpression = () => model.StringProperty + }; // Act - context.SupplyParameters(editContext, valueExpression: () => model.StringProperty); + var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent); // Assert - Assert.Equal(FieldIdentifier.Create(() => model.StringProperty), context.Component.FieldIdentifier); + Assert.Equal(FieldIdentifier.Create(() => model.StringProperty), inputComponent.FieldIdentifier); } [Fact] - public void CanReadBackChangesToCurrentValue() + public async Task CanReadBackChangesToCurrentValue() { // Arrange - var context = new TestRenderingContext>(); var model = new TestModel(); - context.SupplyParameters(new EditContext(model), value: "some value", valueExpression: () => model.StringProperty); - Assert.Single(context.Component.RenderedStates); + var rootComponent = new TestInputHostComponent + { + EditContext = new EditContext(model), + Value = "initial value", + ValueExpression = () => model.StringProperty + }; + var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent); + Assert.Equal("initial value", inputComponent.CurrentValue); // Act - context.Component.CurrentValue = "new value"; + inputComponent.CurrentValue = "new value"; // Assert - Assert.Equal("new value", context.Component.CurrentValue); - Assert.Single(context.Component.RenderedStates); // Writing to CurrentValue doesn't inherently trigger a render (though the fact that it invokes ValueChanged might) + Assert.Equal("new value", inputComponent.CurrentValue); } [Fact] - public void WritingToCurrentValueInvokesValueChangedIfDifferent() + public async Task WritingToCurrentValueInvokesValueChangedIfDifferent() { // Arrange - var context = new TestRenderingContext>(); var model = new TestModel(); var valueChangedCallLog = new List(); - Action valueChanged = val => valueChangedCallLog.Add(val); - context.SupplyParameters(new EditContext(model), valueChanged: valueChanged, valueExpression: () => model.StringProperty); - Assert.Single(context.Component.RenderedStates); + var rootComponent = new TestInputHostComponent + { + EditContext = new EditContext(model), + Value = "initial value", + ValueChanged = val => valueChangedCallLog.Add(val), + ValueExpression = () => model.StringProperty + }; + var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent); + Assert.Empty(valueChangedCallLog); // Act - context.Component.CurrentValue = "new value"; + inputComponent.CurrentValue = "new value"; // Assert Assert.Single(valueChangedCallLog, "new value"); } [Fact] - public void WritingToCurrentValueDoesNotInvokeValueChangedIfUnchanged() + public async Task WritingToCurrentValueDoesNotInvokeValueChangedIfUnchanged() { // Arrange - var context = new TestRenderingContext>(); var model = new TestModel(); var valueChangedCallLog = new List(); - Action valueChanged = val => valueChangedCallLog.Add(val); - context.SupplyParameters(new EditContext(model), value: "initial value", valueChanged: valueChanged, valueExpression: () => model.StringProperty); - Assert.Single(context.Component.RenderedStates); + var rootComponent = new TestInputHostComponent + { + EditContext = new EditContext(model), + Value = "initial value", + ValueChanged = val => valueChangedCallLog.Add(val), + ValueExpression = () => model.StringProperty + }; + var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent); + Assert.Empty(valueChangedCallLog); // Act - context.Component.CurrentValue = "initial value"; + inputComponent.CurrentValue = "initial value"; // Assert Assert.Empty(valueChangedCallLog); } [Fact] - public void WritingToCurrentValueNotifiesEditContext() + public async Task WritingToCurrentValueNotifiesEditContext() { // Arrange - var context = new TestRenderingContext>(); var model = new TestModel(); - var editContext = new EditContext(model); - context.SupplyParameters(editContext, valueExpression: () => model.StringProperty); - Assert.False(editContext.IsModified(() => model.StringProperty)); + var rootComponent = new TestInputHostComponent + { + EditContext = new EditContext(model), + Value = "initial value", + ValueExpression = () => model.StringProperty + }; + var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent); + Assert.False(rootComponent.EditContext.IsModified(() => model.StringProperty)); // Act - context.Component.CurrentValue = "new value"; + inputComponent.CurrentValue = "new value"; // Assert - Assert.True(editContext.IsModified(() => model.StringProperty)); + Assert.True(rootComponent.EditContext.IsModified(() => model.StringProperty)); + } + + private static TestInputComponent FindInputComponent(CapturedBatch batch) + => batch.ReferenceFrames + .Where(f => f.FrameType == RenderTreeFrameType.Component) + .Select(f => f.Component) + .OfType>() + .Single(); + + private static async Task> RenderAndGetTestInputComponentAsync(TestInputHostComponent hostComponent) + { + var testRenderer = new TestRenderer(); + var componentId = testRenderer.AssignRootComponentId(hostComponent); + await testRenderer.RenderRootComponentAsync(componentId); + return FindInputComponent(testRenderer.Batches.Single()); } class TestModel { public string StringProperty { get; set; } - - public string AnotherStringProperty { get; set; } } - class TestInputComponent : InputBase where T: IEquatable + class TestInputComponent : InputBase { - public List RenderedStates { get; } = new List(); - - protected override void BuildRenderTree(RenderTreeBuilder builder) - { - // No need to actually render anything. We just want to assert about what data is given to derived classes. - RenderedStates.Add(new StateWhenRendering { CurrentValue = CurrentValue }); - } - - public class StateWhenRendering - { - public T CurrentValue { get; set; } - } - // Expose publicly for tests public new T CurrentValue { @@ -208,70 +234,29 @@ public class StateWhenRendering public new FieldIdentifier FieldIdentifier => base.FieldIdentifier; } - class TestComponent : AutoRenderComponent + class TestInputHostComponent : AutoRenderComponent { - private readonly RenderFragment _renderFragment; - - public TestComponent(RenderFragment renderFragment) - { - _renderFragment = renderFragment; - } + public EditContext EditContext { get; set; } - protected override void BuildRenderTree(RenderTreeBuilder builder) - => _renderFragment(builder); - } + public T Value { get; set; } - class TestRenderingContext where TComponent: IComponent - { - private readonly TestRenderer _renderer = new TestRenderer(); - private readonly TestComponent _rootComponent; - private RenderFragment _renderFragment; + public Action ValueChanged { get; set; } - public TestRenderingContext() - { - _rootComponent = new TestComponent(builder => builder.AddContent(0, _renderFragment)); - } + public Expression> ValueExpression { get; set; } - public TComponent Component { get; private set; } - - public void SupplyParameters(EditContext editContext, T value = default, Action valueChanged = default, Expression> valueExpression = default) + protected override void BuildRenderTree(RenderTreeBuilder builder) { - _renderFragment = builder => - { - builder.OpenComponent>(0); - builder.AddAttribute(1, "Value", editContext); - builder.AddAttribute(2, RenderTreeBuilder.ChildContent, new RenderFragment(childBuilder => - { - childBuilder.OpenComponent(0); - childBuilder.AddAttribute(0, "Value", value); - childBuilder.AddAttribute(1, "ValueChanged", valueChanged); - childBuilder.AddAttribute(2, "ValueExpression", valueExpression); - childBuilder.CloseComponent(); - })); - builder.CloseComponent(); - }; - - if (Component == null) - { - var rootComponentId = _renderer.AssignRootComponentId(_rootComponent); - var renderTask = _renderer.RenderRootComponentAsync(rootComponentId); - if (renderTask.IsFaulted) - { - throw renderTask.Exception; - } - Assert.True(renderTask.IsCompletedSuccessfully); // Everything's synchronous here - - var batch = _renderer.Batches.Single(); - Component = batch.ReferenceFrames - .Where(f => f.FrameType == RenderTreeFrameType.Component) - .Select(f => f.Component) - .OfType() - .Single(); - } - else + builder.OpenComponent>(0); + builder.AddAttribute(1, "Value", EditContext); + builder.AddAttribute(2, RenderTreeBuilder.ChildContent, new RenderFragment(childBuilder => { - _rootComponent.TriggerRender(); - } + childBuilder.OpenComponent>(0); + childBuilder.AddAttribute(0, "Value", Value); + childBuilder.AddAttribute(1, "ValueChanged", ValueChanged); + childBuilder.AddAttribute(2, "ValueExpression", ValueExpression); + childBuilder.CloseComponent(); + })); + builder.CloseComponent(); } } } From 5bd06227d767bcf72e5f0139eefa2d92aa642bca Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 14 Feb 2019 22:45:57 +0000 Subject: [PATCH 25/70] Add CssClass to InputBase --- .../Components/src/Forms/InputBase.cs | 21 ++++++++++ .../Components/test/Forms/InputBaseTest.cs | 39 ++++++++++++++++++- 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/src/Components/Components/src/Forms/InputBase.cs b/src/Components/Components/src/Forms/InputBase.cs index 5dbd422eb963..fff7a9bc66c2 100644 --- a/src/Components/Components/src/Forms/InputBase.cs +++ b/src/Components/Components/src/Forms/InputBase.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Linq.Expressions; using System.Threading.Tasks; @@ -63,6 +64,26 @@ protected T CurrentValue } } + /// + /// Gets a string that indicates the status of the field being edited. This will include + /// some combination of "modified", "valid", or "invalid", depending on the status of the field. + /// + protected string CssClass + { + get + { + var isValid = !EditContext.GetValidationMessages(FieldIdentifier).Any(); + if (EditContext.IsModified(FieldIdentifier)) + { + return isValid ? "modified valid" : "modified invalid"; + } + else + { + return isValid ? "valid" : "invalid"; + } + } + } + /// public override Task SetParametersAsync(ParameterCollection parameters) { diff --git a/src/Components/Components/test/Forms/InputBaseTest.cs b/src/Components/Components/test/Forms/InputBaseTest.cs index d0a87e6d3031..10d350f28faa 100644 --- a/src/Components/Components/test/Forms/InputBaseTest.cs +++ b/src/Components/Components/test/Forms/InputBaseTest.cs @@ -200,6 +200,40 @@ public async Task WritingToCurrentValueNotifiesEditContext() Assert.True(rootComponent.EditContext.IsModified(() => model.StringProperty)); } + [Fact] + public async Task SuppliesCssClassCorrespondingToFieldState() + { + // Arrange + var model = new TestModel(); + var rootComponent = new TestInputHostComponent + { + EditContext = new EditContext(model), + ValueExpression = () => model.StringProperty + }; + var fieldIdentifier = FieldIdentifier.Create(() => model.StringProperty); + + // Act/Assert: Initally, it's valid and unmodified + var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent); + Assert.Equal("valid", inputComponent.CssClass); + + // Act/Assert: Modify the field + rootComponent.EditContext.NotifyFieldChanged(fieldIdentifier); + Assert.Equal("modified valid", inputComponent.CssClass); + + // Act/Assert: Make it invalid + var messages = new ValidationMessageStore(rootComponent.EditContext); + messages.Add(fieldIdentifier, "I do not like this value"); + Assert.Equal("modified invalid", inputComponent.CssClass); + + // Act/Assert: Clear the modification flag + rootComponent.EditContext.MarkAsUnmodified(fieldIdentifier); + Assert.Equal("invalid", inputComponent.CssClass); + + // Act/Assert: Make it valid + messages.Clear(); + Assert.Equal("valid", inputComponent.CssClass); + } + private static TestInputComponent FindInputComponent(CapturedBatch batch) => batch.ReferenceFrames .Where(f => f.FrameType == RenderTreeFrameType.Component) @@ -222,7 +256,8 @@ class TestModel class TestInputComponent : InputBase { - // Expose publicly for tests + // Expose protected members publicly for tests + public new T CurrentValue { get => base.CurrentValue; @@ -232,6 +267,8 @@ class TestInputComponent : InputBase public new EditContext EditContext => base.EditContext; public new FieldIdentifier FieldIdentifier => base.FieldIdentifier; + + public new string CssClass => base.CssClass; } class TestInputHostComponent : AutoRenderComponent From aa03e7924979cb4fc86d5c58c9c21d312da0e4e6 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 14 Feb 2019 23:39:51 +0000 Subject: [PATCH 26/70] Add CurrentValueAsString with virtual formatting/parsing methods --- .../Components/src/Forms/InputBase.cs | 63 +++++++ .../Components/test/Forms/InputBaseTest.cs | 161 +++++++++++++++--- 2 files changed, 205 insertions(+), 19 deletions(-) diff --git a/src/Components/Components/src/Forms/InputBase.cs b/src/Components/Components/src/Forms/InputBase.cs index fff7a9bc66c2..9789028aa534 100644 --- a/src/Components/Components/src/Forms/InputBase.cs +++ b/src/Components/Components/src/Forms/InputBase.cs @@ -16,6 +16,9 @@ namespace Microsoft.AspNetCore.Components.Forms /// public abstract class InputBase : ComponentBase { + private bool _previousParsingAttemptFailed; + private ValidationMessageStore _parsingValidationMessages; + [CascadingParameter] EditContext CascadedEditContext { get; set; } /// @@ -64,6 +67,66 @@ protected T CurrentValue } } + /// + /// Gets or sets the current value of the input, represented as a string. + /// + protected string CurrentValueAsString + { + get => FormatValueAsString(CurrentValue); + set + { + _parsingValidationMessages?.Clear(); + + bool parsingFailed; + if (TryParseValueFromString(value, out var parsedValue, out var validationErrorMessage)) + { + parsingFailed = false; + CurrentValue = parsedValue; + } + else + { + parsingFailed = true; + + if (_parsingValidationMessages == null) + { + _parsingValidationMessages = new ValidationMessageStore(EditContext); + } + + _parsingValidationMessages.Add(FieldIdentifier, validationErrorMessage); + + // Since we're not writing to CurrentValue, we'll need to notify about modification from here + EditContext.NotifyFieldChanged(FieldIdentifier); + } + + // We can skip the validation notification if we were previously valid and still are + if (parsingFailed || _previousParsingAttemptFailed) + { + EditContext.NotifyValidationStateChanged(); + _previousParsingAttemptFailed = parsingFailed; + } + } + } + + /// + /// Formats the value as a string. Derived classes can override this to determine the formating used for . + /// + /// The value to format. + /// A string representation of the value. + protected virtual string FormatValueAsString(T value) + => value?.ToString(); + + /// + /// Parses a string to create an instance of . Derived classes can override this to change how + /// interprets incoming values. + /// + /// The string value to be parsed. + /// An instance of . + /// If the value could not be parsed, provides a validation error message. + /// True if the value could be parsed; otherwise false. + protected virtual bool TryParseValueFromString(string value, out T result, out string validationErrorMessage) + => throw new NotImplementedException($"Components that inherit from {nameof(InputBase)} must override " + + $"{nameof(TryParseValueFromString)} in order to use {nameof(CurrentValueAsString)}."); + /// /// Gets a string that indicates the status of the field being edited. This will include /// some combination of "modified", "valid", or "invalid", depending on the status of the field. diff --git a/src/Components/Components/test/Forms/InputBaseTest.cs b/src/Components/Components/test/Forms/InputBaseTest.cs index 10d350f28faa..a7a8867158c5 100644 --- a/src/Components/Components/test/Forms/InputBaseTest.cs +++ b/src/Components/Components/test/Forms/InputBaseTest.cs @@ -34,7 +34,7 @@ public async Task ThrowsIfEditContextChanges() { // Arrange var model = new TestModel(); - var rootComponent = new TestInputHostComponent { EditContext = new EditContext(model), ValueExpression = () => model.StringProperty }; + var rootComponent = new TestInputHostComponent> { EditContext = new EditContext(model), ValueExpression = () => model.StringProperty }; await RenderAndGetTestInputComponentAsync(rootComponent); // Act/Assert @@ -48,7 +48,7 @@ public async Task ThrowsIfNoValueExpressionIsSupplied() { // Arrange var model = new TestModel(); - var rootComponent = new TestInputHostComponent { EditContext = new EditContext(model) }; + var rootComponent = new TestInputHostComponent> { EditContext = new EditContext(model) }; // Act/Assert var ex = await Assert.ThrowsAsync(() => RenderAndGetTestInputComponentAsync(rootComponent)); @@ -60,7 +60,7 @@ public async Task GetsCurrentValueFromValueParameter() { // Arrange var model = new TestModel(); - var rootComponent = new TestInputHostComponent + var rootComponent = new TestInputHostComponent> { EditContext = new EditContext(model), Value = "some value", @@ -79,7 +79,7 @@ public async Task ExposesEditContextToSubclass() { // Arrange var model = new TestModel(); - var rootComponent = new TestInputHostComponent + var rootComponent = new TestInputHostComponent> { EditContext = new EditContext(model), Value = "some value", @@ -98,7 +98,7 @@ public async Task ExposesFieldIdentifierToSubclass() { // Arrange var model = new TestModel(); - var rootComponent = new TestInputHostComponent + var rootComponent = new TestInputHostComponent> { EditContext = new EditContext(model), Value = "some value", @@ -117,7 +117,7 @@ public async Task CanReadBackChangesToCurrentValue() { // Arrange var model = new TestModel(); - var rootComponent = new TestInputHostComponent + var rootComponent = new TestInputHostComponent> { EditContext = new EditContext(model), Value = "initial value", @@ -139,7 +139,7 @@ public async Task WritingToCurrentValueInvokesValueChangedIfDifferent() // Arrange var model = new TestModel(); var valueChangedCallLog = new List(); - var rootComponent = new TestInputHostComponent + var rootComponent = new TestInputHostComponent> { EditContext = new EditContext(model), Value = "initial value", @@ -162,7 +162,7 @@ public async Task WritingToCurrentValueDoesNotInvokeValueChangedIfUnchanged() // Arrange var model = new TestModel(); var valueChangedCallLog = new List(); - var rootComponent = new TestInputHostComponent + var rootComponent = new TestInputHostComponent> { EditContext = new EditContext(model), Value = "initial value", @@ -184,7 +184,7 @@ public async Task WritingToCurrentValueNotifiesEditContext() { // Arrange var model = new TestModel(); - var rootComponent = new TestInputHostComponent + var rootComponent = new TestInputHostComponent> { EditContext = new EditContext(model), Value = "initial value", @@ -205,7 +205,7 @@ public async Task SuppliesCssClassCorrespondingToFieldState() { // Arrange var model = new TestModel(); - var rootComponent = new TestInputHostComponent + var rootComponent = new TestInputHostComponent> { EditContext = new EditContext(model), ValueExpression = () => model.StringProperty @@ -234,24 +234,121 @@ public async Task SuppliesCssClassCorrespondingToFieldState() Assert.Equal("valid", inputComponent.CssClass); } - private static TestInputComponent FindInputComponent(CapturedBatch batch) + [Fact] + public async Task CannotUseCurrentValueAsStringWithoutOverridingTryParseValueFromString() + { + // Arrange + var model = new TestModel(); + var rootComponent = new TestInputHostComponent> { EditContext = new EditContext(model), ValueExpression = () => model.StringProperty }; + var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent); + + // Act/Assert + var ex = Assert.Throws(() => { inputComponent.CurrentValueAsString = "something"; }); + Assert.Contains($"must override TryParseValueFromString", ex.Message); + } + + [Fact] + public async Task SuppliesCurrentValueAsStringWithFormatting() + { + // Arrange + var model = new TestModel(); + var rootComponent = new TestInputHostComponent + { + EditContext = new EditContext(model), + Value = new DateTime(1915, 3, 2), + ValueExpression = () => model.DateProperty + }; + var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent); + + // Act/Assert + Assert.Equal("1915/03/02", inputComponent.CurrentValueAsString); + } + + [Fact] + public async Task ParsesCurrentValueAsStringWhenChanged_Valid() + { + // Arrange + var model = new TestModel(); + var valueChangedArgs = new List(); + var rootComponent = new TestInputHostComponent + { + EditContext = new EditContext(model), + ValueChanged = valueChangedArgs.Add, + ValueExpression = () => model.DateProperty + }; + var fieldIdentifier = FieldIdentifier.Create(() => model.DateProperty); + var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent); + var numValidationStateChanges = 0; + rootComponent.EditContext.OnValidationStateChanged += (sender, eventArgs) => { numValidationStateChanges++; }; + + // Act + inputComponent.CurrentValueAsString = "1991/11/20"; + + // Assert + var receivedParsedValue = valueChangedArgs.Single(); + Assert.Equal(1991, receivedParsedValue.Year); + Assert.Equal(11, receivedParsedValue.Month); + Assert.Equal(20, receivedParsedValue.Day); + Assert.True(rootComponent.EditContext.IsModified(fieldIdentifier)); + Assert.Empty(rootComponent.EditContext.GetValidationMessages(fieldIdentifier)); + Assert.Equal(0, numValidationStateChanges); + } + + [Fact] + public async Task ParsesCurrentValueAsStringWhenChanged_Invalid() + { + // Arrange + var model = new TestModel(); + var valueChangedArgs = new List(); + var rootComponent = new TestInputHostComponent + { + EditContext = new EditContext(model), + ValueChanged = valueChangedArgs.Add, + ValueExpression = () => model.DateProperty + }; + var fieldIdentifier = FieldIdentifier.Create(() => model.DateProperty); + var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent); + var numValidationStateChanges = 0; + rootComponent.EditContext.OnValidationStateChanged += (sender, eventArgs) => { numValidationStateChanges++; }; + + // Act/Assert 1: Transition to invalid + inputComponent.CurrentValueAsString = "1991/11/40"; + Assert.Empty(valueChangedArgs); + Assert.True(rootComponent.EditContext.IsModified(fieldIdentifier)); + Assert.Equal(new[] { "Bad date value" }, rootComponent.EditContext.GetValidationMessages(fieldIdentifier)); + Assert.Equal(1, numValidationStateChanges); + + // Act/Assert 2: Transition to valid + inputComponent.CurrentValueAsString = "1991/11/20"; + var receivedParsedValue = valueChangedArgs.Single(); + Assert.Equal(1991, receivedParsedValue.Year); + Assert.Equal(11, receivedParsedValue.Month); + Assert.Equal(20, receivedParsedValue.Day); + Assert.True(rootComponent.EditContext.IsModified(fieldIdentifier)); + Assert.Empty(rootComponent.EditContext.GetValidationMessages(fieldIdentifier)); + Assert.Equal(2, numValidationStateChanges); + } + + private static TComponent FindComponent(CapturedBatch batch) => batch.ReferenceFrames .Where(f => f.FrameType == RenderTreeFrameType.Component) .Select(f => f.Component) - .OfType>() + .OfType() .Single(); - private static async Task> RenderAndGetTestInputComponentAsync(TestInputHostComponent hostComponent) + private static async Task RenderAndGetTestInputComponentAsync(TestInputHostComponent hostComponent) where TComponent: TestInputComponent { var testRenderer = new TestRenderer(); var componentId = testRenderer.AssignRootComponentId(hostComponent); await testRenderer.RenderRootComponentAsync(componentId); - return FindInputComponent(testRenderer.Batches.Single()); + return FindComponent(testRenderer.Batches.Single()); } class TestModel { public string StringProperty { get; set; } + + public DateTime DateProperty { get; set; } } class TestInputComponent : InputBase @@ -264,6 +361,12 @@ class TestInputComponent : InputBase set { base.CurrentValue = value; } } + public new string CurrentValueAsString + { + get => base.CurrentValueAsString; + set { base.CurrentValueAsString = value; } + } + public new EditContext EditContext => base.EditContext; public new FieldIdentifier FieldIdentifier => base.FieldIdentifier; @@ -271,15 +374,35 @@ class TestInputComponent : InputBase public new string CssClass => base.CssClass; } - class TestInputHostComponent : AutoRenderComponent + class TestDateInputComponent : TestInputComponent + { + protected override string FormatValueAsString(DateTime value) + => value.ToString("yyyy/MM/dd"); + + protected override bool TryParseValueFromString(string value, out DateTime result, out string validationErrorMessage) + { + if (DateTime.TryParse(value, out result)) + { + validationErrorMessage = null; + return true; + } + else + { + validationErrorMessage = "Bad date value"; + return false; + } + } + } + + class TestInputHostComponent : AutoRenderComponent where TComponent: TestInputComponent { public EditContext EditContext { get; set; } - public T Value { get; set; } + public TValue Value { get; set; } - public Action ValueChanged { get; set; } + public Action ValueChanged { get; set; } - public Expression> ValueExpression { get; set; } + public Expression> ValueExpression { get; set; } protected override void BuildRenderTree(RenderTreeBuilder builder) { @@ -287,7 +410,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) builder.AddAttribute(1, "Value", EditContext); builder.AddAttribute(2, RenderTreeBuilder.ChildContent, new RenderFragment(childBuilder => { - childBuilder.OpenComponent>(0); + childBuilder.OpenComponent(0); childBuilder.AddAttribute(0, "Value", Value); childBuilder.AddAttribute(1, "ValueChanged", ValueChanged); childBuilder.AddAttribute(2, "ValueExpression", ValueExpression); From 4c12f5180f9decc1d6fe1c4253851acc84a7c9de Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Fri, 15 Feb 2019 00:29:05 +0000 Subject: [PATCH 27/70] Add InputText, InputNumber --- .../Forms/{ => InputComponents}/InputBase.cs | 0 .../src/Forms/InputComponents/InputNumber.cs | 169 ++++++++++++++++++ .../src/Forms/InputComponents/InputText.cs | 33 ++++ .../TypicalValidationComponent.cshtml | 4 +- 4 files changed, 204 insertions(+), 2 deletions(-) rename src/Components/Components/src/Forms/{ => InputComponents}/InputBase.cs (100%) create mode 100644 src/Components/Components/src/Forms/InputComponents/InputNumber.cs create mode 100644 src/Components/Components/src/Forms/InputComponents/InputText.cs diff --git a/src/Components/Components/src/Forms/InputBase.cs b/src/Components/Components/src/Forms/InputComponents/InputBase.cs similarity index 100% rename from src/Components/Components/src/Forms/InputBase.cs rename to src/Components/Components/src/Forms/InputComponents/InputBase.cs diff --git a/src/Components/Components/src/Forms/InputComponents/InputNumber.cs b/src/Components/Components/src/Forms/InputComponents/InputNumber.cs new file mode 100644 index 000000000000..b7c99560bfe2 --- /dev/null +++ b/src/Components/Components/src/Forms/InputComponents/InputNumber.cs @@ -0,0 +1,169 @@ +// 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 +{ + /// + /// An input component for editing numeric values. + /// Supported numeric types are , , , , , . + /// + public class InputNumber : InputBase + { + delegate bool Parser(string value, out T result); + private static Parser _parser; + + // Determine the parsing logic once per T and cache it, so we don't have to consider all the possible types on each parse + static InputNumber() + { + if (typeof(T) == typeof(short)) + { + _parser = TryParseShort; + } + else if (typeof(T) == typeof(int)) + { + _parser = TryParseInt; + } + else if (typeof(T) == typeof(long)) + { + _parser = TryParseLong; + } + else if (typeof(T) == typeof(float)) + { + _parser = TryParseFloat; + } + else if (typeof(T) == typeof(double)) + { + _parser = TryParseDouble; + } + else if (typeof(T) == typeof(decimal)) + { + _parser = TryParseDecimal; + } + else + { + throw new InvalidOperationException($"The type '{typeof(T)}' is not a supported numeric type."); + } + } + + [Parameter] string ParsingErrorMessage { get; set; } = "The value for '{0}' must be a number."; + + /// + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + base.BuildRenderTree(builder); + builder.OpenElement(0, "input"); + builder.AddAttribute(1, "class", CssClass); + builder.AddAttribute(2, "value", BindMethods.GetValue(CurrentValueAsString)); + builder.AddAttribute(3, "onchange", BindMethods.SetValueHandler(__value => CurrentValueAsString = __value, CurrentValueAsString)); + builder.CloseElement(); + } + + /// + protected override bool TryParseValueFromString(string value, out T result, out string validationErrorMessage) + { + if (_parser(value, out result)) + { + validationErrorMessage = null; + return true; + } + else + { + validationErrorMessage = string.Format(ParsingErrorMessage, FieldIdentifier.FieldName); + return false; + } + } + + static bool TryParseShort(string value, out T result) + { + var success = short.TryParse(value, out var parsedValue); + if (success) + { + result = (T)(object)parsedValue; + return true; + } + else + { + result = default; + return false; + } + } + + static bool TryParseInt(string value, out T result) + { + var success = int.TryParse(value, out var parsedValue); + if (success) + { + result = (T)(object)parsedValue; + return true; + } + else + { + result = default; + return false; + } + } + + static bool TryParseLong(string value, out T result) + { + var success = long.TryParse(value, out var parsedValue); + if (success) + { + result = (T)(object)parsedValue; + return true; + } + else + { + result = default; + return false; + } + } + + static bool TryParseFloat(string value, out T result) + { + var success = float.TryParse(value, out var parsedValue); + if (success) + { + result = (T)(object)parsedValue; + return true; + } + else + { + result = default; + return false; + } + } + + static bool TryParseDouble(string value, out T result) + { + var success = double.TryParse(value, out var parsedValue); + if (success) + { + result = (T)(object)parsedValue; + return true; + } + else + { + result = default; + return false; + } + } + + static bool TryParseDecimal(string value, out T result) + { + var success = decimal.TryParse(value, out var parsedValue); + if (success) + { + result = (T)(object)parsedValue; + return true; + } + else + { + result = default; + return false; + } + } + } +} diff --git a/src/Components/Components/src/Forms/InputComponents/InputText.cs b/src/Components/Components/src/Forms/InputComponents/InputText.cs new file mode 100644 index 000000000000..1aa45dc241a0 --- /dev/null +++ b/src/Components/Components/src/Forms/InputComponents/InputText.cs @@ -0,0 +1,33 @@ +// 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 +{ + /* This is exactly equivalent to a .razor file containing: + * + * @inherits InputBase + * + * + * The only reason it's not implemented as a .razor file is that we don't presently have the ability to compile those + * files within this project. Developers building their own input components should use Razor syntax. + */ + + /// + /// An input component for editing values. + /// + public class InputText : InputBase + { + /// + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + base.BuildRenderTree(builder); + builder.OpenElement(0, "input"); + builder.AddAttribute(1, "class", CssClass); + builder.AddAttribute(2, "value", BindMethods.GetValue(CurrentValue)); + builder.AddAttribute(3, "onchange", BindMethods.SetValueHandler(__value => CurrentValue = __value, CurrentValue)); + builder.CloseElement(); + } + } +} diff --git a/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.cshtml b/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.cshtml index b881d244677b..1663cb7d03b4 100644 --- a/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.cshtml +++ b/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.cshtml @@ -5,10 +5,10 @@

- Name: + Name:

- Age (years): + Age (years):

From 9a290458fee0ee887b991cd5d3db98a509587ef2 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Fri, 15 Feb 2019 00:46:11 +0000 Subject: [PATCH 28/70] Add InputCheckbox --- .../targets/BuiltInBclLinkerDescriptor.xml | 5 +++ .../Forms/InputComponents/InputCheckbox.cs | 34 +++++++++++++++++++ .../TypicalValidationComponent.cshtml | 15 ++++++-- .../BasicTestApp/wwwroot/index.html | 3 +- .../testassets/BasicTestApp/wwwroot/style.css | 7 ++++ 5 files changed, 60 insertions(+), 4 deletions(-) create mode 100644 src/Components/Components/src/Forms/InputComponents/InputCheckbox.cs create mode 100644 src/Components/test/testassets/BasicTestApp/wwwroot/style.css diff --git a/src/Components/Blazor/Build/src/targets/BuiltInBclLinkerDescriptor.xml b/src/Components/Blazor/Build/src/targets/BuiltInBclLinkerDescriptor.xml index ba6c7faa11a9..4b442b1bb87e 100644 --- a/src/Components/Blazor/Build/src/targets/BuiltInBclLinkerDescriptor.xml +++ b/src/Components/Blazor/Build/src/targets/BuiltInBclLinkerDescriptor.xml @@ -9,4 +9,9 @@ to implement timers. Fixes https://github.com/aspnet/Blazor/issues/239 --> + + + + + diff --git a/src/Components/Components/src/Forms/InputComponents/InputCheckbox.cs b/src/Components/Components/src/Forms/InputComponents/InputCheckbox.cs new file mode 100644 index 000000000000..fee81618547f --- /dev/null +++ b/src/Components/Components/src/Forms/InputComponents/InputCheckbox.cs @@ -0,0 +1,34 @@ +// 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 +{ + /* This is exactly equivalent to a .razor file containing: + * + * @inherits InputBase + * + * + * The only reason it's not implemented as a .razor file is that we don't presently have the ability to compile those + * files within this project. Developers building their own input components should use Razor syntax. + */ + + /// + /// An input component for editing values. + /// + public class InputCheckbox : InputBase + { + /// + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + base.BuildRenderTree(builder); + builder.OpenElement(0, "input"); + builder.AddAttribute(1, "type", "checkbox"); + builder.AddAttribute(2, "class", CssClass); + builder.AddAttribute(3, "value", BindMethods.GetValue(CurrentValue)); + builder.AddAttribute(4, "onchange", BindMethods.SetValueHandler(__value => CurrentValue = __value, CurrentValue)); + builder.CloseElement(); + } + } +} diff --git a/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.cshtml b/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.cshtml index 1663cb7d03b4..78762932e2d5 100644 --- a/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.cshtml +++ b/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.cshtml @@ -10,6 +10,9 @@

Age (years):

+

+ Accepts terms: +

@@ -19,15 +22,21 @@
    @foreach (var entry in submissionLog) {
  • @entry
  • }
@functions { + Person person = new Person(); + // Usually this would be in a different file class Person { - [Required] public string Name { get; set; } + [Required] + public string Name { get; set; } + + [Range(0, 200)] + public int AgeInYears { get; set; } - [Required, Range(0, 200)] public int AgeInYears { get; set; } + [Required, Range(typeof(bool), "true", "true", ErrorMessage = "Must accept terms")] + public bool AcceptsTerms { get; set; } } - Person person = new Person(); List submissionLog = new List(); // So we can assert about the callbacks Task HandleValidSubmitAsync(EditContext editContext) diff --git a/src/Components/test/testassets/BasicTestApp/wwwroot/index.html b/src/Components/test/testassets/BasicTestApp/wwwroot/index.html index bf671c987a05..edd3f4121447 100644 --- a/src/Components/test/testassets/BasicTestApp/wwwroot/index.html +++ b/src/Components/test/testassets/BasicTestApp/wwwroot/index.html @@ -3,7 +3,8 @@ Basic test app - + + Loading... diff --git a/src/Components/test/testassets/BasicTestApp/wwwroot/style.css b/src/Components/test/testassets/BasicTestApp/wwwroot/style.css new file mode 100644 index 000000000000..385f2b0e20f9 --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/wwwroot/style.css @@ -0,0 +1,7 @@ +.modified.valid { + box-shadow: 0px 0px 0px 2px rgb(78, 203, 37); +} + +.invalid { + box-shadow: 0px 0px 0px 2px rgb(255, 0, 0); +} From 4ed7793c86914414b60e5988e7d5785b75337bea Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Fri, 15 Feb 2019 00:47:39 +0000 Subject: [PATCH 29/70] Tweak message for consistency with Data Annotations defaults --- .../Components/src/Forms/InputComponents/InputNumber.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Components/Components/src/Forms/InputComponents/InputNumber.cs b/src/Components/Components/src/Forms/InputComponents/InputNumber.cs index b7c99560bfe2..11a534b25075 100644 --- a/src/Components/Components/src/Forms/InputComponents/InputNumber.cs +++ b/src/Components/Components/src/Forms/InputComponents/InputNumber.cs @@ -48,7 +48,7 @@ static InputNumber() } } - [Parameter] string ParsingErrorMessage { get; set; } = "The value for '{0}' must be a number."; + [Parameter] string ParsingErrorMessage { get; set; } = "The {0} field must be a number."; /// protected override void BuildRenderTree(RenderTreeBuilder builder) From 12a1a45be9f147a914a53a643e9260bcca24b096 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Fri, 15 Feb 2019 00:51:35 +0000 Subject: [PATCH 30/70] Add InputTextArea --- .../Forms/InputComponents/InputTextArea.cs | 33 +++++++++++++++++++ .../TypicalValidationComponent.cshtml | 8 ++++- 2 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 src/Components/Components/src/Forms/InputComponents/InputTextArea.cs diff --git a/src/Components/Components/src/Forms/InputComponents/InputTextArea.cs b/src/Components/Components/src/Forms/InputComponents/InputTextArea.cs new file mode 100644 index 000000000000..04d521b567f7 --- /dev/null +++ b/src/Components/Components/src/Forms/InputComponents/InputTextArea.cs @@ -0,0 +1,33 @@ +// 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 +{ + /* This is exactly equivalent to a .razor file containing: + * + * @inherits InputBase + * + * + * The only reason it's not implemented as a .razor file is that we don't presently have the ability to compile those + * files within this project. Developers building their own input components should use Razor syntax. + */ + + /// + /// An input component for editing values. + /// + public class InputTextArea : InputBase + { + /// + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + base.BuildRenderTree(builder); + builder.OpenElement(0, "textarea"); + builder.AddAttribute(1, "class", CssClass); + builder.AddAttribute(2, "value", BindMethods.GetValue(CurrentValue)); + builder.AddAttribute(3, "onchange", BindMethods.SetValueHandler(__value => CurrentValue = __value, CurrentValue)); + builder.CloseElement(); + } + } +} diff --git a/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.cshtml b/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.cshtml index 78762932e2d5..8e51847503c0 100644 --- a/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.cshtml +++ b/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.cshtml @@ -10,6 +10,9 @@

Age (years):

+

+ Description: +

Accepts terms:

@@ -27,7 +30,7 @@ // Usually this would be in a different file class Person { - [Required] + [Required, StringLength(10)] public string Name { get; set; } [Range(0, 200)] @@ -35,6 +38,9 @@ [Required, Range(typeof(bool), "true", "true", ErrorMessage = "Must accept terms")] public bool AcceptsTerms { get; set; } + + [Required, StringLength(20)] + public string Description { get; set; } } List submissionLog = new List(); // So we can assert about the callbacks From 16b468c052ed3183a0bb662b47713d5cec9f3616 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Fri, 15 Feb 2019 00:52:51 +0000 Subject: [PATCH 31/70] Add notes --- .../Components/src/Forms/InputComponents/InputText.cs | 2 ++ .../Components/src/Forms/InputComponents/InputTextArea.cs | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Components/Components/src/Forms/InputComponents/InputText.cs b/src/Components/Components/src/Forms/InputComponents/InputText.cs index 1aa45dc241a0..09f7ed13e22a 100644 --- a/src/Components/Components/src/Forms/InputComponents/InputText.cs +++ b/src/Components/Components/src/Forms/InputComponents/InputText.cs @@ -5,6 +5,8 @@ namespace Microsoft.AspNetCore.Components.Forms { + // TODO: Support maxlength etc. + /* This is exactly equivalent to a .razor file containing: * * @inherits InputBase diff --git a/src/Components/Components/src/Forms/InputComponents/InputTextArea.cs b/src/Components/Components/src/Forms/InputComponents/InputTextArea.cs index 04d521b567f7..240a831e2928 100644 --- a/src/Components/Components/src/Forms/InputComponents/InputTextArea.cs +++ b/src/Components/Components/src/Forms/InputComponents/InputTextArea.cs @@ -5,6 +5,8 @@ namespace Microsoft.AspNetCore.Components.Forms { + // TODO: Support rows/cols/etc + /* This is exactly equivalent to a .razor file containing: * * @inherits InputBase @@ -15,7 +17,7 @@ namespace Microsoft.AspNetCore.Components.Forms */ /// - /// An input component for editing values. + /// A multiline input component for editing values. /// public class InputTextArea : InputBase { From 91ee810c38265138e58afdcca00ee4c7c7210976 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Fri, 15 Feb 2019 01:14:24 +0000 Subject: [PATCH 32/70] Add InputDate --- .../src/Forms/InputComponents/InputDate.cs | 111 ++++++++++++++++++ .../TypicalValidationComponent.cshtml | 5 + 2 files changed, 116 insertions(+) create mode 100644 src/Components/Components/src/Forms/InputComponents/InputDate.cs diff --git a/src/Components/Components/src/Forms/InputComponents/InputDate.cs b/src/Components/Components/src/Forms/InputComponents/InputDate.cs new file mode 100644 index 000000000000..b919a77568f8 --- /dev/null +++ b/src/Components/Components/src/Forms/InputComponents/InputDate.cs @@ -0,0 +1,111 @@ +// 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 +{ + // TODO: Consider support for Nullable, Nullable + // otherwise it may be impossible to have optional date inputs + + /// + /// An input component for editing date values. + /// Supported types are and . + /// + public class InputDate : InputBase + { + const string dateFormat = "yyyy-MM-dd"; // Compatible with HTML date inputs + + [Parameter] string ParsingErrorMessage { get; set; } = "The {0} field must be a date."; + + /// + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + base.BuildRenderTree(builder); + builder.OpenElement(0, "input"); + builder.AddAttribute(1, "type", "date"); + builder.AddAttribute(2, "class", CssClass); + builder.AddAttribute(3, "value", BindMethods.GetValue(CurrentValueAsString)); + builder.AddAttribute(4, "onchange", BindMethods.SetValueHandler(__value => CurrentValueAsString = __value, CurrentValueAsString)); + builder.CloseElement(); + } + + /// + protected override string FormatValueAsString(T value) + { + if (typeof(T) == typeof(DateTime)) + { + return ((DateTime)(object)value).ToString(dateFormat); + } + else if (typeof(T) == typeof(DateTimeOffset)) + { + return ((DateTimeOffset)(object)value).ToString(dateFormat); + } + else + { + throw new InvalidOperationException($"The type '{typeof(T)}' is not a supported date type."); + } + } + + /// + protected override bool TryParseValueFromString(string value, out T result, out string validationErrorMessage) + { + bool success; + + if (typeof(T) == typeof(DateTime)) + { + success = TryParseDateTime(value, out result); + } + else if (typeof (T) == typeof(DateTimeOffset)) + { + success = TryParseDateTimeOffset(value, out result); + } + else + { + throw new InvalidOperationException($"The type '{typeof(T)}' is not a supported date type."); + } + + if (success) + { + validationErrorMessage = null; + return true; + } + else + { + validationErrorMessage = string.Format(ParsingErrorMessage, FieldIdentifier.FieldName); + return false; + } + } + + static bool TryParseDateTime(string value, out T result) + { + var success = DateTime.TryParse(value, out var parsedValue); + if (success) + { + result = (T)(object)parsedValue; + return true; + } + else + { + result = default; + return false; + } + } + + static bool TryParseDateTimeOffset(string value, out T result) + { + var success = DateTimeOffset.TryParse(value, out var parsedValue); + if (success) + { + result = (T)(object)parsedValue; + return true; + } + else + { + result = default; + return false; + } + } + } +} diff --git a/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.cshtml b/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.cshtml index 8e51847503c0..d1b43f7466e0 100644 --- a/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.cshtml +++ b/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.cshtml @@ -13,6 +13,9 @@

Description:

+

+ Renewal date: +

Accepts terms:

@@ -36,6 +39,8 @@ [Range(0, 200)] public int AgeInYears { get; set; } + public DateTime RenewalDate { get; set; } = DateTime.Now; + [Required, Range(typeof(bool), "true", "true", ErrorMessage = "Must accept terms")] public bool AcceptsTerms { get; set; } From 64865a23eaa4cf83565f197994110776e1a8a6ee Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Fri, 15 Feb 2019 01:42:14 +0000 Subject: [PATCH 33/70] Add InputSelect --- .../src/Forms/InputComponents/InputSelect.cs | 57 +++++++++++++++++++ .../TypicalValidationComponent.cshtml | 14 +++++ 2 files changed, 71 insertions(+) create mode 100644 src/Components/Components/src/Forms/InputComponents/InputSelect.cs diff --git a/src/Components/Components/src/Forms/InputComponents/InputSelect.cs b/src/Components/Components/src/Forms/InputComponents/InputSelect.cs new file mode 100644 index 000000000000..f0e125301006 --- /dev/null +++ b/src/Components/Components/src/Forms/InputComponents/InputSelect.cs @@ -0,0 +1,57 @@ +// 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 +{ + /// + /// A dropdown selection component. + /// + public class InputSelect : InputBase + { + [Parameter] RenderFragment ChildContent { get; set; } + + /// + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + base.BuildRenderTree(builder); + builder.OpenElement(0, "select"); + builder.AddAttribute(2, "class", CssClass); + builder.AddAttribute(3, "value", BindMethods.GetValue(CurrentValueAsString)); + builder.AddAttribute(4, "onchange", BindMethods.SetValueHandler(__value => CurrentValueAsString = __value, CurrentValueAsString)); + builder.AddContent(5, ChildContent); + builder.CloseElement(); + } + + /// + protected override bool TryParseValueFromString(string value, out T result, out string validationErrorMessage) + { + if (typeof(T) == typeof(string)) + { + result = (T)(object)value; + validationErrorMessage = null; + return true; + } + else if (typeof(T).IsEnum) + { + // There's no non-generic Enum.TryParse (https://github.com/dotnet/corefx/issues/692) + try + { + result = (T)Enum.Parse(typeof(T), value); + validationErrorMessage = null; + return true; + } + catch (ArgumentException) + { + result = default; + validationErrorMessage = $"The {FieldIdentifier.FieldName} field is not valid."; + return false; + } + } + + throw new InvalidOperationException($"{GetType()} does not support the type '{typeof(T)}'."); + } + } +} diff --git a/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.cshtml b/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.cshtml index d1b43f7466e0..e677440a0984 100644 --- a/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.cshtml +++ b/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.cshtml @@ -16,6 +16,15 @@

Renewal date:

+

+ Ticket class: + + + + + + +

Accepts terms:

@@ -46,8 +55,13 @@ [Required, StringLength(20)] public string Description { get; set; } + + [Required, EnumDataType(typeof(TicketClass))] + public TicketClass TicketClass { get; set; } } + enum TicketClass { Economy, Premium, First } + List submissionLog = new List(); // So we can assert about the callbacks Task HandleValidSubmitAsync(EditContext editContext) From 0842d0146158e6084dc8e7ca9087eb68fda363ac Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Fri, 15 Feb 2019 01:45:43 +0000 Subject: [PATCH 34/70] Add note --- src/Components/Components/src/Forms/InputComponents/InputDate.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Components/Components/src/Forms/InputComponents/InputDate.cs b/src/Components/Components/src/Forms/InputComponents/InputDate.cs index b919a77568f8..7ff5046a6e67 100644 --- a/src/Components/Components/src/Forms/InputComponents/InputDate.cs +++ b/src/Components/Components/src/Forms/InputComponents/InputDate.cs @@ -8,6 +8,7 @@ namespace Microsoft.AspNetCore.Components.Forms { // TODO: Consider support for Nullable, Nullable // otherwise it may be impossible to have optional date inputs + // Maybe it's possible to support Nullable for arbitrary T:struct in InputBase, so all the InputNumber cases work with it too /// /// An input component for editing date values. From 1de404397df9c03d5981c7f64adfe8d84c1a50dd Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Fri, 15 Feb 2019 11:20:01 +0000 Subject: [PATCH 35/70] Support nullable input types --- .../src/Forms/InputComponents/InputBase.cs | 13 +++++++- .../src/Forms/InputComponents/InputDate.cs | 32 ++++++++----------- .../src/Forms/InputComponents/InputNumber.cs | 18 +++++++---- .../TypicalValidationComponent.cshtml | 10 ++++++ 4 files changed, 47 insertions(+), 26 deletions(-) diff --git a/src/Components/Components/src/Forms/InputComponents/InputBase.cs b/src/Components/Components/src/Forms/InputComponents/InputBase.cs index 9789028aa534..dbd2ccd9120f 100644 --- a/src/Components/Components/src/Forms/InputComponents/InputBase.cs +++ b/src/Components/Components/src/Forms/InputComponents/InputBase.cs @@ -18,6 +18,7 @@ public abstract class InputBase : ComponentBase { private bool _previousParsingAttemptFailed; private ValidationMessageStore _parsingValidationMessages; + private Type _nullableUnderlyingType; [CascadingParameter] EditContext CascadedEditContext { get; set; } @@ -78,7 +79,16 @@ protected string CurrentValueAsString _parsingValidationMessages?.Clear(); bool parsingFailed; - if (TryParseValueFromString(value, out var parsedValue, out var validationErrorMessage)) + + if (_nullableUnderlyingType != null && string.IsNullOrEmpty(value)) + { + // Assume if it's a nullable type, null/empty inputs should correspond to default(T) + // Then all subclasses get nullable support almost automatically (they just have to + // not reject Nullable based on the type itself). + parsingFailed = false; + CurrentValue = default; + } + else if (TryParseValueFromString(value, out var parsedValue, out var validationErrorMessage)) { parsingFailed = false; CurrentValue = parsedValue; @@ -172,6 +182,7 @@ public override Task SetParametersAsync(ParameterCollection parameters) EditContext = CascadedEditContext; FieldIdentifier = FieldIdentifier.Create(ValueExpression); + _nullableUnderlyingType = Nullable.GetUnderlyingType(typeof(T)); } else if (CascadedEditContext != EditContext) { diff --git a/src/Components/Components/src/Forms/InputComponents/InputDate.cs b/src/Components/Components/src/Forms/InputComponents/InputDate.cs index 7ff5046a6e67..af8eb191045b 100644 --- a/src/Components/Components/src/Forms/InputComponents/InputDate.cs +++ b/src/Components/Components/src/Forms/InputComponents/InputDate.cs @@ -6,10 +6,6 @@ namespace Microsoft.AspNetCore.Components.Forms { - // TODO: Consider support for Nullable, Nullable - // otherwise it may be impossible to have optional date inputs - // Maybe it's possible to support Nullable for arbitrary T:struct in InputBase, so all the InputNumber cases work with it too - /// /// An input component for editing date values. /// Supported types are and . @@ -35,36 +31,36 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) /// protected override string FormatValueAsString(T value) { - if (typeof(T) == typeof(DateTime)) - { - return ((DateTime)(object)value).ToString(dateFormat); - } - else if (typeof(T) == typeof(DateTimeOffset)) - { - return ((DateTimeOffset)(object)value).ToString(dateFormat); - } - else + switch (value) { - throw new InvalidOperationException($"The type '{typeof(T)}' is not a supported date type."); + case DateTime dateTimeValue: + return dateTimeValue.ToString(dateFormat); + case DateTimeOffset dateTimeOffsetValue: + return dateTimeOffsetValue.ToString(dateFormat); + default: + return string.Empty; // Handles null for Nullable, etc. } } /// protected override bool TryParseValueFromString(string value, out T result, out string validationErrorMessage) { - bool success; + // Unwrap nullable types. We don't have to deal with receiving empty values for nullable + // types here, because the underlying InputBase already covers that. + var targetType = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T); - if (typeof(T) == typeof(DateTime)) + bool success; + if (targetType == typeof(DateTime)) { success = TryParseDateTime(value, out result); } - else if (typeof (T) == typeof(DateTimeOffset)) + else if (targetType == typeof(DateTimeOffset)) { success = TryParseDateTimeOffset(value, out result); } else { - throw new InvalidOperationException($"The type '{typeof(T)}' is not a supported date type."); + throw new InvalidOperationException($"The type '{targetType}' is not a supported date type."); } if (success) diff --git a/src/Components/Components/src/Forms/InputComponents/InputNumber.cs b/src/Components/Components/src/Forms/InputComponents/InputNumber.cs index 11a534b25075..8ed5f7dc20d3 100644 --- a/src/Components/Components/src/Forms/InputComponents/InputNumber.cs +++ b/src/Components/Components/src/Forms/InputComponents/InputNumber.cs @@ -18,33 +18,37 @@ public class InputNumber : InputBase // Determine the parsing logic once per T and cache it, so we don't have to consider all the possible types on each parse static InputNumber() { - if (typeof(T) == typeof(short)) + // Unwrap Nullable, because InputBase already deals with the Nullable aspect + // of it for us. We will only get asked to parse the T for nonempty inputs. + var targetType = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T); + + if (targetType == typeof(short)) { _parser = TryParseShort; } - else if (typeof(T) == typeof(int)) + else if (targetType == typeof(int)) { _parser = TryParseInt; } - else if (typeof(T) == typeof(long)) + else if (targetType == typeof(long)) { _parser = TryParseLong; } - else if (typeof(T) == typeof(float)) + else if (targetType == typeof(float)) { _parser = TryParseFloat; } - else if (typeof(T) == typeof(double)) + else if (targetType == typeof(double)) { _parser = TryParseDouble; } - else if (typeof(T) == typeof(decimal)) + else if (targetType == typeof(decimal)) { _parser = TryParseDecimal; } else { - throw new InvalidOperationException($"The type '{typeof(T)}' is not a supported numeric type."); + throw new InvalidOperationException($"The type '{targetType}' is not a supported numeric type."); } } diff --git a/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.cshtml b/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.cshtml index e677440a0984..897c24890b37 100644 --- a/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.cshtml +++ b/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.cshtml @@ -10,12 +10,18 @@

Age (years):

+

+ Height (optional): +

Description:

Renewal date:

+

+ Expiry date (optional): +

Ticket class: @@ -48,8 +54,12 @@ [Range(0, 200)] public int AgeInYears { get; set; } + public float? OptionalHeight { get; set; } + public DateTime RenewalDate { get; set; } = DateTime.Now; + public DateTimeOffset? OptionalExpiryDate { get; set; } + [Required, Range(typeof(bool), "true", "true", ErrorMessage = "Must accept terms")] public bool AcceptsTerms { get; set; } From 98ca69bf01ff63b6d5ba2f97a1510efbc64d7bf2 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Fri, 15 Feb 2019 11:35:23 +0000 Subject: [PATCH 36/70] Add EditContextFieldClassExtensions, so we can use FieldClass from outside InputBase too --- .../Forms/EditContextFieldClassExtensions.cs | 46 +++++++++++++++++++ .../src/Forms/InputComponents/InputBase.cs | 17 +------ .../Forms/InputComponents/InputCheckbox.cs | 2 +- .../src/Forms/InputComponents/InputDate.cs | 2 +- .../src/Forms/InputComponents/InputNumber.cs | 2 +- .../src/Forms/InputComponents/InputSelect.cs | 2 +- .../src/Forms/InputComponents/InputText.cs | 2 +- .../Forms/InputComponents/InputTextArea.cs | 2 +- .../Components/test/Forms/InputBaseTest.cs | 14 +++--- .../SimpleValidationComponent.cshtml | 4 +- 10 files changed, 63 insertions(+), 30 deletions(-) create mode 100644 src/Components/Components/src/Forms/EditContextFieldClassExtensions.cs diff --git a/src/Components/Components/src/Forms/EditContextFieldClassExtensions.cs b/src/Components/Components/src/Forms/EditContextFieldClassExtensions.cs new file mode 100644 index 000000000000..b01498656dfa --- /dev/null +++ b/src/Components/Components/src/Forms/EditContextFieldClassExtensions.cs @@ -0,0 +1,46 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Linq; +using System.Linq.Expressions; + +namespace Microsoft.AspNetCore.Components.Forms +{ + ///

+ /// Provides extension methods to describe the state of + /// fields as CSS class names. + /// + public static class EditContextFieldClassExtensions + { + /// + /// Gets a string that indicates the status of the specified field. This will include + /// some combination of "modified", "valid", or "invalid", depending on the status of the field. + /// + /// The . + /// An identifier for the field. + /// A string that indicates the status of the field. + public static string FieldClass(this EditContext editContext, Expression> accessor) + => FieldClass(editContext, FieldIdentifier.Create(accessor)); + + /// + /// Gets a string that indicates the status of the specified field. This will include + /// some combination of "modified", "valid", or "invalid", depending on the status of the field. + /// + /// The . + /// An identifier for the field. + /// A string that indicates the status of the field. + public static string FieldClass(this EditContext editContext, FieldIdentifier fieldIdentifier) + { + var isValid = !editContext.GetValidationMessages(fieldIdentifier).Any(); + if (editContext.IsModified(fieldIdentifier)) + { + return isValid ? "modified valid" : "modified invalid"; + } + else + { + return isValid ? "valid" : "invalid"; + } + } + } +} diff --git a/src/Components/Components/src/Forms/InputComponents/InputBase.cs b/src/Components/Components/src/Forms/InputComponents/InputBase.cs index dbd2ccd9120f..949e655e146e 100644 --- a/src/Components/Components/src/Forms/InputComponents/InputBase.cs +++ b/src/Components/Components/src/Forms/InputComponents/InputBase.cs @@ -141,21 +141,8 @@ protected virtual bool TryParseValueFromString(string value, out T result, out s /// Gets a string that indicates the status of the field being edited. This will include /// some combination of "modified", "valid", or "invalid", depending on the status of the field. ///
- protected string CssClass - { - get - { - var isValid = !EditContext.GetValidationMessages(FieldIdentifier).Any(); - if (EditContext.IsModified(FieldIdentifier)) - { - return isValid ? "modified valid" : "modified invalid"; - } - else - { - return isValid ? "valid" : "invalid"; - } - } - } + protected string FieldClass + => EditContext.FieldClass(FieldIdentifier); /// public override Task SetParametersAsync(ParameterCollection parameters) diff --git a/src/Components/Components/src/Forms/InputComponents/InputCheckbox.cs b/src/Components/Components/src/Forms/InputComponents/InputCheckbox.cs index fee81618547f..bb00ec73bcec 100644 --- a/src/Components/Components/src/Forms/InputComponents/InputCheckbox.cs +++ b/src/Components/Components/src/Forms/InputComponents/InputCheckbox.cs @@ -25,7 +25,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) base.BuildRenderTree(builder); builder.OpenElement(0, "input"); builder.AddAttribute(1, "type", "checkbox"); - builder.AddAttribute(2, "class", CssClass); + builder.AddAttribute(2, "class", FieldClass); builder.AddAttribute(3, "value", BindMethods.GetValue(CurrentValue)); builder.AddAttribute(4, "onchange", BindMethods.SetValueHandler(__value => CurrentValue = __value, CurrentValue)); builder.CloseElement(); diff --git a/src/Components/Components/src/Forms/InputComponents/InputDate.cs b/src/Components/Components/src/Forms/InputComponents/InputDate.cs index af8eb191045b..a288937cd8a2 100644 --- a/src/Components/Components/src/Forms/InputComponents/InputDate.cs +++ b/src/Components/Components/src/Forms/InputComponents/InputDate.cs @@ -22,7 +22,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) base.BuildRenderTree(builder); builder.OpenElement(0, "input"); builder.AddAttribute(1, "type", "date"); - builder.AddAttribute(2, "class", CssClass); + builder.AddAttribute(2, "class", FieldClass); builder.AddAttribute(3, "value", BindMethods.GetValue(CurrentValueAsString)); builder.AddAttribute(4, "onchange", BindMethods.SetValueHandler(__value => CurrentValueAsString = __value, CurrentValueAsString)); builder.CloseElement(); diff --git a/src/Components/Components/src/Forms/InputComponents/InputNumber.cs b/src/Components/Components/src/Forms/InputComponents/InputNumber.cs index 8ed5f7dc20d3..8e69be2e88e6 100644 --- a/src/Components/Components/src/Forms/InputComponents/InputNumber.cs +++ b/src/Components/Components/src/Forms/InputComponents/InputNumber.cs @@ -59,7 +59,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) { base.BuildRenderTree(builder); builder.OpenElement(0, "input"); - builder.AddAttribute(1, "class", CssClass); + builder.AddAttribute(1, "class", FieldClass); builder.AddAttribute(2, "value", BindMethods.GetValue(CurrentValueAsString)); builder.AddAttribute(3, "onchange", BindMethods.SetValueHandler(__value => CurrentValueAsString = __value, CurrentValueAsString)); builder.CloseElement(); diff --git a/src/Components/Components/src/Forms/InputComponents/InputSelect.cs b/src/Components/Components/src/Forms/InputComponents/InputSelect.cs index f0e125301006..0ac5b8a98c63 100644 --- a/src/Components/Components/src/Forms/InputComponents/InputSelect.cs +++ b/src/Components/Components/src/Forms/InputComponents/InputSelect.cs @@ -18,7 +18,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) { base.BuildRenderTree(builder); builder.OpenElement(0, "select"); - builder.AddAttribute(2, "class", CssClass); + builder.AddAttribute(2, "class", FieldClass); builder.AddAttribute(3, "value", BindMethods.GetValue(CurrentValueAsString)); builder.AddAttribute(4, "onchange", BindMethods.SetValueHandler(__value => CurrentValueAsString = __value, CurrentValueAsString)); builder.AddContent(5, ChildContent); diff --git a/src/Components/Components/src/Forms/InputComponents/InputText.cs b/src/Components/Components/src/Forms/InputComponents/InputText.cs index 09f7ed13e22a..12e1326a688c 100644 --- a/src/Components/Components/src/Forms/InputComponents/InputText.cs +++ b/src/Components/Components/src/Forms/InputComponents/InputText.cs @@ -26,7 +26,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) { base.BuildRenderTree(builder); builder.OpenElement(0, "input"); - builder.AddAttribute(1, "class", CssClass); + builder.AddAttribute(1, "class", FieldClass); builder.AddAttribute(2, "value", BindMethods.GetValue(CurrentValue)); builder.AddAttribute(3, "onchange", BindMethods.SetValueHandler(__value => CurrentValue = __value, CurrentValue)); builder.CloseElement(); diff --git a/src/Components/Components/src/Forms/InputComponents/InputTextArea.cs b/src/Components/Components/src/Forms/InputComponents/InputTextArea.cs index 240a831e2928..d039de6d9f93 100644 --- a/src/Components/Components/src/Forms/InputComponents/InputTextArea.cs +++ b/src/Components/Components/src/Forms/InputComponents/InputTextArea.cs @@ -26,7 +26,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) { base.BuildRenderTree(builder); builder.OpenElement(0, "textarea"); - builder.AddAttribute(1, "class", CssClass); + builder.AddAttribute(1, "class", FieldClass); builder.AddAttribute(2, "value", BindMethods.GetValue(CurrentValue)); builder.AddAttribute(3, "onchange", BindMethods.SetValueHandler(__value => CurrentValue = __value, CurrentValue)); builder.CloseElement(); diff --git a/src/Components/Components/test/Forms/InputBaseTest.cs b/src/Components/Components/test/Forms/InputBaseTest.cs index a7a8867158c5..66755ff46a2c 100644 --- a/src/Components/Components/test/Forms/InputBaseTest.cs +++ b/src/Components/Components/test/Forms/InputBaseTest.cs @@ -201,7 +201,7 @@ public async Task WritingToCurrentValueNotifiesEditContext() } [Fact] - public async Task SuppliesCssClassCorrespondingToFieldState() + public async Task SuppliesFieldClassCorrespondingToFieldState() { // Arrange var model = new TestModel(); @@ -214,24 +214,24 @@ public async Task SuppliesCssClassCorrespondingToFieldState() // Act/Assert: Initally, it's valid and unmodified var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent); - Assert.Equal("valid", inputComponent.CssClass); + Assert.Equal("valid", inputComponent.FieldClass); // Act/Assert: Modify the field rootComponent.EditContext.NotifyFieldChanged(fieldIdentifier); - Assert.Equal("modified valid", inputComponent.CssClass); + Assert.Equal("modified valid", inputComponent.FieldClass); // Act/Assert: Make it invalid var messages = new ValidationMessageStore(rootComponent.EditContext); messages.Add(fieldIdentifier, "I do not like this value"); - Assert.Equal("modified invalid", inputComponent.CssClass); + Assert.Equal("modified invalid", inputComponent.FieldClass); // Act/Assert: Clear the modification flag rootComponent.EditContext.MarkAsUnmodified(fieldIdentifier); - Assert.Equal("invalid", inputComponent.CssClass); + Assert.Equal("invalid", inputComponent.FieldClass); // Act/Assert: Make it valid messages.Clear(); - Assert.Equal("valid", inputComponent.CssClass); + Assert.Equal("valid", inputComponent.FieldClass); } [Fact] @@ -371,7 +371,7 @@ class TestInputComponent : InputBase public new FieldIdentifier FieldIdentifier => base.FieldIdentifier; - public new string CssClass => base.CssClass; + public new string FieldClass => base.FieldClass; } class TestDateInputComponent : TestInputComponent diff --git a/src/Components/test/testassets/BasicTestApp/FormsTest/SimpleValidationComponent.cshtml b/src/Components/test/testassets/BasicTestApp/FormsTest/SimpleValidationComponent.cshtml index 55c0a0f1059d..8a94270f0302 100644 --- a/src/Components/test/testassets/BasicTestApp/FormsTest/SimpleValidationComponent.cshtml +++ b/src/Components/test/testassets/BasicTestApp/FormsTest/SimpleValidationComponent.cshtml @@ -5,10 +5,10 @@

- User name: + User name:

- Accept terms: + Accept terms:

From f22e807bb4e665d25ae2b3ae228bc66419ce0979 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Fri, 15 Feb 2019 12:11:25 +0000 Subject: [PATCH 37/70] Add example of integrating with INotifyPropertyChanged --- eng/Dependencies.props | 2 + .../BasicTestApp/BasicTestApp.csproj | 3 +- ...yPropertyChangedValidationComponent.cshtml | 105 ++++++++++++++++++ .../test/testassets/BasicTestApp/Index.cshtml | 1 + .../testassets/BasicTestApp/wwwroot/style.css | 4 + 5 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 src/Components/test/testassets/BasicTestApp/FormsTest/NotifyPropertyChangedValidationComponent.cshtml diff --git a/eng/Dependencies.props b/eng/Dependencies.props index dd9f4f86ca30..fa5b1bbbf22c 100644 --- a/eng/Dependencies.props +++ b/eng/Dependencies.props @@ -134,6 +134,8 @@ and are generated based on the last package release. + + diff --git a/src/Components/test/testassets/BasicTestApp/BasicTestApp.csproj b/src/Components/test/testassets/BasicTestApp/BasicTestApp.csproj index 7524ef0c1889..7de6b2d63a95 100644 --- a/src/Components/test/testassets/BasicTestApp/BasicTestApp.csproj +++ b/src/Components/test/testassets/BasicTestApp/BasicTestApp.csproj @@ -1,4 +1,4 @@ - + netstandard2.0 @@ -9,6 +9,7 @@ + diff --git a/src/Components/test/testassets/BasicTestApp/FormsTest/NotifyPropertyChangedValidationComponent.cshtml b/src/Components/test/testassets/BasicTestApp/FormsTest/NotifyPropertyChangedValidationComponent.cshtml new file mode 100644 index 000000000000..012ba7cb1020 --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/FormsTest/NotifyPropertyChangedValidationComponent.cshtml @@ -0,0 +1,105 @@ +@using System.ComponentModel +@using System.ComponentModel.DataAnnotations +@using System.Runtime.CompilerServices; +@using Microsoft.AspNetCore.Components.Forms + +

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

+

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

+ +
+

+ User name: + +

+

+ Accept terms: + +

+ + + + + + +
+ +@submissionStatus + +@functions { + MyModel person = new MyModel(); + EditContext editContext; + string submissionStatus; + + protected override void OnInit() + { + editContext = new EditContext(person).AddDataAnnotationsValidation(); + + // Wire up INotifyPropertyChanged to the EditContext + person.PropertyChanged += (sender, eventArgs) => + { + var fieldIdentifier = new FieldIdentifier(sender, eventArgs.PropertyName); + editContext.NotifyFieldChanged(fieldIdentifier); + }; + } + + void HandleSubmit() + { + if (editContext.Validate()) + { + submissionStatus = $"Submitted at {DateTime.Now.ToLongTimeString()}"; + editContext.MarkAsUnmodified(); + } + } + + class MyModel : INotifyPropertyChanged + { + string _userName; + bool _acceptsTerms; + + [Required] + public string UserName + { + get => _userName; + set => SetProperty(ref _userName, value); + } + + [Range(typeof(bool), "true", "true", ErrorMessage = "You must accept the terms")] + public bool AcceptsTerms + { + get => _acceptsTerms; + set => SetProperty(ref _acceptsTerms, value); + } + + #region INotifyPropertyChanged boilerplate + + public event PropertyChangedEventHandler PropertyChanged; + + void OnPropertyChanged([CallerMemberName] string propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + + bool SetProperty(ref T storage, T value, [CallerMemberName] string propertyName = null) + { + if (Equals(storage, value)) + { + return false; + } + + storage = value; + OnPropertyChanged(propertyName); + return true; + } + + #endregion + } +} diff --git a/src/Components/test/testassets/BasicTestApp/Index.cshtml b/src/Components/test/testassets/BasicTestApp/Index.cshtml index 825ec4c4631f..4b5a04c54a29 100644 --- a/src/Components/test/testassets/BasicTestApp/Index.cshtml +++ b/src/Components/test/testassets/BasicTestApp/Index.cshtml @@ -48,6 +48,7 @@ + @if (SelectedComponentType != null) diff --git a/src/Components/test/testassets/BasicTestApp/wwwroot/style.css b/src/Components/test/testassets/BasicTestApp/wwwroot/style.css index 385f2b0e20f9..c865a790a564 100644 --- a/src/Components/test/testassets/BasicTestApp/wwwroot/style.css +++ b/src/Components/test/testassets/BasicTestApp/wwwroot/style.css @@ -5,3 +5,7 @@ .invalid { box-shadow: 0px 0px 0px 2px rgb(255, 0, 0); } + +.validation-errors { + color: red; +} From 5eaf3cd67b5351f7684e119087ae49203ab95317 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Fri, 15 Feb 2019 13:01:53 +0000 Subject: [PATCH 38/70] Don't leak --- .../src/Forms/EditContextDataAnnotationsExtensions.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Components/Components/src/Forms/EditContextDataAnnotationsExtensions.cs b/src/Components/Components/src/Forms/EditContextDataAnnotationsExtensions.cs index 1f559a686fee..55988ac59f3a 100644 --- a/src/Components/Components/src/Forms/EditContextDataAnnotationsExtensions.cs +++ b/src/Components/Components/src/Forms/EditContextDataAnnotationsExtensions.cs @@ -15,8 +15,8 @@ namespace Microsoft.AspNetCore.Components.Forms ///
public static class EditContextDataAnnotationsExtensions { - private static ConcurrentDictionary _propertyInfoCache - = new ConcurrentDictionary(); + private static ConcurrentDictionary<(Type ModelType, string FieldName), PropertyInfo> _propertyInfoCache + = new ConcurrentDictionary<(Type, string), PropertyInfo>(); /// /// Adds DataAnnotations validation support to the . @@ -84,14 +84,15 @@ private static void ValidateField(EditContext editContext, ValidationMessageStor private static bool TryGetValidatableProperty(FieldIdentifier fieldIdentifier, out PropertyInfo propertyInfo) { - if (!_propertyInfoCache.TryGetValue(fieldIdentifier, out propertyInfo)) + var cacheKey = (fieldIdentifier.Model.GetType(), fieldIdentifier.FieldName); + if (!_propertyInfoCache.TryGetValue(cacheKey, out propertyInfo)) { // DataAnnotations only validates public properties, so that's all we'll look for // If we can't find it, cache 'null' so we don't have to try again next time propertyInfo = 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; + _propertyInfoCache[cacheKey] = propertyInfo; } return propertyInfo != null; From ddfa68274f19b1940fc8dddf373e00bf857fa6ea Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Fri, 15 Feb 2019 13:05:48 +0000 Subject: [PATCH 39/70] Clean up --- .../src/Forms/EditContextDataAnnotationsExtensions.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Components/Components/src/Forms/EditContextDataAnnotationsExtensions.cs b/src/Components/Components/src/Forms/EditContextDataAnnotationsExtensions.cs index 55988ac59f3a..77a942bb5b07 100644 --- a/src/Components/Components/src/Forms/EditContextDataAnnotationsExtensions.cs +++ b/src/Components/Components/src/Forms/EditContextDataAnnotationsExtensions.cs @@ -84,12 +84,12 @@ private static void ValidateField(EditContext editContext, ValidationMessageStor private static bool TryGetValidatableProperty(FieldIdentifier fieldIdentifier, out PropertyInfo propertyInfo) { - var cacheKey = (fieldIdentifier.Model.GetType(), fieldIdentifier.FieldName); + var cacheKey = (ModelType: fieldIdentifier.Model.GetType(), fieldIdentifier.FieldName); if (!_propertyInfoCache.TryGetValue(cacheKey, out propertyInfo)) { // DataAnnotations only validates public properties, so that's all we'll look for // If we can't find it, cache 'null' so we don't have to try again next time - propertyInfo = fieldIdentifier.Model.GetType().GetProperty(fieldIdentifier.FieldName); + propertyInfo = cacheKey.ModelType.GetProperty(cacheKey.FieldName); // No need to lock, because it doesn't matter if we write the same value twice _propertyInfoCache[cacheKey] = propertyInfo; From 965617ec00ecc5a92d11456eb719f09c85607214 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 18 Feb 2019 09:23:50 +0000 Subject: [PATCH 40/70] Add 'id' support to Input* --- .../src/Forms/InputComponents/InputBase.cs | 6 ++++- .../Forms/InputComponents/InputCheckbox.cs | 9 +++---- .../src/Forms/InputComponents/InputDate.cs | 7 +++--- .../src/Forms/InputComponents/InputNumber.cs | 7 +++--- .../src/Forms/InputComponents/InputSelect.cs | 1 + .../src/Forms/InputComponents/InputText.cs | 9 +++---- .../Forms/InputComponents/InputTextArea.cs | 9 +++---- .../Components/test/Forms/InputBaseTest.cs | 24 +++++++++++++++++++ 8 files changed, 53 insertions(+), 19 deletions(-) diff --git a/src/Components/Components/src/Forms/InputComponents/InputBase.cs b/src/Components/Components/src/Forms/InputComponents/InputBase.cs index 949e655e146e..8b553419ec6f 100644 --- a/src/Components/Components/src/Forms/InputComponents/InputBase.cs +++ b/src/Components/Components/src/Forms/InputComponents/InputBase.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Linq.Expressions; using System.Threading.Tasks; @@ -22,6 +21,11 @@ public abstract class InputBase : ComponentBase [CascadingParameter] EditContext CascadedEditContext { get; set; } + /// + /// Gets or sets a value for the component's 'id' attribute. + /// + [Parameter] protected string Id { get; set; } + /// /// Gets or sets the value of the input. This should be used with two-way binding. /// diff --git a/src/Components/Components/src/Forms/InputComponents/InputCheckbox.cs b/src/Components/Components/src/Forms/InputComponents/InputCheckbox.cs index bb00ec73bcec..b3917b8053da 100644 --- a/src/Components/Components/src/Forms/InputComponents/InputCheckbox.cs +++ b/src/Components/Components/src/Forms/InputComponents/InputCheckbox.cs @@ -8,7 +8,7 @@ namespace Microsoft.AspNetCore.Components.Forms /* This is exactly equivalent to a .razor file containing: * * @inherits InputBase - * + * * * The only reason it's not implemented as a .razor file is that we don't presently have the ability to compile those * files within this project. Developers building their own input components should use Razor syntax. @@ -25,9 +25,10 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) base.BuildRenderTree(builder); builder.OpenElement(0, "input"); builder.AddAttribute(1, "type", "checkbox"); - builder.AddAttribute(2, "class", FieldClass); - builder.AddAttribute(3, "value", BindMethods.GetValue(CurrentValue)); - builder.AddAttribute(4, "onchange", BindMethods.SetValueHandler(__value => CurrentValue = __value, CurrentValue)); + builder.AddAttribute(2, "id", Id); + builder.AddAttribute(3, "class", FieldClass); + builder.AddAttribute(4, "value", BindMethods.GetValue(CurrentValue)); + builder.AddAttribute(5, "onchange", BindMethods.SetValueHandler(__value => CurrentValue = __value, CurrentValue)); builder.CloseElement(); } } diff --git a/src/Components/Components/src/Forms/InputComponents/InputDate.cs b/src/Components/Components/src/Forms/InputComponents/InputDate.cs index a288937cd8a2..3692e2c1da29 100644 --- a/src/Components/Components/src/Forms/InputComponents/InputDate.cs +++ b/src/Components/Components/src/Forms/InputComponents/InputDate.cs @@ -22,9 +22,10 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) base.BuildRenderTree(builder); builder.OpenElement(0, "input"); builder.AddAttribute(1, "type", "date"); - builder.AddAttribute(2, "class", FieldClass); - builder.AddAttribute(3, "value", BindMethods.GetValue(CurrentValueAsString)); - builder.AddAttribute(4, "onchange", BindMethods.SetValueHandler(__value => CurrentValueAsString = __value, CurrentValueAsString)); + builder.AddAttribute(2, "id", Id); + builder.AddAttribute(3, "class", FieldClass); + builder.AddAttribute(4, "value", BindMethods.GetValue(CurrentValueAsString)); + builder.AddAttribute(5, "onchange", BindMethods.SetValueHandler(__value => CurrentValueAsString = __value, CurrentValueAsString)); builder.CloseElement(); } diff --git a/src/Components/Components/src/Forms/InputComponents/InputNumber.cs b/src/Components/Components/src/Forms/InputComponents/InputNumber.cs index 8e69be2e88e6..126c84f22db7 100644 --- a/src/Components/Components/src/Forms/InputComponents/InputNumber.cs +++ b/src/Components/Components/src/Forms/InputComponents/InputNumber.cs @@ -59,9 +59,10 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) { base.BuildRenderTree(builder); builder.OpenElement(0, "input"); - builder.AddAttribute(1, "class", FieldClass); - builder.AddAttribute(2, "value", BindMethods.GetValue(CurrentValueAsString)); - builder.AddAttribute(3, "onchange", BindMethods.SetValueHandler(__value => CurrentValueAsString = __value, CurrentValueAsString)); + builder.AddAttribute(1, "id", Id); + builder.AddAttribute(2, "class", FieldClass); + builder.AddAttribute(3, "value", BindMethods.GetValue(CurrentValueAsString)); + builder.AddAttribute(4, "onchange", BindMethods.SetValueHandler(__value => CurrentValueAsString = __value, CurrentValueAsString)); builder.CloseElement(); } diff --git a/src/Components/Components/src/Forms/InputComponents/InputSelect.cs b/src/Components/Components/src/Forms/InputComponents/InputSelect.cs index 0ac5b8a98c63..0d8ed983e97b 100644 --- a/src/Components/Components/src/Forms/InputComponents/InputSelect.cs +++ b/src/Components/Components/src/Forms/InputComponents/InputSelect.cs @@ -18,6 +18,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) { base.BuildRenderTree(builder); builder.OpenElement(0, "select"); + builder.AddAttribute(1, "id", Id); builder.AddAttribute(2, "class", FieldClass); builder.AddAttribute(3, "value", BindMethods.GetValue(CurrentValueAsString)); builder.AddAttribute(4, "onchange", BindMethods.SetValueHandler(__value => CurrentValueAsString = __value, CurrentValueAsString)); diff --git a/src/Components/Components/src/Forms/InputComponents/InputText.cs b/src/Components/Components/src/Forms/InputComponents/InputText.cs index 12e1326a688c..38055dc4e9c8 100644 --- a/src/Components/Components/src/Forms/InputComponents/InputText.cs +++ b/src/Components/Components/src/Forms/InputComponents/InputText.cs @@ -10,7 +10,7 @@ namespace Microsoft.AspNetCore.Components.Forms /* This is exactly equivalent to a .razor file containing: * * @inherits InputBase - * + * * * The only reason it's not implemented as a .razor file is that we don't presently have the ability to compile those * files within this project. Developers building their own input components should use Razor syntax. @@ -26,9 +26,10 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) { base.BuildRenderTree(builder); builder.OpenElement(0, "input"); - builder.AddAttribute(1, "class", FieldClass); - builder.AddAttribute(2, "value", BindMethods.GetValue(CurrentValue)); - builder.AddAttribute(3, "onchange", BindMethods.SetValueHandler(__value => CurrentValue = __value, CurrentValue)); + builder.AddAttribute(1, "id", Id); + builder.AddAttribute(2, "class", FieldClass); + builder.AddAttribute(3, "value", BindMethods.GetValue(CurrentValue)); + builder.AddAttribute(4, "onchange", BindMethods.SetValueHandler(__value => CurrentValue = __value, CurrentValue)); builder.CloseElement(); } } diff --git a/src/Components/Components/src/Forms/InputComponents/InputTextArea.cs b/src/Components/Components/src/Forms/InputComponents/InputTextArea.cs index d039de6d9f93..45a71b1fa10c 100644 --- a/src/Components/Components/src/Forms/InputComponents/InputTextArea.cs +++ b/src/Components/Components/src/Forms/InputComponents/InputTextArea.cs @@ -10,7 +10,7 @@ namespace Microsoft.AspNetCore.Components.Forms /* This is exactly equivalent to a .razor file containing: * * @inherits InputBase - * + * * * The only reason it's not implemented as a .razor file is that we don't presently have the ability to compile those * files within this project. Developers building their own input components should use Razor syntax. @@ -26,9 +26,10 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) { base.BuildRenderTree(builder); builder.OpenElement(0, "textarea"); - builder.AddAttribute(1, "class", FieldClass); - builder.AddAttribute(2, "value", BindMethods.GetValue(CurrentValue)); - builder.AddAttribute(3, "onchange", BindMethods.SetValueHandler(__value => CurrentValue = __value, CurrentValue)); + builder.AddAttribute(1, "id", Id); + builder.AddAttribute(2, "class", FieldClass); + builder.AddAttribute(3, "value", BindMethods.GetValue(CurrentValue)); + builder.AddAttribute(4, "onchange", BindMethods.SetValueHandler(__value => CurrentValue = __value, CurrentValue)); builder.CloseElement(); } } diff --git a/src/Components/Components/test/Forms/InputBaseTest.cs b/src/Components/Components/test/Forms/InputBaseTest.cs index 66755ff46a2c..874588ea8433 100644 --- a/src/Components/Components/test/Forms/InputBaseTest.cs +++ b/src/Components/Components/test/Forms/InputBaseTest.cs @@ -74,6 +74,25 @@ public async Task GetsCurrentValueFromValueParameter() Assert.Equal("some value", inputComponent.CurrentValue); } + [Fact] + public async Task ExposesIdToSubclass() + { + // Arrange + var model = new TestModel(); + var rootComponent = new TestInputHostComponent> + { + Id = "test-id", + EditContext = new EditContext(model), + ValueExpression = () => model.StringProperty + }; + + // Act + var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent); + + // Assert + Assert.Same(rootComponent.Id, inputComponent.Id); + } + [Fact] public async Task ExposesEditContextToSubclass() { @@ -367,6 +386,8 @@ class TestInputComponent : InputBase set { base.CurrentValueAsString = value; } } + public new string Id => base.Id; + public new EditContext EditContext => base.EditContext; public new FieldIdentifier FieldIdentifier => base.FieldIdentifier; @@ -396,6 +417,8 @@ protected override bool TryParseValueFromString(string value, out DateTime resul class TestInputHostComponent : AutoRenderComponent where TComponent: TestInputComponent { + public string Id { get; set; } + public EditContext EditContext { get; set; } public TValue Value { get; set; } @@ -414,6 +437,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) childBuilder.AddAttribute(0, "Value", Value); childBuilder.AddAttribute(1, "ValueChanged", ValueChanged); childBuilder.AddAttribute(2, "ValueExpression", ValueExpression); + childBuilder.AddAttribute(3, nameof(Id), Id); childBuilder.CloseComponent(); })); builder.CloseComponent(); From c88fce8f26450100d12c95c72a864c59eab614a1 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 18 Feb 2019 09:36:22 +0000 Subject: [PATCH 41/70] Add 'class' support to Input* --- .../src/Forms/InputComponents/InputBase.cs | 19 +++++++++-- .../Forms/InputComponents/InputCheckbox.cs | 2 +- .../src/Forms/InputComponents/InputDate.cs | 2 +- .../src/Forms/InputComponents/InputNumber.cs | 2 +- .../src/Forms/InputComponents/InputSelect.cs | 2 +- .../src/Forms/InputComponents/InputText.cs | 2 +- .../Forms/InputComponents/InputTextArea.cs | 2 +- .../Components/test/Forms/InputBaseTest.cs | 34 +++++++++++++++++++ 8 files changed, 57 insertions(+), 8 deletions(-) diff --git a/src/Components/Components/src/Forms/InputComponents/InputBase.cs b/src/Components/Components/src/Forms/InputComponents/InputBase.cs index 8b553419ec6f..31278224a521 100644 --- a/src/Components/Components/src/Forms/InputComponents/InputBase.cs +++ b/src/Components/Components/src/Forms/InputComponents/InputBase.cs @@ -22,9 +22,14 @@ public abstract class InputBase : ComponentBase [CascadingParameter] EditContext CascadedEditContext { get; set; } /// - /// Gets or sets a value for the component's 'id' attribute. + /// Gets a value for the component's 'id' attribute. /// - [Parameter] protected string Id { get; set; } + [Parameter] protected string Id { get; private set; } + + /// + /// Gets a value for the component's 'class' attribute. + /// + [Parameter] protected string Class { get; private set; } /// /// Gets or sets the value of the input. This should be used with two-way binding. @@ -148,6 +153,16 @@ protected virtual bool TryParseValueFromString(string value, out T result, out s protected string FieldClass => EditContext.FieldClass(FieldIdentifier); + /// + /// Gets a CSS class string that combines the and + /// properties. Derived components should typically use this value for the primary HTML element's + /// 'class' attribute. + /// + protected string CssClass + => string.IsNullOrEmpty(Class) + ? FieldClass // Never null or empty + : $"{Class} {FieldClass}"; + /// public override Task SetParametersAsync(ParameterCollection parameters) { diff --git a/src/Components/Components/src/Forms/InputComponents/InputCheckbox.cs b/src/Components/Components/src/Forms/InputComponents/InputCheckbox.cs index b3917b8053da..5423ac17f2f3 100644 --- a/src/Components/Components/src/Forms/InputComponents/InputCheckbox.cs +++ b/src/Components/Components/src/Forms/InputComponents/InputCheckbox.cs @@ -26,7 +26,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) builder.OpenElement(0, "input"); builder.AddAttribute(1, "type", "checkbox"); builder.AddAttribute(2, "id", Id); - builder.AddAttribute(3, "class", FieldClass); + builder.AddAttribute(3, "class", CssClass); builder.AddAttribute(4, "value", BindMethods.GetValue(CurrentValue)); builder.AddAttribute(5, "onchange", BindMethods.SetValueHandler(__value => CurrentValue = __value, CurrentValue)); builder.CloseElement(); diff --git a/src/Components/Components/src/Forms/InputComponents/InputDate.cs b/src/Components/Components/src/Forms/InputComponents/InputDate.cs index 3692e2c1da29..040b21e3e79e 100644 --- a/src/Components/Components/src/Forms/InputComponents/InputDate.cs +++ b/src/Components/Components/src/Forms/InputComponents/InputDate.cs @@ -23,7 +23,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) builder.OpenElement(0, "input"); builder.AddAttribute(1, "type", "date"); builder.AddAttribute(2, "id", Id); - builder.AddAttribute(3, "class", FieldClass); + builder.AddAttribute(3, "class", CssClass); builder.AddAttribute(4, "value", BindMethods.GetValue(CurrentValueAsString)); builder.AddAttribute(5, "onchange", BindMethods.SetValueHandler(__value => CurrentValueAsString = __value, CurrentValueAsString)); builder.CloseElement(); diff --git a/src/Components/Components/src/Forms/InputComponents/InputNumber.cs b/src/Components/Components/src/Forms/InputComponents/InputNumber.cs index 126c84f22db7..7d513c448066 100644 --- a/src/Components/Components/src/Forms/InputComponents/InputNumber.cs +++ b/src/Components/Components/src/Forms/InputComponents/InputNumber.cs @@ -60,7 +60,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) base.BuildRenderTree(builder); builder.OpenElement(0, "input"); builder.AddAttribute(1, "id", Id); - builder.AddAttribute(2, "class", FieldClass); + builder.AddAttribute(2, "class", CssClass); builder.AddAttribute(3, "value", BindMethods.GetValue(CurrentValueAsString)); builder.AddAttribute(4, "onchange", BindMethods.SetValueHandler(__value => CurrentValueAsString = __value, CurrentValueAsString)); builder.CloseElement(); diff --git a/src/Components/Components/src/Forms/InputComponents/InputSelect.cs b/src/Components/Components/src/Forms/InputComponents/InputSelect.cs index 0d8ed983e97b..77f757209332 100644 --- a/src/Components/Components/src/Forms/InputComponents/InputSelect.cs +++ b/src/Components/Components/src/Forms/InputComponents/InputSelect.cs @@ -19,7 +19,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) base.BuildRenderTree(builder); builder.OpenElement(0, "select"); builder.AddAttribute(1, "id", Id); - builder.AddAttribute(2, "class", FieldClass); + builder.AddAttribute(2, "class", CssClass); builder.AddAttribute(3, "value", BindMethods.GetValue(CurrentValueAsString)); builder.AddAttribute(4, "onchange", BindMethods.SetValueHandler(__value => CurrentValueAsString = __value, CurrentValueAsString)); builder.AddContent(5, ChildContent); diff --git a/src/Components/Components/src/Forms/InputComponents/InputText.cs b/src/Components/Components/src/Forms/InputComponents/InputText.cs index 38055dc4e9c8..424fda81d760 100644 --- a/src/Components/Components/src/Forms/InputComponents/InputText.cs +++ b/src/Components/Components/src/Forms/InputComponents/InputText.cs @@ -27,7 +27,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) base.BuildRenderTree(builder); builder.OpenElement(0, "input"); builder.AddAttribute(1, "id", Id); - builder.AddAttribute(2, "class", FieldClass); + builder.AddAttribute(2, "class", CssClass); builder.AddAttribute(3, "value", BindMethods.GetValue(CurrentValue)); builder.AddAttribute(4, "onchange", BindMethods.SetValueHandler(__value => CurrentValue = __value, CurrentValue)); builder.CloseElement(); diff --git a/src/Components/Components/src/Forms/InputComponents/InputTextArea.cs b/src/Components/Components/src/Forms/InputComponents/InputTextArea.cs index 45a71b1fa10c..49114bdd5be7 100644 --- a/src/Components/Components/src/Forms/InputComponents/InputTextArea.cs +++ b/src/Components/Components/src/Forms/InputComponents/InputTextArea.cs @@ -27,7 +27,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) base.BuildRenderTree(builder); builder.OpenElement(0, "textarea"); builder.AddAttribute(1, "id", Id); - builder.AddAttribute(2, "class", FieldClass); + builder.AddAttribute(2, "class", CssClass); builder.AddAttribute(3, "value", BindMethods.GetValue(CurrentValue)); builder.AddAttribute(4, "onchange", BindMethods.SetValueHandler(__value => CurrentValue = __value, CurrentValue)); builder.CloseElement(); diff --git a/src/Components/Components/test/Forms/InputBaseTest.cs b/src/Components/Components/test/Forms/InputBaseTest.cs index 874588ea8433..a3723b243fe4 100644 --- a/src/Components/Components/test/Forms/InputBaseTest.cs +++ b/src/Components/Components/test/Forms/InputBaseTest.cs @@ -234,23 +234,52 @@ public async Task SuppliesFieldClassCorrespondingToFieldState() // Act/Assert: Initally, it's valid and unmodified var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent); Assert.Equal("valid", inputComponent.FieldClass); + Assert.Equal("valid", inputComponent.CssClass); // Same because no Class was specified // Act/Assert: Modify the field rootComponent.EditContext.NotifyFieldChanged(fieldIdentifier); Assert.Equal("modified valid", inputComponent.FieldClass); + Assert.Equal("modified valid", inputComponent.CssClass); // Act/Assert: Make it invalid var messages = new ValidationMessageStore(rootComponent.EditContext); messages.Add(fieldIdentifier, "I do not like this value"); Assert.Equal("modified invalid", inputComponent.FieldClass); + Assert.Equal("modified invalid", inputComponent.CssClass); // Act/Assert: Clear the modification flag rootComponent.EditContext.MarkAsUnmodified(fieldIdentifier); Assert.Equal("invalid", inputComponent.FieldClass); + Assert.Equal("invalid", inputComponent.CssClass); // Act/Assert: Make it valid messages.Clear(); Assert.Equal("valid", inputComponent.FieldClass); + Assert.Equal("valid", inputComponent.CssClass); + } + + [Fact] + public async Task CssClassCombinesClassWithFieldClass() + { + // Arrange + var model = new TestModel(); + var rootComponent = new TestInputHostComponent> + { + Class = "my-class other-class", + EditContext = new EditContext(model), + ValueExpression = () => model.StringProperty + }; + var fieldIdentifier = FieldIdentifier.Create(() => model.StringProperty); + + // Act/Assert + var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent); + Assert.Equal("valid", inputComponent.FieldClass); + Assert.Equal("my-class other-class valid", inputComponent.CssClass); + + // Act/Assert: Retains custom class when changing field class + rootComponent.EditContext.NotifyFieldChanged(fieldIdentifier); + Assert.Equal("modified valid", inputComponent.FieldClass); + Assert.Equal("my-class other-class modified valid", inputComponent.CssClass); } [Fact] @@ -388,6 +417,8 @@ class TestInputComponent : InputBase public new string Id => base.Id; + public new string CssClass => base.CssClass; + public new EditContext EditContext => base.EditContext; public new FieldIdentifier FieldIdentifier => base.FieldIdentifier; @@ -419,6 +450,8 @@ class TestInputHostComponent : AutoRenderComponent where TCo { public string Id { get; set; } + public string Class { get; set; } + public EditContext EditContext { get; set; } public TValue Value { get; set; } @@ -438,6 +471,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) childBuilder.AddAttribute(1, "ValueChanged", ValueChanged); childBuilder.AddAttribute(2, "ValueExpression", ValueExpression); childBuilder.AddAttribute(3, nameof(Id), Id); + childBuilder.AddAttribute(4, nameof(Class), Class); childBuilder.CloseComponent(); })); builder.CloseComponent(); From c309cb7e0189fbcf0f69a2d3b8d5b51982e59080 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 18 Feb 2019 09:52:54 +0000 Subject: [PATCH 42/70] Update references following rebase --- eng/SharedFramework.External.props | 1 + eng/Version.Details.xml | 4 ++++ eng/Versions.props | 2 ++ 3 files changed, 7 insertions(+) diff --git a/eng/SharedFramework.External.props b/eng/SharedFramework.External.props index 3d3f44d186b8..6d927bd2f2b9 100644 --- a/eng/SharedFramework.External.props +++ b/eng/SharedFramework.External.props @@ -51,6 +51,7 @@ + diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index 406748124e81..bee29e5095fd 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -293,6 +293,10 @@ https://github.com/dotnet/corefx 0abec4390b30fdda97dc496594f9b1f9c9b20e17 + + https://github.com/dotnet/corefx + 000000 + https://github.com/dotnet/corefx 0abec4390b30fdda97dc496594f9b1f9c9b20e17 diff --git a/eng/Versions.props b/eng/Versions.props index a95e715477b3..a39a0b8bce71 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -25,6 +25,8 @@ 4.6.0-preview.19109.6 4.6.0-preview.19109.6 4.6.0-preview.19109.6 + 4.3.0 + 4.6.0-preview.19109.6 4.7.0-preview.19109.6 4.6.0-preview.19109.6 4.6.0-preview.19109.6 From 28f77577f3bce0d63fd43e0f99a933221a2a335e Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 18 Feb 2019 10:10:12 +0000 Subject: [PATCH 43/70] CR feedback: Number parsing --- .../src/Forms/InputComponents/InputNumber.cs | 34 +++++-------------- 1 file changed, 8 insertions(+), 26 deletions(-) diff --git a/src/Components/Components/src/Forms/InputComponents/InputNumber.cs b/src/Components/Components/src/Forms/InputComponents/InputNumber.cs index 7d513c448066..751cf6ba9f8d 100644 --- a/src/Components/Components/src/Forms/InputComponents/InputNumber.cs +++ b/src/Components/Components/src/Forms/InputComponents/InputNumber.cs @@ -3,12 +3,13 @@ using Microsoft.AspNetCore.Components.RenderTree; using System; +using System.Globalization; namespace Microsoft.AspNetCore.Components.Forms { /// /// An input component for editing numeric values. - /// Supported numeric types are , , , , , . + /// Supported numeric types are , , , , . /// public class InputNumber : InputBase { @@ -22,11 +23,7 @@ static InputNumber() // of it for us. We will only get asked to parse the T for nonempty inputs. var targetType = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T); - if (targetType == typeof(short)) - { - _parser = TryParseShort; - } - else if (targetType == typeof(int)) + if (targetType == typeof(int)) { _parser = TryParseInt; } @@ -81,24 +78,9 @@ protected override bool TryParseValueFromString(string value, out T result, out } } - static bool TryParseShort(string value, out T result) - { - var success = short.TryParse(value, out var parsedValue); - if (success) - { - result = (T)(object)parsedValue; - return true; - } - else - { - result = default; - return false; - } - } - static bool TryParseInt(string value, out T result) { - var success = int.TryParse(value, out var parsedValue); + var success = int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedValue); if (success) { result = (T)(object)parsedValue; @@ -113,7 +95,7 @@ static bool TryParseInt(string value, out T result) static bool TryParseLong(string value, out T result) { - var success = long.TryParse(value, out var parsedValue); + var success = long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedValue); if (success) { result = (T)(object)parsedValue; @@ -128,7 +110,7 @@ static bool TryParseLong(string value, out T result) static bool TryParseFloat(string value, out T result) { - var success = float.TryParse(value, out var parsedValue); + var success = float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsedValue); if (success) { result = (T)(object)parsedValue; @@ -143,7 +125,7 @@ static bool TryParseFloat(string value, out T result) static bool TryParseDouble(string value, out T result) { - var success = double.TryParse(value, out var parsedValue); + var success = double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsedValue); if (success) { result = (T)(object)parsedValue; @@ -158,7 +140,7 @@ static bool TryParseDouble(string value, out T result) static bool TryParseDecimal(string value, out T result) { - var success = decimal.TryParse(value, out var parsedValue); + var success = decimal.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsedValue); if (success) { result = (T)(object)parsedValue; From f207979c899727a6d21cdcc518920c4ba6712a17 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 18 Feb 2019 10:16:27 +0000 Subject: [PATCH 44/70] CR: In InputNumber, add "type='number'" and conditional "step='any'" --- .../src/Forms/InputComponents/InputNumber.cs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/Components/Components/src/Forms/InputComponents/InputNumber.cs b/src/Components/Components/src/Forms/InputComponents/InputNumber.cs index 751cf6ba9f8d..ad003b273bf1 100644 --- a/src/Components/Components/src/Forms/InputComponents/InputNumber.cs +++ b/src/Components/Components/src/Forms/InputComponents/InputNumber.cs @@ -15,6 +15,7 @@ public class InputNumber : InputBase { delegate bool Parser(string value, out T result); private static Parser _parser; + private static string _stepAttributeValue; // Null by default, so only allows whole numbers as per HTML spec // Determine the parsing logic once per T and cache it, so we don't have to consider all the possible types on each parse static InputNumber() @@ -34,14 +35,17 @@ static InputNumber() else if (targetType == typeof(float)) { _parser = TryParseFloat; + _stepAttributeValue = "any"; } else if (targetType == typeof(double)) { _parser = TryParseDouble; + _stepAttributeValue = "any"; } else if (targetType == typeof(decimal)) { _parser = TryParseDecimal; + _stepAttributeValue = "any"; } else { @@ -56,10 +60,12 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) { base.BuildRenderTree(builder); builder.OpenElement(0, "input"); - builder.AddAttribute(1, "id", Id); - builder.AddAttribute(2, "class", CssClass); - builder.AddAttribute(3, "value", BindMethods.GetValue(CurrentValueAsString)); - builder.AddAttribute(4, "onchange", BindMethods.SetValueHandler(__value => CurrentValueAsString = __value, CurrentValueAsString)); + builder.AddAttribute(1, "type", "number"); + builder.AddAttribute(2, "step", _stepAttributeValue); + builder.AddAttribute(3, "id", Id); + builder.AddAttribute(4, "class", CssClass); + builder.AddAttribute(5, "value", BindMethods.GetValue(CurrentValueAsString)); + builder.AddAttribute(6, "onchange", BindMethods.SetValueHandler(__value => CurrentValueAsString = __value, CurrentValueAsString)); builder.CloseElement(); } From 741c0ad66ddf6eab7373d7b8c577664e72167d32 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 18 Feb 2019 10:21:01 +0000 Subject: [PATCH 45/70] CR: Remove async validation comments --- .../Components/src/Forms/EditContext.cs | 25 ------------------- 1 file changed, 25 deletions(-) diff --git a/src/Components/Components/src/Forms/EditContext.cs b/src/Components/Components/src/Forms/EditContext.cs index f44ad8fd1420..2de58a9b63bb 100644 --- a/src/Components/Components/src/Forms/EditContext.cs +++ b/src/Components/Components/src/Forms/EditContext.cs @@ -7,31 +7,6 @@ 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 f4b77c6168d829edcada2495e037d67636ea6702 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 18 Feb 2019 10:22:13 +0000 Subject: [PATCH 46/70] CR: sealed EditContext --- src/Components/Components/src/Forms/EditContext.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Components/Components/src/Forms/EditContext.cs b/src/Components/Components/src/Forms/EditContext.cs index 2de58a9b63bb..53b2efac9d00 100644 --- a/src/Components/Components/src/Forms/EditContext.cs +++ b/src/Components/Components/src/Forms/EditContext.cs @@ -11,7 +11,7 @@ namespace Microsoft.AspNetCore.Components.Forms /// Holds metadata related to a data editing process, such as flags to indicate which /// fields have been modified and the current set of validation messages. /// - public class EditContext + public sealed class EditContext { private readonly Dictionary _fieldStates = new Dictionary(); From c35639f646956ca3f25047983049d20e837157d6 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 18 Feb 2019 10:37:41 +0000 Subject: [PATCH 47/70] CR: Add eventargs classes --- .../Components/src/Forms/EditContext.cs | 12 +++++------ .../EditContextDataAnnotationsExtensions.cs | 2 +- .../src/Forms/FieldChangedEventArgs.cs | 21 +++++++++++++++++++ .../src/Forms/ValidationRequestedEventArgs.cs | 17 +++++++++++++++ .../Forms/ValidationStateChangedEventArgs.cs | 17 +++++++++++++++ .../Components/src/Forms/ValidationSummary.cs | 12 +++++------ .../Components/test/Forms/EditContextTest.cs | 6 +++--- 7 files changed, 70 insertions(+), 17 deletions(-) create mode 100644 src/Components/Components/src/Forms/FieldChangedEventArgs.cs create mode 100644 src/Components/Components/src/Forms/ValidationRequestedEventArgs.cs create mode 100644 src/Components/Components/src/Forms/ValidationStateChangedEventArgs.cs diff --git a/src/Components/Components/src/Forms/EditContext.cs b/src/Components/Components/src/Forms/EditContext.cs index 53b2efac9d00..de5d0bd1c995 100644 --- a/src/Components/Components/src/Forms/EditContext.cs +++ b/src/Components/Components/src/Forms/EditContext.cs @@ -30,17 +30,17 @@ public EditContext(object model) /// /// An event that is raised when a field value changes. /// - public event EventHandler OnFieldChanged; + public event EventHandler OnFieldChanged; /// /// An event that is raised when validation is requested. /// - public event EventHandler OnValidationRequested; + public event EventHandler OnValidationRequested; /// /// An event that is raised when validation state has changed. /// - public event EventHandler OnValidationStateChanged; + public event EventHandler OnValidationStateChanged; /// /// Supplies a corresponding to a specified field name @@ -63,7 +63,7 @@ public FieldIdentifier Field(string fieldName) public void NotifyFieldChanged(FieldIdentifier fieldIdentifier) { GetFieldState(fieldIdentifier, ensureExists: true).IsModified = true; - OnFieldChanged?.Invoke(this, fieldIdentifier); + OnFieldChanged?.Invoke(this, new FieldChangedEventArgs(fieldIdentifier)); } /// @@ -71,7 +71,7 @@ public void NotifyFieldChanged(FieldIdentifier fieldIdentifier) /// public void NotifyValidationStateChanged() { - OnValidationStateChanged?.Invoke(this, null); + OnValidationStateChanged?.Invoke(this, ValidationStateChangedEventArgs.Empty); } /// @@ -142,7 +142,7 @@ public bool IsModified(FieldIdentifier fieldIdentifier) /// True if there are no validation messages after validation; otherwise false. public bool Validate() { - OnValidationRequested?.Invoke(this, null); + OnValidationRequested?.Invoke(this, ValidationRequestedEventArgs.Empty); return !GetValidationMessages().Any(); } diff --git a/src/Components/Components/src/Forms/EditContextDataAnnotationsExtensions.cs b/src/Components/Components/src/Forms/EditContextDataAnnotationsExtensions.cs index 77a942bb5b07..03be235f8532 100644 --- a/src/Components/Components/src/Forms/EditContextDataAnnotationsExtensions.cs +++ b/src/Components/Components/src/Forms/EditContextDataAnnotationsExtensions.cs @@ -37,7 +37,7 @@ public static EditContext AddDataAnnotationsValidation(this EditContext editCont // Perform per-field validation on each field edit editContext.OnFieldChanged += - (sender, fieldIdentifier) => ValidateField(editContext, messages, fieldIdentifier); + (sender, eventArgs) => ValidateField(editContext, messages, eventArgs.FieldIdentifier); return editContext; } diff --git a/src/Components/Components/src/Forms/FieldChangedEventArgs.cs b/src/Components/Components/src/Forms/FieldChangedEventArgs.cs new file mode 100644 index 000000000000..0a06d3c9f8de --- /dev/null +++ b/src/Components/Components/src/Forms/FieldChangedEventArgs.cs @@ -0,0 +1,21 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Components.Forms +{ + /// + /// Provides information about the event. + /// + public sealed class FieldChangedEventArgs + { + /// + /// Identifies the field whose value has changed. + /// + public FieldIdentifier FieldIdentifier { get; } + + internal FieldChangedEventArgs(FieldIdentifier fieldIdentifier) + { + FieldIdentifier = fieldIdentifier; + } + } +} diff --git a/src/Components/Components/src/Forms/ValidationRequestedEventArgs.cs b/src/Components/Components/src/Forms/ValidationRequestedEventArgs.cs new file mode 100644 index 000000000000..cd7f0db2b602 --- /dev/null +++ b/src/Components/Components/src/Forms/ValidationRequestedEventArgs.cs @@ -0,0 +1,17 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Components.Forms +{ + /// + /// Provides information about the event. + /// + public sealed class ValidationRequestedEventArgs + { + internal static readonly ValidationRequestedEventArgs Empty = new ValidationRequestedEventArgs(); + + internal ValidationRequestedEventArgs() + { + } + } +} diff --git a/src/Components/Components/src/Forms/ValidationStateChangedEventArgs.cs b/src/Components/Components/src/Forms/ValidationStateChangedEventArgs.cs new file mode 100644 index 000000000000..0ac4af6658b0 --- /dev/null +++ b/src/Components/Components/src/Forms/ValidationStateChangedEventArgs.cs @@ -0,0 +1,17 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Components.Forms +{ + /// + /// Provides information about the event. + /// + public sealed class ValidationStateChangedEventArgs + { + internal static readonly ValidationStateChangedEventArgs Empty = new ValidationStateChangedEventArgs(); + + internal ValidationStateChangedEventArgs() + { + } + } +} diff --git a/src/Components/Components/src/Forms/ValidationSummary.cs b/src/Components/Components/src/Forms/ValidationSummary.cs index 1165af3336fc..6a5d40dbac49 100644 --- a/src/Components/Components/src/Forms/ValidationSummary.cs +++ b/src/Components/Components/src/Forms/ValidationSummary.cs @@ -6,11 +6,9 @@ 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. - */ + // 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 . @@ -18,7 +16,7 @@ namespace Microsoft.AspNetCore.Components.Forms public class ValidationSummary : ComponentBase, IDisposable { private EditContext _previousEditContext; - private readonly EventHandler _validationStateChangedHandler; + private readonly EventHandler _validationStateChangedHandler; [CascadingParameter] EditContext CurrentEditContext { get; set; } @@ -74,7 +72,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) } } - private void HandleValidationStateChanged(object sender, EventArgs eventArgs) + private void HandleValidationStateChanged(object sender, ValidationStateChangedEventArgs eventArgs) { StateHasChanged(); } diff --git a/src/Components/Components/test/Forms/EditContextTest.cs b/src/Components/Components/test/Forms/EditContextTest.cs index f1daf6fad5e1..51bd9a78358f 100644 --- a/src/Components/Components/test/Forms/EditContextTest.cs +++ b/src/Components/Components/test/Forms/EditContextTest.cs @@ -113,10 +113,10 @@ public void RaisesEventWhenFieldIsChanged() var editContext = new EditContext(new object()); var field1 = new FieldIdentifier(new object(), "fieldname"); // Shows it can be on a different model var didReceiveNotification = false; - editContext.OnFieldChanged += (sender, changedFieldIdentifier) => + editContext.OnFieldChanged += (sender, eventArgs) => { Assert.Same(editContext, sender); - Assert.Equal(field1, changedFieldIdentifier); + Assert.Equal(field1, eventArgs.FieldIdentifier); didReceiveNotification = true; }; @@ -224,7 +224,7 @@ public void RequestsValidationWhenValidateIsCalled() editContext.OnValidationRequested += (sender, eventArgs) => { Assert.Same(editContext, sender); - Assert.Null(eventArgs); // Not currently used + Assert.NotNull(eventArgs); messages.Add( new FieldIdentifier(new object(), "some field"), "Some message"); From 2d760f25f98934f562de55f840ed3fd9bc7d4d73 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 18 Feb 2019 10:45:13 +0000 Subject: [PATCH 48/70] CR: Comment about EditContext storage --- src/Components/Components/src/Forms/EditContext.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Components/Components/src/Forms/EditContext.cs b/src/Components/Components/src/Forms/EditContext.cs index de5d0bd1c995..3c0771c98790 100644 --- a/src/Components/Components/src/Forms/EditContext.cs +++ b/src/Components/Components/src/Forms/EditContext.cs @@ -13,6 +13,11 @@ namespace Microsoft.AspNetCore.Components.Forms /// public sealed class EditContext { + // Note that EditContext tracks state for any FieldIdentifier you give to it, plus + // the underlying storage is sparse. As such, none of the APIs have a "field not found" + // error state. If you give us an unrecognized FieldIdentifier, that just means we + // didn't yet track any state for it, so we behave as if it's in the default state + // (valid and unmodified). private readonly Dictionary _fieldStates = new Dictionary(); /// From 59be3b601314f3db7c8b143c50e0f94fca85901f Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 18 Feb 2019 11:09:11 +0000 Subject: [PATCH 49/70] Use EventCallback in EditForm --- .../Components/src/Forms/EditForm.cs | 20 +++++++++---------- .../SimpleValidationComponent.cshtml | 2 +- .../TypicalValidationComponent.cshtml | 2 +- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/Components/Components/src/Forms/EditForm.cs b/src/Components/Components/src/Forms/EditForm.cs index 6894f617127b..4bc76464ea9b 100644 --- a/src/Components/Components/src/Forms/EditForm.cs +++ b/src/Components/Components/src/Forms/EditForm.cs @@ -49,19 +49,19 @@ public EditForm() /// If using this parameter, you are responsible for triggering any validation /// manually, e.g., by calling . /// - [Parameter] Func OnSubmit { get; set; } + [Parameter] EventCallback 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; } + [Parameter] EventCallback 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; } + [Parameter] EventCallback OnInvalidSubmit { get; set; } /// protected override void OnParametersSet() @@ -76,7 +76,7 @@ protected override void OnParametersSet() // (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)) + if (OnSubmit.HasDelegate && (OnValidSubmit.HasDelegate || OnInvalidSubmit.HasDelegate)) { throw new InvalidOperationException($"When supplying an {nameof(OnSubmit)} parameter to " + $"{nameof(EditForm)}, do not also supply {nameof(OnValidSubmit)} or {nameof(OnInvalidSubmit)}."); @@ -125,24 +125,24 @@ private void RenderChildContentInWorkaround(RenderTreeBuilder builder) private async Task HandleSubmitAsync() { - if (OnSubmit != null) + if (OnSubmit.HasDelegate) { // When using OnSubmit, the developer takes control of the validation lifecycle - await OnSubmit(_fixedEditContext); + await OnSubmit.InvokeAsync(_fixedEditContext); } else { // Otherwise, the system implicitly runs validation on form submission var isValid = _fixedEditContext.Validate(); // This will likely become ValidateAsync later - if (isValid && OnValidSubmit != null) + if (isValid && OnValidSubmit.HasDelegate) { - await OnValidSubmit(_fixedEditContext); + await OnValidSubmit.InvokeAsync(_fixedEditContext); } - if (!isValid && OnInvalidSubmit != null) + if (!isValid && OnInvalidSubmit.HasDelegate) { - await OnInvalidSubmit(_fixedEditContext); + await OnInvalidSubmit.InvokeAsync(_fixedEditContext); } } } diff --git a/src/Components/test/testassets/BasicTestApp/FormsTest/SimpleValidationComponent.cshtml b/src/Components/test/testassets/BasicTestApp/FormsTest/SimpleValidationComponent.cshtml index 8a94270f0302..b9f525ef26f6 100644 --- a/src/Components/test/testassets/BasicTestApp/FormsTest/SimpleValidationComponent.cshtml +++ b/src/Components/test/testassets/BasicTestApp/FormsTest/SimpleValidationComponent.cshtml @@ -1,7 +1,7 @@ @using System.ComponentModel.DataAnnotations @using Microsoft.AspNetCore.Components.Forms - +

diff --git a/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.cshtml b/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.cshtml index 897c24890b37..08cb2039565d 100644 --- a/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.cshtml +++ b/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.cshtml @@ -1,7 +1,7 @@ @using System.ComponentModel.DataAnnotations @using Microsoft.AspNetCore.Components.Forms - +

From 57d4279c646236dc15adce0c838caaa19f601fc1 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 18 Feb 2019 11:13:00 +0000 Subject: [PATCH 50/70] Eliminate events routing bug workarounds since the underlying issue is fixed --- .../Components/src/Forms/EditForm.cs | 13 +------- .../Forms/TemporaryEventRoutingWorkaround.cs | 31 ------------------- .../SimpleValidationComponent.cshtml | 14 ++------- .../TypicalValidationComponent.cshtml | 6 ++-- 4 files changed, 6 insertions(+), 58 deletions(-) delete mode 100644 src/Components/Components/src/Forms/TemporaryEventRoutingWorkaround.cs diff --git a/src/Components/Components/src/Forms/EditForm.cs b/src/Components/Components/src/Forms/EditForm.cs index 4bc76464ea9b..d41cdf86ba3d 100644 --- a/src/Components/Components/src/Forms/EditForm.cs +++ b/src/Components/Components/src/Forms/EditForm.cs @@ -105,24 +105,13 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) 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.AddAttribute(5, RenderTreeBuilder.ChildContent, ChildContent?.Invoke(_fixedEditContext)); 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.HasDelegate) diff --git a/src/Components/Components/src/Forms/TemporaryEventRoutingWorkaround.cs b/src/Components/Components/src/Forms/TemporaryEventRoutingWorkaround.cs deleted file mode 100644 index 54940e38c36c..000000000000 --- a/src/Components/Components/src/Forms/TemporaryEventRoutingWorkaround.cs +++ /dev/null @@ -1,31 +0,0 @@ -// 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 index b9f525ef26f6..c8f77a149667 100644 --- a/src/Components/test/testassets/BasicTestApp/FormsTest/SimpleValidationComponent.cshtml +++ b/src/Components/test/testassets/BasicTestApp/FormsTest/SimpleValidationComponent.cshtml @@ -1,7 +1,7 @@ @using System.ComponentModel.DataAnnotations @using Microsoft.AspNetCore.Components.Forms - +

@@ -38,21 +38,13 @@ [Range(typeof(bool), "true", "true", ErrorMessage = "You must accept the terms")] public bool AcceptsTerms { get; set; } - Task HandleValidSubmitAsync(EditContext editContext) + void HandleValidSubmit() { lastCallback = "OnValidSubmit"; - - StateHasChanged(); // This is only needed as a temporary workaround to the event routing issue - - return Task.CompletedTask; } - Task HandleInvalidSubmitAsync(EditContext editContext) + void HandleInvalidSubmit() { 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/FormsTest/TypicalValidationComponent.cshtml b/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.cshtml index 08cb2039565d..5421f1cb8da6 100644 --- a/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.cshtml +++ b/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.cshtml @@ -1,7 +1,7 @@ @using System.ComponentModel.DataAnnotations @using Microsoft.AspNetCore.Components.Forms - +

@@ -74,10 +74,8 @@ List submissionLog = new List(); // So we can assert about the callbacks - Task HandleValidSubmitAsync(EditContext editContext) + void HandleValidSubmit() { submissionLog.Add("OnValidSubmit"); - StateHasChanged(); // Temporary workaround for event routing bug - return Task.CompletedTask; } } From 35e36b052f94785b47e8fb9243c223540218adcd Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 18 Feb 2019 11:26:13 +0000 Subject: [PATCH 51/70] CR: Avoid some System.Linq allocations --- .../Components/src/Forms/EditContext.cs | 32 +++++++++++++++++-- .../Components/src/Forms/FieldState.cs | 14 ++++++-- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/src/Components/Components/src/Forms/EditContext.cs b/src/Components/Components/src/Forms/EditContext.cs index 3c0771c98790..0c0b1dfba8fa 100644 --- a/src/Components/Components/src/Forms/EditContext.cs +++ b/src/Components/Components/src/Forms/EditContext.cs @@ -107,9 +107,19 @@ public void MarkAsUnmodified() ///

/// True if any of the fields in this have been modified; otherwise false. public bool IsModified() + { // If necessary, we could consider caching the overall "is modified" state and only recomputing // when there's a call to NotifyFieldModified/NotifyFieldUnmodified - => _fieldStates.Values.Any(state => state.IsModified); + foreach (var state in _fieldStates) + { + if (state.Value.IsModified) + { + return true; + } + } + + return false; + } /// /// Gets the current validation messages across all fields. @@ -118,9 +128,17 @@ public bool IsModified() /// /// 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()); + foreach (var state in _fieldStates) + { + foreach (var message in state.Value.GetValidationMessages()) + { + yield return message; + } + } + } /// /// Gets the current validation messages for the specified field. @@ -130,7 +148,15 @@ public IEnumerable GetValidationMessages() /// 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(); + { + if (_fieldStates.TryGetValue(fieldIdentifier, out var state)) + { + foreach (var message in state.GetValidationMessages()) + { + yield return message; + } + } + } /// /// Determines whether the specified fields in this has been modified. diff --git a/src/Components/Components/src/Forms/FieldState.cs b/src/Components/Components/src/Forms/FieldState.cs index 2497952c4ca5..3a8d1f0eb36c 100644 --- a/src/Components/Components/src/Forms/FieldState.cs +++ b/src/Components/Components/src/Forms/FieldState.cs @@ -2,7 +2,6 @@ // 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 { @@ -24,7 +23,18 @@ public FieldState(FieldIdentifier fieldIdentifier) public bool IsModified { get; set; } public IEnumerable GetValidationMessages() - => _validationMessageStores == null ? Enumerable.Empty() : _validationMessageStores.SelectMany(store => store[_fieldIdentifier]); + { + if (_validationMessageStores != null) + { + foreach (var store in _validationMessageStores) + { + foreach (var message in store[_fieldIdentifier]) + { + yield return message; + } + } + } + } public void AssociateWithValidationMessageStore(ValidationMessageStore validationMessageStore) { From a270131e7bc1f374b96a0abf5a3b04e6a5fc2699 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 18 Feb 2019 11:28:03 +0000 Subject: [PATCH 52/70] CR: Null check accessor --- src/Components/Components/src/Forms/FieldIdentifier.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Components/Components/src/Forms/FieldIdentifier.cs b/src/Components/Components/src/Forms/FieldIdentifier.cs index 753a857a9bcc..c7832f8209e1 100644 --- a/src/Components/Components/src/Forms/FieldIdentifier.cs +++ b/src/Components/Components/src/Forms/FieldIdentifier.cs @@ -18,6 +18,11 @@ public readonly struct FieldIdentifier /// An expression that identifies an object member. public static FieldIdentifier Create(Expression> accessor) { + if (accessor == null) + { + throw new ArgumentNullException(nameof(accessor)); + } + ParseAccessor(accessor, out var model, out var fieldName); return new FieldIdentifier(model, fieldName); } From 4b52cb3306f55c34f34265c57618ec641d57cdd2 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 18 Feb 2019 11:44:12 +0000 Subject: [PATCH 53/70] CR: Use 'in' with FieldIdentifier --- src/Components/Components/src/Forms/EditContext.cs | 8 ++++---- .../Forms/EditContextDataAnnotationsExtensions.cs | 4 ++-- .../src/Forms/EditContextFieldClassExtensions.cs | 2 +- .../Components/src/Forms/FieldChangedEventArgs.cs | 2 +- .../Components/src/Forms/ValidationMessageStore.cs | 12 ++++++------ 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/Components/Components/src/Forms/EditContext.cs b/src/Components/Components/src/Forms/EditContext.cs index 0c0b1dfba8fa..0c01dfb72460 100644 --- a/src/Components/Components/src/Forms/EditContext.cs +++ b/src/Components/Components/src/Forms/EditContext.cs @@ -65,7 +65,7 @@ public FieldIdentifier Field(string fieldName) /// Signals that the value for the specified field has changed. /// /// Identifies the field whose value has been changed. - public void NotifyFieldChanged(FieldIdentifier fieldIdentifier) + public void NotifyFieldChanged(in FieldIdentifier fieldIdentifier) { GetFieldState(fieldIdentifier, ensureExists: true).IsModified = true; OnFieldChanged?.Invoke(this, new FieldChangedEventArgs(fieldIdentifier)); @@ -83,7 +83,7 @@ public void NotifyValidationStateChanged() /// 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) + public void MarkAsUnmodified(in FieldIdentifier fieldIdentifier) { if (_fieldStates.TryGetValue(fieldIdentifier, out var state)) { @@ -162,7 +162,7 @@ public IEnumerable GetValidationMessages(FieldIdentifier fieldIdentifier /// Determines whether the specified fields in this has been modified. ///
/// True if the field has been modified; otherwise false. - public bool IsModified(FieldIdentifier fieldIdentifier) + public bool IsModified(in FieldIdentifier fieldIdentifier) => _fieldStates.TryGetValue(fieldIdentifier, out var state) ? state.IsModified : false; @@ -177,7 +177,7 @@ public bool Validate() return !GetValidationMessages().Any(); } - internal FieldState GetFieldState(FieldIdentifier fieldIdentifier, bool ensureExists) + internal FieldState GetFieldState(in FieldIdentifier fieldIdentifier, bool ensureExists) { if (!_fieldStates.TryGetValue(fieldIdentifier, out var state) && ensureExists) { diff --git a/src/Components/Components/src/Forms/EditContextDataAnnotationsExtensions.cs b/src/Components/Components/src/Forms/EditContextDataAnnotationsExtensions.cs index 03be235f8532..6542114a8dbe 100644 --- a/src/Components/Components/src/Forms/EditContextDataAnnotationsExtensions.cs +++ b/src/Components/Components/src/Forms/EditContextDataAnnotationsExtensions.cs @@ -61,7 +61,7 @@ private static void ValidateModel(EditContext editContext, ValidationMessageStor editContext.NotifyValidationStateChanged(); } - private static void ValidateField(EditContext editContext, ValidationMessageStore messages, FieldIdentifier fieldIdentifier) + private static void ValidateField(EditContext editContext, ValidationMessageStore messages, in FieldIdentifier fieldIdentifier) { if (TryGetValidatableProperty(fieldIdentifier, out var propertyInfo)) { @@ -82,7 +82,7 @@ private static void ValidateField(EditContext editContext, ValidationMessageStor } } - private static bool TryGetValidatableProperty(FieldIdentifier fieldIdentifier, out PropertyInfo propertyInfo) + private static bool TryGetValidatableProperty(in FieldIdentifier fieldIdentifier, out PropertyInfo propertyInfo) { var cacheKey = (ModelType: fieldIdentifier.Model.GetType(), fieldIdentifier.FieldName); if (!_propertyInfoCache.TryGetValue(cacheKey, out propertyInfo)) diff --git a/src/Components/Components/src/Forms/EditContextFieldClassExtensions.cs b/src/Components/Components/src/Forms/EditContextFieldClassExtensions.cs index b01498656dfa..bd925635352f 100644 --- a/src/Components/Components/src/Forms/EditContextFieldClassExtensions.cs +++ b/src/Components/Components/src/Forms/EditContextFieldClassExtensions.cs @@ -30,7 +30,7 @@ public static string FieldClass(this EditContext editContext, Expression /// The . /// An identifier for the field. /// A string that indicates the status of the field. - public static string FieldClass(this EditContext editContext, FieldIdentifier fieldIdentifier) + public static string FieldClass(this EditContext editContext, in FieldIdentifier fieldIdentifier) { var isValid = !editContext.GetValidationMessages(fieldIdentifier).Any(); if (editContext.IsModified(fieldIdentifier)) diff --git a/src/Components/Components/src/Forms/FieldChangedEventArgs.cs b/src/Components/Components/src/Forms/FieldChangedEventArgs.cs index 0a06d3c9f8de..9bf18dd486fb 100644 --- a/src/Components/Components/src/Forms/FieldChangedEventArgs.cs +++ b/src/Components/Components/src/Forms/FieldChangedEventArgs.cs @@ -13,7 +13,7 @@ public sealed class FieldChangedEventArgs ///
public FieldIdentifier FieldIdentifier { get; } - internal FieldChangedEventArgs(FieldIdentifier fieldIdentifier) + internal FieldChangedEventArgs(in FieldIdentifier fieldIdentifier) { FieldIdentifier = fieldIdentifier; } diff --git a/src/Components/Components/src/Forms/ValidationMessageStore.cs b/src/Components/Components/src/Forms/ValidationMessageStore.cs index c493d75ebd4a..0143066ee72e 100644 --- a/src/Components/Components/src/Forms/ValidationMessageStore.cs +++ b/src/Components/Components/src/Forms/ValidationMessageStore.cs @@ -30,7 +30,7 @@ public ValidationMessageStore(EditContext editContext) ///
/// The identifier for the field. /// The validation message. - public void Add(FieldIdentifier fieldIdentifier, string message) + public void Add(in FieldIdentifier fieldIdentifier, string message) => GetOrCreateMessagesListForField(fieldIdentifier).Add(message); /// @@ -38,7 +38,7 @@ public void Add(FieldIdentifier fieldIdentifier, string message) /// /// The identifier for the field. /// The validation messages to be added. - public void AddRange(FieldIdentifier fieldIdentifier, IEnumerable messages) + public void AddRange(in FieldIdentifier fieldIdentifier, IEnumerable messages) => GetOrCreateMessagesListForField(fieldIdentifier).AddRange(messages); /// @@ -78,13 +78,13 @@ public void Clear() /// Removes all messages within this for the specified field. /// /// The identifier for the field. - public void Clear(FieldIdentifier fieldIdentifier) + public void Clear(in FieldIdentifier fieldIdentifier) { DissociateFromField(fieldIdentifier); _messages.Remove(fieldIdentifier); } - private List GetOrCreateMessagesListForField(FieldIdentifier fieldIdentifier) + private List GetOrCreateMessagesListForField(in FieldIdentifier fieldIdentifier) { if (!_messages.TryGetValue(fieldIdentifier, out var messagesForField)) { @@ -96,10 +96,10 @@ private List GetOrCreateMessagesListForField(FieldIdentifier fieldIdenti return messagesForField; } - private void AssociateWithField(FieldIdentifier fieldIdentifier) + private void AssociateWithField(in FieldIdentifier fieldIdentifier) => _editContext.GetFieldState(fieldIdentifier, ensureExists: true).AssociateWithValidationMessageStore(this); - private void DissociateFromField(FieldIdentifier fieldIdentifier) + private void DissociateFromField(in FieldIdentifier fieldIdentifier) => _editContext.GetFieldState(fieldIdentifier, ensureExists: false)?.DissociateFromValidationMessageStore(this); } } From fa8e377a6bfe1116765bc73b3d618c48d87dae36 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 18 Feb 2019 11:56:15 +0000 Subject: [PATCH 54/70] CR: Make TryParseValueFromString abstract --- .../src/Forms/InputComponents/InputBase.cs | 4 +--- .../src/Forms/InputComponents/InputCheckbox.cs | 5 +++++ .../src/Forms/InputComponents/InputText.cs | 10 +++++++++- .../src/Forms/InputComponents/InputTextArea.cs | 10 +++++++++- .../Components/test/Forms/InputBaseTest.cs | 18 +++++------------- 5 files changed, 29 insertions(+), 18 deletions(-) diff --git a/src/Components/Components/src/Forms/InputComponents/InputBase.cs b/src/Components/Components/src/Forms/InputComponents/InputBase.cs index 31278224a521..a1c61da5bd16 100644 --- a/src/Components/Components/src/Forms/InputComponents/InputBase.cs +++ b/src/Components/Components/src/Forms/InputComponents/InputBase.cs @@ -142,9 +142,7 @@ protected virtual string FormatValueAsString(T value) /// An instance of . /// If the value could not be parsed, provides a validation error message. /// True if the value could be parsed; otherwise false. - protected virtual bool TryParseValueFromString(string value, out T result, out string validationErrorMessage) - => throw new NotImplementedException($"Components that inherit from {nameof(InputBase)} must override " + - $"{nameof(TryParseValueFromString)} in order to use {nameof(CurrentValueAsString)}."); + protected abstract bool TryParseValueFromString(string value, out T result, out string validationErrorMessage); /// /// Gets a string that indicates the status of the field being edited. This will include diff --git a/src/Components/Components/src/Forms/InputComponents/InputCheckbox.cs b/src/Components/Components/src/Forms/InputComponents/InputCheckbox.cs index 5423ac17f2f3..e5c32c9e0d89 100644 --- a/src/Components/Components/src/Forms/InputComponents/InputCheckbox.cs +++ b/src/Components/Components/src/Forms/InputComponents/InputCheckbox.cs @@ -2,6 +2,7 @@ // 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 { @@ -31,5 +32,9 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) builder.AddAttribute(5, "onchange", BindMethods.SetValueHandler(__value => CurrentValue = __value, CurrentValue)); builder.CloseElement(); } + + /// + protected override bool TryParseValueFromString(string value, out bool result, out string validationErrorMessage) + => throw new NotImplementedException($"This component does not parse string inputs. Bind to the '{nameof(CurrentValue)}' property, not '{nameof(CurrentValueAsString)}'."); } } diff --git a/src/Components/Components/src/Forms/InputComponents/InputText.cs b/src/Components/Components/src/Forms/InputComponents/InputText.cs index 424fda81d760..44c7eaf6ea5f 100644 --- a/src/Components/Components/src/Forms/InputComponents/InputText.cs +++ b/src/Components/Components/src/Forms/InputComponents/InputText.cs @@ -7,7 +7,7 @@ namespace Microsoft.AspNetCore.Components.Forms { // TODO: Support maxlength etc. - /* This is exactly equivalent to a .razor file containing: + /* This is almost equivalent to a .razor file containing: * * @inherits InputBase * @@ -32,5 +32,13 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) builder.AddAttribute(4, "onchange", BindMethods.SetValueHandler(__value => CurrentValue = __value, CurrentValue)); builder.CloseElement(); } + + /// + protected override bool TryParseValueFromString(string value, out string result, out string validationErrorMessage) + { + result = value; + validationErrorMessage = null; + return true; + } } } diff --git a/src/Components/Components/src/Forms/InputComponents/InputTextArea.cs b/src/Components/Components/src/Forms/InputComponents/InputTextArea.cs index 49114bdd5be7..2ddc5cf3befb 100644 --- a/src/Components/Components/src/Forms/InputComponents/InputTextArea.cs +++ b/src/Components/Components/src/Forms/InputComponents/InputTextArea.cs @@ -7,7 +7,7 @@ namespace Microsoft.AspNetCore.Components.Forms { // TODO: Support rows/cols/etc - /* This is exactly equivalent to a .razor file containing: + /* This is almost equivalent to a .razor file containing: * * @inherits InputBase * @@ -32,5 +32,13 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) builder.AddAttribute(4, "onchange", BindMethods.SetValueHandler(__value => CurrentValue = __value, CurrentValue)); builder.CloseElement(); } + + /// + protected override bool TryParseValueFromString(string value, out string result, out string validationErrorMessage) + { + result = value; + validationErrorMessage = null; + return true; + } } } diff --git a/src/Components/Components/test/Forms/InputBaseTest.cs b/src/Components/Components/test/Forms/InputBaseTest.cs index a3723b243fe4..8a053aa57bfc 100644 --- a/src/Components/Components/test/Forms/InputBaseTest.cs +++ b/src/Components/Components/test/Forms/InputBaseTest.cs @@ -282,19 +282,6 @@ public async Task CssClassCombinesClassWithFieldClass() Assert.Equal("my-class other-class modified valid", inputComponent.CssClass); } - [Fact] - public async Task CannotUseCurrentValueAsStringWithoutOverridingTryParseValueFromString() - { - // Arrange - var model = new TestModel(); - var rootComponent = new TestInputHostComponent> { EditContext = new EditContext(model), ValueExpression = () => model.StringProperty }; - var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent); - - // Act/Assert - var ex = Assert.Throws(() => { inputComponent.CurrentValueAsString = "something"; }); - Assert.Contains($"must override TryParseValueFromString", ex.Message); - } - [Fact] public async Task SuppliesCurrentValueAsStringWithFormatting() { @@ -424,6 +411,11 @@ class TestInputComponent : InputBase public new FieldIdentifier FieldIdentifier => base.FieldIdentifier; public new string FieldClass => base.FieldClass; + + protected override bool TryParseValueFromString(string value, out T result, out string validationErrorMessage) + { + throw new NotImplementedException(); + } } class TestDateInputComponent : TestInputComponent From f39239b1d9bc1608876db1f0eb97bba7fa76e7ed Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 18 Feb 2019 12:02:31 +0000 Subject: [PATCH 55/70] CR: Seal ValidationMessageStore --- src/Components/Components/src/Forms/ValidationMessageStore.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Components/Components/src/Forms/ValidationMessageStore.cs b/src/Components/Components/src/Forms/ValidationMessageStore.cs index 0143066ee72e..2b520d68df5a 100644 --- a/src/Components/Components/src/Forms/ValidationMessageStore.cs +++ b/src/Components/Components/src/Forms/ValidationMessageStore.cs @@ -11,7 +11,7 @@ namespace Microsoft.AspNetCore.Components.Forms /// /// Holds validation messages for an . /// - public class ValidationMessageStore + public sealed class ValidationMessageStore { private readonly EditContext _editContext; private readonly Dictionary> _messages = new Dictionary>(); From a072653523243c4d6b925027d1017320d5cb8c98 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 18 Feb 2019 12:05:22 +0000 Subject: [PATCH 56/70] CR: Avoid use of .Values --- src/Components/Components/src/Forms/EditContext.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Components/Components/src/Forms/EditContext.cs b/src/Components/Components/src/Forms/EditContext.cs index 0c01dfb72460..fd09a241f413 100644 --- a/src/Components/Components/src/Forms/EditContext.cs +++ b/src/Components/Components/src/Forms/EditContext.cs @@ -96,9 +96,9 @@ public void MarkAsUnmodified(in FieldIdentifier fieldIdentifier) /// public void MarkAsUnmodified() { - foreach (var state in _fieldStates.Values) + foreach (var state in _fieldStates) { - state.IsModified = false; + state.Value.IsModified = false; } } From 39ab4dc06a0d8b802c790d89f91d89baa5e1acc9 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 18 Feb 2019 13:48:11 +0000 Subject: [PATCH 57/70] Fix Blazor test --- .../Blazor/Build/test/RuntimeDependenciesResolverTest.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Components/Blazor/Build/test/RuntimeDependenciesResolverTest.cs b/src/Components/Blazor/Build/test/RuntimeDependenciesResolverTest.cs index c7435cacdf66..8f00691a2852 100644 --- a/src/Components/Blazor/Build/test/RuntimeDependenciesResolverTest.cs +++ b/src/Components/Blazor/Build/test/RuntimeDependenciesResolverTest.cs @@ -84,6 +84,8 @@ uncalled implementation code from mscorlib.dll anyway. "System.Collections.dll", "System.ComponentModel.Composition.dll", "System.ComponentModel.dll", + "System.ComponentModel.Annotations.dll", + "System.ComponentModel.DataAnnotations.dll", "System.Core.dll", "System.Data.dll", "System.Diagnostics.Debug.dll", From 283a7dc5653596454306f2f2dcec74db61ecf01c Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 18 Feb 2019 13:58:17 +0000 Subject: [PATCH 58/70] CR: In tests, don't make assumpions about DataAnnotations messages --- ...ditContextDataAnnotationsExtensionsTest.cs | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/Components/Components/test/Forms/EditContextDataAnnotationsExtensionsTest.cs b/src/Components/Components/test/Forms/EditContextDataAnnotationsExtensionsTest.cs index 1cd2ef0bdd25..590c14c3318d 100644 --- a/src/Components/Components/test/Forms/EditContextDataAnnotationsExtensionsTest.cs +++ b/src/Components/Components/test/Forms/EditContextDataAnnotationsExtensionsTest.cs @@ -41,17 +41,17 @@ public void GetsValidationMessagesFromDataAnnotations() Assert.Equal(new string[] { - "The RequiredString field is required.", - "The field IntFrom1To100 must be between 1 and 100." + "RequiredString:required", + "IntFrom1To100:range" }, editContext.GetValidationMessages()); - Assert.Equal(new string[] { "The RequiredString field is required." }, + Assert.Equal(new string[] { "RequiredString:required" }, editContext.GetValidationMessages(editContext.Field(nameof(TestModel.RequiredString)))); // This shows we're including non-[Required] properties in the validation results, i.e, // that we're correctly passing "validateAllProperties: true" to DataAnnotations - Assert.Equal(new string[] { "The field IntFrom1To100 must be between 1 and 100." }, + Assert.Equal(new string[] { "IntFrom1To100:range" }, editContext.GetValidationMessages(editContext.Field(nameof(TestModel.IntFrom1To100)))); } @@ -113,7 +113,7 @@ public void PerformsPerPropertyValidationOnFieldChange() // 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()); + Assert.Equal(new[] { "RequiredString:required" }, editContext.GetValidationMessages()); // Act/Assert 2: Fix RequiredString, but only notify about IntFrom1To100 // Only IntFrom1To100 gets validated; messages for RequiredString are left unchanged @@ -122,15 +122,15 @@ public void PerformsPerPropertyValidationOnFieldChange() Assert.Equal(2, onValidationStateChangedCount); Assert.Equal(new string[] { - "The RequiredString field is required.", - "The field IntFrom1To100 must be between 1 and 100." + "RequiredString:required", + "IntFrom1To100:range" }, 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()); + Assert.Equal(new[] { "IntFrom1To100:range" }, editContext.GetValidationMessages()); } [Theory] @@ -157,9 +157,9 @@ public void IgnoresFieldChangesThatDoNotCorrespondToAValidatableProperty(string class TestModel { - [Required] public string RequiredString { get; set; } + [Required(ErrorMessage = "RequiredString:required")] public string RequiredString { get; set; } - [Range(1, 100)] public int IntFrom1To100 { get; set; } + [Range(1, 100, ErrorMessage = "IntFrom1To100:range")] public int IntFrom1To100 { get; set; } #pragma warning disable 649 [Required] public string ThisWillNotBeValidatedBecauseItIsAField; From 4fb9486d9fa739d1f94fd09772df848684302d59 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 18 Feb 2019 14:00:20 +0000 Subject: [PATCH 59/70] CR: Clean up test namespaces --- .../test/Forms/EditContextDataAnnotationsExtensionsTest.cs | 3 +-- src/Components/Components/test/Forms/EditContextTest.cs | 3 +-- src/Components/Components/test/Forms/FieldIdentifierTest.cs | 3 +-- src/Components/Components/test/Forms/InputBaseTest.cs | 3 +-- .../Components/test/Forms/ValidationMessageStoreTest.cs | 3 +-- 5 files changed, 5 insertions(+), 10 deletions(-) diff --git a/src/Components/Components/test/Forms/EditContextDataAnnotationsExtensionsTest.cs b/src/Components/Components/test/Forms/EditContextDataAnnotationsExtensionsTest.cs index 590c14c3318d..bb7837e2f08f 100644 --- a/src/Components/Components/test/Forms/EditContextDataAnnotationsExtensionsTest.cs +++ b/src/Components/Components/test/Forms/EditContextDataAnnotationsExtensionsTest.cs @@ -1,12 +1,11 @@ // 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 +namespace Microsoft.AspNetCore.Components.Forms { public class EditContextDataAnnotationsExtensionsTest { diff --git a/src/Components/Components/test/Forms/EditContextTest.cs b/src/Components/Components/test/Forms/EditContextTest.cs index 51bd9a78358f..5c8e7af36e1d 100644 --- a/src/Components/Components/test/Forms/EditContextTest.cs +++ b/src/Components/Components/test/Forms/EditContextTest.cs @@ -1,12 +1,11 @@ // 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.Linq; using Xunit; -namespace Microsoft.AspNetCore.Components.Tests.Forms +namespace Microsoft.AspNetCore.Components.Forms { public class EditContextTest { diff --git a/src/Components/Components/test/Forms/FieldIdentifierTest.cs b/src/Components/Components/test/Forms/FieldIdentifierTest.cs index 5dfe4d3539e4..1bb5f34c914f 100644 --- a/src/Components/Components/test/Forms/FieldIdentifierTest.cs +++ b/src/Components/Components/test/Forms/FieldIdentifierTest.cs @@ -1,12 +1,11 @@ // 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.Linq.Expressions; using Xunit; -namespace Microsoft.AspNetCore.Components.Tests.Forms +namespace Microsoft.AspNetCore.Components.Forms { public class FieldIdentifierTest { diff --git a/src/Components/Components/test/Forms/InputBaseTest.cs b/src/Components/Components/test/Forms/InputBaseTest.cs index 8a053aa57bfc..b902cce3e46f 100644 --- a/src/Components/Components/test/Forms/InputBaseTest.cs +++ b/src/Components/Components/test/Forms/InputBaseTest.cs @@ -1,7 +1,6 @@ // 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 Microsoft.AspNetCore.Components.RenderTree; using Microsoft.AspNetCore.Components.Test.Helpers; using System; @@ -11,7 +10,7 @@ using System.Threading.Tasks; using Xunit; -namespace Microsoft.AspNetCore.Components.Tests.Forms +namespace Microsoft.AspNetCore.Components.Forms { public class InputBaseTest { diff --git a/src/Components/Components/test/Forms/ValidationMessageStoreTest.cs b/src/Components/Components/test/Forms/ValidationMessageStoreTest.cs index f3822ab89941..75e4ef3452f8 100644 --- a/src/Components/Components/test/Forms/ValidationMessageStoreTest.cs +++ b/src/Components/Components/test/Forms/ValidationMessageStoreTest.cs @@ -1,11 +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. -using Microsoft.AspNetCore.Components.Forms; using System; using Xunit; -namespace Microsoft.AspNetCore.Components.Tests.Forms +namespace Microsoft.AspNetCore.Components.Forms { public class ValidationMessageStoreTest { From 87a71612bf30242a1cc7dd45f4fdf972a2a8c4d9 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 18 Feb 2019 14:01:56 +0000 Subject: [PATCH 60/70] CR: Reorder usings --- src/Components/Components/src/Forms/EditForm.cs | 2 +- .../Components/src/Forms/InputComponents/InputCheckbox.cs | 2 +- .../Components/src/Forms/InputComponents/InputDate.cs | 2 +- .../Components/src/Forms/InputComponents/InputNumber.cs | 2 +- .../Components/src/Forms/InputComponents/InputSelect.cs | 2 +- src/Components/Components/src/Forms/ValidationSummary.cs | 2 +- src/Components/Components/test/Forms/InputBaseTest.cs | 4 ++-- 7 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Components/Components/src/Forms/EditForm.cs b/src/Components/Components/src/Forms/EditForm.cs index d41cdf86ba3d..9ad279a51998 100644 --- a/src/Components/Components/src/Forms/EditForm.cs +++ b/src/Components/Components/src/Forms/EditForm.cs @@ -1,9 +1,9 @@ // 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; +using Microsoft.AspNetCore.Components.RenderTree; namespace Microsoft.AspNetCore.Components.Forms { diff --git a/src/Components/Components/src/Forms/InputComponents/InputCheckbox.cs b/src/Components/Components/src/Forms/InputComponents/InputCheckbox.cs index e5c32c9e0d89..4ccef84347f9 100644 --- a/src/Components/Components/src/Forms/InputComponents/InputCheckbox.cs +++ b/src/Components/Components/src/Forms/InputComponents/InputCheckbox.cs @@ -1,8 +1,8 @@ // 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 Microsoft.AspNetCore.Components.RenderTree; namespace Microsoft.AspNetCore.Components.Forms { diff --git a/src/Components/Components/src/Forms/InputComponents/InputDate.cs b/src/Components/Components/src/Forms/InputComponents/InputDate.cs index 040b21e3e79e..b193f2333e77 100644 --- a/src/Components/Components/src/Forms/InputComponents/InputDate.cs +++ b/src/Components/Components/src/Forms/InputComponents/InputDate.cs @@ -1,8 +1,8 @@ // 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 Microsoft.AspNetCore.Components.RenderTree; namespace Microsoft.AspNetCore.Components.Forms { diff --git a/src/Components/Components/src/Forms/InputComponents/InputNumber.cs b/src/Components/Components/src/Forms/InputComponents/InputNumber.cs index ad003b273bf1..e8394ab6b309 100644 --- a/src/Components/Components/src/Forms/InputComponents/InputNumber.cs +++ b/src/Components/Components/src/Forms/InputComponents/InputNumber.cs @@ -1,9 +1,9 @@ // 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.Globalization; +using Microsoft.AspNetCore.Components.RenderTree; namespace Microsoft.AspNetCore.Components.Forms { diff --git a/src/Components/Components/src/Forms/InputComponents/InputSelect.cs b/src/Components/Components/src/Forms/InputComponents/InputSelect.cs index 77f757209332..c396ba0b994f 100644 --- a/src/Components/Components/src/Forms/InputComponents/InputSelect.cs +++ b/src/Components/Components/src/Forms/InputComponents/InputSelect.cs @@ -1,8 +1,8 @@ // 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 Microsoft.AspNetCore.Components.RenderTree; namespace Microsoft.AspNetCore.Components.Forms { diff --git a/src/Components/Components/src/Forms/ValidationSummary.cs b/src/Components/Components/src/Forms/ValidationSummary.cs index 6a5d40dbac49..094b7779fcf3 100644 --- a/src/Components/Components/src/Forms/ValidationSummary.cs +++ b/src/Components/Components/src/Forms/ValidationSummary.cs @@ -1,8 +1,8 @@ // 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 Microsoft.AspNetCore.Components.RenderTree; namespace Microsoft.AspNetCore.Components.Forms { diff --git a/src/Components/Components/test/Forms/InputBaseTest.cs b/src/Components/Components/test/Forms/InputBaseTest.cs index b902cce3e46f..cf0e33a0b614 100644 --- a/src/Components/Components/test/Forms/InputBaseTest.cs +++ b/src/Components/Components/test/Forms/InputBaseTest.cs @@ -1,13 +1,13 @@ // 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 Microsoft.AspNetCore.Components.Test.Helpers; using System; using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; using System.Threading.Tasks; +using Microsoft.AspNetCore.Components.RenderTree; +using Microsoft.AspNetCore.Components.Test.Helpers; using Xunit; namespace Microsoft.AspNetCore.Components.Forms From 5573ebe9d9c89f7b0030814a87ff78ee9df8cb76 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 18 Feb 2019 14:14:33 +0000 Subject: [PATCH 61/70] Update build config --- eng/Version.Details.xml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index bee29e5095fd..27bde9dc1a6b 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -293,6 +293,10 @@ https://github.com/dotnet/corefx 0abec4390b30fdda97dc496594f9b1f9c9b20e17 + + https://github.com/dotnet/corefx + 000000 + https://github.com/dotnet/corefx 000000 From 454ee02ac90ae761280437572535683b15f2966b Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 18 Feb 2019 14:56:20 +0000 Subject: [PATCH 62/70] E2E test for simple validation scenario --- .../test/E2ETest/Tests/FormsTest.cs | 67 +++++++++++++++++++ .../SimpleValidationComponent.cshtml | 8 +-- 2 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 src/Components/test/E2ETest/Tests/FormsTest.cs diff --git a/src/Components/test/E2ETest/Tests/FormsTest.cs b/src/Components/test/E2ETest/Tests/FormsTest.cs new file mode 100644 index 000000000000..868c18b5b2e8 --- /dev/null +++ b/src/Components/test/E2ETest/Tests/FormsTest.cs @@ -0,0 +1,67 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using BasicTestApp; +using BasicTestApp.FormsTest; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures; +using OpenQA.Selenium; +using OpenQA.Selenium.Support.UI; +using System.Linq; +using System.Threading.Tasks; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.AspNetCore.Components.E2ETest.Tests +{ + public class FormsTest : BasicTestAppTestBase + { + public FormsTest( + BrowserFixture browserFixture, + ToggleExecutionModeServerFixture serverFixture, + ITestOutputHelper output) + : base(browserFixture, serverFixture, output) + { + // On WebAssembly, page reloads are expensive so skip if possible + Navigate(ServerPathBase, noReload: !serverFixture.UsingAspNetHost); + } + + [Fact] + public async Task EditFormWorksWithDataAnnotationsValidator() + { + var appElement = MountTestComponent(); + var userNameInput = appElement.FindElement(By.ClassName("user-name")).FindElement(By.TagName("input")); + var acceptsTermsInput = appElement.FindElement(By.ClassName("accepts-terms")).FindElement(By.TagName("input")); + var submitButton = appElement.FindElement(By.TagName("button")); + + // Editing a field doesn't trigger validation on its own + userNameInput.SendKeys("Bert\t"); + acceptsTermsInput.Click(); // Accept terms + acceptsTermsInput.Click(); // Un-accept terms + await Task.Delay(500); // There's no expected change to the UI, so just wait a moment before asserting + Assert.Empty(appElement.FindElements(By.ClassName("validation-message"))); + Assert.Empty(appElement.FindElements(By.Id("last-callback"))); + + // Submitting the form does validate + submitButton.Click(); + WaitAssert.Collection(() => appElement.FindElements(By.ClassName("validation-message")), + li => Assert.Equal("You must accept the terms", li.Text)); + WaitAssert.Equal("OnInvalidSubmit", () => appElement.FindElement(By.Id("last-callback")).Text); + + // Can make another field invalid + userNameInput.Clear(); + submitButton.Click(); + WaitAssert.Collection(() => appElement.FindElements(By.ClassName("validation-message")).OrderBy(x => x.Text), + li => Assert.Equal("Please choose a username", li.Text), + li => Assert.Equal("You must accept the terms", li.Text)); + WaitAssert.Equal("OnInvalidSubmit", () => appElement.FindElement(By.Id("last-callback")).Text); + + // Can make valid + userNameInput.SendKeys("Bert\t"); + acceptsTermsInput.Click(); + submitButton.Click(); + WaitAssert.Empty(() => appElement.FindElements(By.ClassName("validation-message"))); + WaitAssert.Equal("OnValidSubmit", () => appElement.FindElement(By.Id("last-callback")).Text); + } + } +} diff --git a/src/Components/test/testassets/BasicTestApp/FormsTest/SimpleValidationComponent.cshtml b/src/Components/test/testassets/BasicTestApp/FormsTest/SimpleValidationComponent.cshtml index c8f77a149667..faca63ad32ad 100644 --- a/src/Components/test/testassets/BasicTestApp/FormsTest/SimpleValidationComponent.cshtml +++ b/src/Components/test/testassets/BasicTestApp/FormsTest/SimpleValidationComponent.cshtml @@ -4,10 +4,10 @@ -

+

User name:

-

+

Accept terms:

@@ -17,7 +17,7 @@
    @foreach (var message in context.GetValidationMessages()) { -
  • @message
  • +
  • @message
  • }
@@ -31,7 +31,7 @@ @functions { string lastCallback; - [Required] + [Required(ErrorMessage = "Please choose a username")] public string UserName { get; set; } [Required] From 1c685696aa7df763c5a07a835ffca5c6987d085c Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 18 Feb 2019 15:44:15 +0000 Subject: [PATCH 63/70] E2E tests for built-in Input* components --- .../test/E2ETest/Tests/FormsTest.cs | 198 ++++++++++++++++++ .../TypicalValidationComponent.cshtml | 22 +- 2 files changed, 209 insertions(+), 11 deletions(-) diff --git a/src/Components/test/E2ETest/Tests/FormsTest.cs b/src/Components/test/E2ETest/Tests/FormsTest.cs index 868c18b5b2e8..3631dd82a847 100644 --- a/src/Components/test/E2ETest/Tests/FormsTest.cs +++ b/src/Components/test/E2ETest/Tests/FormsTest.cs @@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures; using OpenQA.Selenium; using OpenQA.Selenium.Support.UI; +using System; using System.Linq; using System.Threading.Tasks; using Xunit; @@ -63,5 +64,202 @@ public async Task EditFormWorksWithDataAnnotationsValidator() WaitAssert.Empty(() => appElement.FindElements(By.ClassName("validation-message"))); WaitAssert.Equal("OnValidSubmit", () => appElement.FindElement(By.Id("last-callback")).Text); } + + [Fact] + public void InputTextInteractsWithEditContext() + { + var appElement = MountTestComponent(); + var nameInput = appElement.FindElement(By.ClassName("name")).FindElement(By.TagName("input")); + var messagesAccessor = CreateValidationMessagesAccessor(appElement); + + // Validates on edit + WaitAssert.Equal("valid", () => nameInput.GetAttribute("class")); + nameInput.SendKeys("Bert\t"); + WaitAssert.Equal("modified valid", () => nameInput.GetAttribute("class")); + + // Can become invalid + nameInput.SendKeys("01234567890123456789\t"); + WaitAssert.Equal("modified invalid", () => nameInput.GetAttribute("class")); + WaitAssert.Equal(new[] { "That name is too long" }, messagesAccessor); + + // Can become valid + nameInput.Clear(); + nameInput.SendKeys("Bert\t"); + WaitAssert.Equal("modified valid", () => nameInput.GetAttribute("class")); + WaitAssert.Empty(messagesAccessor); + } + + [Fact] + public void InputNumberInteractsWithEditContext_NonNullableInt() + { + var appElement = MountTestComponent(); + var ageInput = appElement.FindElement(By.ClassName("age")).FindElement(By.TagName("input")); + var messagesAccessor = CreateValidationMessagesAccessor(appElement); + + // Validates on edit + WaitAssert.Equal("valid", () => ageInput.GetAttribute("class")); + ageInput.SendKeys("123\t"); + WaitAssert.Equal("modified valid", () => ageInput.GetAttribute("class")); + + // Can become invalid + ageInput.SendKeys("e100\t"); + WaitAssert.Equal("modified invalid", () => ageInput.GetAttribute("class")); + WaitAssert.Equal(new[] { "The AgeInYears field must be a number." }, messagesAccessor); + + // Empty is invalid, because it's not a nullable int + ageInput.Clear(); + ageInput.SendKeys("\t"); + WaitAssert.Equal("modified invalid", () => ageInput.GetAttribute("class")); + WaitAssert.Equal(new[] { "The AgeInYears field must be a number." }, messagesAccessor); + + // Zero is within the allowed range + ageInput.SendKeys("0\t"); + WaitAssert.Equal("modified valid", () => ageInput.GetAttribute("class")); + WaitAssert.Empty(messagesAccessor); + } + + [Fact] + public void InputNumberInteractsWithEditContext_NullableFloat() + { + var appElement = MountTestComponent(); + var heightInput = appElement.FindElement(By.ClassName("height")).FindElement(By.TagName("input")); + var messagesAccessor = CreateValidationMessagesAccessor(appElement); + + // Validates on edit + WaitAssert.Equal("valid", () => heightInput.GetAttribute("class")); + heightInput.SendKeys("123.456\t"); + WaitAssert.Equal("modified valid", () => heightInput.GetAttribute("class")); + + // Can become invalid + heightInput.SendKeys("e100\t"); + WaitAssert.Equal("modified invalid", () => heightInput.GetAttribute("class")); + WaitAssert.Equal(new[] { "The OptionalHeight field must be a number." }, messagesAccessor); + + // Empty is valid, because it's a nullable float + heightInput.Clear(); + heightInput.SendKeys("\t"); + WaitAssert.Equal("modified valid", () => heightInput.GetAttribute("class")); + WaitAssert.Empty(messagesAccessor); + } + + [Fact] + public void InputTextAreaInteractsWithEditContext() + { + var appElement = MountTestComponent(); + var descriptionInput = appElement.FindElement(By.ClassName("description")).FindElement(By.TagName("textarea")); + var messagesAccessor = CreateValidationMessagesAccessor(appElement); + + // Validates on edit + WaitAssert.Equal("valid", () => descriptionInput.GetAttribute("class")); + descriptionInput.SendKeys("Hello\t"); + WaitAssert.Equal("modified valid", () => descriptionInput.GetAttribute("class")); + + // Can become invalid + descriptionInput.SendKeys("too long too long too long too long too long\t"); + WaitAssert.Equal("modified invalid", () => descriptionInput.GetAttribute("class")); + WaitAssert.Equal(new[] { "Description is max 20 chars" }, messagesAccessor); + + // Can become valid + descriptionInput.Clear(); + descriptionInput.SendKeys("Hello\t"); + WaitAssert.Equal("modified valid", () => descriptionInput.GetAttribute("class")); + WaitAssert.Empty(messagesAccessor); + } + + [Fact] + public void InputDateInteractsWithEditContext_NonNullableDateTime() + { + var appElement = MountTestComponent(); + var renewalDateInput = appElement.FindElement(By.ClassName("renewal-date")).FindElement(By.TagName("input")); + var messagesAccessor = CreateValidationMessagesAccessor(appElement); + + // Validates on edit + WaitAssert.Equal("valid", () => renewalDateInput.GetAttribute("class")); + renewalDateInput.SendKeys("01/01/2000\t"); + WaitAssert.Equal("modified valid", () => renewalDateInput.GetAttribute("class")); + + // Can become invalid + renewalDateInput.SendKeys("0/0/0"); + WaitAssert.Equal("modified invalid", () => renewalDateInput.GetAttribute("class")); + WaitAssert.Equal(new[] { "The RenewalDate field must be a date." }, messagesAccessor); + + // Empty is invalid, because it's not nullable + renewalDateInput.SendKeys($"{Keys.Backspace}\t{Keys.Backspace}\t{Keys.Backspace}\t"); + WaitAssert.Equal("modified invalid", () => renewalDateInput.GetAttribute("class")); + WaitAssert.Equal(new[] { "The RenewalDate field must be a date." }, messagesAccessor); + + // Can become valid + renewalDateInput.SendKeys("01/01/01\t"); + WaitAssert.Equal("modified valid", () => renewalDateInput.GetAttribute("class")); + WaitAssert.Empty(messagesAccessor); + } + + [Fact] + public void InputDateInteractsWithEditContext_NullableDateTimeOffset() + { + var appElement = MountTestComponent(); + var expiryDateInput = appElement.FindElement(By.ClassName("expiry-date")).FindElement(By.TagName("input")); + var messagesAccessor = CreateValidationMessagesAccessor(appElement); + + // Validates on edit + WaitAssert.Equal("valid", () => expiryDateInput.GetAttribute("class")); + expiryDateInput.SendKeys("01/01/2000\t"); + WaitAssert.Equal("modified valid", () => expiryDateInput.GetAttribute("class")); + + // Can become invalid + expiryDateInput.SendKeys("111111111"); + WaitAssert.Equal("modified invalid", () => expiryDateInput.GetAttribute("class")); + WaitAssert.Equal(new[] { "The OptionalExpiryDate field must be a date." }, messagesAccessor); + + // Empty is valid, because it's nullable + expiryDateInput.SendKeys($"{Keys.Backspace}\t{Keys.Backspace}\t{Keys.Backspace}\t"); + WaitAssert.Equal("modified valid", () => expiryDateInput.GetAttribute("class")); + WaitAssert.Empty(messagesAccessor); + } + + [Fact] + public void InputSelectInteractsWithEditContext() + { + var appElement = MountTestComponent(); + var ticketClassInput = new SelectElement(appElement.FindElement(By.ClassName("ticket-class")).FindElement(By.TagName("select"))); + var select = ticketClassInput.WrappedElement; + var messagesAccessor = CreateValidationMessagesAccessor(appElement); + + // Validates on edit + WaitAssert.Equal("valid", () => select.GetAttribute("class")); + ticketClassInput.SelectByText("First class"); + WaitAssert.Equal("modified valid", () => select.GetAttribute("class")); + + // Can become invalid + ticketClassInput.SelectByText("(select)"); + WaitAssert.Equal("modified invalid", () => select.GetAttribute("class")); + WaitAssert.Equal(new[] { "The TicketClass field is not valid." }, messagesAccessor); + } + + [Fact] + public void InputCheckboxInteractsWithEditContext() + { + var appElement = MountTestComponent(); + var acceptsTermsInput = appElement.FindElement(By.ClassName("accepts-terms")).FindElement(By.TagName("input")); + var messagesAccessor = CreateValidationMessagesAccessor(appElement); + + // Validates on edit + WaitAssert.Equal("valid", () => acceptsTermsInput.GetAttribute("class")); + acceptsTermsInput.Click(); + WaitAssert.Equal("modified valid", () => acceptsTermsInput.GetAttribute("class")); + + // Can become invalid + acceptsTermsInput.Click(); + WaitAssert.Equal("modified invalid", () => acceptsTermsInput.GetAttribute("class")); + WaitAssert.Equal(new[] { "Must accept terms" }, messagesAccessor); + } + + private Func CreateValidationMessagesAccessor(IWebElement appElement) + { + return () => appElement.FindElements(By.ClassName("validation-message")) + .Select(x => x.Text) + .OrderBy(x => x) + .ToArray(); + } } } diff --git a/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.cshtml b/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.cshtml index 5421f1cb8da6..a2ce4977782f 100644 --- a/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.cshtml +++ b/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.cshtml @@ -4,25 +4,25 @@ -

+

Name:

-

+

Age (years):

-

+

Height (optional):

-

+

Description:

-

+

Renewal date:

-

+

Expiry date (optional):

-

+

Ticket class: @@ -31,7 +31,7 @@

-

+

Accepts terms:

@@ -48,10 +48,10 @@ // Usually this would be in a different file class Person { - [Required, StringLength(10)] + [Required(ErrorMessage = "Enter a name"), StringLength(10, ErrorMessage = "That name is too long")] public string Name { get; set; } - [Range(0, 200)] + [Range(0, 200, ErrorMessage = "Nobody is that old")] public int AgeInYears { get; set; } public float? OptionalHeight { get; set; } @@ -63,7 +63,7 @@ [Required, Range(typeof(bool), "true", "true", ErrorMessage = "Must accept terms")] public bool AcceptsTerms { get; set; } - [Required, StringLength(20)] + [Required, StringLength(20, ErrorMessage = "Description is max 20 chars")] public string Description { get; set; } [Required, EnumDataType(typeof(TicketClass))] From 1b5f9652e51bf80604bdc8f991c09bcbc8ec1652 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 18 Feb 2019 15:55:13 +0000 Subject: [PATCH 64/70] E2E test for INotifyPropertyChanged integration scenario --- .../test/E2ETest/Tests/FormsTest.cs | 49 ++++++++++++++++--- ...yPropertyChangedValidationComponent.cshtml | 8 +-- 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/src/Components/test/E2ETest/Tests/FormsTest.cs b/src/Components/test/E2ETest/Tests/FormsTest.cs index 3631dd82a847..9e03a56b2388 100644 --- a/src/Components/test/E2ETest/Tests/FormsTest.cs +++ b/src/Components/test/E2ETest/Tests/FormsTest.cs @@ -34,34 +34,32 @@ public async Task EditFormWorksWithDataAnnotationsValidator() var userNameInput = appElement.FindElement(By.ClassName("user-name")).FindElement(By.TagName("input")); var acceptsTermsInput = appElement.FindElement(By.ClassName("accepts-terms")).FindElement(By.TagName("input")); var submitButton = appElement.FindElement(By.TagName("button")); + var messagesAccessor = CreateValidationMessagesAccessor(appElement); // Editing a field doesn't trigger validation on its own userNameInput.SendKeys("Bert\t"); acceptsTermsInput.Click(); // Accept terms acceptsTermsInput.Click(); // Un-accept terms await Task.Delay(500); // There's no expected change to the UI, so just wait a moment before asserting - Assert.Empty(appElement.FindElements(By.ClassName("validation-message"))); + WaitAssert.Empty(messagesAccessor); Assert.Empty(appElement.FindElements(By.Id("last-callback"))); // Submitting the form does validate submitButton.Click(); - WaitAssert.Collection(() => appElement.FindElements(By.ClassName("validation-message")), - li => Assert.Equal("You must accept the terms", li.Text)); + WaitAssert.Equal(new[] { "You must accept the terms" }, messagesAccessor); WaitAssert.Equal("OnInvalidSubmit", () => appElement.FindElement(By.Id("last-callback")).Text); // Can make another field invalid userNameInput.Clear(); submitButton.Click(); - WaitAssert.Collection(() => appElement.FindElements(By.ClassName("validation-message")).OrderBy(x => x.Text), - li => Assert.Equal("Please choose a username", li.Text), - li => Assert.Equal("You must accept the terms", li.Text)); + WaitAssert.Equal(new[] { "Please choose a username", "You must accept the terms" }, messagesAccessor); WaitAssert.Equal("OnInvalidSubmit", () => appElement.FindElement(By.Id("last-callback")).Text); // Can make valid userNameInput.SendKeys("Bert\t"); acceptsTermsInput.Click(); submitButton.Click(); - WaitAssert.Empty(() => appElement.FindElements(By.ClassName("validation-message"))); + WaitAssert.Empty(messagesAccessor); WaitAssert.Equal("OnValidSubmit", () => appElement.FindElement(By.Id("last-callback")).Text); } @@ -254,6 +252,43 @@ public void InputCheckboxInteractsWithEditContext() WaitAssert.Equal(new[] { "Must accept terms" }, messagesAccessor); } + [Fact] + public void CanWireUpINotifyPropertyChangedToEditContext() + { + var appElement = MountTestComponent(); + var userNameInput = appElement.FindElement(By.ClassName("user-name")).FindElement(By.TagName("input")); + var acceptsTermsInput = appElement.FindElement(By.ClassName("accepts-terms")).FindElement(By.TagName("input")); + var submitButton = appElement.FindElement(By.TagName("button")); + var messagesAccessor = CreateValidationMessagesAccessor(appElement); + var submissionStatus = appElement.FindElement(By.Id("submission-status")); + + // Editing a field triggers validation immediately + WaitAssert.Equal("valid", () => userNameInput.GetAttribute("class")); + userNameInput.SendKeys("Too long too long\t"); + WaitAssert.Equal("modified invalid", () => userNameInput.GetAttribute("class")); + WaitAssert.Equal(new[] { "That name is too long" }, messagesAccessor); + + // Submitting the form validates remaining fields + submitButton.Click(); + WaitAssert.Equal(new[] { "That name is too long", "You must accept the terms" }, messagesAccessor); + WaitAssert.Equal("modified invalid", () => userNameInput.GetAttribute("class")); + WaitAssert.Equal("invalid", () => acceptsTermsInput.GetAttribute("class")); + + // Can make fields valid + userNameInput.Clear(); + userNameInput.SendKeys("Bert\t"); + WaitAssert.Equal("modified valid", () => userNameInput.GetAttribute("class")); + acceptsTermsInput.Click(); + WaitAssert.Equal("modified valid", () => acceptsTermsInput.GetAttribute("class")); + WaitAssert.Equal(string.Empty, () => submissionStatus.Text); + submitButton.Click(); + WaitAssert.True(() => submissionStatus.Text.StartsWith("Submitted")); + + // Fields can revert to unmodified + WaitAssert.Equal("valid", () => userNameInput.GetAttribute("class")); + WaitAssert.Equal("valid", () => acceptsTermsInput.GetAttribute("class")); + } + private Func CreateValidationMessagesAccessor(IWebElement appElement) { return () => appElement.FindElements(By.ClassName("validation-message")) diff --git a/src/Components/test/testassets/BasicTestApp/FormsTest/NotifyPropertyChangedValidationComponent.cshtml b/src/Components/test/testassets/BasicTestApp/FormsTest/NotifyPropertyChangedValidationComponent.cshtml index 012ba7cb1020..e921bc58aaa0 100644 --- a/src/Components/test/testassets/BasicTestApp/FormsTest/NotifyPropertyChangedValidationComponent.cshtml +++ b/src/Components/test/testassets/BasicTestApp/FormsTest/NotifyPropertyChangedValidationComponent.cshtml @@ -16,11 +16,11 @@

-

+

User name:

-

+

Accept terms:

@@ -32,7 +32,7 @@
-@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:

+

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.0 4.4.0 + 4.3.0 4.3.2 4.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