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

@@ -40,6 +40,11 @@ public static partial class Constants
/// </summary>
public const string BlockList = "Umbraco.BlockList";
/// <summary>
/// Block List.
/// </summary>
public const string SingleBlock = "Umbraco.SingleBlock";
/// <summary>
/// Block Grid.
/// </summary>

View File

@@ -0,0 +1,17 @@
using Umbraco.Cms.Core.Serialization;
namespace Umbraco.Cms.Core.Models.Blocks;
/// <summary>
/// Data converter for the single block property editor
/// </summary>
public class SingleBlockEditorDataConverter : BlockEditorDataConverter<SingleBlockValue, SingleBlockLayoutItem>
{
public SingleBlockEditorDataConverter(IJsonSerializer jsonSerializer)
: base(jsonSerializer)
{
}
protected override IEnumerable<ContentAndSettingsReference> GetBlockReferences(IEnumerable<SingleBlockLayoutItem> layout)
=> layout.Select(x => new ContentAndSettingsReference(x.ContentKey, x.SettingsKey)).ToList();
}

View File

@@ -0,0 +1,7 @@
using Umbraco.Cms.Core.PropertyEditors;
namespace Umbraco.Cms.Core.Models.Blocks;
public class SingleBlockLayoutItem : BlockLayoutItemBase
{
}

View File

@@ -0,0 +1,26 @@
using System.Text.Json.Serialization;
namespace Umbraco.Cms.Core.Models.Blocks;
/// <summary>
/// Represents a single block value.
/// </summary>
public class SingleBlockValue : BlockValue<SingleBlockLayoutItem>
{
/// <summary>
/// Initializes a new instance of the <see cref="SingleBlockValue" /> class.
/// </summary>
public SingleBlockValue()
{ }
/// <summary>
/// Initializes a new instance of the <see cref="SingleBlockValue" /> class.
/// </summary>
/// <param name="layout">The layout.</param>
public SingleBlockValue(SingleBlockLayoutItem layout)
=> Layout[PropertyEditorAlias] = [layout];
/// <inheritdoc />
[JsonIgnore]
public override string PropertyEditorAlias => Constants.PropertyEditors.Aliases.SingleBlock;
}

View File

@@ -15,6 +15,7 @@ public class BlockListConfiguration
public NumberRange ValidationLimit { get; set; } = new();
[ConfigurationField("useSingleBlockMode")]
[Obsolete("Use SingleBlockPropertyEditor and its configuration instead")]
public bool UseSingleBlockMode { get; set; }
public class BlockConfiguration : IBlockConfiguration

View File

@@ -0,0 +1,13 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
namespace Umbraco.Cms.Core.PropertyEditors;
/// <summary>
/// The configuration object for the Single Block editor
/// </summary>
public class SingleBlockConfiguration
{
[ConfigurationField("blocks")]
public BlockListConfiguration.BlockConfiguration[] Blocks { get; set; } = [];
}

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<,>);
}
}

View File

@@ -0,0 +1,107 @@
using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewEngines;
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Core.Models.Blocks;
using Umbraco.Cms.Core.Models.PublishedContent;
namespace Umbraco.Extensions;
public static class SingleBlockTemplateExtensions
{
public const string DefaultFolder = "singleblock/";
public const string DefaultTemplate = "default";
#region Async
public static async Task<IHtmlContent> GetBlockHtmlAsync(this IHtmlHelper html, BlockListItem? model, string template = DefaultTemplate)
{
if (model is null)
{
return new HtmlString(string.Empty);
}
return await html.PartialAsync(DefaultFolderTemplate(template), model);
}
public static async Task<IHtmlContent> GetBlockHtmlAsync(this IHtmlHelper html, IPublishedProperty property, string template = DefaultTemplate)
=> await GetBlockHtmlAsync(html, property.GetValue() as BlockListItem, template);
public static async Task<IHtmlContent> GetBlockHtmlAsync(this IHtmlHelper html, IPublishedContent contentItem, string propertyAlias)
=> await GetBlockHtmlAsync(html, contentItem, propertyAlias, DefaultTemplate);
public static async Task<IHtmlContent> GetBlockHtmlAsync(this IHtmlHelper html, IPublishedContent contentItem, string propertyAlias, string template)
{
IPublishedProperty property = GetRequiredProperty(contentItem, propertyAlias);
return await GetBlockHtmlAsync(html, property.GetValue() as BlockListItem, template);
}
#endregion
#region Sync
public static IHtmlContent GetBlockHtml(this IHtmlHelper html, BlockListItem? model, string template = DefaultTemplate)
{
if (model is null)
{
return new HtmlString(string.Empty);
}
return html.Partial(DefaultFolderTemplate(template), model);
}
public static IHtmlContent GetBlockHtml(this IHtmlHelper html, IPublishedProperty property, string template = DefaultTemplate)
=> GetBlockHtml(html, property.GetValue() as BlockListItem, template);
public static IHtmlContent GetBlockHtml(this IHtmlHelper html, IPublishedContent contentItem, string propertyAlias)
=> GetBlockHtml(html, contentItem, propertyAlias, DefaultTemplate);
public static IHtmlContent GetBlockHtml(this IHtmlHelper html, IPublishedContent contentItem, string propertyAlias, string template)
{
IPublishedProperty property = GetRequiredProperty(contentItem, propertyAlias);
return GetBlockHtml(html, property.GetValue() as BlockListItem, template);
}
public static string SingleBlockPartialWithFallback(this IHtmlHelper html, string template, string fallbackTemplate)
{
IServiceProvider requestServices = html.ViewContext.HttpContext.RequestServices;
ICompositeViewEngine? viewEngine = requestServices.GetService<ICompositeViewEngine>();
if (viewEngine is null)
{
return template;
}
// .Getview, and likely .FindView, will be invoked when invoking html.Partial
// the heavy lifting in the underlying logic seems to be cached so it should be ok to offer this logic
// as a DX feature in the default block renderer.
return
viewEngine.GetView(html.ViewContext.ExecutingFilePath, template, isMainPage: false).Success
? template
: viewEngine.FindView(html.ViewContext, template, isMainPage: false).Success
? template
: fallbackTemplate;
}
#endregion
private static string DefaultFolderTemplate(string template) => $"{DefaultFolder}{template}";
private static IPublishedProperty GetRequiredProperty(IPublishedContent contentItem, string propertyAlias)
{
ArgumentNullException.ThrowIfNull(propertyAlias);
if (string.IsNullOrWhiteSpace(propertyAlias))
{
throw new ArgumentException(
"Value can't be empty or consist only of white-space characters.",
nameof(propertyAlias));
}
IPublishedProperty? property = contentItem.GetProperty(propertyAlias);
if (property == null)
{
throw new InvalidOperationException("No property type found with alias " + propertyAlias);
}
return property;
}
}

