Feature: single block property editor (#20098)

* First Go at the single block property editor based on blocklistpropertyeditor

* Add simalar tests to the blocklist editor

Also check whether either block of configured blocks can be picked and used from a data perspective

* WIP singleblock Valiation tests

* Finished first full pass off SingleBlock validation testing

* Typos, Future test function

* Restore accidently removed file

* Introduce propertyValueConverter

* Comment updates

* Add singleBlock renderer

* Textual improvements

Comment improvements, remove licensing in file

* Update DataEditorCount by 1 as we introduced a new one

* Align test naming

* Add ignored singleblock default renderer

* Enable SingleBlock Property Indexing

* Enable Partial value merging

* Fix indentation

---------

Co-authored-by: kjac <kja@umbraco.dk>
This commit is contained in:
Sven Geusens
2025-09-17 07:20:09 +02:00
committed by GitHub
parent eea970a648
commit dd01a56d2a
19 changed files with 1488 additions and 5 deletions

View File

@@ -29,6 +29,16 @@ internal abstract class BlockEditorMinMaxValidatorBase<TValue, TLayout> : IValue
/// <inheritdoc/>
public abstract IEnumerable<ValidationResult> Validate(object? value, string? valueType, object? dataTypeConfiguration, PropertyValidationContext validationContext);
// internal method so we can test for specific error messages being returned without keeping strings in sync
internal static string BuildErrorMessage(
ILocalizedTextService textService,
int? maxNumberOfBlocks,
int numberOfBlocks)
=> textService.Localize(
"validation",
"entriesExceed",
[maxNumberOfBlocks.ToString(), (numberOfBlocks - maxNumberOfBlocks).ToString(),]);
/// <summary>
/// Validates the number of blocks are within the configured minimum and maximum values.
/// </summary>
@@ -53,10 +63,7 @@ internal abstract class BlockEditorMinMaxValidatorBase<TValue, TLayout> : IValue
if (blockEditorData != null && max.HasValue && numberOfBlocks > max)
{
yield return new ValidationResult(
TextService.Localize(
"validation",
"entriesExceed",
[max.ToString(), (numberOfBlocks - max).ToString(),]),
BuildErrorMessage(TextService, max, numberOfBlocks),
["value"]);
}
}

View File

@@ -0,0 +1,12 @@
using Umbraco.Cms.Core.IO;
using Umbraco.Cms.Core.PropertyEditors;
namespace Umbraco.Cms.Infrastructure.PropertyEditors;
internal sealed class SingleBlockConfigurationEditor : ConfigurationEditor<SingleBlockConfiguration>
{
public SingleBlockConfigurationEditor(IIOHelper ioHelper)
: base(ioHelper)
{
}
}

View File

