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)]