diff --git a/src/HtmlTags.AspNetCore.TestSite/Views/Home/Index.cshtml b/src/HtmlTags.AspNetCore.TestSite/Views/Home/Index.cshtml index f56984d..9ed4030 100644 --- a/src/HtmlTags.AspNetCore.TestSite/Views/Home/Index.cshtml +++ b/src/HtmlTags.AspNetCore.TestSite/Views/Home/Index.cshtml @@ -91,30 +91,34 @@ Html.LinkButton("Cancel", nameof(HomeController.Index)))) Input Tag value: - + Editor for value: @Html.EditorFor(m => m.Value) Input tag blarg: - + Editor for blarg: @Html.EditorFor(m => m.Blarg) Input tag blorg.blorg: - + Editor for blorg.blorg: @Html.EditorFor(m => m.Blorg.Blorg) Input tag Blorgs[0].Blarg: - + Editor for Blorgs[0].Blarg: @Html.EditorFor(m => m.Blorgs[0].Blorg) @Html.Input(m => m.Value).AddClass("foo") Display tag for Value: - + Display for Vlaue: @Html.Display(m => m.Value) Label tag for Value: - + Label for Value: @Html.Label(m => m.Value) + Valiidator tag for Value: + + Validation for Value: + @Html.ValidationMessage(m => m.Value) @{ var tag = new HtmlTag("canvas"); diff --git a/src/HtmlTags.AspNetCore/Conventions/Elements/Builders/ValidationMessageBuilder.cs b/src/HtmlTags.AspNetCore/Conventions/Elements/Builders/ValidationMessageBuilder.cs new file mode 100644 index 0000000..6a64409 --- /dev/null +++ b/src/HtmlTags.AspNetCore/Conventions/Elements/Builders/ValidationMessageBuilder.cs @@ -0,0 +1,59 @@ +using System; +using System.Linq; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.AspNetCore.Mvc.ViewFeatures.Internal; + +namespace HtmlTags.Conventions.Elements.Builders +{ + public class DefaultValidationMessageBuilder : IElementBuilder + { + public HtmlTag Build(ElementRequest request) + { + var viewContext = request.Get() ?? throw new InvalidOperationException("Validation messages require a ViewContext"); + + var formContext = viewContext.ClientValidationEnabled ? viewContext.FormContext : null; + if (!viewContext.ViewData.ModelState.ContainsKey(request.ElementId) && formContext == null) + { + return HtmlTag.Empty(); + } + + var tryGetModelStateResult = viewContext.ViewData.ModelState.TryGetValue(request.ElementId, out var entry); + var modelErrors = tryGetModelStateResult ? entry.Errors : null; + + ModelError modelError = null; + if (modelErrors != null && modelErrors.Count != 0) + { + modelError = modelErrors.FirstOrDefault(m => !string.IsNullOrEmpty(m.ErrorMessage)) ?? modelErrors[0]; + } + + if (modelError == null && formContext == null) + { + return HtmlTag.Empty(); + } + + var tag = new HtmlTag(viewContext.ValidationMessageElement); + + var className = modelError != null ? + HtmlHelper.ValidationMessageCssClassName : + HtmlHelper.ValidationMessageValidCssClassName; + tag.AddClass(className); + + if (modelError != null) + { + var modelExplorer = request.Get() ?? throw new InvalidOperationException("Validation messages require a ModelExplorer"); + tag.Text(ValidationHelpers.GetModelErrorMessageOrDefault(modelError, entry, modelExplorer)); + } + + if (formContext != null) + { + tag.Attr("data-valmsg-for", request.ElementId); + + tag.Attr("data-valmsg-replace", "true"); + } + + return tag; + } + } +} \ No newline at end of file diff --git a/src/HtmlTags.AspNetCore/HtmlHelperExtensions.cs b/src/HtmlTags.AspNetCore/HtmlHelperExtensions.cs index d4b2631..bd2eb5a 100644 --- a/src/HtmlTags.AspNetCore/HtmlHelperExtensions.cs +++ b/src/HtmlTags.AspNetCore/HtmlHelperExtensions.cs @@ -23,6 +23,13 @@ public static HtmlTag Input(this IHtmlHelper helper, Expression(this IHtmlHelper helper, Expression> expression) + where T : class + { + var generator = GetGenerator(helper, expression); + return generator.ValidationMessageFor(expression); + } + public static HtmlTag Label(this IHtmlHelper helper, Expression> expression) where T : class { diff --git a/src/HtmlTags.AspNetCore/ModelMetadataTagExtensions.cs b/src/HtmlTags.AspNetCore/ModelMetadataTagExtensions.cs index f9e3fe2..cc14090 100644 --- a/src/HtmlTags.AspNetCore/ModelMetadataTagExtensions.cs +++ b/src/HtmlTags.AspNetCore/ModelMetadataTagExtensions.cs @@ -11,7 +11,7 @@ namespace HtmlTags { public static class ModelMetadataTagExtensions { - public static void ModelMetadata(this HtmlConventionRegistry registry) + public static HtmlConventionRegistry ModelMetadata(this HtmlConventionRegistry registry) { registry.Labels.Modifier(); registry.Displays.Modifier(); @@ -19,6 +19,8 @@ public static void ModelMetadata(this HtmlConventionRegistry registry) registry.Editors.Modifier(); registry.Editors.Modifier(); registry.Editors.Modifier(); + + return registry; } private class DisplayNameElementModifier : IElementModifier diff --git a/src/HtmlTags.AspNetCore/ModelStateTagExtensions.cs b/src/HtmlTags.AspNetCore/ModelStateTagExtensions.cs new file mode 100644 index 0000000..e5abca6 --- /dev/null +++ b/src/HtmlTags.AspNetCore/ModelStateTagExtensions.cs @@ -0,0 +1,17 @@ +using HtmlTags.Conventions; +using HtmlTags.Conventions.Elements; +using HtmlTags.Conventions.Elements.Builders; + +namespace HtmlTags +{ + public static class ModelStateTagExtensions + { + public static HtmlConventionRegistry ModelState(this HtmlConventionRegistry registry) + { + registry.ValidationMessages.Always.BuildBy(); + registry.ValidationMessages.NamingConvention(new DotNotationElementNamingConvention()); + + return registry; + } + } +} \ No newline at end of file diff --git a/src/HtmlTags.AspNetCore/ServiceCollectionExtensions.cs b/src/HtmlTags.AspNetCore/ServiceCollectionExtensions.cs index e6574c6..f4f284f 100644 --- a/src/HtmlTags.AspNetCore/ServiceCollectionExtensions.cs +++ b/src/HtmlTags.AspNetCore/ServiceCollectionExtensions.cs @@ -6,8 +6,20 @@ public static class ServiceCollectionExtensions { + /// + /// Configures HtmlTags without ASP.NET Core defaults without modifying the library + /// + /// Service collection + /// Convention library + /// Service collection public static IServiceCollection AddHtmlTags(this IServiceCollection services, HtmlConventionLibrary library) => services.AddSingleton(library); + /// + /// Configures HtmlTags with ASP.NET Core defaults + /// + /// Service collection + /// Custom convention registries + /// Service collection public static IServiceCollection AddHtmlTags(this IServiceCollection services, params HtmlConventionRegistry[] registries) { var library = new HtmlConventionLibrary(); @@ -15,18 +27,29 @@ public static IServiceCollection AddHtmlTags(this IServiceCollection services, p { registry.Apply(library); } + + var defaultRegistry = new HtmlConventionRegistry() + .Defaults() + .ModelMetadata() + .ModelState(); + + defaultRegistry.Apply(library); + return services.AddHtmlTags(library); } + /// + /// Configures HtmlTags with ASP.NET Core defaults + /// + /// Service collection + /// Additional configuration callback + /// Service collection public static IServiceCollection AddHtmlTags(this IServiceCollection services, Action config) { var registry = new HtmlConventionRegistry(); config(registry); - registry.Defaults(); - registry.ModelMetadata(); - return services.AddHtmlTags(registry); } } diff --git a/src/HtmlTags.AspNetCore/ValidationMessageTagHelper.cs b/src/HtmlTags.AspNetCore/ValidationMessageTagHelper.cs new file mode 100644 index 0000000..42993cc --- /dev/null +++ b/src/HtmlTags.AspNetCore/ValidationMessageTagHelper.cs @@ -0,0 +1,11 @@ +namespace HtmlTags +{ + using Conventions.Elements; + using Microsoft.AspNetCore.Razor.TagHelpers; + + [HtmlTargetElement("validation-message-tag", Attributes = ForAttributeName, TagStructure = TagStructure.WithoutEndTag)] + public class ValidationMessageTagHelper : HtmlTagTagHelper + { + protected override string Category { get; } = ElementConstants.ValidationMessage; + } +} \ No newline at end of file diff --git a/src/HtmlTags/Cache.cs b/src/HtmlTags/Cache.cs index b6c4db3..2b05fda 100755 --- a/src/HtmlTags/Cache.cs +++ b/src/HtmlTags/Cache.cs @@ -42,9 +42,11 @@ public Cache(IDictionary dictionary) _values = dictionary; } - public Func OnMissing { set { _onMissing = value; } } + public Func OnMissing { set => _onMissing = value; } - public Func GetKey { get { return _getKey; } set { _getKey = value; } } + public Func GetKey { get => _getKey; + set => _getKey = value; + } public int Count => _values.Count; diff --git a/src/HtmlTags/Conventions/ElementGenerator.cs b/src/HtmlTags/Conventions/ElementGenerator.cs index 3c2deae..ffe0340 100644 --- a/src/HtmlTags/Conventions/ElementGenerator.cs +++ b/src/HtmlTags/Conventions/ElementGenerator.cs @@ -17,7 +17,7 @@ private ElementGenerator(ITagGenerator tags) public static ElementGenerator For(HtmlConventionLibrary library, Func serviceLocator = null, T model = null) { - serviceLocator = serviceLocator ?? (Activator.CreateInstance); + serviceLocator = serviceLocator ?? Activator.CreateInstance; var tags = new TagGenerator(library.TagLibrary, new ActiveProfile(), serviceLocator); @@ -33,6 +33,9 @@ public HtmlTag LabelFor(Expression> expression, string public HtmlTag InputFor(Expression> expression, string profile = null, T model = null) => Build(expression, ElementConstants.Editor, profile, model); + public HtmlTag ValidationMessageFor(Expression> expression, string profile = null, T model = null) + => Build(expression, ElementConstants.ValidationMessage, profile, model); + public HtmlTag DisplayFor(Expression> expression, string profile = null, T model = null) => Build(expression, ElementConstants.Display, profile, model); @@ -41,8 +44,8 @@ public HtmlTag TagFor(Expression> expression, string c public T Model { - get { return _model.Value; } - set { _model = new Lazy(() => value); } + get => _model.Value; + set => _model = new Lazy(() => value); } public ElementRequest GetRequest(Expression> expression, T model = null) @@ -70,6 +73,8 @@ private HtmlTag Build(ElementRequest request, string category, string profile = public HtmlTag InputFor(ElementRequest request, string profile = null, T model = null) => Build(request, ElementConstants.Editor, profile, model); + public HtmlTag ValidationMessageFor(ElementRequest request, string profile = null, T model = null) => Build(request, ElementConstants.ValidationMessage, profile, model); + public HtmlTag DisplayFor(ElementRequest request, string profile = null, T model = null) => Build(request, ElementConstants.Display, profile, model); public HtmlTag TagFor(ElementRequest request, string category, string profile = null, T model = null) => Build(request, category, profile, model); diff --git a/src/HtmlTags/Conventions/Elements/Builders/TextboxBuilder.cs b/src/HtmlTags/Conventions/Elements/Builders/TextboxBuilder.cs index e50ab45..4e5195a 100644 --- a/src/HtmlTags/Conventions/Elements/Builders/TextboxBuilder.cs +++ b/src/HtmlTags/Conventions/Elements/Builders/TextboxBuilder.cs @@ -2,8 +2,6 @@ namespace HtmlTags.Conventions.Elements.Builders { public class TextboxBuilder : IElementBuilder { - public bool Matches(ElementRequest subject) => true; - public HtmlTag Build(ElementRequest request) { return new TextboxTag().Attr("value", (request.RawValue ?? string.Empty).ToString()); diff --git a/src/HtmlTags/Conventions/Elements/ElementConstants.cs b/src/HtmlTags/Conventions/Elements/ElementConstants.cs index 1f0270c..7182995 100644 --- a/src/HtmlTags/Conventions/Elements/ElementConstants.cs +++ b/src/HtmlTags/Conventions/Elements/ElementConstants.cs @@ -5,6 +5,7 @@ public static class ElementConstants public static readonly string Label = "Label"; public static readonly string Display = "Display"; public static readonly string Editor = "Editor"; + public static readonly string ValidationMessage = "ValidationMessage"; public static readonly string Templates = "Templates"; } diff --git a/src/HtmlTags/Conventions/Elements/IElementGenerator.cs b/src/HtmlTags/Conventions/Elements/IElementGenerator.cs index ef97b48..62b93a9 100644 --- a/src/HtmlTags/Conventions/Elements/IElementGenerator.cs +++ b/src/HtmlTags/Conventions/Elements/IElementGenerator.cs @@ -8,11 +8,13 @@ public interface IElementGenerator where T : class T Model { get; set; } HtmlTag LabelFor(Expression> expression, string profile = null, T model = null); HtmlTag InputFor(Expression> expression, string profile = null, T model = null); + HtmlTag ValidationMessageFor(Expression> expression, string profile = null, T model = null); HtmlTag DisplayFor(Expression> expression, string profile = null, T model = null); HtmlTag TagFor(Expression> expression, string category, string profile = null, T model = null); HtmlTag LabelFor(ElementRequest request, string profile = null, T model = null); HtmlTag InputFor(ElementRequest request, string profile = null, T model = null); + HtmlTag ValidationMessageFor(ElementRequest request, string profile = null, T model = null); HtmlTag DisplayFor(ElementRequest request, string profile = null, T model = null); HtmlTag TagFor(ElementRequest request, string category, string profile = null, T model = null); } diff --git a/src/HtmlTags/Conventions/Formatting/GetStringRequest.cs b/src/HtmlTags/Conventions/Formatting/GetStringRequest.cs index 8937ffd..0934038 100644 --- a/src/HtmlTags/Conventions/Formatting/GetStringRequest.cs +++ b/src/HtmlTags/Conventions/Formatting/GetStringRequest.cs @@ -73,7 +73,7 @@ public Type PropertyType return _propertyType; } - set { _propertyType = value; } + set => _propertyType = value; } public PropertyInfo Property { get; } diff --git a/src/HtmlTags/Conventions/HtmlConventionRegistryExtensions.cs b/src/HtmlTags/Conventions/HtmlConventionRegistryExtensions.cs index abedca5..d0b3849 100644 --- a/src/HtmlTags/Conventions/HtmlConventionRegistryExtensions.cs +++ b/src/HtmlTags/Conventions/HtmlConventionRegistryExtensions.cs @@ -5,7 +5,7 @@ namespace HtmlTags.Conventions public static class HtmlConventionRegistryExtensions { - public static void Defaults(this HtmlConventionRegistry registry) + public static HtmlConventionRegistry Defaults(this HtmlConventionRegistry registry) { registry.Editors.BuilderPolicy(); @@ -21,7 +21,7 @@ public static void Defaults(this HtmlConventionRegistry registry) registry.Labels.Always.BuildBy(); - + return registry; } } } \ No newline at end of file diff --git a/src/HtmlTags/Conventions/ProfileExpression.cs b/src/HtmlTags/Conventions/ProfileExpression.cs index 94a6fba..f443868 100644 --- a/src/HtmlTags/Conventions/ProfileExpression.cs +++ b/src/HtmlTags/Conventions/ProfileExpression.cs @@ -21,6 +21,8 @@ public ProfileExpression(HtmlConventionLibrary library, string profileName) public ElementCategoryExpression Editors => new ElementCategoryExpression(BuildersFor(ElementConstants.Editor)); + public ElementCategoryExpression ValidationMessages => new ElementCategoryExpression(BuildersFor(ElementConstants.ValidationMessage)); + public void Apply(HtmlConventionLibrary library) => library.Import(Library); } } \ No newline at end of file diff --git a/src/HtmlTags/HtmlDocument.cs b/src/HtmlTags/HtmlDocument.cs index 387243c..a4ea016 100755 --- a/src/HtmlTags/HtmlDocument.cs +++ b/src/HtmlTags/HtmlDocument.cs @@ -27,7 +27,9 @@ public HtmlDocument() public HtmlTag RootTag { get; } public HtmlTag Head { get; } public HtmlTag Body { get; } - public string Title { get { return _title.Text(); } set { _title.Text(value); } } + public string Title { get => _title.Text(); + set => _title.Text(value); + } public HtmlTag Current => _currentStack.Any() ? _currentStack.Peek() : Body; public HtmlTag Last { get; private set; } diff --git a/src/HtmlTags/Reflection/Expressions/StringStartsWithPropertyOperation.cs b/src/HtmlTags/Reflection/Expressions/StringStartsWithPropertyOperation.cs index 655ff3d..8277d16 100644 --- a/src/HtmlTags/Reflection/Expressions/StringStartsWithPropertyOperation.cs +++ b/src/HtmlTags/Reflection/Expressions/StringStartsWithPropertyOperation.cs @@ -17,10 +17,7 @@ public StringStartsWithPropertyOperation() { } - public override string Text - { - get { return "starts with"; } - } + public override string Text => "starts with"; } public class CollectionContainsPropertyOperation : IPropertyOperation diff --git a/src/HtmlTags/Reflection/MethodValueGetter.cs b/src/HtmlTags/Reflection/MethodValueGetter.cs index 90f71ee..a07a3ad 100644 --- a/src/HtmlTags/Reflection/MethodValueGetter.cs +++ b/src/HtmlTags/Reflection/MethodValueGetter.cs @@ -34,20 +34,11 @@ public string Name } } - public Type DeclaringType - { - get { return _methodInfo.DeclaringType; } - } + public Type DeclaringType => _methodInfo.DeclaringType; - public Type ValueType - { - get { return _methodInfo.ReturnType; } - } + public Type ValueType => _methodInfo.ReturnType; - public Type ReturnType - { - get { return _methodInfo.ReturnType; } - } + public Type ReturnType => _methodInfo.ReturnType; public Expression ChainExpression(Expression body) { diff --git a/test/HtmlTags.AspNetCore.Testing/ModelMetadataTagExtensionsTester.cs b/test/HtmlTags.AspNetCore.Testing/ModelMetadataTagExtensionsTester.cs index 94d758c..d25da0a 100644 --- a/test/HtmlTags.AspNetCore.Testing/ModelMetadataTagExtensionsTester.cs +++ b/test/HtmlTags.AspNetCore.Testing/ModelMetadataTagExtensionsTester.cs @@ -129,6 +129,31 @@ public void ShouldAddClientSideValidationClasses() editor.Attr("data-val-maxlength-max").ShouldNotBeNullOrEmpty(); } + [Fact] + public void ShouldBuildValidationMessage() + { + var subject = new Subject { Value = null }; + var helper = GetHtmlHelper(subject); + + var validationMessage = helper.ValidationMessage(s => s.Value); + + validationMessage.TagName().ShouldBe("span"); + validationMessage.Text().ShouldNotBeEmpty(); + validationMessage.HasClass(HtmlHelper.ValidationMessageCssClassName).ShouldBeTrue(); + } + + [Fact] + public void ShouldHaveEmptyValidationTagWhenNotInvalid() + { + var subject = new Subject { Value = "value" }; + var helper = GetHtmlHelper(subject); + + var validationMessage = helper.ValidationMessage(s => s.Value); + + validationMessage.TagName().ShouldBe("span"); + validationMessage.Text().ShouldBeEmpty(); + validationMessage.HasClass(HtmlHelper.ValidationMessageValidCssClassName).ShouldBeTrue(); + } [Fact] public void ShouldNotAddClientSideValidationClassesWhenNoClientValidationEnabled() @@ -268,13 +293,6 @@ private static HtmlHelper GetHtmlHelper( innerHelper = innerHelperWrapper(innerHelper); } - var registry = new HtmlConventionRegistry(); - registry.ModelMetadata(); - registry.Defaults(); - - var library = new HtmlConventionLibrary(); - registry.Apply(library); - var serviceCollection = new ServiceCollection(); serviceCollection @@ -284,7 +302,7 @@ private static HtmlHelper GetHtmlHelper( .AddSingleton(innerHelper) .AddSingleton() .AddSingleton(attributeProvider) - .AddHtmlTags(library); + .AddHtmlTags(); var serviceProvider = serviceCollection.BuildServiceProvider();