View File

@@ -0,0 +1,9 @@
@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage<Umbraco.Cms.Core.Models.Blocks.BlockListItem>
@{
if (Model.ContentKey == Guid.Empty) { return; }
var data = Model.Content;
}
@await Html.PartialAsync(
Html.SingleBlockPartialWithFallback("singleBlock/Components/" + data.ContentType.Alias,
"blocklist/Components/" + data.ContentType.Alias )
, Model)

View File

@@ -56,6 +56,10 @@
<Content Include="..\src\Umbraco.Web.UI\Views\Partials\blockgrid\**">
<Link>UmbracoProject\Views\Partials\blockgrid\%(RecursiveDir)%(Filename)%(Extension)</Link>
<PackagePath>UmbracoProject\Views\Partials\blockgrid</PackagePath>
</Content>
<Content Include="..\src\Umbraco.Web.UI\Views\Partials\singleblock\**">
<Link>UmbracoProject\Views\Partials\singleblock\%(RecursiveDir)%(Filename)%(Extension)</Link>
<PackagePath>UmbracoProject\Views\Partials\singleblock</PackagePath>
</Content>
<Content Include="..\src\Umbraco.Web.UI\Views\_ViewImports.cshtml">
<Link>UmbracoProject\Views\_ViewImports.cshtml</Link>

View File

@@ -5,6 +5,7 @@ using Umbraco.Cms.Core;
using Umbraco.Cms.Core.IO;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.PropertyEditors;
using Umbraco.Cms.Infrastructure.PropertyEditors;
using Umbraco.Cms.Infrastructure.Serialization;
using Umbraco.Cms.Tests.Common.Builders.Extensions;
using Umbraco.Cms.Tests.Common.Builders.Interfaces;
@@ -194,6 +195,10 @@ public class DataTypeBuilder
dataTypeBuilder.WithConfigurationEditor(
new BlockListConfigurationEditor(ioHelper) { DefaultConfiguration = configuration });
break;
case Constants.PropertyEditors.Aliases.SingleBlock:
dataTypeBuilder.WithConfigurationEditor(
new SingleBlockConfigurationEditor(ioHelper) { DefaultConfiguration = configuration });
break;
case Constants.PropertyEditors.Aliases.RichText:
dataTypeBuilder.WithConfigurationEditor(
new RichTextConfigurationEditor(ioHelper) { DefaultConfiguration = configuration });

View File

