From 043b39d553d82820fad5299e2ce49f11b3ab3eaa Mon Sep 17 00:00:00 2001 From: kjac Date: Sun, 30 Mar 2025 13:02:00 +0200 Subject: [PATCH] Allow for creating and editing segment variant documents --- .../Content/ContentControllerBase.cs | 4 + .../Services/ContentEditingServiceBase.cs | 37 ++- .../ContentEditingOperationStatus.cs | 1 + ...ntEditingServiceTests.CreateFromContent.cs | 4 +- .../ContentEditingServiceTests.Create.cs | 246 ++++++++++++++++++ .../ContentEditingServiceTests.Delete.cs | 4 +- ...ditingServiceTests.DeleteFromRecycleBin.cs | 4 +- .../ContentEditingServiceTests.Get.cs | 2 +- ...entEditingServiceTests.MoveToRecycleBin.cs | 4 +- .../ContentEditingServiceTests.Update.cs | 75 +++++- .../ContentEditingServiceTestsBase.cs | 59 ++++- 11 files changed, 412 insertions(+), 28 deletions(-) diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Content/ContentControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Content/ContentControllerBase.cs index e3bd948632..24ba42b2d5 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Content/ContentControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Content/ContentControllerBase.cs @@ -27,6 +27,10 @@ public abstract class ContentControllerBase : ManagementApiControllerBase .WithTitle("Content type culture variance mismatch") .WithDetail("The content type variance did not match that of the passed content data.") .Build()), + ContentEditingOperationStatus.ContentTypeSegmentVarianceMismatch => BadRequest(problemDetailsBuilder + .WithTitle("Content type segment variance mismatch") + .WithDetail("The content type variance did not match that of the passed content data.") + .Build()), ContentEditingOperationStatus.NotFound => NotFound(problemDetailsBuilder .WithTitle("The content could not be found") .Build()), diff --git a/src/Umbraco.Core/Services/ContentEditingServiceBase.cs b/src/Umbraco.Core/Services/ContentEditingServiceBase.cs index 63232f7262..96874fb677 100644 --- a/src/Umbraco.Core/Services/ContentEditingServiceBase.cs +++ b/src/Umbraco.Core/Services/ContentEditingServiceBase.cs @@ -318,13 +318,25 @@ internal abstract class ContentEditingServiceBase v.Culture is null)) + { + // varies by culture with one or more variants not bound to a culture = invalid + operationStatus = ContentEditingOperationStatus.ContentTypeCultureVarianceMismatch; + return null; + } + + if (contentType.VariesBySegment() && contentEditingModelBase.Variants.Any(v => v.Segment is null) is false) + { + // varies by segment with no default segment variants = invalid + operationStatus = ContentEditingOperationStatus.ContentTypeSegmentVarianceMismatch; + return null; } var propertyTypesByAlias = contentType.CompositionPropertyTypes.ToDictionary(pt => pt.Alias); @@ -342,7 +354,7 @@ internal abstract class ContentEditingServiceBase new { - VariesByCulture = true, + VariesByCulture = contentType.VariesByCulture(), VariesBySegment = v.Segment.IsNullOrWhiteSpace() == false, PropertyValue = vpv }))) @@ -359,7 +371,8 @@ internal abstract class ContentEditingServiceBase { IPropertyType propertyType = propertyTypesByAlias[pv.PropertyValue.Alias]; - return propertyType.VariesByCulture() != pv.VariesByCulture || propertyType.VariesBySegment() != pv.VariesBySegment; + return (propertyType.VariesByCulture() != pv.VariesByCulture) + || (propertyType.VariesBySegment() is false && pv.VariesBySegment); })) { operationStatus = ContentEditingOperationStatus.PropertyTypeNotFound; @@ -424,6 +437,12 @@ internal abstract class ContentEditingServiceBase v.Segment is null)?.Name + ?? throw new ArgumentException("Could not find the default segment variant", nameof(contentEditingModelBase)); + } else { // this should be validated already so it's OK to throw an exception here @@ -456,7 +475,7 @@ internal abstract class ContentEditingServiceBase + { + Assert.AreEqual("The Name", createdContent.Name); + Assert.AreEqual("The Invariant Title", createdContent.GetValue("invariantTitle")); + Assert.AreEqual("The Default Title", createdContent.GetValue("variantTitle", segment: null)); + Assert.AreEqual("The Seg-1 Title", createdContent.GetValue("variantTitle", segment: "seg-1")); + Assert.AreEqual("The Seg-2 Title", createdContent.GetValue("variantTitle", segment: "seg-2")); + }); + } + } + + [Test] + public async Task Can_Create_Culture_And_Segment_Variant() + { + var contentType = await CreateVariantContentType(ContentVariation.CultureAndSegment); + + var createModel = new ContentCreateModel + { + ContentTypeKey = contentType.Key, + ParentKey = Constants.System.RootKey, + InvariantProperties = + [ + new () { Alias = "invariantTitle", Value = "The Invariant Title" } + ], + Variants = + [ + new () + { + Name = "The English Name", + Culture = "en-US", + Segment = null, + Properties = + [ + new () { Alias = "variantTitle", Value = "The Default Title in English" } + ] + }, + new () + { + Name = "The English Name", + Culture = "en-US", + Segment = "seg-1", + Properties = + [ + new () { Alias = "variantTitle", Value = "The Seg-1 Title in English" } + ] + }, + new () + { + Name = "The English Name", + Culture = "en-US", + Segment = "seg-2", + Properties = + [ + new () { Alias = "variantTitle", Value = "The Seg-2 Title in English" } + ] + }, + new () + { + Name = "The Danish Name", + Culture = "da-DK", + Segment = null, + Properties = + [ + new () { Alias = "variantTitle", Value = "The Default Title in Danish" } + ] + }, + new () + { + Name = "The Danish Name", + Culture = "da-DK", + Segment = "seg-1", + Properties = + [ + new () { Alias = "variantTitle", Value = "The Seg-1 Title in Danish" } + ] + }, + new () + { + Name = "The Danish Name", + Culture = "da-DK", + Segment = "seg-2", + Properties = + [ + new () { Alias = "variantTitle", Value = "The Seg-2 Title in Danish" } + ] + } + ] + }; + + var result = await ContentEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status); + Assert.IsNotNull(result.Result.Content); + VerifyCreate(result.Result.Content); + + // re-get and re-test + VerifyCreate(await ContentEditingService.GetAsync(result.Result.Content.Key)); + + void VerifyCreate(IContent? createdContent) + { + Assert.IsNotNull(createdContent); + Assert.Multiple(() => + { + Assert.AreEqual("The English Name", createdContent.GetCultureName("en-US")); + Assert.AreEqual("The Danish Name", createdContent.GetCultureName("da-DK")); + Assert.AreEqual("The Invariant Title", createdContent.GetValue("invariantTitle")); + Assert.AreEqual("The Default Title in English", createdContent.GetValue("variantTitle", culture: "en-US", segment: null)); + Assert.AreEqual("The Seg-1 Title in English", createdContent.GetValue("variantTitle", culture: "en-US", segment: "seg-1")); + Assert.AreEqual("The Seg-2 Title in English", createdContent.GetValue("variantTitle", culture: "en-US", segment: "seg-2")); + Assert.AreEqual("The Default Title in Danish", createdContent.GetValue("variantTitle", culture: "da-DK", segment: null)); + Assert.AreEqual("The Seg-1 Title in Danish", createdContent.GetValue("variantTitle", culture: "da-DK", segment: "seg-1")); + Assert.AreEqual("The Seg-2 Title in Danish", createdContent.GetValue("variantTitle", culture: "da-DK", segment: "seg-2")); + }); + } + } + [Test] public async Task Can_Create_With_Explicit_Key() { @@ -521,6 +694,38 @@ public partial class ContentEditingServiceTests Assert.IsNull(result.Result.Content); } + [Test] + public async Task Cannot_Create_With_Segment_Variant_Property_Value_For_Culture_Variant_Content() + { + var contentType = await CreateVariantContentType(ContentVariation.Culture); + + var createModel = new ContentCreateModel + { + ContentTypeKey = contentType.Key, + ParentKey = Constants.System.RootKey, + InvariantProperties = [ + new () { Alias = "invariantTitle", Value = "The Invariant Title" }, + ], + Variants = [ + new () + { + Name = "The name", + Culture = "en-US", + Segment = "segment", + Properties = [ + new () { Alias = "variantTitle", Value = "The Variant Title" } + ] + } + ] + }; + + var result = await ContentEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + Assert.IsFalse(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.PropertyTypeNotFound, result.Status); + Assert.IsNotNull(result.Result); + Assert.IsNull(result.Result.Content); + } + [Test] public async Task Cannot_Create_Under_Trashed_Parent() { @@ -595,6 +800,47 @@ public partial class ContentEditingServiceTests Assert.AreEqual(ContentEditingOperationStatus.InvalidCulture, result.Status); } + [Test] + public async Task Cannot_Create_Segment_Variant_Without_Default_Segment() + { + var contentType = await CreateVariantContentType(ContentVariation.Segment); + + var createModel = new ContentCreateModel + { + ContentTypeKey = contentType.Key, + ParentKey = Constants.System.RootKey, + InvariantProperties = new[] + { + new PropertyValueModel { Alias = "invariantTitle", Value = "The Invariant Title" } + }, + Variants = + [ + new () + { + Segment = "seg-1", + Name = "The Name", + Properties = + [ + new () { Alias = "variantTitle", Value = "The Seg-1 Title" } + ] + }, + new () + { + Segment = "seg-2", + Name = "The Name", + Properties = + [ + new () { Alias = "variantTitle", Value = "The Seg-2 Title" } + ] + } + ] + }; + + var result = await ContentEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + Assert.IsFalse(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.ContentTypeSegmentVarianceMismatch, result.Status); + } + private void AssertBodyTextEquals(string expected, IContent content) { var bodyTextValue = content.GetValue("bodyText"); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Delete.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Delete.cs index 93395a768e..6944f38068 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Delete.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Delete.cs @@ -48,7 +48,7 @@ public partial class ContentEditingServiceTests [TestCase(false)] public async Task Can_Delete_FromRecycleBin(bool variant) { - var content = await (variant ? CreateVariantContent() : CreateInvariantContent()); + var content = await (variant ? CreateCultureVariantContent() : CreateInvariantContent()); await ContentEditingService.MoveToRecycleBinAsync(content.Key, Constants.Security.SuperUserKey); var result = await ContentEditingService.DeleteFromRecycleBinAsync(content.Key, Constants.Security.SuperUserKey); @@ -64,7 +64,7 @@ public partial class ContentEditingServiceTests [TestCase(false)] public async Task Can_Delete_FromOutsideOfRecycleBin(bool variant) { - var content = await (variant ? CreateVariantContent() : CreateInvariantContent()); + var content = await (variant ? CreateCultureVariantContent() : CreateInvariantContent()); var result = await ContentEditingService.DeleteAsync(content.Key, Constants.Security.SuperUserKey); Assert.IsTrue(result.Success); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.DeleteFromRecycleBin.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.DeleteFromRecycleBin.cs index 32be47e00d..e55ff8cf21 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.DeleteFromRecycleBin.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.DeleteFromRecycleBin.cs @@ -10,7 +10,7 @@ public partial class ContentEditingServiceTests [TestCase(false)] public async Task Can_DeleteFromRecycleBin_If_InsideRecycleBin(bool variant) { - var content = await (variant ? CreateVariantContent() : CreateInvariantContent()); + var content = await (variant ? CreateCultureVariantContent() : CreateInvariantContent()); await ContentEditingService.MoveToRecycleBinAsync(content.Key, Constants.Security.SuperUserKey); var result = await ContentEditingService.DeleteFromRecycleBinAsync(content.Key, Constants.Security.SuperUserKey); @@ -34,7 +34,7 @@ public partial class ContentEditingServiceTests [TestCase(false)] public async Task Cannot_Delete_FromRecycleBin_If_Not_In_Recycle_Bin(bool variant) { - var content = await (variant ? CreateVariantContent() : CreateInvariantContent()); + var content = await (variant ? CreateCultureVariantContent() : CreateInvariantContent()); var result = await ContentEditingService.DeleteFromRecycleBinAsync(content.Key, Constants.Security.SuperUserKey); Assert.IsFalse(result.Success); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Get.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Get.cs index a293f891b9..7769ec8ca4 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Get.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Get.cs @@ -8,7 +8,7 @@ public partial class ContentEditingServiceTests [TestCase(false)] public async Task Can_Get(bool variant) { - var content = await (variant ? CreateVariantContent() : CreateInvariantContent()); + var content = await (variant ? CreateCultureVariantContent() : CreateInvariantContent()); var result = await ContentEditingService.GetAsync(content.Key); Assert.IsNotNull(result); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.MoveToRecycleBin.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.MoveToRecycleBin.cs index ac7700131f..c35ed49f44 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.MoveToRecycleBin.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.MoveToRecycleBin.cs @@ -20,7 +20,7 @@ public partial class ContentEditingServiceTests [TestCase(false)] public async Task Can_Move_To_Recycle_Bin(bool variant) { - var content = await (variant ? CreateVariantContent() : CreateInvariantContent()); + var content = await (variant ? CreateCultureVariantContent() : CreateInvariantContent()); var result = await ContentEditingService.MoveToRecycleBinAsync(content.Key, Constants.Security.SuperUserKey); Assert.IsTrue(result.Success); @@ -59,7 +59,7 @@ public partial class ContentEditingServiceTests [TestCase(false)] public async Task Cannot_Move_To_Recycle_Bin_If_Already_In_Recycle_Bin(bool variant) { - var content = await (variant ? CreateVariantContent() : CreateInvariantContent()); + var content = await (variant ? CreateCultureVariantContent() : CreateInvariantContent()); await ContentEditingService.MoveToRecycleBinAsync(content.Key, Constants.Security.SuperUserKey); var result = await ContentEditingService.MoveToRecycleBinAsync(content.Key, Constants.Security.SuperUserKey); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Update.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Update.cs index 5e4e74b793..7ae2495f11 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Update.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Update.cs @@ -43,9 +43,9 @@ public partial class ContentEditingServiceTests } [Test] - public async Task Can_Update_Variant() + public async Task Can_Update_Culture_Variant() { - var content = await CreateVariantContent(); + var content = await CreateCultureVariantContent(); var updateModel = new ContentUpdateModel { @@ -95,6 +95,71 @@ public partial class ContentEditingServiceTests } } + [Test] + public async Task Can_Update_Segment_Variant() + { + var content = await CreateSegmentVariantContent(); + + var updateModel = new ContentUpdateModel + { + InvariantProperties = new[] + { + new PropertyValueModel { Alias = "invariantTitle", Value = "The updated invariant title" } + }, + Variants = new [] + { + new VariantModel + { + Segment = null, + Name = "The Updated Name", + Properties = new [] + { + new PropertyValueModel { Alias = "variantTitle", Value = "The updated default title" } + } + }, + new VariantModel + { + Segment = "seg-1", + Name = "The Updated Name", + Properties = new [] + { + new PropertyValueModel { Alias = "variantTitle", Value = "The updated seg-1 title" } + } + }, + new VariantModel + { + Segment = "seg-2", + Name = "The Updated Name", + Properties = new [] + { + new PropertyValueModel { Alias = "variantTitle", Value = "The updated seg-2 title" } + } + }, + } + }; + + var result = await ContentEditingService.UpdateAsync(content.Key, updateModel, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status); + VerifyUpdate(result.Result.Content); + + // re-get and re-test + VerifyUpdate(await ContentEditingService.GetAsync(content.Key)); + + void VerifyUpdate(IContent? updatedContent) + { + Assert.IsNotNull(updatedContent); + Assert.Multiple(() => + { + Assert.AreEqual("The Updated Name", updatedContent.Name); + Assert.AreEqual("The updated invariant title", updatedContent.GetValue("invariantTitle")); + Assert.AreEqual("The updated default title", updatedContent.GetValue("variantTitle", segment: null)); + Assert.AreEqual("The updated seg-1 title", updatedContent.GetValue("variantTitle", segment: "seg-1")); + Assert.AreEqual("The updated seg-2 title", updatedContent.GetValue("variantTitle", segment: "seg-2")); + }); + } + } + [Test] public async Task Can_Update_Template() { @@ -269,7 +334,7 @@ public partial class ContentEditingServiceTests [Test] public async Task Cannot_Update_With_Invariant_Property_Value_For_Variant_Content() { - var content = await CreateVariantContent(); + var content = await CreateCultureVariantContent(); var updateModel = new ContentUpdateModel { @@ -298,7 +363,7 @@ public partial class ContentEditingServiceTests [Test] public async Task Cannot_Update_Variant_With_Incorrect_Culture_Casing() { - var content = await CreateVariantContent(); + var content = await CreateCultureVariantContent(); var updateModel = new ContentUpdateModel { @@ -371,7 +436,7 @@ public partial class ContentEditingServiceTests [Test] public async Task Cannot_Update_Variant_Readonly_Property_Value() { - var content = await CreateVariantContent(); + var content = await CreateCultureVariantContent(); content.SetValue("variantLabel", "The initial English label value", "en-US"); content.SetValue("variantLabel", "The initial Danish label value", "da-DK"); ContentService.Save(content); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTestsBase.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTestsBase.cs index f9dd811f0f..88dc741aa3 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTestsBase.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTestsBase.cs @@ -69,7 +69,7 @@ public abstract class ContentEditingServiceTestsBase : UmbracoIntegrationTestWit return contentType; } - protected async Task CreateVariantContentType() + protected async Task CreateVariantContentType(ContentVariation variation = ContentVariation.Culture) { var language = new LanguageBuilder() .WithCultureInfo("da-DK") @@ -79,11 +79,11 @@ public abstract class ContentEditingServiceTestsBase : UmbracoIntegrationTestWit var contentType = new ContentTypeBuilder() .WithAlias("cultureVariationTest") .WithName("Culture Variation Test") - .WithContentVariation(ContentVariation.Culture) + .WithContentVariation(variation) .AddPropertyType() .WithAlias("variantTitle") .WithName("Variant Title") - .WithVariations(ContentVariation.Culture) + .WithVariations(variation) .Done() .AddPropertyType() .WithAlias("invariantTitle") @@ -95,7 +95,7 @@ public abstract class ContentEditingServiceTestsBase : UmbracoIntegrationTestWit .WithName("Variant Label") .WithDataTypeId(Constants.DataTypes.LabelString) .WithPropertyEditorAlias(Constants.PropertyEditors.Aliases.Label) - .WithVariations(ContentVariation.Culture) + .WithVariations(variation) .Done() .Build(); contentType.AllowedAsRoot = true; @@ -125,7 +125,7 @@ public abstract class ContentEditingServiceTestsBase : UmbracoIntegrationTestWit return result.Result.Content!; } - protected async Task CreateVariantContent() + protected async Task CreateCultureVariantContent() { var contentType = await CreateVariantContentType(); @@ -164,4 +164,53 @@ public abstract class ContentEditingServiceTestsBase : UmbracoIntegrationTestWit Assert.IsTrue(result.Success); return result.Result.Content!; } + + protected async Task CreateSegmentVariantContent() + { + var contentType = await CreateVariantContentType(ContentVariation.Segment); + + var createModel = new ContentCreateModel + { + ContentTypeKey = contentType.Key, + ParentKey = Constants.System.RootKey, + InvariantProperties = new[] + { + new PropertyValueModel { Alias = "invariantTitle", Value = "The initial invariant title" }, + }, + Variants = new[] + { + new VariantModel + { + Segment = null, + Name = "The Name", + Properties = new[] + { + new PropertyValueModel { Alias = "variantTitle", Value = "The initial default title" }, + }, + }, + new VariantModel + { + Segment = "seg-1", + Name = "The Name", + Properties = new[] + { + new PropertyValueModel { Alias = "variantTitle", Value = "The initial seg-1 title" }, + }, + }, + new VariantModel + { + Segment = "seg-2", + Name = "The Name", + Properties = new[] + { + new PropertyValueModel { Alias = "variantTitle", Value = "The initial seg-2 title" }, + }, + }, + }, + }; + + var result = await ContentEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + return result.Result.Content!; + } }