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()); } ///