@@ -0,0 +1,187 @@
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.ContentEditing;
using Umbraco.Extensions;
namespace Umbraco.Cms.Core.Services;
// Disclaimer: Based on code generated by Claude code.
// Most likely not complete, but the logic looks sound.
public interface IContentEditingModelFactory
{
Task<ContentUpdateModel> CreateFromAsync(IContent content);
}
public class ContentEditingModelFactory : IContentEditingModelFactory
{
private readonly ITemplateService _templateService;
public ContentEditingModelFactory(ITemplateService templateService)
{
_templateService = templateService;
}
public async Task<ContentUpdateModel> CreateFromAsync(IContent content)
{
{
var templateKey = content.TemplateId.HasValue
? (await _templateService.GetAsync(content.TemplateId.Value))?.Key
: null;
var model = new ContentUpdateModel { TemplateKey = templateKey };
var properties = new List<PropertyValueModel>();
var variants = new List<VariantModel>();
MapProperties(content, properties);
MapNames(content, properties, variants);
model.Properties = properties;
model.Variants = variants;
return model;
}
}
private static void MapNames(IContent content, List<PropertyValueModel> properties, List<VariantModel> variants)
{
// Handle variants (content names per culture/segment)
var contentVariesByCulture = content.ContentType.VariesByCulture();
var contentVariesBySegment = content.ContentType.VariesBySegment();
if (contentVariesByCulture || contentVariesBySegment)
{
// Get all unique culture/segment combinations from CultureInfos
var cultureSegmentCombinations = new HashSet<(string? culture, string? segment)>();
// Add invariant combination
cultureSegmentCombinations.Add((null, null));
if (contentVariesByCulture)
{
// Add cultures
foreach (var culture in content.AvailableCultures)
{
cultureSegmentCombinations.Add((culture, null));
}
}
// For segment support, we need to extract segments from property values
// since content doesn't have "AvailableSegments" like cultures
if (contentVariesBySegment)
{
var segmentsFromProperties = properties
.Where(p => !string.IsNullOrEmpty(p.Segment))
.Select(p => p.Segment)
.Distinct()
.ToList();
foreach (var segment in segmentsFromProperties)
{
cultureSegmentCombinations.Add((null, segment));
// If content also varies by culture, add culture+segment combinations
if (contentVariesByCulture)
{
foreach (var culture in content.AvailableCultures)
{
cultureSegmentCombinations.Add((culture, segment));
}
}
}
}
// Create variants for each combination
foreach (var (culture, segment) in cultureSegmentCombinations)
{
string? variantName;
if (culture == null && segment == null)
{
// Invariant
variantName = content.Name;
}
else if (culture != null && segment == null)
{
// Culture-specific
variantName = content.GetCultureName(culture);
}
else
{
// For segment-specific or culture+segment combinations,
// we'll use the invariant or culture name as segments don't have separate names
variantName = culture != null ? content.GetCultureName(culture) : content.Name;
}
if (!string.IsNullOrEmpty(variantName))
{
variants.Add(new VariantModel { Culture = culture, Segment = segment, Name = variantName });
}
}
}
else
{
// For invariant content, add single variant
variants.Add(new VariantModel { Culture = null, Segment = null, Name = content.Name ?? string.Empty });
}
}
private static void MapProperties(IContent content, List<PropertyValueModel> properties)
{
// Handle properties
foreach (var property in content.Properties)
{
var propertyVariesByCulture = property.PropertyType.VariesByCulture();
var propertyVariesBySegment = property.PropertyType.VariesBySegment();
// Get all property values from the property's Values collection
foreach (var propertyValue in property.Values)
{
if (propertyValue.EditedValue != null)
{
properties.Add(new PropertyValueModel
{
Alias = property.Alias,
Value = propertyValue.EditedValue,
Culture = propertyVariesByCulture ? propertyValue.Culture : null,
Segment = propertyVariesBySegment ? propertyValue.Segment : null,
});
}
}
// Fallback: if no values found in the Values collection, try the traditional approach
if (!property.Values.Any())
{
if (propertyVariesByCulture && content.AvailableCultures.Any())
{
// Handle culture variants
foreach (var culture in content.AvailableCultures)
{
var cultureValue = property.GetValue(culture);
if (cultureValue != null)
{
properties.Add(new PropertyValueModel
{
Alias = property.Alias, Value = cultureValue, Culture = culture, Segment = null,
});
}
}
// Also add the invariant value if it exists
var invariantValue = property.GetValue();
if (invariantValue != null)
{
properties.Add(new PropertyValueModel
{
Alias = property.Alias, Value = invariantValue, Culture = null, Segment = null,
});
}
}
else
{
// Handle invariant properties
var value = property.GetValue();
if (value != null)
{
properties.Add(new PropertyValueModel
{
Alias = property.Alias, Value = value, Culture = null, Segment = null,
});
}
}
}
}
}
}

View File

@@ -185,6 +185,9 @@ public abstract class UmbracoIntegrationTest : UmbracoIntegrationTestBase
CustomTestSetup(builder);
ExecuteBuilderAttributes(builder);
// custom helper services that might be moved out of tests eventually to benefit the community
services.AddSingleton<IContentEditingModelFactory, ContentEditingModelFactory>();
builder.Build();
}

View File

