diff --git a/src/Umbraco.Core/Constants-PropertyEditors.cs b/src/Umbraco.Core/Constants-PropertyEditors.cs
index 0d0b5d97675a..5f670a176117 100644
--- a/src/Umbraco.Core/Constants-PropertyEditors.cs
+++ b/src/Umbraco.Core/Constants-PropertyEditors.cs
@@ -40,6 +40,11 @@ public static class Aliases
///
public const string BlockList = "Umbraco.BlockList";
+ ///
+ /// Block List.
+ ///
+ public const string SingleBlock = "Umbraco.SingleBlock";
+
///
/// Block Grid.
///
diff --git a/src/Umbraco.Core/Models/Blocks/SingleBlockEditorDataConverter.cs b/src/Umbraco.Core/Models/Blocks/SingleBlockEditorDataConverter.cs
new file mode 100644
index 000000000000..6d1911f337c3
--- /dev/null
+++ b/src/Umbraco.Core/Models/Blocks/SingleBlockEditorDataConverter.cs
@@ -0,0 +1,17 @@
+using Umbraco.Cms.Core.Serialization;
+
+namespace Umbraco.Cms.Core.Models.Blocks;
+
+///
+/// Data converter for the single block property editor
+///
+public class SingleBlockEditorDataConverter : BlockEditorDataConverter
+{
+ public SingleBlockEditorDataConverter(IJsonSerializer jsonSerializer)
+ : base(jsonSerializer)
+ {
+ }
+
+ protected override IEnumerable GetBlockReferences(IEnumerable layout)
+ => layout.Select(x => new ContentAndSettingsReference(x.ContentKey, x.SettingsKey)).ToList();
+}
diff --git a/src/Umbraco.Core/Models/Blocks/SingleBlockLayoutItem.cs b/src/Umbraco.Core/Models/Blocks/SingleBlockLayoutItem.cs
new file mode 100644
index 000000000000..aa405cb1f369
--- /dev/null
+++ b/src/Umbraco.Core/Models/Blocks/SingleBlockLayoutItem.cs
@@ -0,0 +1,7 @@
+using Umbraco.Cms.Core.PropertyEditors;
+
+namespace Umbraco.Cms.Core.Models.Blocks;
+
+public class SingleBlockLayoutItem : BlockLayoutItemBase
+{
+}
diff --git a/src/Umbraco.Core/Models/Blocks/SingleBlockValue.cs b/src/Umbraco.Core/Models/Blocks/SingleBlockValue.cs
new file mode 100644
index 000000000000..d828d22b15ee
--- /dev/null
+++ b/src/Umbraco.Core/Models/Blocks/SingleBlockValue.cs
@@ -0,0 +1,26 @@
+using System.Text.Json.Serialization;
+
+namespace Umbraco.Cms.Core.Models.Blocks;
+
+///
+/// Represents a single block value.
+///
+public class SingleBlockValue : BlockValue
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public SingleBlockValue()
+ { }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The layout.
+ public SingleBlockValue(SingleBlockLayoutItem layout)
+ => Layout[PropertyEditorAlias] = [layout];
+
+ ///
+ [JsonIgnore]
+ public override string PropertyEditorAlias => Constants.PropertyEditors.Aliases.SingleBlock;
+}
diff --git a/src/Umbraco.Core/PropertyEditors/BlockListConfiguration.cs b/src/Umbraco.Core/PropertyEditors/BlockListConfiguration.cs
index 7ebf2fd112a8..0e4c5fe00f0b 100644
--- a/src/Umbraco.Core/PropertyEditors/BlockListConfiguration.cs
+++ b/src/Umbraco.Core/PropertyEditors/BlockListConfiguration.cs
@@ -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
diff --git a/src/Umbraco.Core/PropertyEditors/SingleBlockConfiguration.cs b/src/Umbraco.Core/PropertyEditors/SingleBlockConfiguration.cs
new file mode 100644
index 000000000000..69d9d2812f4d
--- /dev/null
+++ b/src/Umbraco.Core/PropertyEditors/SingleBlockConfiguration.cs
@@ -0,0 +1,13 @@
+// Copyright (c) Umbraco.
+// See LICENSE for more details.
+
+namespace Umbraco.Cms.Core.PropertyEditors;
+
+///
+/// The configuration object for the Single Block editor
+///
+public class SingleBlockConfiguration
+{
+ [ConfigurationField("blocks")]
+ public BlockListConfiguration.BlockConfiguration[] Blocks { get; set; } = [];
+}
diff --git a/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorMinMaxValidatorBase.cs b/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorMinMaxValidatorBase.cs
index f970ae8f9a2d..9088130a2dfd 100644
--- a/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorMinMaxValidatorBase.cs
+++ b/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorMinMaxValidatorBase.cs
@@ -29,6 +29,16 @@ internal abstract class BlockEditorMinMaxValidatorBase : IValue
///
public abstract IEnumerable 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(),]);
+
///
/// Validates the number of blocks are within the configured minimum and maximum values.
///
@@ -53,10 +63,7 @@ protected IEnumerable ValidateNumberOfBlocks(BlockEditorData max)
{
yield return new ValidationResult(
- TextService.Localize(
- "validation",
- "entriesExceed",
- [max.ToString(), (numberOfBlocks - max).ToString(),]),
+ BuildErrorMessage(TextService, max, numberOfBlocks),
["value"]);
}
}
diff --git a/src/Umbraco.Infrastructure/PropertyEditors/SingleBlockConfigurationEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/SingleBlockConfigurationEditor.cs
new file mode 100644
index 000000000000..993d263e96bf
--- /dev/null
+++ b/src/Umbraco.Infrastructure/PropertyEditors/SingleBlockConfigurationEditor.cs
@@ -0,0 +1,12 @@
+using Umbraco.Cms.Core.IO;
+using Umbraco.Cms.Core.PropertyEditors;
+
+namespace Umbraco.Cms.Infrastructure.PropertyEditors;
+
+internal sealed class SingleBlockConfigurationEditor : ConfigurationEditor
+{
+ public SingleBlockConfigurationEditor(IIOHelper ioHelper)
+ : base(ioHelper)
+ {
+ }
+}
diff --git a/src/Umbraco.Infrastructure/PropertyEditors/SingleBlockPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/SingleBlockPropertyEditor.cs
new file mode 100644
index 000000000000..40132b3af00b
--- /dev/null
+++ b/src/Umbraco.Infrastructure/PropertyEditors/SingleBlockPropertyEditor.cs
@@ -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;
+
+///
+/// Represents a single block property editor.
+///
+[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;
+
+ ///
+ public override bool SupportsConfigurableElements => true;
+
+ ///
+ /// Instantiates a new for use with the single block editor property value editor.
+ ///
+ /// A new instance of .
+ protected virtual BlockEditorDataConverter CreateBlockEditorDataConverter()
+ => new SingleBlockEditorDataConverter(_jsonSerializer);
+
+ ///
+ protected override IDataValueEditor CreateValueEditor() =>
+ DataValueEditorFactory.Create(Attribute!, CreateBlockEditorDataConverter());
+
+ ///
+ public override bool CanMergePartialPropertyValues(IPropertyType propertyType) => propertyType.VariesByCulture() is false;
+
+ ///
+ public override object? MergePartialPropertyValueForCulture(object? sourceValue, object? targetValue, string? culture)
+ {
+ var valueEditor = (SingleBlockEditorPropertyValueEditor)GetValueEditor();
+ return valueEditor.MergePartialPropertyValueForCulture(sourceValue, targetValue, culture);
+ }
+
+ ///
+ protected override IConfigurationEditor CreateConfigurationEditor() =>
+ new SingleBlockConfigurationEditor(_ioHelper);
+
+ ///
+ public override object? MergeVariantInvariantPropertyValue(
+ object? sourceValue,
+ object? targetValue,
+ bool canUpdateInvariantData,
+ HashSet allowedCultures)
+ {
+ var valueEditor = (SingleBlockEditorPropertyValueEditor)GetValueEditor();
+ return valueEditor.MergeVariantInvariantPropertyValue(sourceValue, targetValue, canUpdateInvariantData, allowedCultures);
+ }
+
+ internal sealed class SingleBlockEditorPropertyValueEditor : BlockEditorPropertyValueEditor
+ {
+ public SingleBlockEditorPropertyValueEditor(
+ DataEditorAttribute attribute,
+ BlockEditorDataConverter blockEditorDataConverter,
+ PropertyEditorCollection propertyEditors,
+ DataValueReferenceFactoryCollection dataValueReferenceFactories,
+ IDataTypeConfigurationCache dataTypeConfigurationCache,
+ IShortStringHelper shortStringHelper,
+ IJsonSerializer jsonSerializer,
+ BlockEditorVarianceHandler blockEditorVarianceHandler,
+ ILanguageService languageService,
+ IIOHelper ioHelper,
+ IBlockEditorElementTypeCache elementTypeCache,
+ ILogger logger,
+ ILocalizedTextService textService,
+ IPropertyValidationService propertyValidationService)
+ : base(propertyEditors, dataValueReferenceFactories, dataTypeConfigurationCache, shortStringHelper, jsonSerializer, blockEditorVarianceHandler, languageService, ioHelper, attribute)
+ {
+ BlockEditorValues = new BlockEditorValues(blockEditorDataConverter, elementTypeCache, logger);
+ Validators.Add(new BlockEditorValidator(propertyValidationService, BlockEditorValues, elementTypeCache));
+ Validators.Add(new SingleBlockValidator(BlockEditorValues, textService));
+ }
+
+ protected override SingleBlockValue CreateWithLayout(IEnumerable layout) =>
+ new(layout.Single());
+
+ ///
+ public override IEnumerable ConfiguredElementTypeKeys()
+ {
+ var configuration = ConfigurationObject as SingleBlockConfiguration;
+ return configuration?.Blocks.SelectMany(ConfiguredElementTypeKeys) ?? Enumerable.Empty();
+ }
+
+ ///
+ /// Validates whether the block editor holds a single value
+ ///
+ internal sealed class SingleBlockValidator : BlockEditorMinMaxValidatorBase
+ {
+ private readonly BlockEditorValues _blockEditorValues;
+
+ public SingleBlockValidator(BlockEditorValues blockEditorValues, ILocalizedTextService textService)
+ : base(textService) =>
+ _blockEditorValues = blockEditorValues;
+
+ public override IEnumerable Validate(object? value, string? valueType, object? dataTypeConfiguration, PropertyValidationContext validationContext)
+ {
+ BlockEditorData? blockEditorData = _blockEditorValues.DeserializeAndClean(value);
+
+ return ValidateNumberOfBlocks(blockEditorData, 0, 1);
+ }
+ }
+ }
+}
diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/SingleBlockPropertyValueConverter.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/SingleBlockPropertyValueConverter.cs
new file mode 100644
index 000000000000..8b287b9ec714
--- /dev/null
+++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/SingleBlockPropertyValueConverter.cs
@@ -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;
+ }
+
+ ///
+ public override bool IsConverter(IPublishedPropertyType propertyType)
+ => propertyType.EditorAlias.InvariantEquals(Constants.PropertyEditors.Aliases.SingleBlock);
+
+ ///
+ public override Type GetPropertyValueType(IPublishedPropertyType propertyType) => typeof( BlockListItem);
+
+ ///
+ public override PropertyCacheLevel GetPropertyCacheLevel(IPublishedPropertyType propertyType)
+ => PropertyCacheLevel.Element;
+
+ ///
+ public override object? ConvertSourceToIntermediate(IPublishedElement owner, IPublishedPropertyType propertyType, object? source, bool preview)
+ => source?.ToString();
+
+ ///
+ 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(
+ $"ConvertPropertyToBlockList ({propertyType.DataType.Id})"))
+ {
+ return ConvertIntermediateToBlockListItem(owner, propertyType, referenceCacheLevel, inter, preview);
+ }
+ }
+
+ ///
+ public PropertyCacheLevel GetDeliveryApiPropertyCacheLevel(IPublishedPropertyType propertyType) => GetPropertyCacheLevel(propertyType);
+
+ ///
+ public PropertyCacheLevel GetDeliveryApiPropertyCacheLevelForExpansion(IPublishedPropertyType propertyType) => PropertyCacheLevel.Snapshot;
+
+ ///
+ public Type GetDeliveryApiPropertyValueType(IPublishedPropertyType propertyType)
+ => typeof(ApiBlockItem);
+
+ ///
+ 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(
+ $"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();
+ if (configuration is null)
+ {
+ return null;
+ }
+
+
+ var creator = new SingleBlockPropertyValueCreator(_blockConverter, _variationContextAccessor, _blockEditorVarianceHandler, _jsonSerializer, _constructorCache);
+ return creator.CreateBlockModel(owner, referenceCacheLevel, intermediateBlockModelValue, preview, configuration.Blocks);
+ }
+ }
+}
diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/SingleBlockPropertyValueCreator.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/SingleBlockPropertyValueCreator.cs
new file mode 100644
index 000000000000..26cc90f3cc6b
--- /dev/null
+++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/SingleBlockPropertyValueCreator.cs
@@ -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
+{
+ 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 items) => new BlockListModel(items);
+
+ BlockListItem? blockModel = CreateBlockModel(owner, referenceCacheLevel, intermediateBlockModelValue, preview, blockConfigurations, CreateEmptyModel, CreateModel).SingleOrDefault();
+
+ return blockModel;
+ }
+
+ protected override BlockEditorDataConverter CreateBlockEditorDataConverter() => new SingleBlockEditorDataConverter(_jsonSerializer);
+
+ protected override BlockItemActivator CreateBlockItemActivator() => new BlockListItemActivator(BlockEditorConverter, _constructorCache);
+
+ private sealed class BlockListItemActivator : BlockItemActivator
+ {
+ public BlockListItemActivator(BlockEditorConverter blockConverter, BlockListPropertyValueConstructorCache constructorCache)
+ : base(blockConverter, constructorCache)
+ {
+ }
+
+ protected override Type GenericItemType => typeof(BlockListItem<,>);
+ }
+}
diff --git a/src/Umbraco.Web.Common/Extensions/SingleBlockTemplateExtensions.cs b/src/Umbraco.Web.Common/Extensions/SingleBlockTemplateExtensions.cs
new file mode 100644
index 000000000000..ffb4bd3d93a7
--- /dev/null
+++ b/src/Umbraco.Web.Common/Extensions/SingleBlockTemplateExtensions.cs
@@ -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 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 GetBlockHtmlAsync(this IHtmlHelper html, IPublishedProperty property, string template = DefaultTemplate)
+ => await GetBlockHtmlAsync(html, property.GetValue() as BlockListItem, template);
+
+ public static async Task GetBlockHtmlAsync(this IHtmlHelper html, IPublishedContent contentItem, string propertyAlias)
+ => await GetBlockHtmlAsync(html, contentItem, propertyAlias, DefaultTemplate);
+
+ public static async Task 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();
+ 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;
+ }
+}
diff --git a/src/Umbraco.Web.UI/Views/Partials/singleblock/default.cshtml b/src/Umbraco.Web.UI/Views/Partials/singleblock/default.cshtml
new file mode 100644
index 000000000000..237f9b95ebeb
--- /dev/null
+++ b/src/Umbraco.Web.UI/Views/Partials/singleblock/default.cshtml
@@ -0,0 +1,9 @@
+@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage
+@{
+ 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)
diff --git a/templates/Umbraco.Templates.csproj b/templates/Umbraco.Templates.csproj
index 7527f011d434..a8ea810c1fc7 100644
--- a/templates/Umbraco.Templates.csproj
+++ b/templates/Umbraco.Templates.csproj
@@ -56,6 +56,10 @@
UmbracoProject\Views\Partials\blockgrid\%(RecursiveDir)%(Filename)%(Extension)
UmbracoProject\Views\Partials\blockgrid
+
+
+ UmbracoProject\Views\Partials\singleblock\%(RecursiveDir)%(Filename)%(Extension)
+ UmbracoProject\Views\Partials\singleblock
UmbracoProject\Views\_ViewImports.cshtml
diff --git a/tests/Umbraco.Tests.Common/Builders/DataTypeBuilder.cs b/tests/Umbraco.Tests.Common/Builders/DataTypeBuilder.cs
index 7bcc7f430e03..071e4290d1ee 100644
--- a/tests/Umbraco.Tests.Common/Builders/DataTypeBuilder.cs
+++ b/tests/Umbraco.Tests.Common/Builders/DataTypeBuilder.cs
@@ -5,6 +5,7 @@
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 static DataType CreateSimpleElementDataType(
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 });
diff --git a/tests/Umbraco.Tests.Common/Factories/ContentEditingModelFactory.cs b/tests/Umbraco.Tests.Common/Factories/ContentEditingModelFactory.cs
new file mode 100644
index 000000000000..95963551b63e
--- /dev/null
+++ b/tests/Umbraco.Tests.Common/Factories/ContentEditingModelFactory.cs
@@ -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 CreateFromAsync(IContent content);
+}
+
+public class ContentEditingModelFactory : IContentEditingModelFactory
+{
+ private readonly ITemplateService _templateService;
+
+ public ContentEditingModelFactory(ITemplateService templateService)
+ {
+ _templateService = templateService;
+ }
+
+ public async Task 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();
+ var variants = new List();
+
+ MapProperties(content, properties);
+
+ MapNames(content, properties, variants);
+
+ model.Properties = properties;
+ model.Variants = variants;
+ return model;
+ }
+ }
+
+ private static void MapNames(IContent content, List properties, List 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 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,
+ });
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/tests/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTest.cs b/tests/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTest.cs
index d2293806c32a..f880a3c068b0 100644
--- a/tests/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTest.cs
+++ b/tests/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTest.cs
@@ -185,6 +185,9 @@ protected void ConfigureServices(IServiceCollection services)
CustomTestSetup(builder);
ExecuteBuilderAttributes(builder);
+ // custom helper services that might be moved out of tests eventually to benefit the community
+ services.AddSingleton();
+
builder.Build();
}
diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/SingleBlockPropertyEditorTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/SingleBlockPropertyEditorTests.cs
new file mode 100644
index 000000000000..696ced2ff35c
--- /dev/null
+++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/SingleBlockPropertyEditorTests.cs
@@ -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();
+
+ private IContentService ContentService => GetRequiredService();
+
+ private IDataTypeService DataTypeService => GetRequiredService();
+
+ private IJsonSerializer JsonSerializer => GetRequiredService();
+
+ private IConfigurationEditorJsonSerializer ConfigurationEditorJsonSerializer => GetRequiredService();
+
+ private PropertyEditorCollection PropertyEditorCollection => GetRequiredService();
+
+ private ILanguageService LanguageService => GetRequiredService();
+
+ private IContentValidationService ContentValidationService => GetRequiredService();
+
+ private IContentEditingModelFactory ContentEditingModelFactory => GetRequiredService();
+
+ private ILocalizedTextService LocalizedTextService => GetRequiredService();
+
+ 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>
+ {
+ {
+ 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>
+ {
+ {
+ 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());
+ }
+
+ ///
+ /// There should be some validation when publishing through the contentEditingService
+ ///
+ [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>
+ {
+ {
+ 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>
+ {
+ {
+ 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>
+ {
+ {
+ 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>
+ {
+ {
+ 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>
+ {
+ {
+ 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>
+ {
+ {
+ 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 CreateSingleBlockContentTypePage(IContentType[] allowedElementTypes)
+ {
+ var singleBlockDataType = new DataType(PropertyEditorCollection[Constants.PropertyEditors.Aliases.SingleBlock], ConfigurationEditorJsonSerializer)
+ {
+ ConfigurationData = new Dictionary
+ {
+ {
+ "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 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;
+ }
+}
diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Composing/TypeLoaderTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Composing/TypeLoaderTests.cs
index 068bf44db66f..4987432aea70 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 void Resolves_Types()
public void GetDataEditors()
{
var types = _typeLoader.GetDataEditors();
- Assert.AreEqual(36, types.Count());
+ Assert.AreEqual(37, types.Count());
}
///