From 55506bac3ac143140374e2071bef3f4d43246fb2 Mon Sep 17 00:00:00 2001 From: Laura Neto <12862535+lauraneto@users.noreply.github.com> Date: Tue, 24 Jun 2025 13:43:34 +0200 Subject: [PATCH] Simplify creating content from a blueprint programmatically (#19528) * Rename `IContentService.CreateContentFromBlueprint` to `CreateBlueprintFromContent` In reality, this method is used by the core to create a blueprint from content, and not the other way around, which doesn't need new ids. This was causing confusion, so the old name has been marked as deprecated in favor of the new name. If developers want to create content from blueprints they should use `IContentBlueprintEditingService.GetScaffoldedAsync()` instead, which is what is used by the management api. * Added integration tests to verify that new block ids are generated when creating content from a blueprint * Return copy of the blueprint in `ContentBlueprintEditingService.GetScaffoldedAsync` instead of the blueprint itself * Update CreateContentFromBlueprint xml docs to mention both replacement methods * Fix tests for rich text blocks * Small re-organization * Adjusted tests that were still referencing `ContentService.CreateContentFromBlueprint` * Add default implementation to new CreateBlueprintFromContent method * Update tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentBlueprintEditingServiceTests.GetScaffold.cs Co-authored-by: Andy Butland --------- Co-authored-by: Andy Butland --- .../ContentBlueprintEditingService.cs | 8 +- src/Umbraco.Core/Services/ContentService.cs | 27 ++- src/Umbraco.Core/Services/IContentService.cs | 12 +- .../Builders/DataTypeBuilder.cs | 101 +++++++- .../UmbracoIntegrationTestWithContent.cs | 2 + .../Services/ContentServiceTests.cs | 29 ++- .../Services/ElementSwitchValidatorTests.cs | 100 +------- .../Services/TelemetryProviderTests.cs | 8 +- ...lueprintEditingServiceTests.GetScaffold.cs | 223 +++++++++++++++++- .../ContentBlueprintEditingServiceTests.cs | 12 +- 10 files changed, 386 insertions(+), 136 deletions(-) diff --git a/src/Umbraco.Core/Services/ContentBlueprintEditingService.cs b/src/Umbraco.Core/Services/ContentBlueprintEditingService.cs index 7af5171bc9..5e8357e453 100644 --- a/src/Umbraco.Core/Services/ContentBlueprintEditingService.cs +++ b/src/Umbraco.Core/Services/ContentBlueprintEditingService.cs @@ -45,11 +45,13 @@ internal sealed class ContentBlueprintEditingService return Task.FromResult(null); } + IContent scaffold = blueprint.DeepCloneWithResetIdentities(); + using ICoreScope scope = CoreScopeProvider.CreateCoreScope(); - scope.Notifications.Publish(new ContentScaffoldedNotification(blueprint, blueprint, Constants.System.Root, new EventMessages())); + scope.Notifications.Publish(new ContentScaffoldedNotification(blueprint, scaffold, Constants.System.Root, new EventMessages())); scope.Complete(); - return Task.FromResult(blueprint); + return Task.FromResult(scaffold); } public async Task?, ContentEditingOperationStatus>> GetPagedByContentTypeAsync(Guid contentTypeKey, int skip, int take) @@ -112,7 +114,7 @@ internal sealed class ContentBlueprintEditingService // Create Blueprint var currentUserId = await GetUserIdAsync(userKey); - IContent blueprint = ContentService.CreateContentFromBlueprint(content, name, currentUserId); + IContent blueprint = ContentService.CreateBlueprintFromContent(content, name, currentUserId); if (key.HasValue) { diff --git a/src/Umbraco.Core/Services/ContentService.cs b/src/Umbraco.Core/Services/ContentService.cs index f246b9c3f0..34ac8db8ff 100644 --- a/src/Umbraco.Core/Services/ContentService.cs +++ b/src/Umbraco.Core/Services/ContentService.cs @@ -3654,12 +3654,12 @@ public class ContentService : RepositoryService, IContentService private static readonly string?[] ArrayOfOneNullString = { null }; - public IContent CreateContentFromBlueprint(IContent blueprint, string name, int userId = Constants.Security.SuperUserId) + public IContent CreateBlueprintFromContent( + IContent blueprint, + string name, + int userId = Constants.Security.SuperUserId) { - if (blueprint == null) - { - throw new ArgumentNullException(nameof(blueprint)); - } + ArgumentNullException.ThrowIfNull(blueprint); IContentType contentType = GetContentType(blueprint.ContentType.Alias); var content = new Content(name, -1, contentType); @@ -3672,15 +3672,13 @@ public class ContentService : RepositoryService, IContentService if (blueprint.CultureInfos?.Count > 0) { cultures = blueprint.CultureInfos.Values.Select(x => x.Culture); - using (ICoreScope scope = ScopeProvider.CreateCoreScope()) + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + if (blueprint.CultureInfos.TryGetValue(_languageRepository.GetDefaultIsoCode(), out ContentCultureInfos defaultCulture)) { - if (blueprint.CultureInfos.TryGetValue(_languageRepository.GetDefaultIsoCode(), out ContentCultureInfos defaultCulture)) - { - defaultCulture.Name = name; - } - - scope.Complete(); + defaultCulture.Name = name; } + + scope.Complete(); } DateTime now = DateTime.Now; @@ -3701,6 +3699,11 @@ public class ContentService : RepositoryService, IContentService return content; } + /// + [Obsolete("Use IContentBlueprintEditingService.GetScaffoldedAsync() instead. Scheduled for removal in V18.")] + public IContent CreateContentFromBlueprint(IContent blueprint, string name, int userId = Constants.Security.SuperUserId) + => CreateBlueprintFromContent(blueprint, name, userId); + public IEnumerable GetBlueprintsForContentTypes(params int[] contentTypeId) { using (ScopeProvider.CreateCoreScope(autoComplete: true)) diff --git a/src/Umbraco.Core/Services/IContentService.cs b/src/Umbraco.Core/Services/IContentService.cs index ea14e6771a..423f157874 100644 --- a/src/Umbraco.Core/Services/IContentService.cs +++ b/src/Umbraco.Core/Services/IContentService.cs @@ -55,8 +55,18 @@ public interface IContentService : IContentServiceBase void DeleteBlueprint(IContent content, int userId = Constants.Security.SuperUserId); /// - /// Creates a new content item from a blueprint. + /// Creates a blueprint from a content item. /// + // TODO: Remove the default implementation when CreateContentFromBlueprint is removed. + IContent CreateBlueprintFromContent(IContent blueprint, string name, int userId = Constants.Security.SuperUserId) + => throw new NotImplementedException(); + + /// + /// (Deprecated) Creates a new content item from a blueprint. + /// + /// If creating content from a blueprint, use + /// instead. If creating a blueprint from content use instead. + [Obsolete("Use IContentBlueprintEditingService.GetScaffoldedAsync() instead. Scheduled for removal in V18.")] IContent CreateContentFromBlueprint(IContent blueprint, string name, int userId = Constants.Security.SuperUserId); /// diff --git a/tests/Umbraco.Tests.Common/Builders/DataTypeBuilder.cs b/tests/Umbraco.Tests.Common/Builders/DataTypeBuilder.cs index 9c26114e3d..9226d42474 100644 --- a/tests/Umbraco.Tests.Common/Builders/DataTypeBuilder.cs +++ b/tests/Umbraco.Tests.Common/Builders/DataTypeBuilder.cs @@ -1,9 +1,12 @@ // Copyright (c) Umbraco. // See LICENSE for more details. -using System; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.PropertyEditors; using Umbraco.Cms.Infrastructure.Serialization; +using Umbraco.Cms.Tests.Common.Builders.Extensions; using Umbraco.Cms.Tests.Common.Builders.Interfaces; namespace Umbraco.Cms.Tests.Common.Builders; @@ -155,4 +158,100 @@ public class DataTypeBuilder return dataType; } + + public static DataType CreateSimpleElementDataType( + IIOHelper ioHelper, + string editorAlias, + Guid elementKey, + Guid? elementSettingKey) + { + Dictionary configuration = editorAlias switch + { + Constants.PropertyEditors.Aliases.BlockGrid => GetBlockGridBaseConfiguration(), + Constants.PropertyEditors.Aliases.RichText => GetRteBaseConfiguration(), + _ => [], + }; + + SetBlockConfiguration( + configuration, + elementKey, + elementSettingKey, + editorAlias == Constants.PropertyEditors.Aliases.BlockGrid ? true : null); + + + var dataTypeBuilder = new DataTypeBuilder() + .WithId(0) + .WithDatabaseType(ValueStorageType.Nvarchar) + .AddEditor() + .WithAlias(editorAlias); + + switch (editorAlias) + { + case Constants.PropertyEditors.Aliases.BlockGrid: + dataTypeBuilder.WithConfigurationEditor( + new BlockGridConfigurationEditor(ioHelper) { DefaultConfiguration = configuration }); + break; + case Constants.PropertyEditors.Aliases.BlockList: + dataTypeBuilder.WithConfigurationEditor( + new BlockListConfigurationEditor(ioHelper) { DefaultConfiguration = configuration }); + break; + case Constants.PropertyEditors.Aliases.RichText: + dataTypeBuilder.WithConfigurationEditor( + new RichTextConfigurationEditor(ioHelper) { DefaultConfiguration = configuration }); + break; + } + + return dataTypeBuilder.Done().Build(); + } + + private static void SetBlockConfiguration( + Dictionary dictionary, + Guid? elementKey, + Guid? elementSettingKey, + bool? allowAtRoot) + { + if (elementKey is null) + { + return; + } + + dictionary["blocks"] = new[] { BuildBlockConfiguration(elementKey.Value, elementSettingKey, allowAtRoot) }; + } + + private static Dictionary GetBlockGridBaseConfiguration() => new() { ["gridColumns"] = 12 }; + + private static Dictionary GetRteBaseConfiguration() + { + var dictionary = new Dictionary + { + ["maxImageSize"] = 500, + ["mode"] = "Classic", + ["toolbar"] = new[] + { + "styles", "bold", "italic", "alignleft", "aligncenter", "alignright", "bullist", "numlist", "outdent", + "indent", "sourcecode", "link", "umbmediapicker", "umbembeddialog" + }, + }; + return dictionary; + } + + private static Dictionary BuildBlockConfiguration( + Guid? elementKey, + Guid? elementSettingKey, + bool? allowAtRoot) + { + var dictionary = new Dictionary(); + if (allowAtRoot is not null) + { + dictionary.Add("allowAtRoot", allowAtRoot.Value); + } + + dictionary.Add("contentElementTypeKey", elementKey.ToString()); + if (elementSettingKey is not null) + { + dictionary.Add("settingsElementTypeKey", elementSettingKey.ToString()); + } + + return dictionary; + } } diff --git a/tests/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTestWithContent.cs b/tests/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTestWithContent.cs index 70957f4c83..42aba90eb2 100644 --- a/tests/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTestWithContent.cs +++ b/tests/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTestWithContent.cs @@ -19,6 +19,8 @@ public abstract class UmbracoIntegrationTestWithContent : UmbracoIntegrationTest protected IContentTypeService ContentTypeService => GetRequiredService(); + protected IDataTypeService DataTypeService => GetRequiredService(); + protected IFileService FileService => GetRequiredService(); protected ContentService ContentService => (ContentService)GetRequiredService(); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentServiceTests.cs index 242be71557..2c1ea1e278 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentServiceTests.cs @@ -130,7 +130,7 @@ internal sealed class ContentServiceTests : UmbracoIntegrationTestWithContent } [Test] - public void Create_Content_From_Blueprint() + public void Create_Blueprint_From_Content() { using (var scope = ScopeProvider.CreateScope(autoComplete: true)) { @@ -140,22 +140,21 @@ internal sealed class ContentServiceTests : UmbracoIntegrationTestWithContent var contentType = ContentTypeBuilder.CreateTextPageContentType(defaultTemplateId: template.Id); ContentTypeService.Save(contentType); - var blueprint = ContentBuilder.CreateTextpageContent(contentType, "hello", Constants.System.Root); - blueprint.SetValue("title", "blueprint 1"); - blueprint.SetValue("bodyText", "blueprint 2"); - blueprint.SetValue("keywords", "blueprint 3"); - blueprint.SetValue("description", "blueprint 4"); + var originalPage = ContentBuilder.CreateTextpageContent(contentType, "hello", Constants.System.Root); + originalPage.SetValue("title", "blueprint 1"); + originalPage.SetValue("bodyText", "blueprint 2"); + originalPage.SetValue("keywords", "blueprint 3"); + originalPage.SetValue("description", "blueprint 4"); + ContentService.Save(originalPage); - ContentService.SaveBlueprint(blueprint); + var fromContent = ContentService.CreateBlueprintFromContent(originalPage, "hello world"); + ContentService.SaveBlueprint(fromContent); - var fromBlueprint = ContentService.CreateContentFromBlueprint(blueprint, "hello world"); - ContentService.Save(fromBlueprint); - - Assert.IsTrue(fromBlueprint.HasIdentity); - Assert.AreEqual("blueprint 1", fromBlueprint.Properties["title"].GetValue()); - Assert.AreEqual("blueprint 2", fromBlueprint.Properties["bodyText"].GetValue()); - Assert.AreEqual("blueprint 3", fromBlueprint.Properties["keywords"].GetValue()); - Assert.AreEqual("blueprint 4", fromBlueprint.Properties["description"].GetValue()); + Assert.IsTrue(fromContent.HasIdentity); + Assert.AreEqual("blueprint 1", fromContent.Properties["title"]?.GetValue()); + Assert.AreEqual("blueprint 2", fromContent.Properties["bodyText"]?.GetValue()); + Assert.AreEqual("blueprint 3", fromContent.Properties["keywords"]?.GetValue()); + Assert.AreEqual("blueprint 4", fromContent.Properties["description"]?.GetValue()); } } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ElementSwitchValidatorTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ElementSwitchValidatorTests.cs index 916ae02cea..794dc81efc 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ElementSwitchValidatorTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ElementSwitchValidatorTests.cs @@ -286,104 +286,12 @@ internal sealed class ElementSwitchValidatorTests : UmbracoIntegrationTest Guid elementKey, Guid? elementSettingKey) { - Dictionary configuration; - switch (editorAlias) - { - case Constants.PropertyEditors.Aliases.BlockGrid: - configuration = GetBlockGridBaseConfiguration(); - break; - case Constants.PropertyEditors.Aliases.RichText: - configuration = GetRteBaseConfiguration(); - break; - default: - configuration = new Dictionary(); - break; - } - - SetBlockConfiguration( - configuration, + var dataType = DataTypeBuilder.CreateSimpleElementDataType( + IOHelper, + editorAlias, elementKey, - elementSettingKey, - editorAlias == Constants.PropertyEditors.Aliases.BlockGrid ? true : null); - - - var dataTypeBuilder = new DataTypeBuilder() - .WithId(0) - .WithDatabaseType(ValueStorageType.Nvarchar) - .AddEditor() - .WithAlias(editorAlias); - - switch (editorAlias) - { - case Constants.PropertyEditors.Aliases.BlockGrid: - dataTypeBuilder.WithConfigurationEditor( - new BlockGridConfigurationEditor(IOHelper) { DefaultConfiguration = configuration }); - break; - case Constants.PropertyEditors.Aliases.BlockList: - dataTypeBuilder.WithConfigurationEditor( - new BlockListConfigurationEditor(IOHelper) { DefaultConfiguration = configuration }); - break; - case Constants.PropertyEditors.Aliases.RichText: - dataTypeBuilder.WithConfigurationEditor( - new RichTextConfigurationEditor(IOHelper) { DefaultConfiguration = configuration }); - break; - } - - var dataType = dataTypeBuilder.Done() - .Build(); + elementSettingKey); await DataTypeService.CreateAsync(dataType, Constants.Security.SuperUserKey); } - - private void SetBlockConfiguration( - Dictionary dictionary, - Guid? elementKey, - Guid? elementSettingKey, - bool? allowAtRoot) - { - if (elementKey is null) - { - return; - } - - dictionary["blocks"] = new[] { BuildBlockConfiguration(elementKey.Value, elementSettingKey, allowAtRoot) }; - } - - private Dictionary GetBlockGridBaseConfiguration() - => new Dictionary { ["gridColumns"] = 12 }; - - private Dictionary GetRteBaseConfiguration() - { - var dictionary = new Dictionary - { - ["maxImageSize"] = 500, - ["mode"] = "Classic", - ["toolbar"] = new[] - { - "styles", "bold", "italic", "alignleft", "aligncenter", "alignright", "bullist", "numlist", - "outdent", "indent", "sourcecode", "link", "umbmediapicker", "umbembeddialog" - }, - }; - return dictionary; - } - - private Dictionary BuildBlockConfiguration( - Guid? elementKey, - Guid? elementSettingKey, - bool? allowAtRoot) - { - var dictionary = new Dictionary(); - if (allowAtRoot is not null) - { - dictionary.Add("allowAtRoot", allowAtRoot.Value); - } - - dictionary.Add("contentElementTypeKey", elementKey.ToString()); - if (elementSettingKey is not null) - { - dictionary.Add("settingsElementTypeKey", elementSettingKey.ToString()); - } - - return dictionary; - } } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/TelemetryProviderTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/TelemetryProviderTests.cs index 210d66f28a..c5d6548677 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/TelemetryProviderTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/TelemetryProviderTests.cs @@ -54,6 +54,8 @@ internal sealed class TelemetryProviderTests : UmbracoIntegrationTest private IMediaTypeService MediaTypeService => GetRequiredService(); + private IContentBlueprintEditingService ContentBlueprintEditingService => GetRequiredService(); + private readonly LanguageBuilder _languageBuilder = new(); private readonly UserBuilder _userBuilder = new(); @@ -99,7 +101,7 @@ internal sealed class TelemetryProviderTests : UmbracoIntegrationTest } [Test] - public void SectionService_Can_Get_Allowed_Sections_For_User() + public async Task SectionService_Can_Get_Allowed_Sections_For_User() { // Arrange var template = TemplateBuilder.CreateTextPageTemplate(); @@ -116,7 +118,9 @@ internal sealed class TelemetryProviderTests : UmbracoIntegrationTest ContentService.SaveBlueprint(blueprint); - var fromBlueprint = ContentService.CreateContentFromBlueprint(blueprint, "My test content"); + var fromBlueprint = await ContentBlueprintEditingService.GetScaffoldedAsync(blueprint.Key); + Assert.IsNotNull(fromBlueprint); + fromBlueprint.Name = "My test content"; ContentService.Save(fromBlueprint); IEnumerable result = null; diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentBlueprintEditingServiceTests.GetScaffold.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentBlueprintEditingServiceTests.GetScaffold.cs index 56ac93ba60..32b92a20b2 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentBlueprintEditingServiceTests.GetScaffold.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentBlueprintEditingServiceTests.GetScaffold.cs @@ -1,14 +1,26 @@ using NUnit.Framework; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Blocks; +using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Builders.Extensions; using Umbraco.Cms.Tests.Integration.Attributes; +using IContent = Umbraco.Cms.Core.Models.IContent; namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; public partial class ContentBlueprintEditingServiceTests { public static void AddScaffoldedNotificationHandler(IUmbracoBuilder builder) - => builder.AddNotificationHandler(); + => builder + .AddNotificationHandler() + .AddNotificationHandler() + .AddNotificationHandler() + .AddNotificationHandler(); [TestCase(true)] [TestCase(false)] @@ -28,7 +40,15 @@ public partial class ContentBlueprintEditingServiceTests }; var result = await ContentBlueprintEditingService.GetScaffoldedAsync(blueprint.Key); Assert.IsNotNull(result); - Assert.AreEqual(blueprint.Key, result.Key); + Assert.AreNotEqual(blueprint.Key, result.Key); + Assert.AreEqual( + blueprint.ContentType.Key, + result.ContentType.Key, + "The content type of the scaffolded content should match the original blueprint content type."); + Assert.AreEqual( + blueprint.Properties.Select(p => (p.Alias, p.PropertyType.Key)), + result.Properties.Select(p => (p.Alias, p.PropertyType.Key)), + "The properties of the scaffolded content should match the original blueprint properties."); var propertyValues = result.Properties.SelectMany(property => property.Values).ToArray(); Assert.IsNotEmpty(propertyValues); @@ -51,10 +71,209 @@ public partial class ContentBlueprintEditingServiceTests Assert.IsNull(result); } + [TestCase(false, Constants.PropertyEditors.Aliases.BlockList)] + [TestCase(false, Constants.PropertyEditors.Aliases.BlockGrid)] + [TestCase(false, Constants.PropertyEditors.Aliases.RichText)] + [TestCase(true, Constants.PropertyEditors.Aliases.BlockList)] + [TestCase(true, Constants.PropertyEditors.Aliases.BlockGrid)] + [TestCase(true, Constants.PropertyEditors.Aliases.RichText)] + [ConfigureBuilder(ActionName = nameof(AddScaffoldedNotificationHandler))] + public async Task Get_Scaffold_With_Blocks_Generates_New_Block_Ids(bool variant, string editorAlias) + { + var blueprint = await CreateBlueprintWithBlocksEditor(variant, editorAlias); + var result = await ContentBlueprintEditingService.GetScaffoldedAsync(blueprint.Content.Key); + Assert.IsNotNull(result); + Assert.AreNotEqual(blueprint.Content.Key, result.Key); + + List newKeys = []; + var newInvariantBlocklist = GetBlockValue("invariantBlocks"); + newKeys.AddRange( + newInvariantBlocklist.Layout + .SelectMany(x => x.Value) + .SelectMany(v => new List { v.ContentKey, v.SettingsKey!.Value })); + + if (variant) + { + foreach (var culture in result.AvailableCultures) + { + var newVariantBlocklist = GetBlockValue("blocks", culture); + newKeys.AddRange( + newVariantBlocklist.Layout + .SelectMany(x => x.Value) + .SelectMany(v => new List { v.ContentKey, v.SettingsKey!.Value })); + } + } + + foreach (var newKey in newKeys) + { + Assert.IsFalse(blueprint.BlockKeys.Contains(newKey), "The blocks in a content item generated from a template should have new keys."); + } + + return; + + BlockValue GetBlockValue(string propertyAlias, string? culture = null) + { + return editorAlias switch + { + Constants.PropertyEditors.Aliases.BlockList => JsonSerializer.Deserialize(result.GetValue(propertyAlias, culture)), + Constants.PropertyEditors.Aliases.BlockGrid => JsonSerializer.Deserialize(result.GetValue(propertyAlias, culture)), + Constants.PropertyEditors.Aliases.RichText => JsonSerializer.Deserialize(result.GetValue(propertyAlias, culture)).Blocks!, + _ => throw new NotSupportedException($"Editor alias '{editorAlias}' is not supported for block blueprints."), + }; + } + } + public class ContentScaffoldedNotificationHandler : INotificationHandler { public static Action? ContentScaffolded { get; set; } public void Handle(ContentScaffoldedNotification notification) => ContentScaffolded?.Invoke(notification); } + + private async Task<(IContent Content, List BlockKeys)> CreateBlueprintWithBlocksEditor(bool variant, string editorAlias) + { + var contentType = variant ? await CreateVariantContentType() : CreateInvariantContentType(); + + // Create element type + var elementContentType = new ContentTypeBuilder() + .WithAlias("elementType") + .WithName("Element") + .WithIsElement(true) + .Build(); + await ContentTypeService.CreateAsync(elementContentType, Constants.Security.SuperUserKey); + + // Create settings element type + var settingsContentType = new ContentTypeBuilder() + .WithAlias("settingsType") + .WithName("Settings") + .WithIsElement(true) + .Build(); + await ContentTypeService.CreateAsync(settingsContentType, Constants.Security.SuperUserKey); + + // Create blocks datatype using the created elements + var dataType = DataTypeBuilder.CreateSimpleElementDataType(IOHelper, editorAlias, elementContentType.Key, settingsContentType.Key); + var dataTypeAttempt = await DataTypeService.CreateAsync(dataType, Constants.Security.SuperUserKey); + Assert.True(dataTypeAttempt.Success, $"Failed to create data type: {dataTypeAttempt.Exception?.Message}"); + + // Create new blocks property types + var invariantPropertyType = new PropertyTypeBuilder(new ContentTypeBuilder()) + .WithPropertyEditorAlias(editorAlias) + .WithValueStorageType(ValueStorageType.Ntext) + .WithAlias("invariantBlocks") + .WithName("Invariant Blocks") + .WithDataTypeId(dataType.Id) + .WithVariations(ContentVariation.Nothing) + .Build(); + contentType.AddPropertyType(invariantPropertyType); + + if (contentType.VariesByCulture()) + { + var propertyType = new PropertyTypeBuilder(new ContentTypeBuilder()) + .WithPropertyEditorAlias(editorAlias) + .WithValueStorageType(ValueStorageType.Ntext) + .WithAlias("blocks") + .WithName("Blocks") + .WithDataTypeId(dataType.Id) + .WithVariations(contentType.Variations) + .Build(); + contentType.AddPropertyType(propertyType); + } + + // Update the content type with the new blocks property type + await ContentTypeService.UpdateAsync(contentType, Constants.Security.SuperUserKey); + + string?[] cultures = contentType.VariesByCulture() + ? [null, "en-US", "da-DK"] + : [null]; + + var createModel = new ContentBlueprintCreateModel + { + ContentTypeKey = contentType.Key, + ParentKey = Constants.System.RootKey, + Variants = cultures.Where(c => variant ? c != null : c == null).Select(c => new VariantModel { Culture = c, Name = $"Initial Blueprint {c}" }), + }; + + List allBlockKeys = []; + foreach (var culture in cultures) + { + var (blockValue, blockKeys) = CreateBlockValue(editorAlias, elementContentType, settingsContentType); + createModel.Properties = createModel.Properties.Append( + new PropertyValueModel + { + Alias = culture == null ? "invariantBlocks" : "blocks", + Value = JsonSerializer.Serialize(blockValue), + Culture = culture, + }); + allBlockKeys.AddRange(blockKeys); + } + + var result = await ContentBlueprintEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + return (result.Result.Content, allBlockKeys); + } + + private static (object BlockValue, IEnumerable BlockKeys) CreateBlockValue( + string editorAlias, + IContentType elementContentType, + IContentType settingsContentType) + { + switch (editorAlias) + { + case Constants.PropertyEditors.Aliases.BlockList: + return CreateBlockValueOfType(editorAlias, elementContentType, settingsContentType); + case Constants.PropertyEditors.Aliases.BlockGrid: + return CreateBlockValueOfType(editorAlias, elementContentType, settingsContentType); + case Constants.PropertyEditors.Aliases.RichText: + var res = CreateBlockValueOfType(editorAlias, elementContentType, settingsContentType); + return (new RichTextEditorValue + { + Markup = string.Join(string.Empty, res.BlockKeys.Chunk(2).Select(c => $"")), + Blocks = res.BlockValue, + }, res.BlockKeys); + default: + throw new NotSupportedException($"Editor alias '{editorAlias}' is not supported for block blueprints."); + } + } + + private static (T BlockValue, IEnumerable BlockKeys) CreateBlockValueOfType( + string editorAlias, + IContentType elementContentType, + IContentType settingsContentType) + where T : BlockValue, new() + where TLayout : IBlockLayoutItem, new() + { + // Generate two pairs of Guids as a list of tuples + const int numberOfBlocks = 2; + var blockKeys = Enumerable.Range(0, numberOfBlocks) + .Select(_ => Enumerable.Range(0, 2).Select(_ => Guid.NewGuid()).ToList()) + .ToList(); + return (new T + { + Layout = new Dictionary> + { + [editorAlias] = blockKeys.Select(blockKeyGroup => + new TLayout + { + ContentKey = blockKeyGroup[0], + SettingsKey = blockKeyGroup[1], + }).OfType(), + }, + ContentData = blockKeys.Select(blockKeyGroup => new BlockItemData + { + Key = blockKeyGroup[0], + ContentTypeAlias = elementContentType.Alias, + ContentTypeKey = elementContentType.Key, + Values = [], + }) + .ToList(), + SettingsData = blockKeys.Select(blockKeyGroup => new BlockItemData + { + Key = blockKeyGroup[1], + ContentTypeAlias = settingsContentType.Alias, + ContentTypeKey = settingsContentType.Key, + Values = [], + }) + .ToList(), + }, blockKeys.SelectMany(l => l)); + } } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentBlueprintEditingServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentBlueprintEditingServiceTests.cs index c15e619409..57b59da68a 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentBlueprintEditingServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentBlueprintEditingServiceTests.cs @@ -3,6 +3,7 @@ using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Models.Entities; +using Umbraco.Cms.Core.Serialization; using Umbraco.Cms.Core.Services; namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; @@ -17,6 +18,8 @@ public partial class ContentBlueprintEditingServiceTests : ContentEditingService private IEntityService EntityService => GetRequiredService(); + private IJsonSerializer JsonSerializer => GetRequiredService(); + private async Task CreateInvariantContentBlueprint() { var contentType = CreateInvariantContentType(); @@ -75,8 +78,8 @@ public partial class ContentBlueprintEditingServiceTests : ContentEditingService Properties = [ new PropertyValueModel { Alias = "title", Value = "The title value" }, - new PropertyValueModel { Alias = "author", Value = "The author value" } - ] + new PropertyValueModel { Alias = "author", Value = "The author value" }, + ], }; return createModel; } @@ -90,11 +93,12 @@ public partial class ContentBlueprintEditingServiceTests : ContentEditingService [ new PropertyValueModel { Alias = "title", Value = "The title value updated" }, new PropertyValueModel { Alias = "author", Value = "The author value updated" } - ] + ], }; return createModel; } private IEntitySlim[] GetBlueprintChildren(Guid? containerKey) - => EntityService.GetPagedChildren(containerKey, new[] { UmbracoObjectTypes.DocumentBlueprintContainer }, UmbracoObjectTypes.DocumentBlueprint, 0, 100, out _).ToArray(); + => EntityService.GetPagedChildren(containerKey, [UmbracoObjectTypes.DocumentBlueprintContainer], UmbracoObjectTypes.DocumentBlueprint, 0, 100, out _).ToArray(); } +