diff --git a/src/Umbraco.Core/Models/Blocks/IBlockEditorDataHelper.cs b/src/Umbraco.Core/Models/Blocks/IBlockEditorDataHelper.cs index 32f8431e65..35bcaa49ab 100644 --- a/src/Umbraco.Core/Models/Blocks/IBlockEditorDataHelper.cs +++ b/src/Umbraco.Core/Models/Blocks/IBlockEditorDataHelper.cs @@ -3,8 +3,12 @@ using System.Collections.Generic; namespace Umbraco.Core.Models.Blocks { + // TODO: Rename this, we don't want to use the name "Helper" + // TODO: What is this? This requires code docs + // TODO: This is not used publicly at all - therefore it probably shouldn't be public public interface IBlockEditorDataHelper { + // TODO: Does this abstraction need a reference to JObject? Maybe it does but ideally it doesn't IEnumerable GetBlockReferences(JObject layout); bool IsEditorSpecificPropertyKey(string propertyKey); } diff --git a/src/Umbraco.Core/Services/PropertyValidationService.cs b/src/Umbraco.Core/Services/PropertyValidationService.cs index a037a83920..adadb12ef6 100644 --- a/src/Umbraco.Core/Services/PropertyValidationService.cs +++ b/src/Umbraco.Core/Services/PropertyValidationService.cs @@ -1,9 +1,7 @@ using System; using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; using System.Linq; -using System.Text; -using System.Threading.Tasks; -using Umbraco.Core.Collections; using Umbraco.Core.Composing; using Umbraco.Core.Models; using Umbraco.Core.PropertyEditors; @@ -15,19 +13,73 @@ namespace Umbraco.Core.Services { private readonly PropertyEditorCollection _propertyEditors; private readonly IDataTypeService _dataTypeService; + private readonly ILocalizedTextService _textService; - public PropertyValidationService(PropertyEditorCollection propertyEditors, IDataTypeService dataTypeService) + public PropertyValidationService(PropertyEditorCollection propertyEditors, IDataTypeService dataTypeService, ILocalizedTextService textService) { _propertyEditors = propertyEditors; _dataTypeService = dataTypeService; + _textService = textService; } //TODO: Remove this method in favor of the overload specifying all dependencies public PropertyValidationService() - : this(Current.PropertyEditors, Current.Services.DataTypeService) + : this(Current.PropertyEditors, Current.Services.DataTypeService, Current.Services.TextService) { } + public IEnumerable ValidatePropertyValue( + PropertyType propertyType, + object postedValue) + { + if (propertyType is null) throw new ArgumentNullException(nameof(propertyType)); + var dataType = _dataTypeService.GetDataType(propertyType.DataTypeId); + if (dataType == null) throw new InvalidOperationException("No data type found by id " + propertyType.DataTypeId); + + var editor = _propertyEditors[propertyType.PropertyEditorAlias]; + if (editor == null) throw new InvalidOperationException("No property editor found by alias " + propertyType.PropertyEditorAlias); ; + + return ValidatePropertyValue(_textService, editor, dataType, postedValue, propertyType.Mandatory, propertyType.ValidationRegExp, propertyType.MandatoryMessage, propertyType.ValidationRegExpMessage); + } + + internal static IEnumerable ValidatePropertyValue( + ILocalizedTextService textService, + IDataEditor editor, + IDataType dataType, + object postedValue, + bool isRequired, + string validationRegExp, + string isRequiredMessage, + string validationRegExpMessage) + { + // Retrieve default messages used for required and regex validatation. We'll replace these + // if set with custom ones if they've been provided for a given property. + var requiredDefaultMessages = new[] + { + textService.Localize("validation", "invalidNull"), + textService.Localize("validation", "invalidEmpty") + }; + var formatDefaultMessages = new[] + { + textService.Localize("validation", "invalidPattern"), + }; + + var valueEditor = editor.GetValueEditor(dataType.Configuration); + foreach (var validationResult in valueEditor.Validate(postedValue, isRequired, validationRegExp)) + { + // If we've got custom error messages, we'll replace the default ones that will have been applied in the call to Validate(). + if (isRequired && !string.IsNullOrWhiteSpace(isRequiredMessage) && requiredDefaultMessages.Contains(validationResult.ErrorMessage, StringComparer.OrdinalIgnoreCase)) + { + validationResult.ErrorMessage = isRequiredMessage; + } + if (!string.IsNullOrWhiteSpace(validationRegExp) && !string.IsNullOrWhiteSpace(validationRegExpMessage) && formatDefaultMessages.Contains(validationResult.ErrorMessage, StringComparer.OrdinalIgnoreCase)) + { + validationResult.ErrorMessage = validationRegExpMessage; + } + yield return validationResult; + } + } + /// /// Validates the content item's properties pass validation rules /// diff --git a/src/Umbraco.Tests/Models/VariationTests.cs b/src/Umbraco.Tests/Models/VariationTests.cs index ab5f726894..b87ff499b6 100644 --- a/src/Umbraco.Tests/Models/VariationTests.cs +++ b/src/Umbraco.Tests/Models/VariationTests.cs @@ -442,7 +442,10 @@ namespace Umbraco.Tests.Models [Test] public void ContentPublishValuesWithMixedPropertyTypeVariations() { - var propertyValidationService = new PropertyValidationService(Current.Factory.GetInstance(), Current.Factory.GetInstance().DataTypeService); + var propertyValidationService = new PropertyValidationService( + Current.Factory.GetInstance(), + Current.Factory.GetInstance().DataTypeService, + Current.Factory.GetInstance().TextService); const string langFr = "fr-FR"; // content type varies by Culture @@ -574,7 +577,10 @@ namespace Umbraco.Tests.Models prop.SetValue("a"); Assert.AreEqual("a", prop.GetValue()); Assert.IsNull(prop.GetValue(published: true)); - var propertyValidationService = new PropertyValidationService(Current.Factory.GetInstance(), Current.Factory.GetInstance().DataTypeService); + var propertyValidationService = new PropertyValidationService( + Current.Factory.GetInstance(), + Current.Factory.GetInstance().DataTypeService, + Current.Factory.GetInstance().TextService); Assert.IsTrue(propertyValidationService.IsPropertyValid(prop)); diff --git a/src/Umbraco.Tests/Services/ContentServiceTests.cs b/src/Umbraco.Tests/Services/ContentServiceTests.cs index 553d1b97ba..041dabe7d2 100644 --- a/src/Umbraco.Tests/Services/ContentServiceTests.cs +++ b/src/Umbraco.Tests/Services/ContentServiceTests.cs @@ -1197,7 +1197,7 @@ namespace Umbraco.Tests.Services Assert.IsFalse(content.HasIdentity); // content cannot publish values because they are invalid - var propertyValidationService = new PropertyValidationService(Factory.GetInstance(), ServiceContext.DataTypeService); + var propertyValidationService = new PropertyValidationService(Factory.GetInstance(), ServiceContext.DataTypeService, ServiceContext.TextService); var isValid = propertyValidationService.IsPropertyDataValid(content, out var invalidProperties, CultureImpact.Invariant); Assert.IsFalse(isValid); Assert.IsNotEmpty(invalidProperties); diff --git a/src/Umbraco.Tests/Services/PropertyValidationServiceTests.cs b/src/Umbraco.Tests/Services/PropertyValidationServiceTests.cs index e1e19918ce..28bdf59373 100644 --- a/src/Umbraco.Tests/Services/PropertyValidationServiceTests.cs +++ b/src/Umbraco.Tests/Services/PropertyValidationServiceTests.cs @@ -36,7 +36,7 @@ namespace Umbraco.Tests.Services var propEditors = new PropertyEditorCollection(new DataEditorCollection(new[] { dataEditor })); - validationService = new PropertyValidationService(propEditors, dataTypeService.Object); + validationService = new PropertyValidationService(propEditors, dataTypeService.Object, Mock.Of()); } [Test] diff --git a/src/Umbraco.Tests/TestHelpers/Entities/MockedContentTypes.cs b/src/Umbraco.Tests/TestHelpers/Entities/MockedContentTypes.cs index c55467431d..b4cd4ab05e 100644 --- a/src/Umbraco.Tests/TestHelpers/Entities/MockedContentTypes.cs +++ b/src/Umbraco.Tests/TestHelpers/Entities/MockedContentTypes.cs @@ -41,12 +41,12 @@ namespace Umbraco.Tests.TestHelpers.Entities }; var contentCollection = new PropertyTypeCollection(true); - contentCollection.Add(new PropertyType("test", ValueStorageType.Ntext) { Alias = "title", Name = "Title", Description = "", Mandatory = false, SortOrder = 1, DataTypeId = -88 }); - contentCollection.Add(new PropertyType("test", ValueStorageType.Ntext) { Alias = "bodyText", Name = "Body Text", Description = "", Mandatory = false, SortOrder = 2, DataTypeId = -87 }); + contentCollection.Add(new PropertyType("test", ValueStorageType.Ntext) { Alias = "title", Name = "Title", Description = "", Mandatory = false, SortOrder = 1, DataTypeId = Constants.DataTypes.Textbox }); + contentCollection.Add(new PropertyType("test", ValueStorageType.Ntext) { Alias = "bodyText", Name = "Body Text", Description = "", Mandatory = false, SortOrder = 2, DataTypeId = Constants.DataTypes.RichtextEditor }); var metaCollection = new PropertyTypeCollection(true); - metaCollection.Add(new PropertyType("test", ValueStorageType.Ntext) { Alias = "keywords", Name = "Meta Keywords", Description = "", Mandatory = false, SortOrder = 1, DataTypeId = -88 }); - metaCollection.Add(new PropertyType("test", ValueStorageType.Ntext) { Alias = "description", Name = "Meta Description", Description = "", Mandatory = false, SortOrder = 2, DataTypeId = -89 }); + metaCollection.Add(new PropertyType("test", ValueStorageType.Ntext) { Alias = "keywords", Name = "Meta Keywords", Description = "", Mandatory = false, SortOrder = 1, DataTypeId = Constants.DataTypes.Textbox }); + metaCollection.Add(new PropertyType("test", ValueStorageType.Ntext) { Alias = "description", Name = "Meta Description", Description = "", Mandatory = false, SortOrder = 2, DataTypeId = Constants.DataTypes.Textarea }); contentType.PropertyGroups.Add(new PropertyGroup(contentCollection) { Name = "Content", SortOrder = 1 }); contentType.PropertyGroups.Add(new PropertyGroup(metaCollection) { Name = "Meta", SortOrder = 2 }); diff --git a/src/Umbraco.Tests/Umbraco.Tests.csproj b/src/Umbraco.Tests/Umbraco.Tests.csproj index 08ff3167b5..cfc4686853 100644 --- a/src/Umbraco.Tests/Umbraco.Tests.csproj +++ b/src/Umbraco.Tests/Umbraco.Tests.csproj @@ -266,7 +266,7 @@ - + @@ -513,6 +513,7 @@ + diff --git a/src/Umbraco.Tests/Web/Validation/ContentModelValidatorTests.cs b/src/Umbraco.Tests/Web/Validation/ContentModelValidatorTests.cs new file mode 100644 index 0000000000..cbc4152c17 --- /dev/null +++ b/src/Umbraco.Tests/Web/Validation/ContentModelValidatorTests.cs @@ -0,0 +1,254 @@ +using Moq; +using NUnit.Framework; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Umbraco.Core.Services; +using Umbraco.Core.Logging; +using Umbraco.Web; +using Umbraco.Web.Editors.Filters; +using Umbraco.Tests.TestHelpers.Entities; +using Umbraco.Core.Models; +using Umbraco.Web.Models.ContentEditing; +using Umbraco.Web.Editors.Binders; +using Umbraco.Core; +using Umbraco.Tests.Testing; +using Umbraco.Core.Mapping; +using Umbraco.Core.PropertyEditors; +using Umbraco.Core.Composing; +using System.Web.Http.ModelBinding; +using Umbraco.Web.PropertyEditors; +using System.ComponentModel.DataAnnotations; +using Umbraco.Tests.TestHelpers; +using System.Globalization; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Umbraco.Tests.Web.Validation +{ + [UmbracoTest(Mapper = true, WithApplication = true, Logger = UmbracoTestOptions.Logger.Console)] + [TestFixture] + public class ContentModelValidatorTests : UmbracoTestBase + { + private const int ComplexDataTypeId = 9999; + private const string ContentTypeAlias = "textPage"; + private ContentType _contentType; + + public override void SetUp() + { + base.SetUp(); + + _contentType = MockedContentTypes.CreateTextPageContentType(ContentTypeAlias); + // add complex editor + _contentType.AddPropertyType( + new PropertyType("complexTest", ValueStorageType.Ntext) { Alias = "complex", Name = "Meta Keywords", Description = "", Mandatory = false, SortOrder = 1, DataTypeId = ComplexDataTypeId }, + "Content"); + + // make them all validate with a regex rule that will not pass + foreach (var prop in _contentType.PropertyTypes) + { + prop.ValidationRegExp = "^donotmatch$"; + prop.ValidationRegExpMessage = "Does not match!"; + } + } + + protected override void Compose() + { + base.Compose(); + + var complexEditorConfig = new NestedContentConfiguration + { + ContentTypes = new[] + { + new NestedContentConfiguration.ContentType { Alias = "feature" } + } + }; + var dataTypeService = new Mock(); + dataTypeService.Setup(x => x.GetDataType(It.IsAny())) + .Returns((int id) => id == ComplexDataTypeId + ? Mock.Of(x => x.Configuration == complexEditorConfig) + : Mock.Of()); + + var contentTypeService = new Mock(); + contentTypeService.Setup(x => x.GetAll(It.IsAny())) + .Returns(() => new List + { + _contentType + }); + + var textService = new Mock(); + textService.Setup(x => x.Localize("validation/invalidPattern", It.IsAny(), It.IsAny>())).Returns(() => "invalidPattern"); + textService.Setup(x => x.Localize("validation/invalidNull", It.IsAny(), It.IsAny>())).Returns("invalidNull"); + textService.Setup(x => x.Localize("validation/invalidEmpty", It.IsAny(), It.IsAny>())).Returns("invalidEmpty"); + + Composition.RegisterUnique(x => Mock.Of(x => x.GetDataType(It.IsAny()) == Mock.Of())); + Composition.RegisterUnique(x => dataTypeService.Object); + Composition.RegisterUnique(x => contentTypeService.Object); + Composition.RegisterUnique(x => textService.Object); + + Composition.WithCollectionBuilder() + .Add() + .Add(); + } + + [Test] + public void Test() + { + var validator = new ContentSaveModelValidator( + Factory.GetInstance(), + Mock.Of(), + Factory.GetInstance()); + + var content = MockedContent.CreateTextpageContent(_contentType, "test", -1); + + const string complexValue = @"[{ + ""key"": ""c8df5136-d606-41f0-9134-dea6ae0c2fd9"", + ""name"": ""Hello world"", + ""ncContentTypeAlias"": """ + ContentTypeAlias + @""", + ""title"": ""Hello world"" + }, { + ""key"": ""f916104a-4082-48b2-a515-5c4bf2230f38"", + ""name"": ""Hello worldsss ddf"", + ""ncContentTypeAlias"": """ + ContentTypeAlias + @""", + ""title"": ""Hello worldsss ddf"" + } +]"; + content.SetValue("complex", complexValue); + + // map the persisted properties to a model representing properties to save + //var saveProperties = content.Properties.Select(x => Mapper.Map(x)).ToList(); + var saveProperties = content.Properties.Select(x => + { + return new ContentPropertyBasic + { + Alias = x.Alias, + Id = x.Id, + Value = x.GetValue() + }; + }).ToList(); + + var saveVariants = new List + { + new ContentVariantSave + { + Culture = string.Empty, + Segment = string.Empty, + Name = content.Name, + Save = true, + Properties = saveProperties + } + }; + + var save = new ContentItemSave + { + Id = content.Id, + Action = ContentSaveAction.Save, + ContentTypeAlias = _contentType.Alias, + ParentId = -1, + PersistedContent = content, + TemplateAlias = null, + Variants = saveVariants + }; + + // This will map the ContentItemSave.Variants.PropertyCollectionDto and then map the values in the saved model + // back onto the persisted IContent model. + ContentItemBinder.BindModel(save, content); + + var modelState = new ModelStateDictionary(); + var isValid = validator.ValidatePropertiesData(save, saveVariants[0], saveVariants[0].PropertyCollectionDto, modelState); + + // list results for debugging + foreach (var state in modelState) + { + Console.WriteLine(state.Key); + foreach (var error in state.Value.Errors) + { + Console.WriteLine("\t" + error.ErrorMessage); + } + } + + // assert + + Assert.IsFalse(isValid); + Assert.AreEqual(5, modelState.Keys.Count); + const string complexPropertyKey = "_Properties.complex.invariant.null"; + Assert.IsTrue(modelState.Keys.Contains(complexPropertyKey)); + foreach(var state in modelState.Where(x => x.Key != complexPropertyKey)) + { + foreach (var error in state.Value.Errors) + { + Assert.IsTrue(error.ErrorMessage.DetectIsJson()); + var json = JsonConvert.DeserializeObject(error.ErrorMessage); + Assert.IsNotEmpty(json["errorMessage"].Value()); + Assert.AreEqual(1, json["memberNames"].Value().Count); + } + } + var complexEditorErrors = modelState.Single(x => x.Key == complexPropertyKey).Value.Errors; + Assert.AreEqual(3, complexEditorErrors.Count); + var nestedError = complexEditorErrors.Single(x => x.ErrorMessage.Contains("nestedValidation")); + var jsonNestedError = JsonConvert.DeserializeObject(nestedError.ErrorMessage); + Assert.AreEqual(JTokenType.Array, jsonNestedError["nestedValidation"].Type); + var nestedValidation = (JArray)jsonNestedError["nestedValidation"]; + Assert.AreEqual(2, nestedValidation.Count); // there are 2 because there are 2 nested content rows + foreach(var rowErrors in nestedValidation) + { + var elementTypeErrors = (JArray)rowErrors; // this is an array of errors for the nested content row (element type) + Assert.AreEqual(2, elementTypeErrors.Count); + foreach(var elementTypeErr in elementTypeErrors) + { + Assert.IsNotEmpty(elementTypeErr["errorMessage"].Value()); + Assert.AreEqual(1, elementTypeErr["memberNames"].Value().Count); + } + } + } + + [HideFromTypeFinder] + [DataEditor("complexTest", "test", "test")] + public class ComplexTestEditor : NestedContentPropertyEditor + { + public ComplexTestEditor(ILogger logger, Lazy propertyEditors, IDataTypeService dataTypeService, IContentTypeService contentTypeService) : base(logger, propertyEditors, dataTypeService, contentTypeService) + { + } + + protected override IDataValueEditor CreateValueEditor() + { + var editor = base.CreateValueEditor(); + editor.Validators.Add(new NeverValidateValidator()); + return editor; + } + } + + [HideFromTypeFinder] + [DataEditor("test", "test", "test")] // This alias aligns with the prop editor alias for all properties created from MockedContentTypes.CreateTextPageContentType + public class TestEditor : DataEditor + { + public TestEditor(ILogger logger) + : base(logger) + { + } + + protected override IDataValueEditor CreateValueEditor() => new TestValueEditor(Attribute); + + private class TestValueEditor : DataValueEditor + { + public TestValueEditor(DataEditorAttribute attribute) + : base(attribute) + { + Validators.Add(new NeverValidateValidator()); + } + + } + } + + public class NeverValidateValidator : IValueValidator + { + public IEnumerable Validate(object value, string valueType, object dataTypeConfiguration) + { + yield return new ValidationResult("WRONG!", new[] { "innerFieldId" }); + } + } + + } +} diff --git a/src/Umbraco.Tests/Web/ModelStateExtensionsTests.cs b/src/Umbraco.Tests/Web/Validation/ModelStateExtensionsTests.cs similarity index 99% rename from src/Umbraco.Tests/Web/ModelStateExtensionsTests.cs rename to src/Umbraco.Tests/Web/Validation/ModelStateExtensionsTests.cs index 7b25e60b5a..0355705378 100644 --- a/src/Umbraco.Tests/Web/ModelStateExtensionsTests.cs +++ b/src/Umbraco.Tests/Web/Validation/ModelStateExtensionsTests.cs @@ -6,7 +6,7 @@ using NUnit.Framework; using Umbraco.Core.Services; using Umbraco.Web; -namespace Umbraco.Tests.Web +namespace Umbraco.Tests.Web.Validation { [TestFixture] public class ModelStateExtensionsTests diff --git a/src/Umbraco.Web/Editors/Binders/BlueprintItemBinder.cs b/src/Umbraco.Web/Editors/Binders/BlueprintItemBinder.cs index eb4d482b14..bd86d74f5d 100644 --- a/src/Umbraco.Web/Editors/Binders/BlueprintItemBinder.cs +++ b/src/Umbraco.Web/Editors/Binders/BlueprintItemBinder.cs @@ -11,8 +11,8 @@ namespace Umbraco.Web.Editors.Binders { } - public BlueprintItemBinder(ILogger logger, ServiceContext services, IUmbracoContextAccessor umbracoContextAccessor) - : base(logger, services, umbracoContextAccessor) + public BlueprintItemBinder(ServiceContext services) + : base(services) { } diff --git a/src/Umbraco.Web/Editors/Binders/ContentItemBinder.cs b/src/Umbraco.Web/Editors/Binders/ContentItemBinder.cs index a6cba6ce41..609974bef7 100644 --- a/src/Umbraco.Web/Editors/Binders/ContentItemBinder.cs +++ b/src/Umbraco.Web/Editors/Binders/ContentItemBinder.cs @@ -17,16 +17,13 @@ namespace Umbraco.Web.Editors.Binders /// internal class ContentItemBinder : IModelBinder { - private readonly ContentModelBinderHelper _modelBinderHelper; - - public ContentItemBinder() : this(Current.Logger, Current.Services, Current.UmbracoContextAccessor) + public ContentItemBinder() : this(Current.Services) { } - public ContentItemBinder(ILogger logger, ServiceContext services, IUmbracoContextAccessor umbracoContextAccessor) + public ContentItemBinder(ServiceContext services) { Services = services; - _modelBinderHelper = new ContentModelBinderHelper(); } protected ServiceContext Services { get; } @@ -39,10 +36,20 @@ namespace Umbraco.Web.Editors.Binders /// public bool BindModel(HttpActionContext actionContext, ModelBindingContext bindingContext) { - var model = _modelBinderHelper.BindModelFromMultipartRequest(actionContext, bindingContext); + var model = ContentModelBinderHelper.BindModelFromMultipartRequest(actionContext, bindingContext); if (model == null) return false; - model.PersistedContent = ContentControllerBase.IsCreatingAction(model.Action) ? CreateNew(model) : GetExisting(model); + BindModel(model, ContentControllerBase.IsCreatingAction(model.Action) ? CreateNew(model) : GetExisting(model)); + + return true; + } + + internal static void BindModel(ContentItemSave model, IContent persistedContent) + { + if (model is null) throw new ArgumentNullException(nameof(model)); + if (persistedContent is null) throw new ArgumentNullException(nameof(persistedContent)); + + model.PersistedContent = persistedContent; //create the dto from the persisted model if (model.PersistedContent != null) @@ -60,11 +67,9 @@ namespace Umbraco.Web.Editors.Binders }); //now map all of the saved values to the dto - _modelBinderHelper.MapPropertyValuesFromSaved(variant, variant.PropertyCollectionDto); + ContentModelBinderHelper.MapPropertyValuesFromSaved(variant, variant.PropertyCollectionDto); } } - - return true; } protected virtual IContent GetExisting(ContentItemSave model) diff --git a/src/Umbraco.Web/Editors/Binders/ContentModelBinderHelper.cs b/src/Umbraco.Web/Editors/Binders/ContentModelBinderHelper.cs index b962de74ac..a017ae5afb 100644 --- a/src/Umbraco.Web/Editors/Binders/ContentModelBinderHelper.cs +++ b/src/Umbraco.Web/Editors/Binders/ContentModelBinderHelper.cs @@ -15,9 +15,9 @@ namespace Umbraco.Web.Editors.Binders /// /// Helper methods to bind media/member models /// - internal class ContentModelBinderHelper + internal static class ContentModelBinderHelper { - public TModelSave BindModelFromMultipartRequest(HttpActionContext actionContext, ModelBindingContext bindingContext) + public static TModelSave BindModelFromMultipartRequest(HttpActionContext actionContext, ModelBindingContext bindingContext) where TModelSave : IHaveUploadedFiles { var result = actionContext.ReadAsMultipart(SystemDirectories.TempFileUploads); @@ -86,7 +86,7 @@ namespace Umbraco.Web.Editors.Binders /// /// /// - public void MapPropertyValuesFromSaved(IContentProperties saveModel, ContentPropertyCollectionDto dto) + public static void MapPropertyValuesFromSaved(IContentProperties saveModel, ContentPropertyCollectionDto dto) { //NOTE: Don't convert this to linq, this is much quicker foreach (var p in saveModel.Properties) diff --git a/src/Umbraco.Web/Editors/Binders/MediaItemBinder.cs b/src/Umbraco.Web/Editors/Binders/MediaItemBinder.cs index 1084aa16ea..bfd5c853d5 100644 --- a/src/Umbraco.Web/Editors/Binders/MediaItemBinder.cs +++ b/src/Umbraco.Web/Editors/Binders/MediaItemBinder.cs @@ -13,7 +13,6 @@ namespace Umbraco.Web.Editors.Binders /// internal class MediaItemBinder : IModelBinder { - private readonly ContentModelBinderHelper _modelBinderHelper; private readonly ServiceContext _services; public MediaItemBinder() : this(Current.Services) @@ -23,7 +22,6 @@ namespace Umbraco.Web.Editors.Binders public MediaItemBinder(ServiceContext services) { _services = services; - _modelBinderHelper = new ContentModelBinderHelper(); } /// @@ -34,7 +32,7 @@ namespace Umbraco.Web.Editors.Binders /// public bool BindModel(HttpActionContext actionContext, ModelBindingContext bindingContext) { - var model = _modelBinderHelper.BindModelFromMultipartRequest(actionContext, bindingContext); + var model = ContentModelBinderHelper.BindModelFromMultipartRequest(actionContext, bindingContext); if (model == null) return false; model.PersistedContent = ContentControllerBase.IsCreatingAction(model.Action) ? CreateNew(model) : GetExisting(model); @@ -44,7 +42,7 @@ namespace Umbraco.Web.Editors.Binders { model.PropertyCollectionDto = Current.Mapper.Map(model.PersistedContent); //now map all of the saved values to the dto - _modelBinderHelper.MapPropertyValuesFromSaved(model, model.PropertyCollectionDto); + ContentModelBinderHelper.MapPropertyValuesFromSaved(model, model.PropertyCollectionDto); } model.Name = model.Name.Trim(); diff --git a/src/Umbraco.Web/Editors/Binders/MemberBinder.cs b/src/Umbraco.Web/Editors/Binders/MemberBinder.cs index 60b4f85c21..bd51d268ee 100644 --- a/src/Umbraco.Web/Editors/Binders/MemberBinder.cs +++ b/src/Umbraco.Web/Editors/Binders/MemberBinder.cs @@ -20,7 +20,6 @@ namespace Umbraco.Web.Editors.Binders /// internal class MemberBinder : IModelBinder { - private readonly ContentModelBinderHelper _modelBinderHelper; private readonly ServiceContext _services; public MemberBinder() : this(Current.Services) @@ -30,7 +29,6 @@ namespace Umbraco.Web.Editors.Binders public MemberBinder(ServiceContext services) { _services = services; - _modelBinderHelper = new ContentModelBinderHelper(); } /// @@ -41,7 +39,7 @@ namespace Umbraco.Web.Editors.Binders /// public bool BindModel(HttpActionContext actionContext, ModelBindingContext bindingContext) { - var model = _modelBinderHelper.BindModelFromMultipartRequest(actionContext, bindingContext); + var model = ContentModelBinderHelper.BindModelFromMultipartRequest(actionContext, bindingContext); if (model == null) return false; model.PersistedContent = ContentControllerBase.IsCreatingAction(model.Action) ? CreateNew(model) : GetExisting(model); @@ -51,7 +49,7 @@ namespace Umbraco.Web.Editors.Binders { model.PropertyCollectionDto = Current.Mapper.Map(model.PersistedContent); //now map all of the saved values to the dto - _modelBinderHelper.MapPropertyValuesFromSaved(model, model.PropertyCollectionDto); + ContentModelBinderHelper.MapPropertyValuesFromSaved(model, model.PropertyCollectionDto); } model.Name = model.Name.Trim(); diff --git a/src/Umbraco.Web/Editors/Filters/ContentModelValidator.cs b/src/Umbraco.Web/Editors/Filters/ContentModelValidator.cs index 810c2d1bea..9de27248a6 100644 --- a/src/Umbraco.Web/Editors/Filters/ContentModelValidator.cs +++ b/src/Umbraco.Web/Editors/Filters/ContentModelValidator.cs @@ -1,15 +1,19 @@ -using System; +using Newtonsoft.Json; +using System; using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; using System.Linq; using System.Net; using System.Net.Http; using System.Web.Http.Controllers; using System.Web.Http.ModelBinding; +using Umbraco.Core; using Umbraco.Core.Logging; using Umbraco.Core.Models; using Umbraco.Core.PropertyEditors; using Umbraco.Core.Services; using Umbraco.Web.Models.ContentEditing; +using Umbraco.Web.PropertyEditors.Validation; namespace Umbraco.Web.Editors.Filters { @@ -125,18 +129,6 @@ namespace Umbraco.Web.Editors.Filters { var properties = modelWithProperties.Properties.ToDictionary(x => x.Alias, x => x); - // Retrieve default messages used for required and regex validatation. We'll replace these - // if set with custom ones if they've been provided for a given property. - var requiredDefaultMessages = new[] - { - _textService.Localize("validation", "invalidNull"), - _textService.Localize("validation", "invalidEmpty") - }; - var formatDefaultMessages = new[] - { - _textService.Localize("validation", "invalidPattern"), - }; - foreach (var p in dto.Properties) { var editor = p.PropertyEditor; @@ -156,7 +148,7 @@ namespace Umbraco.Web.Editors.Filters var postedValue = postedProp.Value; - ValidatePropertyValue(model, modelWithProperties, editor, p, postedValue, modelState, requiredDefaultMessages, formatDefaultMessages); + ValidatePropertyValue(model, modelWithProperties, editor, p, postedValue, modelState); } @@ -180,26 +172,29 @@ namespace Umbraco.Web.Editors.Filters IDataEditor editor, ContentPropertyDto property, object postedValue, - ModelStateDictionary modelState, - string[] requiredDefaultMessages, - string[] formatDefaultMessages) + ModelStateDictionary modelState) { - var valueEditor = editor.GetValueEditor(property.DataType.Configuration); - foreach (var r in valueEditor.Validate(postedValue, property.IsRequired, property.ValidationRegExp)) + if (property is null) throw new ArgumentNullException(nameof(property)); + if (property.DataType is null) throw new InvalidOperationException($"{nameof(property)}.{nameof(property.DataType)} cannot be null"); + + foreach (var validationResult in PropertyValidationService.ValidatePropertyValue( + _textService, editor, property.DataType, postedValue, property.IsRequired, + property.ValidationRegExp, property.IsRequiredMessage, property.ValidationRegExpMessage)) { - // If we've got custom error messages, we'll replace the default ones that will have been applied in the call to Validate(). - if (property.IsRequired && !string.IsNullOrWhiteSpace(property.IsRequiredMessage) && requiredDefaultMessages.Contains(r.ErrorMessage, StringComparer.OrdinalIgnoreCase)) - { - r.ErrorMessage = property.IsRequiredMessage; - } - - if (!string.IsNullOrWhiteSpace(property.ValidationRegExp) && !string.IsNullOrWhiteSpace(property.ValidationRegExpMessage) && formatDefaultMessages.Contains(r.ErrorMessage, StringComparer.OrdinalIgnoreCase)) - { - r.ErrorMessage = property.ValidationRegExpMessage; - } - - modelState.AddPropertyError(r, property.Alias, property.Culture, property.Segment); + AddPropertyError(model, modelWithProperties, editor, property, validationResult, modelState); } } + + protected virtual void AddPropertyError( + TModelSave model, + TModelWithProperties modelWithProperties, + IDataEditor editor, + ContentPropertyDto property, + ValidationResult validationResult, + ModelStateDictionary modelState) + { + modelState.AddPropertyError(validationResult, property.Alias, property.Culture, property.Segment); + } + } } diff --git a/src/Umbraco.Web/Editors/Filters/ContentSaveModelValidator.cs b/src/Umbraco.Web/Editors/Filters/ContentSaveModelValidator.cs index 39bd6ab0f4..f9d7203d12 100644 --- a/src/Umbraco.Web/Editors/Filters/ContentSaveModelValidator.cs +++ b/src/Umbraco.Web/Editors/Filters/ContentSaveModelValidator.cs @@ -1,5 +1,8 @@ -using Umbraco.Core.Logging; +using System.ComponentModel.DataAnnotations; +using System.Web.Http.ModelBinding; +using Umbraco.Core.Logging; using Umbraco.Core.Models; +using Umbraco.Core.PropertyEditors; using Umbraco.Core.Services; using Umbraco.Web.Models.ContentEditing; @@ -13,5 +16,30 @@ namespace Umbraco.Web.Editors.Filters public ContentSaveModelValidator(ILogger logger, IUmbracoContextAccessor umbracoContextAccessor, ILocalizedTextService textService) : base(logger, umbracoContextAccessor, textService) { } + + protected override void AddPropertyError(ContentItemSave model, ContentVariantSave modelWithProperties, IDataEditor editor, ContentPropertyDto property, ValidationResult validationResult, ModelStateDictionary modelState) + { + // Original idea: See if we can build up the JSON + JSON Path + // SD: I'm just keeping these notes here for later just to remind myself that we might want to take into account the tab number in validation + // which we might be able to get in the PropertyValidationService anyways? + + // Create a JSON + JSON Path key, see https://gist.github.com/Shazwazza/ad9fcbdb0fdacff1179a9eed88393aa6 + + //var json = new PropertyError + //{ + // Culture = property.Culture, + // Segment = property.Segment + //}; + + // TODO: Hrm, we can't get the tab index without a reference to the content type itself! the IContent doesn't contain a reference to groups/indexes + // BUT! I think it contains a reference to the group alias so we could use JSON Path for a group alias instead of index like: + // .tabs[?(@.alias=='Content')] + //var tabIndex = ?? + + //var jsonPath = "$.variants[0].tabs[0].properties[?(@.alias=='title')].value[0]"; + + base.AddPropertyError(model, modelWithProperties, editor, property, validationResult, modelState); + } + } } diff --git a/src/Umbraco.Web/ModelStateExtensions.cs b/src/Umbraco.Web/ModelStateExtensions.cs index 10b1b5dadd..718094e6b5 100644 --- a/src/Umbraco.Web/ModelStateExtensions.cs +++ b/src/Umbraco.Web/ModelStateExtensions.cs @@ -1,4 +1,5 @@ -using System; +using Newtonsoft.Json; +using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; @@ -6,6 +7,7 @@ using System.Web.Mvc; using Umbraco.Core; using Umbraco.Core.Models; using Umbraco.Core.Services; +using Umbraco.Web.PropertyEditors.Validation; namespace Umbraco.Web { @@ -51,13 +53,26 @@ namespace Umbraco.Web internal static void AddPropertyError(this System.Web.Http.ModelBinding.ModelStateDictionary modelState, ValidationResult result, string propertyAlias, string culture = "", string segment = "") { - if (culture == null) - culture = ""; - modelState.AddValidationError(result, "_Properties", propertyAlias, + + var propValidationResult = new PropertyValidationResult(result); + + var keyParts = new[] + { + "_Properties", + propertyAlias, //if the culture is null, we'll add the term 'invariant' as part of the key culture.IsNullOrWhiteSpace() ? "invariant" : culture, // if the segment is null, we'll add the term 'null' as part of the key - segment.IsNullOrWhiteSpace() ? "null" : segment); + segment.IsNullOrWhiteSpace() ? "null" : segment + }; + + var key = string.Join(".", keyParts); + + modelState.AddModelError( + key, + JsonConvert.SerializeObject( + propValidationResult, + new ValidationResultConverter())); } /// @@ -236,5 +251,6 @@ namespace Umbraco.Web }; } + } } diff --git a/src/Umbraco.Web/Models/ContentEditing/ContentItemSave.cs b/src/Umbraco.Web/Models/ContentEditing/ContentItemSave.cs index 4dbbb1385a..aace4645dd 100644 --- a/src/Umbraco.Web/Models/ContentEditing/ContentItemSave.cs +++ b/src/Umbraco.Web/Models/ContentEditing/ContentItemSave.cs @@ -14,7 +14,7 @@ namespace Umbraco.Web.Models.ContentEditing [DataContract(Name = "content", Namespace = "")] public class ContentItemSave : IContentSave { - protected ContentItemSave() + public ContentItemSave() { UploadedFiles = new List(); Variants = new List(); diff --git a/src/Umbraco.Web/Models/Mapping/ContentPropertyBasicMapper.cs b/src/Umbraco.Web/Models/Mapping/ContentPropertyBasicMapper.cs index cf1bc3c253..2e035430df 100644 --- a/src/Umbraco.Web/Models/Mapping/ContentPropertyBasicMapper.cs +++ b/src/Umbraco.Web/Models/Mapping/ContentPropertyBasicMapper.cs @@ -44,6 +44,9 @@ namespace Umbraco.Web.Models.Mapping property.PropertyType.PropertyEditorAlias); editor = _propertyEditors[Constants.PropertyEditors.Aliases.Label]; + + if (editor == null) + throw new InvalidOperationException($"Could not resolve the property editor {Constants.PropertyEditors.Aliases.Label}"); } dest.Id = property.Id; diff --git a/src/Umbraco.Web/PropertyEditors/BlockEditorPropertyEditor.cs b/src/Umbraco.Web/PropertyEditors/BlockEditorPropertyEditor.cs index b32a91c058..db28b55ee8 100644 --- a/src/Umbraco.Web/PropertyEditors/BlockEditorPropertyEditor.cs +++ b/src/Umbraco.Web/PropertyEditors/BlockEditorPropertyEditor.cs @@ -86,75 +86,24 @@ namespace Umbraco.Web.PropertyEditors } } - internal class BlockEditorValidator : IValueValidator + internal class BlockEditorValidator : ComplexEditorValidator { - private readonly PropertyEditorCollection _propertyEditors; - private readonly IDataTypeService _dataTypeService; private readonly BlockEditorValues _blockEditorValues; - public BlockEditorValidator(PropertyEditorCollection propertyEditors, IDataTypeService dataTypeService, BlockEditorValues blockEditorValues) + public BlockEditorValidator(BlockEditorValues blockEditorValues, PropertyEditorCollection propertyEditors, IDataTypeService dataTypeService, ILocalizedTextService textService) : base(propertyEditors, dataTypeService, textService) { - _propertyEditors = propertyEditors; - _dataTypeService = dataTypeService; _blockEditorValues = blockEditorValues; } - public IEnumerable Validate(object rawValue, string valueType, object dataTypeConfiguration) + protected override IEnumerable GetElementsFromValue(object value) { - var validationResults = new List(); - - foreach (var row in _blockEditorValues.GetPropertyValues(rawValue, out _)) + foreach (var row in _blockEditorValues.GetPropertyValues(value, out _)) { if (row.PropType == null) continue; - var config = _dataTypeService.GetDataType(row.PropType.DataTypeId).Configuration; - var propertyEditor = _propertyEditors[row.PropType.PropertyEditorAlias]; - if (propertyEditor == null) continue; - - foreach (var validator in propertyEditor.GetValueEditor().Validators) - { - foreach (var result in validator.Validate(row.JsonRowValue[row.PropKey], propertyEditor.GetValueEditor().ValueType, config)) - { - result.ErrorMessage = "Item " + (row.RowIndex + 1) + " '" + row.PropType.Name + "' " + result.ErrorMessage; - validationResults.Add(result); - } - } - - // Check mandatory - if (row.PropType.Mandatory) - { - if (row.JsonRowValue[row.PropKey] == null) - { - var message = string.IsNullOrWhiteSpace(row.PropType.MandatoryMessage) - ? $"'{row.PropType.Name}' cannot be null" - : row.PropType.MandatoryMessage; - validationResults.Add(new ValidationResult($"Item {(row.RowIndex + 1)}: {message}", new[] { row.PropKey })); - } - else if (row.JsonRowValue[row.PropKey].ToString().IsNullOrWhiteSpace() || (row.JsonRowValue[row.PropKey].Type == JTokenType.Array && !row.JsonRowValue[row.PropKey].HasValues)) - { - var message = string.IsNullOrWhiteSpace(row.PropType.MandatoryMessage) - ? $"'{row.PropType.Name}' cannot be empty" - : row.PropType.MandatoryMessage; - validationResults.Add(new ValidationResult($"Item {(row.RowIndex + 1)}: {message}", new[] { row.PropKey })); - } - } - - // Check regex - if (!row.PropType.ValidationRegExp.IsNullOrWhiteSpace() - && row.JsonRowValue[row.PropKey] != null && !row.JsonRowValue[row.PropKey].ToString().IsNullOrWhiteSpace()) - { - var regex = new Regex(row.PropType.ValidationRegExp); - if (!regex.IsMatch(row.JsonRowValue[row.PropKey].ToString())) - { - var message = string.IsNullOrWhiteSpace(row.PropType.ValidationRegExpMessage) - ? $"'{row.PropType.Name}' is invalid, it does not match the correct pattern" - : row.PropType.ValidationRegExpMessage; - validationResults.Add(new ValidationResult($"Item {(row.RowIndex + 1)}: {message}", new[] { row.PropKey })); - } - } + var val = row.JsonRowValue[row.PropKey]; + yield return new ElementTypeValidationModel(val, row.PropType); } - - return validationResults; } } diff --git a/src/Umbraco.Web/PropertyEditors/BlockListPropertyEditor.cs b/src/Umbraco.Web/PropertyEditors/BlockListPropertyEditor.cs index 3f8288b0ac..8a639e2932 100644 --- a/src/Umbraco.Web/PropertyEditors/BlockListPropertyEditor.cs +++ b/src/Umbraco.Web/PropertyEditors/BlockListPropertyEditor.cs @@ -34,6 +34,7 @@ namespace Umbraco.Web.PropertyEditors #region IBlockEditorDataHelper + // TODO: Rename this we don't want to use the name "Helper" private class DataHelper : IBlockEditorDataHelper { public IEnumerable GetBlockReferences(JObject layout) diff --git a/src/Umbraco.Web/PropertyEditors/ComplexEditorValidator.cs b/src/Umbraco.Web/PropertyEditors/ComplexEditorValidator.cs new file mode 100644 index 0000000000..4e9dceed0d --- /dev/null +++ b/src/Umbraco.Web/PropertyEditors/ComplexEditorValidator.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using Umbraco.Core; +using Umbraco.Core.Models; +using Umbraco.Core.PropertyEditors; +using Umbraco.Core.Services; +using Umbraco.Web.PropertyEditors.Validation; + +namespace Umbraco.Web.PropertyEditors +{ + /// + /// Used to validate complex editors that contain nested editors + /// + public abstract class ComplexEditorValidator : IValueValidator + { + private readonly PropertyValidationService _propertyValidationService; + + public ComplexEditorValidator(PropertyEditorCollection propertyEditors, IDataTypeService dataTypeService, ILocalizedTextService textService) + { + _propertyValidationService = new PropertyValidationService(propertyEditors, dataTypeService, textService); + } + + public IEnumerable Validate(object value, string valueType, object dataTypeConfiguration) + { + var elements = GetElementsFromValue(value); + var rowResults = GetNestedValidationResults(elements).ToList(); + + return rowResults.Count > 0 + ? new NestedValidationResults(rowResults).Yield() + : Enumerable.Empty(); + } + + protected abstract IEnumerable GetElementsFromValue(object value); + + /// + /// Return a nested validation result per row + /// + /// + /// + protected IEnumerable GetNestedValidationResults(IEnumerable elements) + { + foreach (var row in elements) + { + var nestedValidation = new List(); + + foreach(var validationResult in _propertyValidationService.ValidatePropertyValue(row.PropertyType, row.PostedValue)) + { + nestedValidation.Add(validationResult); + } + + if (nestedValidation.Count > 0) + { + yield return new NestedValidationResults(nestedValidation); + } + } + } + + public class ElementTypeValidationModel + { + public ElementTypeValidationModel(object postedValue, PropertyType propertyType) + { + PostedValue = postedValue ?? throw new ArgumentNullException(nameof(postedValue)); + PropertyType = propertyType ?? throw new ArgumentNullException(nameof(propertyType)); + } + + public object PostedValue { get; } + public PropertyType PropertyType { get; } + + } + } +} diff --git a/src/Umbraco.Web/PropertyEditors/NestedContentPropertyEditor.cs b/src/Umbraco.Web/PropertyEditors/NestedContentPropertyEditor.cs index 564630c574..61a48835de 100644 --- a/src/Umbraco.Web/PropertyEditors/NestedContentPropertyEditor.cs +++ b/src/Umbraco.Web/PropertyEditors/NestedContentPropertyEditor.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; using System.Text.RegularExpressions; +using Microsoft.CodeAnalysis.CSharp.Syntax; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Umbraco.Core; @@ -13,9 +14,11 @@ using Umbraco.Core.Models; using Umbraco.Core.Models.Editors; using Umbraco.Core.PropertyEditors; using Umbraco.Core.Services; +using Umbraco.Web.PropertyEditors.Validation; namespace Umbraco.Web.PropertyEditors { + /// /// Represents a nested content property editor. /// @@ -31,14 +34,20 @@ namespace Umbraco.Web.PropertyEditors private readonly Lazy _propertyEditors; private readonly IDataTypeService _dataTypeService; private readonly IContentTypeService _contentTypeService; + private readonly ILocalizedTextService _localizedTextService; internal const string ContentTypeAliasPropertyKey = "ncContentTypeAlias"; + [Obsolete("Use the constructor specifying all parameters instead")] public NestedContentPropertyEditor(ILogger logger, Lazy propertyEditors, IDataTypeService dataTypeService, IContentTypeService contentTypeService) + : this(logger, propertyEditors, dataTypeService, contentTypeService, Current.Services.TextService) { } + + public NestedContentPropertyEditor(ILogger logger, Lazy propertyEditors, IDataTypeService dataTypeService, IContentTypeService contentTypeService, ILocalizedTextService localizedTextService) : base (logger) { _propertyEditors = propertyEditors; _dataTypeService = dataTypeService; _contentTypeService = contentTypeService; + _localizedTextService = localizedTextService; } // has to be lazy else circular dep in ctor @@ -52,7 +61,7 @@ namespace Umbraco.Web.PropertyEditors #region Value Editor - protected override IDataValueEditor CreateValueEditor() => new NestedContentPropertyValueEditor(Attribute, PropertyEditors, _dataTypeService, _contentTypeService); + protected override IDataValueEditor CreateValueEditor() => new NestedContentPropertyValueEditor(Attribute, PropertyEditors, _dataTypeService, _contentTypeService, _localizedTextService); internal class NestedContentPropertyValueEditor : DataValueEditor, IDataValueReference { @@ -60,13 +69,13 @@ namespace Umbraco.Web.PropertyEditors private readonly IDataTypeService _dataTypeService; private readonly NestedContentValues _nestedContentValues; - public NestedContentPropertyValueEditor(DataEditorAttribute attribute, PropertyEditorCollection propertyEditors, IDataTypeService dataTypeService, IContentTypeService contentTypeService) + public NestedContentPropertyValueEditor(DataEditorAttribute attribute, PropertyEditorCollection propertyEditors, IDataTypeService dataTypeService, IContentTypeService contentTypeService, ILocalizedTextService textService) : base(attribute) { _propertyEditors = propertyEditors; _dataTypeService = dataTypeService; _nestedContentValues = new NestedContentValues(contentTypeService); - Validators.Add(new NestedContentValidator(propertyEditors, dataTypeService, _nestedContentValues)); + Validators.Add(new NestedContentValidator(_nestedContentValues, propertyEditors, dataTypeService, textService)); } /// @@ -255,75 +264,25 @@ namespace Umbraco.Web.PropertyEditors } } - internal class NestedContentValidator : IValueValidator + internal class NestedContentValidator : ComplexEditorValidator { - private readonly PropertyEditorCollection _propertyEditors; - private readonly IDataTypeService _dataTypeService; private readonly NestedContentValues _nestedContentValues; - public NestedContentValidator(PropertyEditorCollection propertyEditors, IDataTypeService dataTypeService, NestedContentValues nestedContentValues) + public NestedContentValidator(NestedContentValues nestedContentValues, PropertyEditorCollection propertyEditors, IDataTypeService dataTypeService, ILocalizedTextService textService) + : base(propertyEditors, dataTypeService, textService) { - _propertyEditors = propertyEditors; - _dataTypeService = dataTypeService; _nestedContentValues = nestedContentValues; } - public IEnumerable Validate(object rawValue, string valueType, object dataTypeConfiguration) + protected override IEnumerable GetElementsFromValue(object value) { - var validationResults = new List(); - - foreach(var row in _nestedContentValues.GetPropertyValues(rawValue, out _)) + foreach (var row in _nestedContentValues.GetPropertyValues(value, out _)) { if (row.PropType == null) continue; - var config = _dataTypeService.GetDataType(row.PropType.DataTypeId).Configuration; - var propertyEditor = _propertyEditors[row.PropType.PropertyEditorAlias]; - if (propertyEditor == null) continue; - - foreach (var validator in propertyEditor.GetValueEditor().Validators) - { - foreach (var result in validator.Validate(row.JsonRowValue[row.PropKey], propertyEditor.GetValueEditor().ValueType, config)) - { - result.ErrorMessage = "Item " + (row.RowIndex + 1) + " '" + row.PropType.Name + "' " + result.ErrorMessage; - validationResults.Add(result); - } - } - - // Check mandatory - if (row.PropType.Mandatory) - { - if (row.JsonRowValue[row.PropKey] == null) - { - var message = string.IsNullOrWhiteSpace(row.PropType.MandatoryMessage) - ? $"'{row.PropType.Name}' cannot be null" - : row.PropType.MandatoryMessage; - validationResults.Add(new ValidationResult($"Item {(row.RowIndex + 1)}: {message}", new[] { row.PropKey })); - } - else if (row.JsonRowValue[row.PropKey].ToString().IsNullOrWhiteSpace() || (row.JsonRowValue[row.PropKey].Type == JTokenType.Array && !row.JsonRowValue[row.PropKey].HasValues)) - { - var message = string.IsNullOrWhiteSpace(row.PropType.MandatoryMessage) - ? $"'{row.PropType.Name}' cannot be empty" - : row.PropType.MandatoryMessage; - validationResults.Add(new ValidationResult($"Item {(row.RowIndex + 1)}: {message}", new[] { row.PropKey })); - } - } - - // Check regex - if (!row.PropType.ValidationRegExp.IsNullOrWhiteSpace() - && row.JsonRowValue[row.PropKey] != null && !row.JsonRowValue[row.PropKey].ToString().IsNullOrWhiteSpace()) - { - var regex = new Regex(row.PropType.ValidationRegExp); - if (!regex.IsMatch(row.JsonRowValue[row.PropKey].ToString())) - { - var message = string.IsNullOrWhiteSpace(row.PropType.ValidationRegExpMessage) - ? $"'{row.PropType.Name}' is invalid, it does not match the correct pattern" - : row.PropType.ValidationRegExpMessage; - validationResults.Add(new ValidationResult($"Item {(row.RowIndex + 1)}: {message}", new[] { row.PropKey })); - } - } + var val = row.JsonRowValue[row.PropKey]; + yield return new ElementTypeValidationModel(val, row.PropType); } - - return validationResults; } } diff --git a/src/Umbraco.Web/PropertyEditors/Validation/NestedValidationResults.cs b/src/Umbraco.Web/PropertyEditors/Validation/NestedValidationResults.cs new file mode 100644 index 0000000000..86462fe333 --- /dev/null +++ b/src/Umbraco.Web/PropertyEditors/Validation/NestedValidationResults.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace Umbraco.Web.PropertyEditors.Validation +{ + /// + /// Custom that contains a list of nested validation results + /// + /// + /// For example, each represents validation results for a row in Nested Content + /// + public class NestedValidationResults : ValidationResult + { + public NestedValidationResults(IEnumerable nested) + : base(string.Empty) + { + ValidationResults = new List(nested); + } + + public IList ValidationResults { get; } + } +} diff --git a/src/Umbraco.Web/PropertyEditors/Validation/PropertyValidationResult.cs b/src/Umbraco.Web/PropertyEditors/Validation/PropertyValidationResult.cs new file mode 100644 index 0000000000..f2c92e441e --- /dev/null +++ b/src/Umbraco.Web/PropertyEditors/Validation/PropertyValidationResult.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations; + +namespace Umbraco.Web.PropertyEditors.Validation +{ + /// + /// Custom for content properties + /// + /// + /// This clones the original result and then ensures the nested result if it's the correct type + /// + public class PropertyValidationResult : ValidationResult + { + public PropertyValidationResult(ValidationResult nested) + : base(nested.ErrorMessage, nested.MemberNames) + { + NestedResuls = nested as NestedValidationResults; + } + + /// + /// Nested validation results for the content property + /// + /// + /// There can be nested results for complex editors that contain other editors + /// + public NestedValidationResults NestedResuls { get; } + } +} diff --git a/src/Umbraco.Web/PropertyEditors/Validation/ValidationResultConverter.cs b/src/Umbraco.Web/PropertyEditors/Validation/ValidationResultConverter.cs new file mode 100644 index 0000000000..a03528628b --- /dev/null +++ b/src/Umbraco.Web/PropertyEditors/Validation/ValidationResultConverter.cs @@ -0,0 +1,71 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Newtonsoft.Json.Serialization; +using System; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Umbraco.Core; + +namespace Umbraco.Web.PropertyEditors.Validation +{ + /// + /// Custom json converter for and + /// + internal class ValidationResultConverter : JsonConverter + { + public override bool CanConvert(Type objectType) => typeof(ValidationResult).IsAssignableFrom(objectType); + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + throw new NotImplementedException(); + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + var camelCaseSerializer = new JsonSerializer + { + ContractResolver = new CamelCasePropertyNamesContractResolver(), + DefaultValueHandling = DefaultValueHandling.Ignore + }; + foreach (var c in serializer.Converters) + camelCaseSerializer.Converters.Add(c); + + var validationResult = (ValidationResult)value; + + if (validationResult is NestedValidationResults nestedResult) + { + if (nestedResult.ValidationResults.Count > 0) + { + var validationItems = JToken.FromObject(nestedResult.ValidationResults, camelCaseSerializer); + validationItems.WriteTo(writer); + } + } + else + { + var jo = new JObject(); + + if (!validationResult.ErrorMessage.IsNullOrWhiteSpace()) + { + jo.Add("errorMessage", JToken.FromObject(validationResult.ErrorMessage, camelCaseSerializer)); + } + + if (validationResult.MemberNames.Any()) + { + jo.Add("memberNames", JToken.FromObject(validationResult.MemberNames, camelCaseSerializer)); + } + + if (validationResult is PropertyValidationResult propertyValidationResult) + { + if (propertyValidationResult.NestedResuls?.ValidationResults.Count > 0) + { + jo.Add("nestedValidation", JToken.FromObject(propertyValidationResult.NestedResuls, camelCaseSerializer)); + } + } + + jo.WriteTo(writer); + } + } + } +} diff --git a/src/Umbraco.Web/Umbraco.Web.csproj b/src/Umbraco.Web/Umbraco.Web.csproj index 1fb37322dd..f1791908f9 100644 --- a/src/Umbraco.Web/Umbraco.Web.csproj +++ b/src/Umbraco.Web/Umbraco.Web.csproj @@ -245,8 +245,12 @@ + + + +