diff --git a/src/Umbraco.Core/Constants-PropertyEditors.cs b/src/Umbraco.Core/Constants-PropertyEditors.cs index f1264d5050..ab1715cf17 100644 --- a/src/Umbraco.Core/Constants-PropertyEditors.cs +++ b/src/Umbraco.Core/Constants-PropertyEditors.cs @@ -90,6 +90,11 @@ public static partial class Constants /// public const string DateOnly = "Umbraco.DateOnly"; + /// + /// Entity Data Picker + /// + public const string EntityDataPicker = "Umbraco.EntityDataPicker"; + /// /// Time Only. /// diff --git a/src/Umbraco.Core/Models/EntityDataPickerValue.cs b/src/Umbraco.Core/Models/EntityDataPickerValue.cs new file mode 100644 index 0000000000..2b4274c3ef --- /dev/null +++ b/src/Umbraco.Core/Models/EntityDataPickerValue.cs @@ -0,0 +1,8 @@ +namespace Umbraco.Cms.Core.Models; + +public sealed class EntityDataPickerValue +{ + public required IEnumerable Ids { get; set; } + + public required string DataSource { get; set; } +} diff --git a/src/Umbraco.Core/PropertyEditors/EntityDataPickerConfiguration.cs b/src/Umbraco.Core/PropertyEditors/EntityDataPickerConfiguration.cs new file mode 100644 index 0000000000..bdbaa636dd --- /dev/null +++ b/src/Umbraco.Core/PropertyEditors/EntityDataPickerConfiguration.cs @@ -0,0 +1,17 @@ +namespace Umbraco.Cms.Core.PropertyEditors; + +public sealed class EntityDataPickerConfiguration +{ + [ConfigurationField("validationLimit")] + public NumberRange ValidationLimit { get; set; } = new(); + + [ConfigurationField("umbEditorDataSource")] + public string DataSource { get; set; } = string.Empty; + + public class NumberRange + { + public int? Min { get; set; } + + public int? Max { get; set; } + } +} diff --git a/src/Umbraco.Core/PropertyEditors/EntityDataPickerConfigurationEditor.cs b/src/Umbraco.Core/PropertyEditors/EntityDataPickerConfigurationEditor.cs new file mode 100644 index 0000000000..16f7d644fa --- /dev/null +++ b/src/Umbraco.Core/PropertyEditors/EntityDataPickerConfigurationEditor.cs @@ -0,0 +1,11 @@ +using Umbraco.Cms.Core.IO; + +namespace Umbraco.Cms.Core.PropertyEditors; + +internal sealed class EntityDataPickerConfigurationEditor : ConfigurationEditor +{ + public EntityDataPickerConfigurationEditor(IIOHelper ioHelper) + : base(ioHelper) + { + } +} diff --git a/src/Umbraco.Core/PropertyEditors/EntityDataPickerPropertyEditor.cs b/src/Umbraco.Core/PropertyEditors/EntityDataPickerPropertyEditor.cs new file mode 100644 index 0000000000..29604e24ef --- /dev/null +++ b/src/Umbraco.Core/PropertyEditors/EntityDataPickerPropertyEditor.cs @@ -0,0 +1,126 @@ +using System.ComponentModel.DataAnnotations; +using Umbraco.Cms.Core.IO; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Validation; +using Umbraco.Cms.Core.PropertyEditors.Validation; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Strings; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Core.PropertyEditors; + +/// +/// Represents an entity data picker property editor. +/// +[DataEditor( + Constants.PropertyEditors.Aliases.EntityDataPicker, + ValueType = ValueTypes.Json, + ValueEditorIsReusable = true)] +internal sealed class EntityDataPickerPropertyEditor : DataEditor +{ + private readonly IIOHelper _ioHelper; + + /// + /// Initializes a new instance of the class. + /// + public EntityDataPickerPropertyEditor(IDataValueEditorFactory dataValueEditorFactory, IIOHelper ioHelper) + : base(dataValueEditorFactory) + { + _ioHelper = ioHelper; + SupportsReadOnly = true; + } + + /// + public override IPropertyIndexValueFactory PropertyIndexValueFactory { get; } = new NoopPropertyIndexValueFactory(); + + /// + protected override IDataValueEditor CreateValueEditor() + => DataValueEditorFactory.Create(Attribute!); + + /// + protected override IConfigurationEditor CreateConfigurationEditor() => new EntityDataPickerConfigurationEditor(_ioHelper); + + /// + /// Defines the value editor for the entity data picker property editor. + /// + internal sealed class EntityDataPickerPropertyValueEditor : DataValueEditor + { + /// + /// Initializes a new instance of the class. + /// + public EntityDataPickerPropertyValueEditor( + IShortStringHelper shortStringHelper, + IJsonSerializer jsonSerializer, + IIOHelper ioHelper, + DataEditorAttribute attribute, + ILocalizedTextService localizedTextService) + : base(shortStringHelper, jsonSerializer, ioHelper, attribute) + { + var validators = new TypedJsonValidatorRunner( + jsonSerializer, + new MinMaxValidator(localizedTextService)); + + Validators.Add(validators); + } + + /// + /// Validates the min/max configuration for the entity data picker property editor. + /// + internal sealed class MinMaxValidator : ITypedJsonValidator + { + private readonly ILocalizedTextService _localizedTextService; + + /// + /// Initializes a new instance of the class. + /// + public MinMaxValidator(ILocalizedTextService localizedTextService) => + _localizedTextService = localizedTextService; + + /// + public IEnumerable Validate( + EntityDataPickerDto? data, + EntityDataPickerConfiguration? configuration, + string? valueType, + PropertyValidationContext validationContext) + { + var validationResults = new List(); + + if (data is null || configuration is null) + { + return validationResults; + } + + if (configuration.ValidationLimit.Min is not null + && data.Ids.Length < configuration.ValidationLimit.Min) + { + validationResults.Add(new ValidationResult( + _localizedTextService.Localize( + "validation", + "entriesShort", + [configuration.ValidationLimit.Min.ToString(), (configuration.ValidationLimit.Min - data.Ids.Length).ToString()]), + ["value"])); + } + + if (configuration.ValidationLimit.Max is not null + && data.Ids.Length > configuration.ValidationLimit.Max) + { + validationResults.Add(new ValidationResult( + _localizedTextService.Localize( + "validation", + "entriesExceed", + [configuration.ValidationLimit.Max.ToString(), (data.Ids.Length - configuration.ValidationLimit.Max).ToString() + ]), + ["value"])); + } + + return validationResults; + } + } + } + + internal sealed class EntityDataPickerDto + { + public string[] Ids { get; set; } = []; + } +} diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/EntityDataPickerValueConverter.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/EntityDataPickerValueConverter.cs new file mode 100644 index 0000000000..811ce10544 --- /dev/null +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/EntityDataPickerValueConverter.cs @@ -0,0 +1,43 @@ +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.Serialization; + +namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters; + +// NOTE: this class is made public on purpose because all value converters should be public +[DefaultPropertyValueConverter(typeof(JsonValueConverter))] +public sealed class EntityDataPickerValueConverter : PropertyValueConverterBase +{ + private readonly IJsonSerializer _jsonSerializer; + + public EntityDataPickerValueConverter(IJsonSerializer jsonSerializer) + => _jsonSerializer = jsonSerializer; + + public override bool IsConverter(IPublishedPropertyType propertyType) + => Constants.PropertyEditors.Aliases.EntityDataPicker.Equals(propertyType.EditorAlias); + + public override Type GetPropertyValueType(IPublishedPropertyType propertyType) + => typeof(EntityDataPickerValue); + + public override PropertyCacheLevel GetPropertyCacheLevel(IPublishedPropertyType propertyType) + => PropertyCacheLevel.Element; + + public override object? ConvertSourceToIntermediate(IPublishedElement owner, IPublishedPropertyType propertyType, object? source, bool preview) + { + if (source is not string sourceString + || propertyType.DataType.ConfigurationObject is not EntityDataPickerConfiguration dataTypeConfiguration) + { + return null; + } + + EntityDataPickerDto? dto = _jsonSerializer.Deserialize(sourceString); + return dto is not null + ? new EntityDataPickerValue { Ids = dto.Ids, DataSource = dataTypeConfiguration.DataSource } + : null; + } + + private class EntityDataPickerDto + { + public required string[] Ids { get; init; } + } +} diff --git a/src/Umbraco.Web.UI.Client/examples/picker-data-source/index.ts b/src/Umbraco.Web.UI.Client/examples/picker-data-source/index.ts index fd4d9f3a2f..1d5a4aa2f6 100644 --- a/src/Umbraco.Web.UI.Client/examples/picker-data-source/index.ts +++ b/src/Umbraco.Web.UI.Client/examples/picker-data-source/index.ts @@ -1,7 +1,9 @@ +import { UMB_PICKER_DATA_SOURCE_TYPE } from '@umbraco-cms/backoffice/picker-data-source'; + export const manifests: Array = [ { type: 'propertyEditorDataSource', - dataSourceType: 'picker', + dataSourceType: UMB_PICKER_DATA_SOURCE_TYPE, alias: 'Umb.PropertyEditorDataSource.CustomPickerCollection', name: 'Custom Picker Collection Data Source', api: () => import('./example-custom-picker-collection-data-source.js'), @@ -13,7 +15,7 @@ export const manifests: Array = [ }, { type: 'propertyEditorDataSource', - dataSourceType: 'picker', + dataSourceType: UMB_PICKER_DATA_SOURCE_TYPE, alias: 'Umb.PropertyEditorDataSource.CustomPickerTree', name: 'Custom Picker Tree Data Source', api: () => import('./example-custom-picker-tree-data-source.js'), @@ -25,7 +27,7 @@ export const manifests: Array = [ }, { type: 'propertyEditorDataSource', - dataSourceType: 'picker', + dataSourceType: UMB_PICKER_DATA_SOURCE_TYPE, alias: 'Umb.PropertyEditorDataSource.DocumentPicker', name: 'Document Picker Data Source', api: () => import('./example-document-picker-data-source.js'), @@ -53,7 +55,7 @@ export const manifests: Array = [ }, { type: 'propertyEditorDataSource', - dataSourceType: 'picker', + dataSourceType: UMB_PICKER_DATA_SOURCE_TYPE, alias: 'Umb.PropertyEditorDataSource.MediaPicker', name: 'Media Picker Data Source', api: () => import('./example-media-picker-data-source.js'), @@ -65,7 +67,7 @@ export const manifests: Array = [ }, { type: 'propertyEditorDataSource', - dataSourceType: 'picker', + dataSourceType: UMB_PICKER_DATA_SOURCE_TYPE, alias: 'Umb.PropertyEditorDataSource.LanguagePicker', name: 'Language Picker Data Source', api: () => import('./example-language-picker-data-source.js'), @@ -77,7 +79,7 @@ export const manifests: Array = [ }, { type: 'propertyEditorDataSource', - dataSourceType: 'picker', + dataSourceType: UMB_PICKER_DATA_SOURCE_TYPE, alias: 'Umb.PropertyEditorDataSource.WebhookPicker', name: 'Webhook Picker Data Source', api: () => import('./example-webhook-picker-data-source.js'), @@ -89,7 +91,7 @@ export const manifests: Array = [ }, { type: 'propertyEditorDataSource', - dataSourceType: 'picker', + dataSourceType: UMB_PICKER_DATA_SOURCE_TYPE, alias: 'Umb.PropertyEditorDataSource.UserPicker', name: 'User Picker Data Source', api: () => import('./example-user-picker-data-source.js'), diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/picker-data-source/constant.ts b/src/Umbraco.Web.UI.Client/src/packages/core/picker-data-source/constant.ts new file mode 100644 index 0000000000..0cf82c236e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/picker-data-source/constant.ts @@ -0,0 +1 @@ +export const UMB_PICKER_DATA_SOURCE_TYPE = 'Umb.DataSourceType.Picker'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/picker-data-source/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/picker-data-source/index.ts index ca0e12ded8..6203532024 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/picker-data-source/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/picker-data-source/index.ts @@ -1,4 +1,5 @@ export * from './collection-data-source/is-picker-collection-data-source.guard.js'; +export * from './constant.js'; export * from './searchable-data-source/is-picker-searchable.data-source.guard.js'; export * from './tree-data-source/is-picker-tree-data-source.guard.js'; export type * from './types.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/entity-data-picker/entry-point.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/entity-data-picker/entry-point.ts index 5632c01f5a..839c9927a5 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/property-editors/entity-data-picker/entry-point.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/entity-data-picker/entry-point.ts @@ -1,5 +1,6 @@ import { manifests as entityDataPickerManifests } from './manifests.js'; import type { UmbEntryPointOnInit } from '@umbraco-cms/backoffice/extension-api'; +import { UMB_PICKER_DATA_SOURCE_TYPE } from '@umbraco-cms/backoffice/picker-data-source'; import type { ManifestPropertyEditorDataSource } from '@umbraco-cms/backoffice/property-editor-data-source'; export const onInit: UmbEntryPointOnInit = (host, extensionRegistry) => { @@ -11,7 +12,7 @@ export const onInit: UmbEntryPointOnInit = (host, extensionRegistry) => { extensionRegistry .byTypeAndFilter<'propertyEditorDataSource', ManifestPropertyEditorDataSource>( 'propertyEditorDataSource', - (manifest) => manifest.dataSourceType === 'picker', + (manifest) => manifest.dataSourceType === UMB_PICKER_DATA_SOURCE_TYPE, ) .subscribe((pickerPropertyEditorDataSource) => { if (pickerPropertyEditorDataSource.length > 0 && !initialized) { diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/entity-data-picker/property-editor/Umbraco.EntityDataPicker.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/entity-data-picker/property-editor/Umbraco.EntityDataPicker.ts new file mode 100644 index 0000000000..68c7a708a2 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/entity-data-picker/property-editor/Umbraco.EntityDataPicker.ts @@ -0,0 +1,21 @@ +export const manifests: Array = [ + { + type: 'propertyEditorSchema', + name: 'Entity Data Picker', + alias: 'Umbraco.EntityDataPicker', + meta: { + defaultPropertyEditorUiAlias: 'Umb.PropertyEditorUi.EntityDataPicker', + settings: { + properties: [ + { + alias: 'validationLimit', + label: 'Amount', + propertyEditorUiAlias: 'Umb.PropertyEditorUi.NumberRange', + config: [{ alias: 'validationRange', value: { min: 0, max: Infinity } }], + weight: 100, + }, + ], + }, + }, + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/entity-data-picker/property-editor/entity-data-picker-property-editor-ui.element.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/entity-data-picker/property-editor/entity-data-picker-property-editor-ui.element.ts index 64a7ef4820..a8611f0f9f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/property-editors/entity-data-picker/property-editor/entity-data-picker-property-editor-ui.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/entity-data-picker/property-editor/entity-data-picker-property-editor-ui.element.ts @@ -128,7 +128,7 @@ export class UmbEntityDataPickerPropertyEditorUIElement // Ensure the value is of the correct type before setting it. if (Array.isArray(selection)) { - this.value = selection; + this.value = { ids: selection }; this.dispatchEvent(new UmbChangeEvent()); } else { throw new Error('Selection is not of type array. Cannot set property value.'); @@ -137,7 +137,7 @@ export class UmbEntityDataPickerPropertyEditorUIElement override render() { return html` import('./entity-data-picker-property-editor-ui.element.js'), - meta: { - label: 'Entity Data Picker', - icon: 'icon-page-add', - group: 'pickers', - propertyEditorSchemaAlias: 'Umbraco.Plain.Json', - supportsReadOnly: true, - supportsDataSource: { - enabled: true, - forDataSourceTypes: ['picker'], - }, - settings: { - properties: [ - // TODO: Move this to schema manifest when server can validate it - { - alias: 'validationLimit', - label: 'Amount', - propertyEditorUiAlias: 'Umb.PropertyEditorUi.NumberRange', - config: [{ alias: 'validationRange', value: { min: 0, max: Infinity } }], - weight: 100, - }, - ], +export const manifests: Array = [ + { + type: 'propertyEditorUi', + alias: 'Umb.PropertyEditorUi.EntityDataPicker', + name: 'Entity Data Picker Property Editor UI', + element: () => import('./entity-data-picker-property-editor-ui.element.js'), + meta: { + label: 'Entity Data Picker', + icon: 'icon-page-add', + group: 'pickers', + propertyEditorSchemaAlias: 'Umbraco.EntityDataPicker', + supportsReadOnly: true, + supportsDataSource: { + enabled: true, + forDataSourceTypes: [UMB_PICKER_DATA_SOURCE_TYPE], + }, }, }, -}; - -export const manifests: Array = [manifest]; + ...schemaManifests, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/entity-data-picker/property-editor/types.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/entity-data-picker/property-editor/types.ts index fa9bbf6564..fd72143a5a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/property-editors/entity-data-picker/property-editor/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/entity-data-picker/property-editor/types.ts @@ -1 +1,3 @@ -export type UmbEntityDataPickerPropertyEditorValue = Array; +export type UmbEntityDataPickerPropertyEditorValue = { + ids: Array; +}; diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Composing/TypeLoaderTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Composing/TypeLoaderTests.cs index 253fcc2b4b..0940c10533 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Composing/TypeLoaderTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Composing/TypeLoaderTests.cs @@ -132,7 +132,7 @@ public class TypeLoaderTests public void GetDataEditors() { var types = _typeLoader.GetDataEditors(); - Assert.AreEqual(41, types.Count()); + Assert.AreEqual(42, types.Count()); } /// diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/EntityDataPickerPropertyValueEditorTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/EntityDataPickerPropertyValueEditorTests.cs new file mode 100644 index 0000000000..7dbb0b2d49 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/EntityDataPickerPropertyValueEditorTests.cs @@ -0,0 +1,95 @@ +using System.Globalization; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core.IO; +using Umbraco.Cms.Core.Models.Validation; +using Umbraco.Cms.Core.PropertyEditors; +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 EntityDataPickerPropertyValueEditorTests +{ + [TestCase(1, false)] + [TestCase(2, true)] + [TestCase(3, true)] + public void Validates_Is_Greater_Than_Or_Equal_To_Configured_Min(int numberOfSelections, bool expectedSuccess) + { + var editor = CreateValueEditor(); + var value = new EntityDataPickerPropertyEditor.EntityDataPickerDto + { + Ids = [.. Enumerable.Range(1, numberOfSelections).Select(i => i.ToString())], + }; + var serializer = new SystemTextJsonSerializer(new DefaultJsonSerializerEncoderFactory()); + var serializedValue = serializer.Serialize(value); + var result = editor.Validate(serializedValue, false, null, PropertyValidationContext.Empty()); + if (expectedSuccess) + { + Assert.IsEmpty(result); + } + else + { + Assert.AreEqual(1, result.Count()); + + var validationResult = result.First(); + Assert.AreEqual("validation_entriesShort", validationResult.ErrorMessage); + } + } + + [TestCase(3, true)] + [TestCase(4, true)] + [TestCase(5, false)] + public void Validates_Is_Less_Than_Or_Equal_To_Configured_Max(int numberOfSelections, bool expectedSuccess) + { + var editor = CreateValueEditor(); + var value = new EntityDataPickerPropertyEditor.EntityDataPickerDto + { + Ids = [.. Enumerable.Range(1, numberOfSelections).Select(i => i.ToString())], + }; + var serializer = new SystemTextJsonSerializer(new DefaultJsonSerializerEncoderFactory()); + var serializedValue = serializer.Serialize(value); + var result = editor.Validate(serializedValue, false, null, PropertyValidationContext.Empty()); + if (expectedSuccess) + { + Assert.IsEmpty(result); + } + else + { + Assert.AreEqual(1, result.Count()); + + var validationResult = result.First(); + Assert.AreEqual("validation_entriesExceed", validationResult.ErrorMessage); + } + } + + private static EntityDataPickerPropertyEditor.EntityDataPickerPropertyValueEditor CreateValueEditor() + { + var localizedTextServiceMock = new Mock(); + localizedTextServiceMock.Setup(x => x.Localize( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>())) + .Returns((string key, string alias, CultureInfo culture, IDictionary args) => $"{key}_{alias}"); + return new EntityDataPickerPropertyEditor.EntityDataPickerPropertyValueEditor( + Mock.Of(), + new SystemTextJsonSerializer(new DefaultJsonSerializerEncoderFactory()), + Mock.Of(), + new DataEditorAttribute("alias"), + localizedTextServiceMock.Object) + { + ConfigurationObject = new EntityDataPickerConfiguration + { + DataSource = "testDataSource", + ValidationLimit = new EntityDataPickerConfiguration.NumberRange + { + Min = 2, + Max = 4 + } + }, + }; + } +}