diff --git a/src/Umbraco.Core/EmbeddedResources/Lang/en.xml b/src/Umbraco.Core/EmbeddedResources/Lang/en.xml index 4b6ca0dd4a27..4156b124bfbb 100644 --- a/src/Umbraco.Core/EmbeddedResources/Lang/en.xml +++ b/src/Umbraco.Core/EmbeddedResources/Lang/en.xml @@ -402,6 +402,7 @@ The chosen media type is invalid. The chosen content is of invalid type. The chosen content does not exist. + The chosen media does not exist. Multiple selected media is not allowed. The value '%0%' is not one of the available options. The values '%0%' are not found in the the available options. diff --git a/src/Umbraco.Core/PropertyEditors/AllowedContentTypeKeysParser.cs b/src/Umbraco.Core/PropertyEditors/AllowedContentTypeKeysParser.cs new file mode 100644 index 000000000000..cd353d03975c --- /dev/null +++ b/src/Umbraco.Core/PropertyEditors/AllowedContentTypeKeysParser.cs @@ -0,0 +1,34 @@ +using Umbraco.Extensions; + +namespace Umbraco.Cms.Core.PropertyEditors; + +/// +/// Parses the comma-separated content type keys stored in a picker's "allowed content types" configuration value +/// (e.g. or ). +/// +internal static class AllowedContentTypeKeysParser +{ + /// + /// Parses the configured value into the set of allowed content type keys. + /// + /// The comma-separated configuration value. Non-GUID entries are ignored. + /// The set of allowed content type keys, or an empty set when nothing is configured. + public static HashSet Parse(string? configValue) + { + if (configValue.IsNullOrWhiteSpace()) + { + return []; + } + + var result = new HashSet(); + foreach (var entry in configValue.Split(Constants.CharArrays.Comma, StringSplitOptions.RemoveEmptyEntries)) + { + if (Guid.TryParse(entry, out Guid guid)) + { + result.Add(guid); + } + } + + return result; + } +} diff --git a/src/Umbraco.Core/PropertyEditors/ContentPickerConfiguration.cs b/src/Umbraco.Core/PropertyEditors/ContentPickerConfiguration.cs index a3a7faffabc3..2236386840db 100644 --- a/src/Umbraco.Core/PropertyEditors/ContentPickerConfiguration.cs +++ b/src/Umbraco.Core/PropertyEditors/ContentPickerConfiguration.cs @@ -8,4 +8,10 @@ public class ContentPickerConfiguration : IIgnoreUserStartNodesConfig /// [ConfigurationField(Constants.DataTypes.ReservedPreValueKeys.IgnoreUserStartNodes)] public bool IgnoreUserStartNodes { get; set; } + + /// + /// Gets or sets the content type filter for allowed selections. + /// + [ConfigurationField("allowedContentTypes")] + public string? AllowedContentTypeIds { get; set; } } diff --git a/src/Umbraco.Core/PropertyEditors/ContentPickerPropertyEditor.cs b/src/Umbraco.Core/PropertyEditors/ContentPickerPropertyEditor.cs index 0c98f5643e62..85096004ed51 100644 --- a/src/Umbraco.Core/PropertyEditors/ContentPickerPropertyEditor.cs +++ b/src/Umbraco.Core/PropertyEditors/ContentPickerPropertyEditor.cs @@ -1,12 +1,16 @@ // Copyright (c) Umbraco. // See LICENSE for more details. +using System.ComponentModel.DataAnnotations; using System.Text.Json.Nodes; using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Editors; +using Umbraco.Cms.Core.Models.Validation; +using Umbraco.Cms.Core.PropertyEditors.Validation; +using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Serialization; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Strings; @@ -70,13 +74,21 @@ internal sealed class ContentPickerPropertyValueEditor : DataValueEditor, IDataV /// The JSON serializer. /// The IO helper. /// The data editor attribute. + /// The core scope provider. + /// The content service. + /// The localized text service. public ContentPickerPropertyValueEditor( IShortStringHelper shortStringHelper, IJsonSerializer jsonSerializer, IIOHelper ioHelper, - DataEditorAttribute attribute) + DataEditorAttribute attribute, + ICoreScopeProvider coreScopeProvider, + IContentService contentService, + ILocalizedTextService localizedTextService) : base(shortStringHelper, jsonSerializer, ioHelper, attribute) { + Validators.Add(new TypedValidatorRunner( + new AllowedTypeValidator(localizedTextService, contentService, coreScopeProvider))); } /// @@ -134,4 +146,61 @@ editorValue.Value is not null return guidUdi.Guid; } } + + /// + /// Validates that the selected content matches the allowed content types configured for the property editor. + /// + /// The localized text service. + /// The content service. + /// The core scope provider. + internal sealed class AllowedTypeValidator(ILocalizedTextService localizedTextService, IContentService contentService, ICoreScopeProvider coreScopeProvider) + : ITypedValidator + { + /// + public IEnumerable Validate( + string? value, + ContentPickerConfiguration? configuration, + string? valueType, + PropertyValidationContext validationContext) + { + if (string.IsNullOrEmpty(value) || + configuration is null || + Guid.TryParse(value, out Guid id) is false) + { + return []; + } + + HashSet allowedContentTypeKeys = AllowedContentTypeKeysParser.Parse(configuration.AllowedContentTypeIds); + + // No filter configured — all content types are allowed. + if (allowedContentTypeKeys.Count == 0) + { + return []; + } + + using ICoreScope scope = coreScopeProvider.CreateCoreScope(); + Guid? key = contentService.GetById(id)?.ContentType?.Key; + scope.Complete(); + + if (key is null) + { + return [new ValidationResult( + localizedTextService.Localize( + "validation", + "missingContent"), + ["value"])]; + } + + if (allowedContentTypeKeys.Contains(key.Value) is false) + { + return [new ValidationResult( + localizedTextService.Localize( + "validation", + "invalidObjectType"), + ["value"])]; + } + + return []; + } + } } diff --git a/src/Umbraco.Core/PropertyEditors/ElementPickerConfiguration.cs b/src/Umbraco.Core/PropertyEditors/ElementPickerConfiguration.cs index 18fbe23c116b..cf4e49313844 100644 --- a/src/Umbraco.Core/PropertyEditors/ElementPickerConfiguration.cs +++ b/src/Umbraco.Core/PropertyEditors/ElementPickerConfiguration.cs @@ -8,4 +8,32 @@ public class ElementPickerConfiguration : IIgnoreUserStartNodesConfig /// [ConfigurationField(Constants.DataTypes.ReservedPreValueKeys.IgnoreUserStartNodes)] public bool IgnoreUserStartNodes { get; set; } + + /// + /// Gets or sets the validation limits for the number of elements allowed. + /// + [ConfigurationField("validationLimit")] + public NumberRange? ValidationLimit { get; set; } + + /// + /// Gets or sets the content type filter for allowed selections. + /// + [ConfigurationField("allowedContentTypes")] + public string? AllowedContentTypeIds { get; set; } + + /// + /// Represents a numeric range with optional minimum and maximum values. + /// + public class NumberRange + { + /// + /// Gets or sets the minimum value of the range. + /// + public int? Min { get; set; } + + /// + /// Gets or sets the maximum value of the range. + /// + public int? Max { get; set; } + } } diff --git a/src/Umbraco.Core/PropertyEditors/ElementPickerPropertyEditor.cs b/src/Umbraco.Core/PropertyEditors/ElementPickerPropertyEditor.cs index 7aa3a72dca96..407081ce7b4e 100644 --- a/src/Umbraco.Core/PropertyEditors/ElementPickerPropertyEditor.cs +++ b/src/Umbraco.Core/PropertyEditors/ElementPickerPropertyEditor.cs @@ -1,13 +1,19 @@ -using Umbraco.Cms.Core.IO; +using System.ComponentModel.DataAnnotations; +using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Editors; +using Umbraco.Cms.Core.Models.Validation; +using Umbraco.Cms.Core.PropertyEditors.Validation; +using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Strings; +using Umbraco.Extensions; namespace Umbraco.Cms.Core.PropertyEditors; /// -/// Element picker property editor that stores element keys +/// Element picker property editor that stores element keys. /// [DataEditor( Constants.PropertyEditors.Aliases.ElementPicker, @@ -17,6 +23,11 @@ public class ElementPickerPropertyEditor : DataEditor { private readonly IIOHelper _ioHelper; + /// + /// Initializes a new instance of the class. + /// + /// The data value editor factory. + /// The IO helper. public ElementPickerPropertyEditor(IDataValueEditorFactory dataValueEditorFactory, IIOHelper ioHelper) : base(dataValueEditorFactory) { @@ -28,21 +39,44 @@ public ElementPickerPropertyEditor(IDataValueEditorFactory dataValueEditorFactor protected override IConfigurationEditor CreateConfigurationEditor() => new ElementPickerConfigurationEditor(_ioHelper); + /// protected override IDataValueEditor CreateValueEditor() => DataValueEditorFactory.Create(Attribute!); + /// + /// Provides the value editor for the element picker property editor. + /// internal sealed class ElementPickerPropertyValueEditor : DataValueEditor, IDataValueReference { private readonly IJsonSerializer _jsonSerializer; + /// + /// Initializes a new instance of the class. + /// + /// The short string helper. + /// The JSON serializer. + /// The IO helper. + /// The data editor attribute. + /// The localized text service. + /// The element service. + /// The core scope provider. public ElementPickerPropertyValueEditor( IShortStringHelper shortStringHelper, IJsonSerializer jsonSerializer, IIOHelper ioHelper, - DataEditorAttribute attribute) + DataEditorAttribute attribute, + ILocalizedTextService localizedTextService, + IElementService elementService, + ICoreScopeProvider coreScopeProvider) : base(shortStringHelper, jsonSerializer, ioHelper, attribute) - => _jsonSerializer = jsonSerializer; + { + _jsonSerializer = jsonSerializer; + Validators.Add(new TypedValidatorRunner, ElementPickerConfiguration>( + new MinMaxValidator(localizedTextService), + new AllowedTypeValidator(localizedTextService, elementService, coreScopeProvider))); + } + /// public IEnumerable GetReferences(object? value) { var asString = value as string ?? value?.ToString(); @@ -63,4 +97,144 @@ public IEnumerable GetReferences(object? value) } } } + + /// + /// Validator to ensure that the number of selected elements is within the configured min/max limits, if any. + /// + internal sealed class MinMaxValidator : ITypedValidator, ElementPickerConfiguration> + { + private readonly ILocalizedTextService _localizedTextService; + + /// + /// Initializes a new instance of the class. + /// + /// The localized text service. + public MinMaxValidator(ILocalizedTextService localizedTextService) + => _localizedTextService = localizedTextService; + + /// + public IEnumerable Validate( + List? value, + ElementPickerConfiguration? configuration, + string? valueType, + PropertyValidationContext validationContext) + { + var validationResults = new List(); + + if (configuration is null || configuration.ValidationLimit is null) + { + return validationResults; + } + + if (configuration.ValidationLimit.Min is int min and > 0 && (value is null || value.Count < min)) + { + validationResults.Add(new ValidationResult( + _localizedTextService.Localize( + "validation", + "entriesShort", + [min.ToString(), (min - (value?.Count ?? 0)).ToString()]), + ["value"])); + } + + if (value is null) + { + return validationResults; + } + + if (configuration.ValidationLimit.Max is int max and > 0 && value.Count > max) + { + validationResults.Add(new ValidationResult( + _localizedTextService.Localize( + "validation", + "entriesExceed", + [max.ToString(), (value.Count - max).ToString()]), + ["value"])); + } + + return validationResults; + } + } + + /// + /// Validator to ensure that all selected elements are of an allowed content type, if any are configured. + /// + internal sealed class AllowedTypeValidator : ITypedValidator, ElementPickerConfiguration> + { + private readonly ILocalizedTextService _localizedTextService; + private readonly IElementService _elementService; + private readonly ICoreScopeProvider _coreScopeProvider; + + /// + /// Initializes a new instance of the class. + /// + /// The localized text service. + /// The element service. + /// The core scope provider. + public AllowedTypeValidator( + ILocalizedTextService localizedTextService, + IElementService elementService, + ICoreScopeProvider coreScopeProvider) + { + _localizedTextService = localizedTextService; + _elementService = elementService; + _coreScopeProvider = coreScopeProvider; + } + + /// + public IEnumerable Validate( + List? value, + ElementPickerConfiguration? configuration, + string? valueType, + PropertyValidationContext validationContext) + { + if (value is null || value.Count == 0 || configuration is null) + { + return []; + } + + HashSet allowedContentTypeKeys = AllowedContentTypeKeysParser.Parse(configuration.AllowedContentTypeIds); + + // No filter configured — all element types are allowed. + if (allowedContentTypeKeys.Count == 0) + { + return []; + } + + Guid[] elementIds = value + .Where(v => Guid.TryParse(v, out _)) + .Select(Guid.Parse) + .Distinct() + .ToArray(); + + using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); + IElement[] elements = _elementService.GetByIds(elementIds).ToArray(); + scope.Complete(); + + // Compare against the distinct requested keys (not the raw value count, which may include + // duplicates or non-GUID entries) so existing elements aren't incorrectly reported as missing. + if (elements.Length != elementIds.Length) + { + return [ + new ValidationResult( + _localizedTextService.Localize("validation", "missingContent"), + ["value"]) + ]; + } + + foreach (IElement element in elements) + { + if (allowedContentTypeKeys.Contains(element.ContentType.Key) is false) + { + return + [ + new ValidationResult( + _localizedTextService.Localize("validation", "invalidObjectType"), + ["value"]) + ]; + } + } + + return []; + } + } } diff --git a/src/Umbraco.Core/PropertyEditors/EntityDataPickerPropertyEditor.cs b/src/Umbraco.Core/PropertyEditors/EntityDataPickerPropertyEditor.cs index d51d0ff57ef5..a7e9ae5d8be8 100644 --- a/src/Umbraco.Core/PropertyEditors/EntityDataPickerPropertyEditor.cs +++ b/src/Umbraco.Core/PropertyEditors/EntityDataPickerPropertyEditor.cs @@ -67,7 +67,7 @@ public EntityDataPickerPropertyValueEditor( /// /// Validates the min/max configuration for the entity data picker property editor. /// - internal sealed class MinMaxValidator : ITypedJsonValidator + internal sealed class MinMaxValidator : ITypedValidator { private readonly ILocalizedTextService _localizedTextService; diff --git a/src/Umbraco.Core/PropertyEditors/Validation/ITypedJsonValidator.cs b/src/Umbraco.Core/PropertyEditors/Validation/ITypedJsonValidator.cs index d9638555d788..0018788d59b2 100644 --- a/src/Umbraco.Core/PropertyEditors/Validation/ITypedJsonValidator.cs +++ b/src/Umbraco.Core/PropertyEditors/Validation/ITypedJsonValidator.cs @@ -9,17 +9,13 @@ namespace Umbraco.Cms.Core.PropertyEditors.Validation; /// /// The type of the value consumed by the validator. /// The type of the configuration consumed by validator. -public interface ITypedJsonValidator +[Obsolete("Use ITypedValidator instead; the validator contract is not JSON-specific. Scheduled for removal in Umbraco 20.")] +public interface ITypedJsonValidator : ITypedValidator { - /// - /// Validates the specified value against the configuration. - /// - /// The deserialized value to validate. - /// The data type configuration. - /// The value type. - /// The property validation context. - /// A collection of validation results. - public abstract IEnumerable Validate( + // Re-declared (rather than purely inherited from ITypedValidator) so the ITypedJsonValidator.Validate member + // remains present for binary compatibility with consumers compiled against this interface in v15-v17. + // TODO (V20): remove together with this interface. + new IEnumerable Validate( TValue? value, TConfiguration? configuration, string? valueType, diff --git a/src/Umbraco.Core/PropertyEditors/Validation/ITypedValidator.cs b/src/Umbraco.Core/PropertyEditors/Validation/ITypedValidator.cs new file mode 100644 index 000000000000..69414235b0a2 --- /dev/null +++ b/src/Umbraco.Core/PropertyEditors/Validation/ITypedValidator.cs @@ -0,0 +1,31 @@ +using System.ComponentModel.DataAnnotations; +using Umbraco.Cms.Core.Models.Validation; + +namespace Umbraco.Cms.Core.PropertyEditors.Validation; + +/// +/// A validator that operates on an already-typed value and configuration. +/// +/// Used together with an runner that materializes the typed value: see +/// for value editors whose value is already typed, and +/// for JSON based value editors, where the value is deserialized once before validation. +/// +/// +/// The type of the value consumed by the validator. +/// The type of the configuration consumed by validator. +public interface ITypedValidator +{ + /// + /// Validates the specified value against the configuration. + /// + /// The typed value to validate. + /// The data type configuration. + /// The value type. + /// The property validation context. + /// A collection of validation results. + IEnumerable Validate( + TValue? value, + TConfiguration? configuration, + string? valueType, + PropertyValidationContext validationContext); +} diff --git a/src/Umbraco.Core/PropertyEditors/Validation/TypedJsonValidatorRunner.cs b/src/Umbraco.Core/PropertyEditors/Validation/TypedJsonValidatorRunner.cs index 029c2bf11c42..048706a31bf8 100644 --- a/src/Umbraco.Core/PropertyEditors/Validation/TypedJsonValidatorRunner.cs +++ b/src/Umbraco.Core/PropertyEditors/Validation/TypedJsonValidatorRunner.cs @@ -6,26 +6,47 @@ namespace Umbraco.Cms.Core.PropertyEditors.Validation; /// /// -/// An aggregate validator for JSON based value editors, to avoid doing multiple deserialization. +/// An aggregate for JSON based value editors. Deserializes the editor value into +/// once (avoiding repeated deserialization), casts the configuration once, and passes both +/// to each , aggregating the results. /// /// -/// Will deserialize once, and cast the configuration once, and pass those values to each , aggregating the results. +/// Use this runner when the editor value reaching validation is raw JSON that must be deserialized before validation — +/// typically an array of complex objects, such as a media picker storing crop data, which the backoffice JSON object +/// converter leaves as un-typed JSON nodes rather than a typed CLR value. +/// +/// +/// When the editor value is already the typed CLR value (so only a cast is needed, with no deserialization) use +/// instead. That is the only difference between the two runners: +/// this one deserializes, the other casts. /// /// /// The type of the expected value. /// The type of the expected configuration +/// public class TypedJsonValidatorRunner : IValueValidator where TValue : class { private readonly IJsonSerializer _jsonSerializer; - private readonly ITypedJsonValidator[] _validators; + private readonly ITypedValidator[] _validators; /// /// Initializes a new instance of the class. /// /// The JSON serializer. /// The collection of validators to run. + [Obsolete("Use the constructor accepting ITypedValidator instances. Scheduled for removal in Umbraco 20.")] public TypedJsonValidatorRunner(IJsonSerializer jsonSerializer, params ITypedJsonValidator[] validators) + : this(jsonSerializer, (ITypedValidator[])validators) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The JSON serializer. + /// The collection of validators to run. + public TypedJsonValidatorRunner(IJsonSerializer jsonSerializer, params ITypedValidator[] validators) { _jsonSerializer = jsonSerializer; _validators = validators; @@ -51,7 +72,7 @@ public IEnumerable Validate( return validationResults; } - foreach (ITypedJsonValidator validator in _validators) + foreach (ITypedValidator validator in _validators) { validationResults.AddRange(validator.Validate(deserializedValue, configuration, valueType, validationContext)); } diff --git a/src/Umbraco.Core/PropertyEditors/Validation/TypedValidatorRunner.cs b/src/Umbraco.Core/PropertyEditors/Validation/TypedValidatorRunner.cs new file mode 100644 index 000000000000..c1d4a6f7a66f --- /dev/null +++ b/src/Umbraco.Core/PropertyEditors/Validation/TypedValidatorRunner.cs @@ -0,0 +1,60 @@ +using System.ComponentModel.DataAnnotations; +using Umbraco.Cms.Core.Models.Validation; + +namespace Umbraco.Cms.Core.PropertyEditors.Validation; + +/// +/// +/// An aggregate that casts the editor value once and passes it, along with the cast +/// configuration, to each , aggregating the results. +/// +/// +/// Use this runner when the editor value reaching validation is already the typed CLR value (), +/// so a cast is all that is needed — for example a content picker (value is a ) or an element picker +/// (value is a List<string>, since the backoffice JSON object converter resolves an array of scalars into a typed list). +/// +/// +/// When the editor value is instead raw JSON that must be deserialized into before validation — +/// typically an array of complex objects, such as a media picker storing crop data — use +/// instead. That is the only difference between the two runners: this one casts, the other deserializes. +/// +/// +/// The type of the expected value. +/// The type of the expected configuration. +/// +public class TypedValidatorRunner : IValueValidator + where TValue : class +{ + private readonly ITypedValidator[] _validators; + + /// + /// Initializes a new instance of the class. + /// + /// The collection of validators to run. + public TypedValidatorRunner(params ITypedValidator[] validators) + => _validators = validators; + + /// + public IEnumerable Validate( + object? value, + string? valueType, + object? dataTypeConfiguration, + PropertyValidationContext validationContext) + { + if (dataTypeConfiguration is not TConfiguration configuration) + { + return []; + } + + if (value is not null and not TValue) + { + return []; + } + + var typedValue = value as TValue; + + return _validators + .SelectMany(v => v.Validate(typedValue, configuration, valueType, validationContext)) + .ToList(); + } +} diff --git a/src/Umbraco.Infrastructure/PropertyEditors/DateTimePropertyEditorBase.cs b/src/Umbraco.Infrastructure/PropertyEditors/DateTimePropertyEditorBase.cs index 8517cf3659ed..b11d120a6982 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/DateTimePropertyEditorBase.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/DateTimePropertyEditorBase.cs @@ -199,7 +199,7 @@ public DateTimeDataValueEditor( /// /// Validates the date time selection for the DateTime2 property editor. /// - private class DateTimeValidator : ITypedJsonValidator + private class DateTimeValidator : ITypedValidator { private readonly ILocalizedTextService _localizedTextService; diff --git a/src/Umbraco.Infrastructure/PropertyEditors/MediaPicker3PropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/MediaPicker3PropertyEditor.cs index b04b6231b545..5b22f912b7f5 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/MediaPicker3PropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/MediaPicker3PropertyEditor.cs @@ -511,7 +511,7 @@ public void ApplyConfiguration(MediaPicker3Configuration? configuration) /// /// Validates the min/max configuration for the media picker property editor. /// - internal sealed class MinMaxValidator : ITypedJsonValidator, MediaPicker3Configuration> + internal sealed class MinMaxValidator : ITypedValidator, MediaPicker3Configuration> { private readonly ILocalizedTextService _localizedTextService; @@ -573,7 +573,7 @@ public IEnumerable Validate( /// /// Validates the allowed type configuration for the media picker property editor. /// - internal sealed class AllowedTypeValidator : ITypedJsonValidator, MediaPicker3Configuration> + internal sealed class AllowedTypeValidator : ITypedValidator, MediaPicker3Configuration> { private readonly ILocalizedTextService _localizedTextService; private readonly IMediaService _mediaService; @@ -615,10 +615,26 @@ public IEnumerable Validate( .Where(x => x.MediaTypeAlias.IsNullOrWhiteSpace() is false) .Select(x => x.MediaTypeAlias); - IEnumerable retrievedMediaKeys = value + Guid[] retrievedMediaKeys = value .Where(x => x.MediaTypeAlias.IsNullOrWhiteSpace()) - .Select(x => x.MediaKey); - IEnumerable retrievedMedia = _mediaService.GetByIds(retrievedMediaKeys); + .Select(x => x.MediaKey) + .Distinct() + .ToArray(); + IMedia[] retrievedMedia = _mediaService.GetByIds(retrievedMediaKeys).ToArray(); + + // If any of the media we had to look up (to resolve the type) could not be found, the selection + // references media that no longer exists, so the configured allowed types cannot be verified. + // Compare against the distinct requested keys so duplicates aren't reported as missing. + if (retrievedMedia.Length != retrievedMediaKeys.Length) + { + return + [ + new ValidationResult( + _localizedTextService.Localize("validation", "missingMedia"), + ["value"]) + ]; + } + IEnumerable retrievedTypeAliases = retrievedMedia .Select(x => x.ContentType.Alias); @@ -644,7 +660,7 @@ public IEnumerable Validate( /// /// Validates the start node configuration for the media picker property editor. /// - internal sealed class StartNodeValidator : ITypedJsonValidator, MediaPicker3Configuration> + internal sealed class StartNodeValidator : ITypedValidator, MediaPicker3Configuration> { private readonly ILocalizedTextService _localizedTextService; private readonly IMediaNavigationQueryService _mediaNavigationQueryService; diff --git a/src/Umbraco.Infrastructure/PropertyEditors/MultiNodeTreePickerPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/MultiNodeTreePickerPropertyEditor.cs index 70bac4e66346..d65aed5501b5 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/MultiNodeTreePickerPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/MultiNodeTreePickerPropertyEditor.cs @@ -217,7 +217,7 @@ public class EditorEntityReference /// /// Validates the min/max configuration for the multi-node tree picker property editor. /// - internal sealed class MinMaxValidator : ITypedJsonValidator + internal sealed class MinMaxValidator : ITypedValidator { private readonly ILocalizedTextService _localizedTextService; @@ -275,7 +275,7 @@ public IEnumerable Validate( /// /// Validates the selected object type for the multi-node tree picker property editor. /// - internal sealed class ObjectTypeValidator : ITypedJsonValidator + internal sealed class ObjectTypeValidator : ITypedValidator { private readonly ILocalizedTextService _localizedTextService; private readonly ICoreScopeProvider _coreScopeProvider; @@ -366,7 +366,7 @@ public IEnumerable Validate( /// /// Validates the selected content type for the multi-node tree picker property editor. /// - internal sealed class ContentTypeValidator : ITypedJsonValidator + internal sealed class ContentTypeValidator : ITypedValidator { private readonly ILocalizedTextService _localizedTextService; private readonly ICoreScopeProvider _coreScopeProvider; diff --git a/src/Umbraco.Infrastructure/PropertyEditors/MultiUrlPickerValueEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/MultiUrlPickerValueEditor.cs index 039d458fe474..6156d7dfa1a2 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/MultiUrlPickerValueEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/MultiUrlPickerValueEditor.cs @@ -391,7 +391,7 @@ public class LinkDto public string? Culture { get; set; } } - internal sealed class MinMaxValidator : ITypedJsonValidator + internal sealed class MinMaxValidator : ITypedValidator { private readonly ILocalizedTextService _localizedTextService; diff --git a/src/Umbraco.Infrastructure/Serialization/SystemTextJsonSerializerBase.cs b/src/Umbraco.Infrastructure/Serialization/SystemTextJsonSerializerBase.cs index 656986676476..157722ff49ae 100644 --- a/src/Umbraco.Infrastructure/Serialization/SystemTextJsonSerializerBase.cs +++ b/src/Umbraco.Infrastructure/Serialization/SystemTextJsonSerializerBase.cs @@ -2,8 +2,6 @@ using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Nodes; -using Microsoft.Extensions.DependencyInjection; -using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Serialization; using Umbraco.Extensions; @@ -49,6 +47,7 @@ string stringValue when stringValue.DetectIsJson() => stringValue, value = jsonString.IsNullOrWhiteSpace() ? null : Deserialize(jsonString); + return value != null; } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/property-editors/document-type-picker/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/property-editors/document-type-picker/manifests.ts index 13f5d3350356..b97e255d1372 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/property-editors/document-type-picker/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/property-editors/document-type-picker/manifests.ts @@ -19,6 +19,12 @@ export const manifest: ManifestPropertyEditorUi = { description: 'Limit to only pick Element Types', propertyEditorUiAlias: 'Umb.PropertyEditorUi.Toggle', }, + { + alias: 'onlyPickDocumentTypes', + label: 'Only Document Types', + description: 'Limit to only pick Document Types', + propertyEditorUiAlias: 'Umb.PropertyEditorUi.Toggle', + }, ], }, }, diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/property-editors/document-type-picker/property-editor-ui-document-type-picker.element.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/property-editors/document-type-picker/property-editor-ui-document-type-picker.element.ts index 12496e91cb42..c3ce190acf86 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/property-editors/document-type-picker/property-editor-ui-document-type-picker.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/property-editors/document-type-picker/property-editor-ui-document-type-picker.element.ts @@ -21,6 +21,7 @@ export class UmbPropertyEditorUIDocumentTypePickerElement extends UmbLitElement this._max = minMax?.max ?? Infinity; this._elementTypesOnly = config.getValueByAlias('onlyPickElementTypes') ?? false; + this._documentTypesOnly = config.getValueByAlias('onlyPickDocumentTypes') ?? false; } @property({ type: Boolean, attribute: 'readonly' }) @@ -35,6 +36,9 @@ export class UmbPropertyEditorUIDocumentTypePickerElement extends UmbLitElement @state() private _elementTypesOnly?: boolean; + @state() + private _documentTypesOnly?: boolean; + #onChange(event: CustomEvent & { target: UmbInputDocumentTypeElement }) { this.value = event.target.value; this.dispatchEvent(new UmbChangeEvent()); @@ -48,6 +52,7 @@ export class UmbPropertyEditorUIDocumentTypePickerElement extends UmbLitElement .value=${this.value} .readonly=${this.readonly} .elementTypesOnly=${this._elementTypesOnly ?? false} + .documentTypesOnly=${this._documentTypesOnly ?? false} @change=${this.#onChange}> `; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/property-editors/document-picker/Umbraco.ContentPicker.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/property-editors/document-picker/Umbraco.ContentPicker.ts index d157c10e38a0..1568d5230408 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/property-editors/document-picker/Umbraco.ContentPicker.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/property-editors/document-picker/Umbraco.ContentPicker.ts @@ -13,6 +13,7 @@ export const manifest: ManifestPropertyEditorSchema = { label: 'Ignore user start nodes', description: 'Selecting this option allows a user to choose nodes that they normally dont have access to.', propertyEditorUiAlias: 'Umb.PropertyEditorUi.Toggle', + weight: 100, }, ], }, diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/property-editors/document-picker/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/property-editors/document-picker/manifests.ts index 7faee8997d3d..3f3893d49811 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/property-editors/document-picker/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/property-editors/document-picker/manifests.ts @@ -16,6 +16,14 @@ export const manifests: Array = [ supportsReadOnly: true, settings: { properties: [ + { + alias: 'allowedContentTypes', + label: 'Accepted types', + description: 'Limit to specific types', + propertyEditorUiAlias: 'Umb.PropertyEditorUi.DocumentTypePicker', + config: [{ alias: 'onlyPickDocumentTypes', value: true }], + weight: 10, + }, { alias: 'startNodeId', label: 'Start node', @@ -27,6 +35,7 @@ export const manifests: Array = [ value: { min: 0, max: 1 }, }, ], + weight: 20, }, ], }, diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/property-editors/document-picker/property-editor-ui-document-picker.element.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/property-editors/document-picker/property-editor-ui-document-picker.element.ts index 5360295429ad..57357c7b3626 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/property-editors/document-picker/property-editor-ui-document-picker.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/property-editors/document-picker/property-editor-ui-document-picker.element.ts @@ -30,6 +30,9 @@ export class UmbPropertyEditorUIDocumentPickerElement } this._startNodeId = config.getValueByAlias('startNodeId'); + + const allowedContentTypes = config.getValueByAlias('allowedContentTypes'); + this._allowedContentTypes = allowedContentTypes ? allowedContentTypes.split(',').filter(Boolean) : undefined; } /** @@ -56,6 +59,9 @@ export class UmbPropertyEditorUIDocumentPickerElement @state() private _startNodeId?: string; + @state() + private _allowedContentTypes?: string[]; + @state() private _interactionMemories: Array = []; @@ -101,6 +107,7 @@ export class UmbPropertyEditorUIDocumentPickerElement .min=${this._min} .max=${this._max} .startNode=${startNode} + .allowedContentTypeIds=${this._allowedContentTypes} .value=${this.value} @change=${this.#onChange} ?readonly=${this.readonly} diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/global-components/input-element.element.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/global-components/input-element.element.ts index 2ca2c25ef6f7..7642ecb0b83e 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/elements/global-components/input-element.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/global-components/input-element.element.ts @@ -16,6 +16,9 @@ export class UmbInputElementElement extends UmbFormControlMixin; #dataType?: { unique: string }; #elementItem = new UmbElementItemRepository(this); #folderItem = new UmbElementFolderItemRepository(this); @@ -44,6 +46,7 @@ export class UmbElementTreePickerDataSource extends UmbControllerBase implements setConfig(config: UmbConfigCollectionModel | undefined) { this.#folderOnly = Boolean(getConfigValue(config, 'folderOnly')); this.#startNode = getConfigValue(config, 'startNode'); + this.#allowedContentTypeIds = getConfigValue(config, 'allowedContentTypeIds'); } async requestTreeStartNode() { @@ -80,5 +83,14 @@ export class UmbElementTreePickerDataSource extends UmbControllerBase implements return this.#folderOnly ? this.#folderItem.requestItems(uniques) : this.#elementItem.requestItems(uniques); } - treePickableFilter = (treeItem: UmbTreeItemModel): boolean => treeItem.isFolder === this.#folderOnly; + treePickableFilter = (treeItem: UmbTreeItemModel): boolean => { + if (treeItem.isFolder !== this.#folderOnly) return false; + + if (!this.#folderOnly && this.#allowedContentTypeIds?.length) { + const elementItem = treeItem as UmbElementTreeItemModel; + return this.#allowedContentTypeIds.includes(elementItem.documentType.unique); + } + + return true; + }; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/property-editor/element-picker/Umbraco.ElementPicker.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/property-editor/element-picker/Umbraco.ElementPicker.ts index 169d893bbe0b..975aed172b1b 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/elements/property-editor/element-picker/Umbraco.ElementPicker.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/property-editor/element-picker/Umbraco.ElementPicker.ts @@ -13,7 +13,7 @@ export const manifest: ManifestPropertyEditorSchema = { label: 'Ignore user start nodes', description: "Selecting this option allows a user to choose nodes that they normally don't have access to.", propertyEditorUiAlias: 'Umb.PropertyEditorUi.Toggle', - weight: 120, + weight: 100, }, ], }, diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/property-editor/element-picker/element-picker-property-editor-ui.element.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/property-editor/element-picker/element-picker-property-editor-ui.element.ts index 41f1c81d9073..d9b0024d059a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/elements/property-editor/element-picker/element-picker-property-editor-ui.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/property-editor/element-picker/element-picker-property-editor-ui.element.ts @@ -40,6 +40,9 @@ export class UmbElementPickerPropertyEditorUIElement this._startNode = startNodeId.length ? { unique: startNodeId[0], entityType: UMB_ELEMENT_FOLDER_ENTITY_TYPE } : undefined; + + const allowedContentTypes = config.getValueByAlias('allowedContentTypes'); + this._allowedContentTypes = allowedContentTypes ? allowedContentTypes.split(',').filter(Boolean) : undefined; } @state() @@ -60,6 +63,9 @@ export class UmbElementPickerPropertyEditorUIElement @state() private _startNode?: UmbTreeStartNode; + @state() + private _allowedContentTypes?: string[]; + override focus() { return this.shadowRoot?.querySelector('umb-input-element')?.focus(); } @@ -91,6 +97,7 @@ export class UmbElementPickerPropertyEditorUIElement .minMessage=${this._minMessage} .max=${this._max} .maxMessage=${this._maxMessage} + .allowedContentTypeIds=${this._allowedContentTypes} ?folderOnly=${this._folderOnly} ?readonly=${this.readonly} @change=${this.#onChange}> diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/property-editor/element-picker/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/property-editor/element-picker/manifests.ts index ff8192de1175..ce8c06c83d1a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/elements/property-editor/element-picker/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/property-editor/element-picker/manifests.ts @@ -14,12 +14,21 @@ const propertyEditorUi: ManifestPropertyEditorUi = { supportsReadOnly: true, settings: { properties: [ + { + alias: 'allowedContentTypes', + label: 'Accepted types', + description: 'Limit to specific types', + propertyEditorUiAlias: 'Umb.PropertyEditorUi.DocumentTypePicker', + config: [{ alias: 'onlyPickElementTypes', value: true }], + weight: 10, + }, { alias: 'validationLimit', label: 'Amount', + description: 'Set a required range of items', propertyEditorUiAlias: 'Umb.PropertyEditorUi.NumberRange', config: [{ alias: 'validationRange', value: { min: 0, max: Infinity } }], - weight: 100, + weight: 20, }, { alias: 'startNodeId', @@ -30,7 +39,7 @@ const propertyEditorUi: ManifestPropertyEditorUi = { { alias: 'folderOnly', value: true }, { alias: 'validationLimit', value: { min: 0, max: 1 } }, ], - weight: 110, + weight: 30, }, ], }, diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/manifests.ts index a691b70fc5da..674f86afbcab 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/manifests.ts @@ -21,8 +21,8 @@ const manifest: ManifestPropertyEditorUi = { properties: [ { alias: 'filter', - label: 'Allow items of type', - description: 'Select the applicable types', + label: 'Accepted types', + description: 'Limit to specific types', propertyEditorUiAlias: 'Umb.PropertyEditorUi.ContentPicker.SourceType', }, ], diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/AllowedContentTypeKeysParserTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/AllowedContentTypeKeysParserTests.cs new file mode 100644 index 000000000000..f7b2900d40c6 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/AllowedContentTypeKeysParserTests.cs @@ -0,0 +1,65 @@ +using NUnit.Framework; +using Umbraco.Cms.Core.PropertyEditors; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors; + +[TestFixture] +public class AllowedContentTypeKeysParserTests +{ + [TestCase(null)] + [TestCase("")] + [TestCase(" ")] + public void Returns_Empty_When_Nothing_Configured(string? configValue) + => Assert.That(AllowedContentTypeKeysParser.Parse(configValue), Is.Empty); + + [Test] + public void Parses_Single_Key() + { + var key = Guid.NewGuid(); + + HashSet result = AllowedContentTypeKeysParser.Parse(key.ToString()); + + Assert.That(result, Is.EquivalentTo(new[] { key })); + } + + [Test] + public void Parses_Multiple_Comma_Separated_Keys() + { + var first = Guid.NewGuid(); + var second = Guid.NewGuid(); + + HashSet result = AllowedContentTypeKeysParser.Parse($"{first},{second}"); + + Assert.That(result, Is.EquivalentTo(new[] { first, second })); + } + + [Test] + public void Ignores_Non_Guid_Entries() + { + var key = Guid.NewGuid(); + + HashSet result = AllowedContentTypeKeysParser.Parse($"not-a-guid,{key},also-not-a-guid"); + + Assert.That(result, Is.EquivalentTo(new[] { key })); + } + + [Test] + public void Ignores_Empty_Entries_From_Extra_Commas() + { + var key = Guid.NewGuid(); + + HashSet result = AllowedContentTypeKeysParser.Parse($",,{key},,"); + + Assert.That(result, Is.EquivalentTo(new[] { key })); + } + + [Test] + public void Deduplicates_Repeated_Keys() + { + var key = Guid.NewGuid(); + + HashSet result = AllowedContentTypeKeysParser.Parse($"{key},{key}"); + + Assert.That(result, Is.EquivalentTo(new[] { key })); + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ContentPickerPropertyEditorValidationTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ContentPickerPropertyEditorValidationTests.cs new file mode 100644 index 000000000000..e21fd8caebc6 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ContentPickerPropertyEditorValidationTests.cs @@ -0,0 +1,159 @@ +using System.ComponentModel.DataAnnotations; +using System.Data; +using System.Globalization; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.IO; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Validation; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Strings; +using Umbraco.Cms.Infrastructure.Serialization; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors; + +[TestFixture] +public class ContentPickerPropertyEditorValidationTests +{ + private ContentPickerPropertyEditor.ContentPickerPropertyValueEditor _valueEditor = null!; + private Mock _contentServiceMock = null!; + + [SetUp] + public void SetUp() + { + _contentServiceMock = new Mock(); + + var localizedTextServiceMock = new Mock(); + localizedTextServiceMock + .Setup(x => x.Localize(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>())) + .Returns("The chosen content is of invalid type."); + + var mockScope = new Mock(); + var mockScopeProvider = new Mock(); + mockScopeProvider + .Setup(x => x.CreateCoreScope( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(mockScope.Object); + + _valueEditor = new ContentPickerPropertyEditor.ContentPickerPropertyValueEditor( + Mock.Of(), + new SystemTextJsonSerializer(new DefaultJsonSerializerEncoderFactory()), + Mock.Of(), + new DataEditorAttribute("alias"), + mockScopeProvider.Object, + _contentServiceMock.Object, + localizedTextServiceMock.Object) + { + ConfigurationObject = new ContentPickerConfiguration(), + }; + } + + [Test] + public void Can_Pass_Validation_When_No_Allowed_Type_Filter_Configured() + { + var documentKey = Guid.NewGuid(); + + _contentServiceMock + .Setup(x => x.GetById(It.IsAny())) + .Returns(CreateContent(documentKey, Guid.NewGuid())); + + // AllowedContentTypeIds is null — no restriction + _valueEditor.ConfigurationObject = new ContentPickerConfiguration { AllowedContentTypeIds = null }; + + Assert.IsEmpty(Validate(documentKey)); + } + + [TestCase(false)] + [TestCase(true)] + public void Can_Pass_Validation_When_Content_Matches_Allowed_Types(bool hasMultipleAllowedTypes) + { + var documentKey = Guid.NewGuid(); + var allowedContentTypeKey = Guid.NewGuid(); + + _contentServiceMock + .Setup(x => x.GetById(It.IsAny())) + .Returns(CreateContent(documentKey, allowedContentTypeKey)); + + var extraAllowedKey = hasMultipleAllowedTypes ? Guid.NewGuid().ToString() : null; + _valueEditor.ConfigurationObject = new ContentPickerConfiguration + { + AllowedContentTypeIds = extraAllowedKey is not null + ? $"{extraAllowedKey},{allowedContentTypeKey}" + : allowedContentTypeKey.ToString(), + }; + + Assert.IsEmpty(Validate(documentKey)); + } + + [Test] + public void Cannot_Pass_Validation_When_Content_Does_Not_Match_Allowed_Type() + { + var documentKey = Guid.NewGuid(); + var allowedContentTypeKey = Guid.NewGuid(); + var actualContentTypeKey = Guid.NewGuid(); // different from allowed + + _contentServiceMock + .Setup(x => x.GetById(It.IsAny())) + .Returns(CreateContent(documentKey, actualContentTypeKey)); + + _valueEditor.ConfigurationObject = new ContentPickerConfiguration + { + AllowedContentTypeIds = allowedContentTypeKey.ToString(), + }; + + Assert.That(Validate(documentKey).Count(), Is.EqualTo(1)); + } + + [Test] + public void Cannot_Pass_Validation_When_Content_Is_Not_Found() + { + var documentKey = Guid.NewGuid(); + + _contentServiceMock + .Setup(x => x.GetById(It.IsAny())) + .Returns((IContent?)null); + + _valueEditor.ConfigurationObject = new ContentPickerConfiguration + { + AllowedContentTypeIds = Guid.NewGuid().ToString(), + }; + + Assert.That(Validate(documentKey).Count(), Is.EqualTo(1)); + } + + [Test] + public void Can_Pass_Validation_When_Value_Is_Empty() + { + // An empty selection is valid even when an allowed-type filter is configured. + _valueEditor.ConfigurationObject = new ContentPickerConfiguration + { + AllowedContentTypeIds = Guid.NewGuid().ToString(), + }; + + Assert.IsEmpty(_valueEditor.Validate(string.Empty, false, null, PropertyValidationContext.Empty())); + } + + private IEnumerable Validate(Guid documentKey) + => _valueEditor.Validate(documentKey.ToString(), false, null, PropertyValidationContext.Empty()); + + private static IContent CreateContent(Guid contentKey, Guid contentTypeKey) + { + var contentTypeMock = new Mock(); + contentTypeMock.Setup(x => x.Key).Returns(contentTypeKey); + + var contentMock = new Mock(); + contentMock.Setup(x => x.ContentType).Returns(contentTypeMock.Object); + contentMock.Setup(x => x.Key).Returns(contentKey); + + return contentMock.Object; + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ElementPickerPropertyEditorMinMaxValidationTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ElementPickerPropertyEditorMinMaxValidationTests.cs new file mode 100644 index 000000000000..5494fe3f525a --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ElementPickerPropertyEditorMinMaxValidationTests.cs @@ -0,0 +1,68 @@ +using System.ComponentModel.DataAnnotations; +using System.Globalization; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core.Models.Validation; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors; + +[TestFixture] +public class ElementPickerPropertyEditorMinMaxValidationTests +{ + private ElementPickerPropertyEditor.MinMaxValidator _validator = null!; + + [SetUp] + public void SetUp() + { + var localizedTextServiceMock = new Mock(); + localizedTextServiceMock + .Setup(x => x.Localize(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>())) + .Returns("Validation error."); + + _validator = new ElementPickerPropertyEditor.MinMaxValidator(localizedTextServiceMock.Object); + } + + [TestCase(2, null, 3)] + [TestCase(null, 2, 2)] + [TestCase(2, 4, 3)] + public void Can_Pass_Validation_When_Element_Count_Is_Within_Min_Max_Limit(int? min, int? max, int count) + => Assert.IsEmpty(Validate(min, max, count)); + + [Test] + public void Can_Pass_Validation_When_Limit_Configuration_Is_Null() + => Assert.IsEmpty(Validate(null, null, 0)); + + [Test] + public void Cannot_Pass_Validation_When_Value_Is_Null_And_Minimum_Is_Required() + { + var config = new ElementPickerConfiguration + { + ValidationLimit = new ElementPickerConfiguration.NumberRange { Min = 1 }, + }; + + Assert.That(_validator.Validate((List?)null, config, null, PropertyValidationContext.Empty()).Count(), Is.EqualTo(1)); + } + + [TestCase(2, null, 1)] + [TestCase(null, 2, 3)] + [TestCase(2, 4, 1)] + [TestCase(2, 4, 5)] + public void Cannot_Pass_Validation_When_Element_Count_Is_Outside_Min_Max_Limit(int? min, int? max, int count) + => Assert.That(Validate(min, max, count).Count(), Is.EqualTo(1)); + + private IEnumerable Validate(int? min, int? max, int count) + { + var config = new ElementPickerConfiguration + { + ValidationLimit = min is null && max is null + ? null + : new ElementPickerConfiguration.NumberRange { Min = min, Max = max }, + }; + + List value = Enumerable.Range(0, count).Select(_ => Guid.NewGuid().ToString()).ToList(); + + return _validator.Validate(value, config, null, PropertyValidationContext.Empty()); + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ElementPickerPropertyEditorValidationTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ElementPickerPropertyEditorValidationTests.cs new file mode 100644 index 000000000000..a834c577f150 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ElementPickerPropertyEditorValidationTests.cs @@ -0,0 +1,203 @@ +using System.ComponentModel.DataAnnotations; +using System.Data; +using System.Globalization; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.IO; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Validation; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Strings; +using Umbraco.Cms.Infrastructure.Serialization; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors; + +[TestFixture] +public class ElementPickerPropertyEditorValidationTests +{ + private ElementPickerPropertyEditor.ElementPickerPropertyValueEditor _valueEditor = null!; + private Mock _elementServiceMock = null!; + + [SetUp] + public void SetUp() + { + _elementServiceMock = new Mock(); + + var localizedTextServiceMock = new Mock(); + localizedTextServiceMock + .Setup(x => x.Localize(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>())) + .Returns("The chosen content is of invalid type."); + + var mockScope = new Mock(); + var mockScopeProvider = new Mock(); + mockScopeProvider + .Setup(x => x.CreateCoreScope( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(mockScope.Object); + + _valueEditor = new ElementPickerPropertyEditor.ElementPickerPropertyValueEditor( + Mock.Of(), + new SystemTextJsonSerializer(new DefaultJsonSerializerEncoderFactory()), + Mock.Of(), + new DataEditorAttribute("alias"), + localizedTextServiceMock.Object, + _elementServiceMock.Object, + mockScopeProvider.Object) + { + ConfigurationObject = new ElementPickerConfiguration(), + }; + } + + [Test] + public void Can_Pass_Validation_When_No_Allowed_Type_Filter_Configured() + { + var elementKey = Guid.NewGuid(); + + _elementServiceMock + .Setup(x => x.GetByIds(It.IsAny>())) + .Returns([CreateElement(elementKey, Guid.NewGuid())]); + + // AllowedContentTypeIds is null — no restriction + _valueEditor.ConfigurationObject = new ElementPickerConfiguration { AllowedContentTypeIds = null }; + + Assert.IsEmpty(Validate([elementKey])); + } + + [TestCase(false)] + [TestCase(true)] + public void Can_Pass_Validation_When_Element_Matches_Allowed_Types(bool hasMultipleAllowedTypes) + { + var elementKey = Guid.NewGuid(); + var allowedContentTypeKey = Guid.NewGuid(); + + _elementServiceMock + .Setup(x => x.GetByIds(It.IsAny>())) + .Returns([CreateElement(elementKey, allowedContentTypeKey)]); + + var extraAllowedKey = hasMultipleAllowedTypes ? Guid.NewGuid().ToString() : null; + _valueEditor.ConfigurationObject = new ElementPickerConfiguration + { + AllowedContentTypeIds = extraAllowedKey is not null + ? $"{extraAllowedKey},{allowedContentTypeKey}" + : allowedContentTypeKey.ToString(), + }; + + Assert.IsEmpty(Validate([elementKey])); + } + + [Test] + public void Cannot_Pass_Validation_When_Element_Does_Not_Match_Allowed_Type() + { + var elementKey = Guid.NewGuid(); + var allowedContentTypeKey = Guid.NewGuid(); + var actualContentTypeKey = Guid.NewGuid(); // different from allowed + + _elementServiceMock + .Setup(x => x.GetByIds(It.IsAny>())) + .Returns([CreateElement(elementKey, actualContentTypeKey)]); + + _valueEditor.ConfigurationObject = new ElementPickerConfiguration + { + AllowedContentTypeIds = allowedContentTypeKey.ToString(), + }; + + Assert.That(Validate([elementKey]).Count(), Is.EqualTo(1)); + } + + [Test] + public void Cannot_Pass_Validation_When_Element_Is_Not_Found() + { + var elementKey = Guid.NewGuid(); + + // The selected element cannot be found, so its type cannot be verified against the allowed types. + _elementServiceMock + .Setup(x => x.GetByIds(It.IsAny>())) + .Returns([]); + + _valueEditor.ConfigurationObject = new ElementPickerConfiguration + { + AllowedContentTypeIds = Guid.NewGuid().ToString(), + }; + + Assert.That(Validate([elementKey]).Count(), Is.EqualTo(1)); + } + + [Test] + public void Can_Pass_Validation_When_Selection_Is_Empty() + { + // An empty selection is valid even when an allowed-type filter is configured. + _valueEditor.ConfigurationObject = new ElementPickerConfiguration + { + AllowedContentTypeIds = Guid.NewGuid().ToString(), + }; + + Assert.IsEmpty(Validate([])); + } + + [Test] + public void Can_Pass_Validation_When_Selection_Contains_Duplicate_Keys() + { + var elementKey = Guid.NewGuid(); + var allowedContentTypeKey = Guid.NewGuid(); + + // The (deduplicated) key resolves to a single element. + _elementServiceMock + .Setup(x => x.GetByIds(It.IsAny>())) + .Returns([CreateElement(elementKey, allowedContentTypeKey)]); + + _valueEditor.ConfigurationObject = new ElementPickerConfiguration + { + AllowedContentTypeIds = allowedContentTypeKey.ToString(), + }; + + // The same key selected twice must not be reported as missing. + Assert.IsEmpty(Validate([elementKey, elementKey])); + } + + [Test] + public void Ignores_Non_Guid_Entries_When_Checking_For_Missing_Elements() + { + var elementKey = Guid.NewGuid(); + var allowedContentTypeKey = Guid.NewGuid(); + + _elementServiceMock + .Setup(x => x.GetByIds(It.IsAny>())) + .Returns([CreateElement(elementKey, allowedContentTypeKey)]); + + _valueEditor.ConfigurationObject = new ElementPickerConfiguration + { + AllowedContentTypeIds = allowedContentTypeKey.ToString(), + }; + + // A non-GUID entry is not a resolvable key and must not be reported as missing. + List value = ["not-a-guid", elementKey.ToString()]; + Assert.IsEmpty(_valueEditor.Validate(value, false, null, PropertyValidationContext.Empty())); + } + + private IEnumerable Validate(IEnumerable elementKeys) + { + List value = elementKeys.Select(k => k.ToString()).ToList(); + return _valueEditor.Validate(value, false, null, PropertyValidationContext.Empty()); + } + + private static IElement CreateElement(Guid elementKey, Guid contentTypeKey) + { + var contentTypeMock = new Mock(); + contentTypeMock.Setup(x => x.Key).Returns(contentTypeKey); + + var elementMock = new Mock(); + elementMock.Setup(x => x.Key).Returns(elementKey); + elementMock.Setup(x => x.ContentType).Returns(contentTypeMock.Object); + + return elementMock.Object; + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/MediaPicker3ValueEditorValidationTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/MediaPicker3ValueEditorValidationTests.cs index 8bd7f3b18790..dc3abf27bf21 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/MediaPicker3ValueEditorValidationTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/MediaPicker3ValueEditorValidationTests.cs @@ -139,6 +139,69 @@ public void Validates_Allowed_Type(bool shouldSucceed, bool hasAllowedType, bool ValidateResult(shouldSucceed, result); } + [TestCase(true)] + [TestCase(false)] + public void Validates_Missing_Media(bool mediaFound) + { + var (valueEditor, mediaTypeServiceMock, mediaServiceMock, _) = CreateValueEditor(); + + var mediaKey = Guid.NewGuid(); + var mediaTypeKey = Guid.NewGuid(); + const string mediaTypeAlias = "Alias"; + + // An allowed-type filter must be configured for the existence check to run. + valueEditor.ConfigurationObject = new MediaPicker3Configuration { Filter = $"{mediaTypeKey}" }; + + var mediaTypeMock = new Mock(); + mediaTypeMock.Setup(x => x.Key).Returns(mediaTypeKey); + mediaTypeServiceMock.Setup(x => x.Get(mediaTypeAlias)).Returns(mediaTypeMock.Object); + + if (mediaFound) + { + var mediaMock = new Mock(); + mediaMock.SetupGet(x => x.ContentType.Alias).Returns(mediaTypeAlias); + mediaServiceMock.Setup(x => x.GetByIds(It.IsAny>())).Returns([mediaMock.Object]); + } + else + { + // The selected media (looked up because no type alias was provided) cannot be found. + mediaServiceMock.Setup(x => x.GetByIds(It.IsAny>())).Returns([]); + } + + var value = "[ {\n \" key\" : \"20266ebe-1f7e-4cf3-a694-7a5fb210223b\",\n \"mediaKey\" : \"" + mediaKey + "\",\n \"mediaTypeAlias\" : \"\",\n \"crops\" : [ ],\n \"focalPoint\" : null\n} ]"; + + var result = valueEditor.Validate(value, false, null, PropertyValidationContext.Empty()); + + ValidateResult(mediaFound, result); + } + + [Test] + public void Can_Pass_Validation_When_Selection_Contains_Duplicate_Media() + { + var (valueEditor, mediaTypeServiceMock, mediaServiceMock, _) = CreateValueEditor(); + + var mediaKey = Guid.NewGuid(); + var mediaTypeKey = Guid.NewGuid(); + const string mediaTypeAlias = "Alias"; + + valueEditor.ConfigurationObject = new MediaPicker3Configuration { Multiple = true, Filter = $"{mediaTypeKey}" }; + + var mediaTypeMock = new Mock(); + mediaTypeMock.Setup(x => x.Key).Returns(mediaTypeKey); + mediaTypeServiceMock.Setup(x => x.Get(mediaTypeAlias)).Returns(mediaTypeMock.Object); + + var mediaMock = new Mock(); + mediaMock.SetupGet(x => x.ContentType.Alias).Returns(mediaTypeAlias); + mediaServiceMock.Setup(x => x.GetByIds(It.IsAny>())).Returns([mediaMock.Object]); + + // The same media key selected twice (no provided alias) must not be reported as missing. + var value = "[ {\n \" key\" : \"20266ebe-1f7e-4cf3-a694-7a5fb210223b\",\n \"mediaKey\" : \"" + mediaKey + "\",\n \"mediaTypeAlias\" : \"\",\n \"crops\" : [ ],\n \"focalPoint\" : null\n}, {\n \" key\" : \"1C70519E-C3AE-4D45-8E48-30B3D02E455E\",\n \"mediaKey\" : \"" + mediaKey + "\",\n \"mediaTypeAlias\" : \"\",\n \"crops\" : [ ],\n \"focalPoint\" : null\n} ]"; + + var result = valueEditor.Validate(value, false, null, PropertyValidationContext.Empty()); + + ValidateResult(true, result); + } + [TestCase("[ {\n \" key\" : \"20266ebe-1f7e-4cf3-a694-7a5fb210223b\",\n \"mediaKey\" : \"7AD39018-0920-4818-89D3-26F47DBCE62E\",\n \"mediaTypeAlias\" : \"\",\n \"crops\" : [ ],\n \"focalPoint\" : null\n} ]", false, true)] [TestCase("[ {\n \" key\" : \"20266ebe-1f7e-4cf3-a694-7a5fb210223b\",\n \"mediaKey\" : \"7AD39018-0920-4818-89D3-26F47DBCE62E\",\n \"mediaTypeAlias\" : \"\",\n \"crops\" : [ ],\n \"focalPoint\" : null\n}, {\n \" key\" : \"1C70519E-C3AE-4D45-8E48-30B3D02E455E\",\n \"mediaKey\" : \"E243A7E2-8D2E-4DC9-88FB-822350A40142\",\n \"mediaTypeAlias\" : \"\",\n \"crops\" : [ ],\n \"focalPoint\" : null\n} ]", false, false)] [TestCase("[ {\n \" key\" : \"20266ebe-1f7e-4cf3-a694-7a5fb210223b\",\n \"mediaKey\" : \"7AD39018-0920-4818-89D3-26F47DBCE62E\",\n \"mediaTypeAlias\" : \"\",\n \"crops\" : [ ],\n \"focalPoint\" : null\n}, {\n \" key\" : \"1C70519E-C3AE-4D45-8E48-30B3D02E455E\",\n \"mediaKey\" : \"E243A7E2-8D2E-4DC9-88FB-822350A40142\",\n \"mediaTypeAlias\" : \"\",\n \"crops\" : [ ],\n \"focalPoint\" : null\n} ]", true, true)]