@@ -0,0 +1,138 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Cache.PropertyEditors;
using Umbraco.Cms.Core.IO;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Blocks;
using Umbraco.Cms.Core.Models.Validation;
using Umbraco.Cms.Core.PropertyEditors;
using Umbraco.Cms.Core.PropertyEditors.ValueConverters;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Strings;
using Umbraco.Extensions;
namespace Umbraco.Cms.Infrastructure.PropertyEditors;
/// <summary>
/// Represents a single block property editor.
/// </summary>
[DataEditor(
Constants.PropertyEditors.Aliases.SingleBlock,
ValueType = ValueTypes.Json,
ValueEditorIsReusable = false)]
public class SingleBlockPropertyEditor : DataEditor
{
private readonly IJsonSerializer _jsonSerializer;
private readonly IIOHelper _ioHelper;
private readonly IBlockValuePropertyIndexValueFactory _blockValuePropertyIndexValueFactory;
public SingleBlockPropertyEditor(
IDataValueEditorFactory dataValueEditorFactory,
IJsonSerializer jsonSerializer,
IIOHelper ioHelper,
IBlockValuePropertyIndexValueFactory blockValuePropertyIndexValueFactory)
: base(dataValueEditorFactory)
{
_jsonSerializer = jsonSerializer;
_ioHelper = ioHelper;
_blockValuePropertyIndexValueFactory = blockValuePropertyIndexValueFactory;
}
public override IPropertyIndexValueFactory PropertyIndexValueFactory => _blockValuePropertyIndexValueFactory;
/// <inheritdoc/>
public override bool SupportsConfigurableElements => true;
/// <summary>
/// Instantiates a new <see cref="BlockEditorDataConverter{SingleBlockValue, SingleBlockLayoutItem}"/> for use with the single block editor property value editor.
/// </summary>
/// <returns>A new instance of <see cref="SingleBlockEditorDataConverter"/>.</returns>
protected virtual BlockEditorDataConverter<SingleBlockValue, SingleBlockLayoutItem> CreateBlockEditorDataConverter()
=> new SingleBlockEditorDataConverter(_jsonSerializer);
/// <inheritdoc/>
protected override IDataValueEditor CreateValueEditor() =>
DataValueEditorFactory.Create<SingleBlockEditorPropertyValueEditor>(Attribute!, CreateBlockEditorDataConverter());
/// <inheritdoc />
public override bool CanMergePartialPropertyValues(IPropertyType propertyType) => propertyType.VariesByCulture() is false;
/// <inheritdoc />
public override object? MergePartialPropertyValueForCulture(object? sourceValue, object? targetValue, string? culture)
{
var valueEditor = (SingleBlockEditorPropertyValueEditor)GetValueEditor();
return valueEditor.MergePartialPropertyValueForCulture(sourceValue, targetValue, culture);
}
/// <inheritdoc/>
protected override IConfigurationEditor CreateConfigurationEditor() =>
new SingleBlockConfigurationEditor(_ioHelper);
/// <inheritdoc/>
public override object? MergeVariantInvariantPropertyValue(
object? sourceValue,
object? targetValue,
bool canUpdateInvariantData,
HashSet<string> allowedCultures)
{
var valueEditor = (SingleBlockEditorPropertyValueEditor)GetValueEditor();
return valueEditor.MergeVariantInvariantPropertyValue(sourceValue, targetValue, canUpdateInvariantData, allowedCultures);
}
internal sealed class SingleBlockEditorPropertyValueEditor : BlockEditorPropertyValueEditor<SingleBlockValue, SingleBlockLayoutItem>
{
public SingleBlockEditorPropertyValueEditor(
DataEditorAttribute attribute,
BlockEditorDataConverter<SingleBlockValue, SingleBlockLayoutItem> blockEditorDataConverter,
PropertyEditorCollection propertyEditors,
DataValueReferenceFactoryCollection dataValueReferenceFactories,
IDataTypeConfigurationCache dataTypeConfigurationCache,
IShortStringHelper shortStringHelper,
IJsonSerializer jsonSerializer,
BlockEditorVarianceHandler blockEditorVarianceHandler,
ILanguageService languageService,
IIOHelper ioHelper,
IBlockEditorElementTypeCache elementTypeCache,
ILogger<SingleBlockEditorPropertyValueEditor> logger,
ILocalizedTextService textService,
IPropertyValidationService propertyValidationService)
: base(propertyEditors, dataValueReferenceFactories, dataTypeConfigurationCache, shortStringHelper, jsonSerializer, blockEditorVarianceHandler, languageService, ioHelper, attribute)
{
BlockEditorValues = new BlockEditorValues<SingleBlockValue, SingleBlockLayoutItem>(blockEditorDataConverter, elementTypeCache, logger);
Validators.Add(new BlockEditorValidator<SingleBlockValue, SingleBlockLayoutItem>(propertyValidationService, BlockEditorValues, elementTypeCache));
Validators.Add(new SingleBlockValidator(BlockEditorValues, textService));
}
protected override SingleBlockValue CreateWithLayout(IEnumerable<SingleBlockLayoutItem> layout) =>
new(layout.Single());
/// <inheritdoc/>
public override IEnumerable<Guid> ConfiguredElementTypeKeys()
{
var configuration = ConfigurationObject as SingleBlockConfiguration;
return configuration?.Blocks.SelectMany(ConfiguredElementTypeKeys) ?? Enumerable.Empty<Guid>();
}
/// <summary>
/// Validates whether the block editor holds a single value
/// </summary>
internal sealed class SingleBlockValidator : BlockEditorMinMaxValidatorBase<SingleBlockValue, SingleBlockLayoutItem>
{
private readonly BlockEditorValues<SingleBlockValue, SingleBlockLayoutItem> _blockEditorValues;
public SingleBlockValidator(BlockEditorValues<SingleBlockValue, SingleBlockLayoutItem> blockEditorValues, ILocalizedTextService textService)
: base(textService) =>
_blockEditorValues = blockEditorValues;
public override IEnumerable<ValidationResult> Validate(object? value, string? valueType, object? dataTypeConfiguration, PropertyValidationContext validationContext)
{
BlockEditorData<SingleBlockValue, SingleBlockLayoutItem>? blockEditorData = _blockEditorValues.DeserializeAndClean(value);
return ValidateNumberOfBlocks(blockEditorData, 0, 1);
}
}
}
}