@@ -0,0 +1,777 @@
using NUnit.Framework;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Blocks;
using Umbraco.Cms.Core.Models.ContentEditing;
using Umbraco.Cms.Core.PropertyEditors;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Infrastructure.PropertyEditors;
using Umbraco.Cms.Tests.Common.Builders;
using Umbraco.Cms.Tests.Common.Builders.Extensions;
using Umbraco.Cms.Tests.Common.Testing;
using Umbraco.Cms.Tests.Integration.Testing;
namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.PropertyEditors;
[TestFixture]
[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)]
internal sealed class SingleBlockPropertyEditorTests : UmbracoIntegrationTest
{
private IContentTypeService ContentTypeService => GetRequiredService<IContentTypeService>();
private IContentService ContentService => GetRequiredService<IContentService>();
private IDataTypeService DataTypeService => GetRequiredService<IDataTypeService>();
private IJsonSerializer JsonSerializer => GetRequiredService<IJsonSerializer>();
private IConfigurationEditorJsonSerializer ConfigurationEditorJsonSerializer => GetRequiredService<IConfigurationEditorJsonSerializer>();
private PropertyEditorCollection PropertyEditorCollection => GetRequiredService<PropertyEditorCollection>();
private ILanguageService LanguageService => GetRequiredService<ILanguageService>();
private IContentValidationService ContentValidationService => GetRequiredService<IContentValidationService>();
private IContentEditingModelFactory ContentEditingModelFactory => GetRequiredService<IContentEditingModelFactory>();
private ILocalizedTextService LocalizedTextService => GetRequiredService<ILocalizedTextService>();
private const string AllTypes = "allTypes";
private const string MetaType = "metaType";
private const string TextType = "textType";
[Theory]
[TestCase(AllTypes)]
[TestCase(MetaType)]
public async Task Can_Select_Different_Configured_Block(string elementTypeName)
{
if (elementTypeName != AllTypes && elementTypeName != MetaType)
{
throw new ArgumentOutOfRangeException(nameof(elementTypeName));
}
var textPageContentType = ContentTypeBuilder.CreateTextPageContentType("myContentType");
textPageContentType.AllowedTemplates = [];
await ContentTypeService.CreateAsync(textPageContentType, Constants.Security.SuperUserKey);
var textPage = ContentBuilder.CreateTextpageContent(textPageContentType, "My Picked Content", -1);
ContentService.Save(textPage);
var allTypesType = ContentTypeBuilder.CreateAllTypesContentType("allTypesType", "All Types type");
allTypesType.IsElement = true;
await ContentTypeService.CreateAsync(allTypesType, Constants.Security.SuperUserKey);
var metaTypesType = ContentTypeBuilder.CreateMetaContentType();
metaTypesType.IsElement = true;
await ContentTypeService.CreateAsync(metaTypesType, Constants.Security.SuperUserKey);
var singleBlockContentType = await CreateSingleBlockContentTypePage([allTypesType, metaTypesType]);
var contentElementKey = Guid.NewGuid();
var blockValue = new SingleBlockValue
{
Layout = new Dictionary<string, IEnumerable<IBlockLayoutItem>>
{
{
Constants.PropertyEditors.Aliases.SingleBlock,
[
new SingleBlockLayoutItem { ContentKey = contentElementKey }
]
},
},
ContentData =
[
elementTypeName == AllTypes
? new BlockItemData
{
Key = contentElementKey,
ContentTypeAlias = allTypesType.Alias,
ContentTypeKey = allTypesType.Key,
Values =
[
new BlockPropertyValue
{
Alias = "contentPicker",
Value = textPage.GetUdi(),
}
],
}
: new BlockItemData
{
Key = contentElementKey,
ContentTypeAlias = metaTypesType.Alias,
ContentTypeKey = metaTypesType.Key,
Values =
[
new BlockPropertyValue
{
Alias = "metadescription",
Value = "something very meta",
}
],
}
],
};
var blocksPropertyValue = JsonSerializer.Serialize(blockValue);
var content = new ContentBuilder()
.WithContentType(singleBlockContentType)
.WithName("My Blocks")
.WithPropertyValues(new { block = blocksPropertyValue })
.Build();
ContentService.Save(content);
var valueEditor = await GetValueEditor(singleBlockContentType);
var toEditorValue = valueEditor.ToEditor(content.Properties["block"]!) as SingleBlockValue;
Assert.IsNotNull(toEditorValue);
Assert.AreEqual(1, toEditorValue.ContentData.Count);
var properties = toEditorValue.ContentData.First().Values;
Assert.AreEqual(1, properties.Count);
Assert.Multiple(() =>
{
var property = properties.First();
Assert.AreEqual(elementTypeName == AllTypes ? "contentPicker" : "metadescription", property.Alias);
Assert.AreEqual(elementTypeName == AllTypes ? textPage.Key : "something very meta", property.Value);
});
// convert to updateModel and run validation
var updateModel = await ContentEditingModelFactory.CreateFromAsync(content);
var validationResult = await ContentValidationService.ValidatePropertiesAsync(updateModel, singleBlockContentType);
Assert.AreEqual(0, validationResult.ValidationErrors.Count());
}
[Theory]
[TestCase(AllTypes, true)]
[TestCase(MetaType, true)]
[TestCase(TextType, false)]
[Ignore("Reenable when configured block validation is introduced")]
public async Task Validates_Configured_Blocks(string elementTypeName, bool shouldPass)
{
if (elementTypeName != AllTypes && elementTypeName != MetaType && elementTypeName != TextType)
{
throw new ArgumentOutOfRangeException(nameof(elementTypeName));
}
var textPageContentType = ContentTypeBuilder.CreateTextPageContentType("myContentType");
textPageContentType.AllowedTemplates = [];
await ContentTypeService.CreateAsync(textPageContentType, Constants.Security.SuperUserKey);
var textPage = ContentBuilder.CreateTextpageContent(textPageContentType, "My Picked Content", -1);
ContentService.Save(textPage);
var allTypesType = ContentTypeBuilder.CreateAllTypesContentType("allTypesType", "All Types type");
allTypesType.IsElement = true;
await ContentTypeService.CreateAsync(allTypesType, Constants.Security.SuperUserKey);
var metaTypesType = ContentTypeBuilder.CreateMetaContentType();
metaTypesType.IsElement = true;
await ContentTypeService.CreateAsync(metaTypesType, Constants.Security.SuperUserKey);
var textType = new ContentTypeBuilder()
.WithAlias("TextType")
.WithName("Text type")
.AddPropertyGroup()
.WithAlias("content")
.WithName("Content")
.WithSortOrder(1)
.WithSupportsPublishing(true)
.AddPropertyType()
.WithAlias("title")
.WithName("Title")
.WithSortOrder(1)
.Done()
.Done()
.Build();
textType.IsElement = true;
await ContentTypeService.CreateAsync(textType, Constants.Security.SuperUserKey);
// do not allow textType to be a valid block
var singleBlockContentType = await CreateSingleBlockContentTypePage([allTypesType, metaTypesType]);
var contentElementKey = Guid.NewGuid();
var blockValue = new SingleBlockValue
{
Layout = new Dictionary<string, IEnumerable<IBlockLayoutItem>>
{
{
Constants.PropertyEditors.Aliases.SingleBlock,
[
new SingleBlockLayoutItem { ContentKey = contentElementKey }
]
},
},
ContentData =
[
elementTypeName == AllTypes
? new BlockItemData
{
Key = contentElementKey,
ContentTypeAlias = allTypesType.Alias,
ContentTypeKey = allTypesType.Key,
Values =
[
new BlockPropertyValue
{
Alias = "contentPicker",
Value = textPage.GetUdi(),
}
],
}
: elementTypeName == MetaType ?
new BlockItemData
{
Key = contentElementKey,
ContentTypeAlias = metaTypesType.Alias,
ContentTypeKey = metaTypesType.Key,
Values =
[
new BlockPropertyValue
{
Alias = "metadescription",
Value = "something very meta",
}
],
}
: new BlockItemData
{
Key = contentElementKey,
ContentTypeAlias = textType.Alias,
ContentTypeKey = textType.Key,
Values =
[
new BlockPropertyValue
{
Alias = "title",
Value = "a random title",
}
],
},
],
};
var blocksPropertyValue = JsonSerializer.Serialize(blockValue);
var content = new ContentBuilder()
.WithContentType(singleBlockContentType)
.WithName("My Blocks")
.WithPropertyValues(new { block = blocksPropertyValue })
.Build();
ContentService.Save(content);
// convert to updateModel and run validation
var updateModel = await ContentEditingModelFactory.CreateFromAsync(content);
var validationResult = await ContentValidationService.ValidatePropertiesAsync(updateModel, singleBlockContentType);
Assert.AreEqual(shouldPass ? 0 : 1, validationResult.ValidationErrors.Count());
}
/// <summary>
/// There should be some validation when publishing through the contentEditingService
/// </summary>
[Test]
public async Task Cannot_Select_Multiple_Configured_Blocks()
{
var textPageContentType = ContentTypeBuilder.CreateTextPageContentType("myContentType");
textPageContentType.AllowedTemplates = [];
await ContentTypeService.CreateAsync(textPageContentType, Constants.Security.SuperUserKey);
var textPage = ContentBuilder.CreateTextpageContent(textPageContentType, "My Picked Content", -1);
ContentService.Save(textPage);
var allTypesType = ContentTypeBuilder.CreateAllTypesContentType("allTypesType", "All Types type");
allTypesType.IsElement = true;
await ContentTypeService.CreateAsync(allTypesType, Constants.Security.SuperUserKey);
var metaTypesType = ContentTypeBuilder.CreateMetaContentType();
metaTypesType.IsElement = true;
await ContentTypeService.CreateAsync(metaTypesType, Constants.Security.SuperUserKey);
var singleBlockContentType = await CreateSingleBlockContentTypePage([allTypesType, metaTypesType]);
var firstElementKey = Guid.NewGuid();
var secondElementKey = Guid.NewGuid();
var blockValue = new SingleBlockValue
{
Layout = new Dictionary<string, IEnumerable<IBlockLayoutItem>>
{
{
Constants.PropertyEditors.Aliases.SingleBlock,
[
new SingleBlockLayoutItem { ContentKey = firstElementKey },
new SingleBlockLayoutItem { ContentKey = secondElementKey }
]
},
},
ContentData =
[
new BlockItemData
{
Key = firstElementKey,
ContentTypeAlias = allTypesType.Alias,
ContentTypeKey = allTypesType.Key,
Values =
[
new BlockPropertyValue
{
Alias = "contentPicker",
Value = textPage.GetUdi(),
}
],
},
new BlockItemData
{
Key = secondElementKey,
ContentTypeAlias = metaTypesType.Alias,
ContentTypeKey = metaTypesType.Key,
Values =
[
new BlockPropertyValue
{
Alias = "metadescription",
Value = "something very meta",
}
],
}
],
};
var blocksPropertyValue = JsonSerializer.Serialize(blockValue);
var content = new ContentBuilder()
.WithContentType(singleBlockContentType)
.WithName("My Blocks")
.WithPropertyValues(new { block = blocksPropertyValue })
.Build();
// No validation, should just save
ContentService.Save(content);
// convert to updateModel and run validation
var updateModel = await ContentEditingModelFactory.CreateFromAsync(content);
var validationResult = await ContentValidationService.ValidatePropertiesAsync(updateModel, singleBlockContentType);
Assert.Multiple(() =>
{
Assert.AreEqual(1, validationResult.ValidationErrors.Count());
var validationError = validationResult.ValidationErrors.Single();
var expectedErrorMessage = SingleBlockPropertyEditor.SingleBlockEditorPropertyValueEditor
.SingleBlockValidator
.BuildErrorMessage(LocalizedTextService, 1, 2);
Assert.AreEqual("block", validationError.Alias);
Assert.AreEqual(expectedErrorMessage, validationError.ErrorMessages.Single());
});
}
[Test]
public async Task Can_Track_References()
{
var textPageContentType = ContentTypeBuilder.CreateTextPageContentType("myContentType");
textPageContentType.AllowedTemplates = [];
await ContentTypeService.CreateAsync(textPageContentType, Constants.Security.SuperUserKey);
var textPage = ContentBuilder.CreateTextpageContent(textPageContentType, "My Picked Content", -1);
ContentService.Save(textPage);
var elementType = ContentTypeBuilder.CreateAllTypesContentType("allTypesType", "All Types type");
elementType.IsElement = true;
await ContentTypeService.CreateAsync(elementType, Constants.Security.SuperUserKey);
var singleBlockContentType = await CreateSingleBlockContentTypePage([elementType]);
var contentElementKey = Guid.NewGuid();
var blockValue = new SingleBlockValue
{
Layout = new Dictionary<string, IEnumerable<IBlockLayoutItem>>
{
{
Constants.PropertyEditors.Aliases.SingleBlock,
[
new SingleBlockLayoutItem { ContentKey = contentElementKey }
]
},
},
ContentData =
[
new()
{
Key = contentElementKey,
ContentTypeAlias = elementType.Alias,
ContentTypeKey = elementType.Key,
Values =
[
new BlockPropertyValue
{
Alias = "contentPicker",
Value = textPage.GetUdi(),
}
],
}
],
};
var blocksPropertyValue = JsonSerializer.Serialize(blockValue);
var content = new ContentBuilder()
.WithContentType(singleBlockContentType)
.WithName("My Blocks")
.WithPropertyValues(new { block = blocksPropertyValue })
.Build();
ContentService.Save(content);
var valueEditor = await GetValueEditor(singleBlockContentType);
var references = valueEditor.GetReferences(content.GetValue("block")).ToArray();
Assert.AreEqual(1, references.Length);
var reference = references.First();
Assert.AreEqual(Constants.Conventions.RelationTypes.RelatedDocumentAlias, reference.RelationTypeAlias);
Assert.AreEqual(textPage.GetUdi(), reference.Udi);
}
[Test]
public async Task Can_Track_Tags()
{
var elementType = ContentTypeBuilder.CreateAllTypesContentType("myElementType", "My Element Type");
elementType.IsElement = true;
await ContentTypeService.CreateAsync(elementType, Constants.Security.SuperUserKey);
var singleBlockContentType = await CreateSingleBlockContentTypePage([elementType]);
var contentElementKey = Guid.NewGuid();
var blockValue = new SingleBlockValue
{
Layout = new Dictionary<string, IEnumerable<IBlockLayoutItem>>
{
{
Constants.PropertyEditors.Aliases.SingleBlock,
[
new SingleBlockLayoutItem { ContentKey = contentElementKey }
]
},
},
ContentData =
[
new()
{
Key = contentElementKey,
ContentTypeAlias = elementType.Alias,
ContentTypeKey = elementType.Key,
Values =
[
new ()
{
Alias = "tags",
// this is a little skewed, but the tags editor expects a serialized array of strings
Value = JsonSerializer.Serialize(new[] { "Tag One", "Tag Two", "Tag Three" }),
}
],
}
],
};
var blockPropertyValue = JsonSerializer.Serialize(blockValue);
var content = new ContentBuilder()
.WithContentType(singleBlockContentType)
.WithName("My Blocks")
.WithPropertyValues(new { block = blockPropertyValue })
.Build();
ContentService.Save(content);
var valueEditor = await GetValueEditor(singleBlockContentType);
var tags = valueEditor.GetTags(content.GetValue("block"), null, null).ToArray();
Assert.AreEqual(3, tags.Length);
Assert.IsNotNull(tags.Single(tag => tag.Text == "Tag One" && tag.LanguageId == null));
Assert.IsNotNull(tags.Single(tag => tag.Text == "Tag Two" && tag.LanguageId == null));
Assert.IsNotNull(tags.Single(tag => tag.Text == "Tag Three" && tag.LanguageId == null));
}
[Test]
public async Task Can_Track_Tags_For_Block_Level_Variance()
{
var result = await LanguageService.CreateAsync(
new Language("da-DK", "Danish"), Constants.Security.SuperUserKey);
Assert.IsTrue(result.Success);
var daDkId = result.Result.Id;
var elementType = ContentTypeBuilder.CreateAllTypesContentType("myElementType", "My Element Type");
elementType.IsElement = true;
elementType.Variations = ContentVariation.Culture;
elementType.PropertyTypes.First(p => p.Alias == "tags").Variations = ContentVariation.Culture;
await ContentTypeService.CreateAsync(elementType, Constants.Security.SuperUserKey);
var singleBlockContentType = await CreateSingleBlockContentTypePage([elementType]);
singleBlockContentType.Variations = ContentVariation.Culture;
await ContentTypeService.CreateAsync(singleBlockContentType, Constants.Security.SuperUserKey);
var contentElementKey = Guid.NewGuid();
var blockValue = new SingleBlockValue
{
Layout = new Dictionary<string, IEnumerable<IBlockLayoutItem>>
{
{
Constants.PropertyEditors.Aliases.SingleBlock,
[
new SingleBlockLayoutItem { ContentKey = contentElementKey }
]
},
},
ContentData =
[
new()
{
Key = contentElementKey,
ContentTypeAlias = elementType.Alias,
ContentTypeKey = elementType.Key,
Values =
[
new()
{
Alias = "tags",
// this is a little skewed, but the tags editor expects a serialized array of strings
Value = JsonSerializer.Serialize(new[] { "Tag One EN", "Tag Two EN", "Tag Three EN" }),
Culture = "en-US",
},
new()
{
Alias = "tags",
// this is a little skewed, but the tags editor expects a serialized array of strings
Value = JsonSerializer.Serialize(new[] { "Tag One DA", "Tag Two DA", "Tag Three DA" }),
Culture = "da-DK",
}
],
}
],
};
var blockPropertyValue = JsonSerializer.Serialize(blockValue);
var content = new ContentBuilder()
.WithContentType(singleBlockContentType)
.WithCultureName("en-US", "My Blocks EN")
.WithCultureName("da-DK", "My Blocks DA")
.WithPropertyValues(new { block = blockPropertyValue })
.Build();
ContentService.Save(content);
var valueEditor = await GetValueEditor(singleBlockContentType);
var tags = valueEditor.GetTags(content.GetValue("block"), null, null).ToArray();
Assert.AreEqual(6, tags.Length);
Assert.IsNotNull(tags.Single(tag => tag.Text == "Tag One EN" && tag.LanguageId == 1));
Assert.IsNotNull(tags.Single(tag => tag.Text == "Tag Two EN" && tag.LanguageId == 1));
Assert.IsNotNull(tags.Single(tag => tag.Text == "Tag Three EN" && tag.LanguageId == 1));
Assert.IsNotNull(tags.Single(tag => tag.Text == "Tag One DA" && tag.LanguageId == daDkId));
Assert.IsNotNull(tags.Single(tag => tag.Text == "Tag Two DA" && tag.LanguageId == daDkId));
Assert.IsNotNull(tags.Single(tag => tag.Text == "Tag Three DA" && tag.LanguageId == daDkId));
}
[Test]
public async Task Can_Handle_Culture_Variance_Addition()
{
var elementType = ContentTypeBuilder.CreateAllTypesContentType("myElementType", "My Element Type");
elementType.IsElement = true;
await ContentTypeService.CreateAsync(elementType, Constants.Security.SuperUserKey);
var singleBlockContentType = await CreateSingleBlockContentTypePage([elementType]);
var contentElementKey = Guid.NewGuid();
var blockValue = new SingleBlockValue
{
Layout = new Dictionary<string, IEnumerable<IBlockLayoutItem>>
{
{
Constants.PropertyEditors.Aliases.SingleBlock,
[
new SingleBlockLayoutItem { ContentKey = contentElementKey }
]
},
},
ContentData =
[
new()
{
Key = contentElementKey,
ContentTypeAlias = elementType.Alias,
ContentTypeKey = elementType.Key,
Values =
[
new ()
{
Alias = "singleLineText",
Value = "The single line text",
}
],
}
],
Expose =
[
new BlockItemVariation(contentElementKey, null, null)
],
};
var blockPropertyValue = JsonSerializer.Serialize(blockValue);
var content = new ContentBuilder()
.WithContentType(singleBlockContentType)
.WithName("My Blocks")
.WithPropertyValues(new { block = blockPropertyValue })
.Build();
ContentService.Save(content);
elementType.Variations = ContentVariation.Culture;
elementType.PropertyTypes.First(pt => pt.Alias == "singleLineText").Variations = ContentVariation.Culture;
ContentTypeService.Save(elementType);
var valueEditor = await GetValueEditor(singleBlockContentType);
var toEditorValue = valueEditor.ToEditor(content.Properties["block"]!) as SingleBlockValue;
Assert.IsNotNull(toEditorValue);
Assert.AreEqual(1, toEditorValue.ContentData.Count);
var properties = toEditorValue.ContentData.First().Values;
Assert.AreEqual(1, properties.Count);
Assert.Multiple(() =>
{
var property = properties.First();
Assert.AreEqual("singleLineText", property.Alias);
Assert.AreEqual("The single line text", property.Value);
Assert.AreEqual("en-US", property.Culture);
});
Assert.AreEqual(1, toEditorValue.Expose.Count);
Assert.Multiple(() =>
{
var itemVariation = toEditorValue.Expose[0];
Assert.AreEqual(contentElementKey, itemVariation.ContentKey);
Assert.AreEqual("en-US", itemVariation.Culture);
});
}
[Test]
public async Task Can_Handle_Culture_Variance_Removal()
{
var elementType = ContentTypeBuilder.CreateAllTypesContentType("myElementType", "My Element Type");
elementType.IsElement = true;
elementType.Variations = ContentVariation.Culture;
elementType.PropertyTypes.First(pt => pt.Alias == "singleLineText").Variations = ContentVariation.Culture;
await ContentTypeService.CreateAsync(elementType, Constants.Security.SuperUserKey);
var singleBlockContentType = await CreateSingleBlockContentTypePage([elementType]);
var contentElementKey = Guid.NewGuid();
var blockValue = new SingleBlockValue
{
Layout = new Dictionary<string, IEnumerable<IBlockLayoutItem>>
{
{
Constants.PropertyEditors.Aliases.SingleBlock,
[
new SingleBlockLayoutItem { ContentKey = contentElementKey },
]
},
},
ContentData =
[
new()
{
Key = contentElementKey,
ContentTypeAlias = elementType.Alias,
ContentTypeKey = elementType.Key,
Values =
[
new ()
{
Alias = "singleLineText",
Value = "The single line text",
Culture = "en-US",
}
],
}
],
Expose =
[
new BlockItemVariation(contentElementKey, "en-US", null)
],
};
var blockPropertyValue = JsonSerializer.Serialize(blockValue);
var content = new ContentBuilder()
.WithContentType(singleBlockContentType)
.WithName("My Blocks")
.WithPropertyValues(new { block = blockPropertyValue })
.Build();
ContentService.Save(content);
elementType.PropertyTypes.First(pt => pt.Alias == "singleLineText").Variations = ContentVariation.Nothing;
elementType.Variations = ContentVariation.Nothing;
await ContentTypeService.SaveAsync(elementType, Constants.Security.SuperUserKey);
var valueEditor = await GetValueEditor(singleBlockContentType);
var toEditorValue = valueEditor.ToEditor(content.Properties["block"]!) as SingleBlockValue;
Assert.IsNotNull(toEditorValue);
Assert.AreEqual(1, toEditorValue.ContentData.Count);
var properties = toEditorValue.ContentData.First().Values;
Assert.AreEqual(1, properties.Count);
Assert.Multiple(() =>
{
var property = properties.First();
Assert.AreEqual("singleLineText", property.Alias);
Assert.AreEqual("The single line text", property.Value);
Assert.AreEqual(null, property.Culture);
});
Assert.AreEqual(1, toEditorValue.Expose.Count);
Assert.Multiple(() =>
{
var itemVariation = toEditorValue.Expose[0];
Assert.AreEqual(contentElementKey, itemVariation.ContentKey);
Assert.AreEqual(null, itemVariation.Culture);
});
}
private async Task<IContentType> CreateSingleBlockContentTypePage(IContentType[] allowedElementTypes)
{
var singleBlockDataType = new DataType(PropertyEditorCollection[Constants.PropertyEditors.Aliases.SingleBlock], ConfigurationEditorJsonSerializer)
{
ConfigurationData = new Dictionary<string, object>
{
{
"blocks",
allowedElementTypes.Select(allowedElementType =>
new BlockListConfiguration.BlockConfiguration
{
ContentElementTypeKey = allowedElementType.Key,
}).ToArray()
},
},
Name = "My Single Block",
DatabaseType = ValueStorageType.Ntext,
ParentId = Constants.System.Root,
CreateDate = DateTime.UtcNow
};
await DataTypeService.CreateAsync(singleBlockDataType, Constants.Security.SuperUserKey);
var contentType = new ContentTypeBuilder()
.WithAlias("myPage")
.WithName("My Page")
.AddPropertyType()
.WithAlias("block")
.WithName("Block")
.WithDataTypeId(singleBlockDataType.Id)
.Done()
.Build();
ContentTypeService.Save(contentType);
// re-fetch to wire up all key bindings (particularly to the datatype)
return await ContentTypeService.GetAsync(contentType.Key);
}
private async Task<SingleBlockPropertyEditor.SingleBlockEditorPropertyValueEditor> GetValueEditor(IContentType contentType)
{
var dataType = await DataTypeService.GetAsync(contentType.PropertyTypes.First(propertyType => propertyType.Alias == "block").DataTypeKey);
Assert.IsNotNull(dataType?.Editor);
var valueEditor = dataType.Editor.GetValueEditor() as SingleBlockPropertyEditor.SingleBlockEditorPropertyValueEditor;
Assert.IsNotNull(valueEditor);
return valueEditor;
}
}

View File

@@ -132,7 +132,7 @@ public class TypeLoaderTests
public void GetDataEditors()
{
var types = _typeLoader.GetDataEditors();
Assert.AreEqual(36, types.Count());
Assert.AreEqual(37, types.Count());
}
/// <summary>