View File

@@ -0,0 +1,116 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.DeliveryApi;
using Umbraco.Cms.Core.Logging;
using Umbraco.Cms.Core.Models.Blocks;
using Umbraco.Cms.Core.Models.DeliveryApi;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.PropertyEditors;
using Umbraco.Cms.Core.PropertyEditors.DeliveryApi;
using Umbraco.Cms.Core.PropertyEditors.ValueConverters;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Cms.Infrastructure.Extensions;
using Umbraco.Extensions;
namespace Umbraco.Cms.Infrastructure.PropertyEditors.ValueConverters;
[DefaultPropertyValueConverter(typeof(JsonValueConverter))]
public class SingleBlockPropertyValueConverter : PropertyValueConverterBase, IDeliveryApiPropertyValueConverter
{
private readonly IProfilingLogger _proflog;
private readonly BlockEditorConverter _blockConverter;
private readonly IApiElementBuilder _apiElementBuilder;
private readonly IJsonSerializer _jsonSerializer;
private readonly BlockListPropertyValueConstructorCache _constructorCache;
private readonly IVariationContextAccessor _variationContextAccessor;
private readonly BlockEditorVarianceHandler _blockEditorVarianceHandler;
public SingleBlockPropertyValueConverter(
IProfilingLogger proflog,
BlockEditorConverter blockConverter,
IApiElementBuilder apiElementBuilder,
IJsonSerializer jsonSerializer,
BlockListPropertyValueConstructorCache constructorCache,
IVariationContextAccessor variationContextAccessor,
BlockEditorVarianceHandler blockEditorVarianceHandler)
{
_proflog = proflog;
_blockConverter = blockConverter;
_apiElementBuilder = apiElementBuilder;
_jsonSerializer = jsonSerializer;
_constructorCache = constructorCache;
_variationContextAccessor = variationContextAccessor;
_blockEditorVarianceHandler = blockEditorVarianceHandler;
}
/// <inheritdoc />
public override bool IsConverter(IPublishedPropertyType propertyType)
=> propertyType.EditorAlias.InvariantEquals(Constants.PropertyEditors.Aliases.SingleBlock);
/// <inheritdoc />
public override Type GetPropertyValueType(IPublishedPropertyType propertyType) => typeof( BlockListItem);
/// <inheritdoc />
public override PropertyCacheLevel GetPropertyCacheLevel(IPublishedPropertyType propertyType)
=> PropertyCacheLevel.Element;
/// <inheritdoc />
public override object? ConvertSourceToIntermediate(IPublishedElement owner, IPublishedPropertyType propertyType, object? source, bool preview)
=> source?.ToString();
/// <inheritdoc />
public override object? ConvertIntermediateToObject(IPublishedElement owner, IPublishedPropertyType propertyType, PropertyCacheLevel referenceCacheLevel, object? inter, bool preview)
{
// NOTE: The intermediate object is just a JSON string, we don't actually convert from source -> intermediate since source is always just a JSON string
using (!_proflog.IsEnabled(Core.Logging.LogLevel.Debug) ? null : _proflog.DebugDuration<BlockListPropertyValueConverter>(
$"ConvertPropertyToBlockList ({propertyType.DataType.Id})"))
{
return ConvertIntermediateToBlockListItem(owner, propertyType, referenceCacheLevel, inter, preview);
}
}
/// <inheritdoc />
public PropertyCacheLevel GetDeliveryApiPropertyCacheLevel(IPublishedPropertyType propertyType) => GetPropertyCacheLevel(propertyType);
/// <inheritdoc />
public PropertyCacheLevel GetDeliveryApiPropertyCacheLevelForExpansion(IPublishedPropertyType propertyType) => PropertyCacheLevel.Snapshot;
/// <inheritdoc />
public Type GetDeliveryApiPropertyValueType(IPublishedPropertyType propertyType)
=> typeof(ApiBlockItem);
/// <inheritdoc />
public object? ConvertIntermediateToDeliveryApiObject(IPublishedElement owner, IPublishedPropertyType propertyType, PropertyCacheLevel referenceCacheLevel, object? inter, bool preview, bool expanding)
{
BlockListItem? model = ConvertIntermediateToBlockListItem(owner, propertyType, referenceCacheLevel, inter, preview);
return
model?.CreateApiBlockItem(_apiElementBuilder);
}
private BlockListItem? ConvertIntermediateToBlockListItem(IPublishedElement owner, IPublishedPropertyType propertyType, PropertyCacheLevel referenceCacheLevel, object? inter, bool preview)
{
using (!_proflog.IsEnabled(LogLevel.Debug) ? null : _proflog.DebugDuration<SingleBlockPropertyValueConverter>(
$"ConvertPropertyToSingleBlock ({propertyType.DataType.Id})"))
{
// NOTE: The intermediate object is just a JSON string, we don't actually convert from source -> intermediate since source is always just a JSON string
if (inter is not string intermediateBlockModelValue)
{
return null;
}
// Get configuration
SingleBlockConfiguration? configuration = propertyType.DataType.ConfigurationAs<SingleBlockConfiguration>();
if (configuration is null)
{
return null;
}
var creator = new SingleBlockPropertyValueCreator(_blockConverter, _variationContextAccessor, _blockEditorVarianceHandler, _jsonSerializer, _constructorCache);
return creator.CreateBlockModel(owner, referenceCacheLevel, intermediateBlockModelValue, preview, configuration.Blocks);
}
}
}

View File

@@ -0,0 +1,49 @@
using Umbraco.Cms.Core.Models.Blocks;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.Serialization;
namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters;
internal sealed class SingleBlockPropertyValueCreator : BlockPropertyValueCreatorBase<BlockListModel, BlockListItem, SingleBlockLayoutItem, BlockListConfiguration.BlockConfiguration, SingleBlockValue>
{
private readonly IJsonSerializer _jsonSerializer;
private readonly BlockListPropertyValueConstructorCache _constructorCache;
public SingleBlockPropertyValueCreator(
BlockEditorConverter blockEditorConverter,
IVariationContextAccessor variationContextAccessor,
BlockEditorVarianceHandler blockEditorVarianceHandler,
IJsonSerializer jsonSerializer,
BlockListPropertyValueConstructorCache constructorCache)
: base(blockEditorConverter, variationContextAccessor, blockEditorVarianceHandler)
{
_jsonSerializer = jsonSerializer;
_constructorCache = constructorCache;
}
// The underlying Value is still stored as an array to allow for code reuse and easier migration
public BlockListItem? CreateBlockModel(IPublishedElement owner, PropertyCacheLevel referenceCacheLevel, string intermediateBlockModelValue, bool preview, BlockListConfiguration.BlockConfiguration[] blockConfigurations)
{
BlockListModel CreateEmptyModel() => BlockListModel.Empty;
BlockListModel CreateModel(IList<BlockListItem> items) => new BlockListModel(items);
BlockListItem? blockModel = CreateBlockModel(owner, referenceCacheLevel, intermediateBlockModelValue, preview, blockConfigurations, CreateEmptyModel, CreateModel).SingleOrDefault();
return blockModel;
}
protected override BlockEditorDataConverter<SingleBlockValue, SingleBlockLayoutItem> CreateBlockEditorDataConverter() => new SingleBlockEditorDataConverter(_jsonSerializer);
protected override BlockItemActivator<BlockListItem> CreateBlockItemActivator() => new BlockListItemActivator(BlockEditorConverter, _constructorCache);
private sealed class BlockListItemActivator : BlockItemActivator<BlockListItem>
{
public BlockListItemActivator(BlockEditorConverter blockConverter, BlockListPropertyValueConstructorCache constructorCache)
: base(blockConverter, constructorCache)
{
}
protected override Type GenericItemType => typeof(BlockListItem<,>);
}
}