Allow for creating and editing segment variant documents

This commit is contained in:
kjac
2025-03-30 13:02:00 +02:00
parent 2ff11afa95
commit 043b39d553
11 changed files with 412 additions and 28 deletions

View File

@@ -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()),

View File

@@ -318,13 +318,25 @@ internal abstract class ContentEditingServiceBase<TContent, TContentType, TConte
return null;
}
if (contentType.VariesByCulture() == false)
if (contentType.VariesByNothing() && (contentEditingModelBase.InvariantName.IsNullOrWhiteSpace() || contentEditingModelBase.Variants.Any()))
{
if (contentEditingModelBase.InvariantName.IsNullOrWhiteSpace() || contentEditingModelBase.Variants.Any())
{
operationStatus = ContentEditingOperationStatus.ContentTypeCultureVarianceMismatch;
return null;
}
// either missing the invariant name or has one more variants = invalid
operationStatus = ContentEditingOperationStatus.ContentTypeCultureVarianceMismatch;
return null;
}
if (contentType.VariesByCulture() && contentEditingModelBase.Variants.Any(v => 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<TContent, TContentType, TConte
.Properties
.Select(vpv => new
{
VariesByCulture = true,
VariesByCulture = contentType.VariesByCulture(),
VariesBySegment = v.Segment.IsNullOrWhiteSpace() == false,
PropertyValue = vpv
})))
@@ -359,7 +371,8 @@ internal abstract class ContentEditingServiceBase<TContent, TContentType, TConte
if (propertyValuesAndVariance.Any(pv =>
{
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<TContent, TContentType, TConte
content.SetCultureName(name, culture);
}
}
else if (contentType.VariesBySegment())
{
// this should be validated already so it's OK to throw an exception here
content.Name = contentEditingModelBase.Variants.FirstOrDefault(v => 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<TContent, TContentType, TConte
// the following checks should already have been validated by now, so it's OK to throw exceptions here
if(propertyTypesByAlias.TryGetValue(propertyValue.Alias, out IPropertyType? propertyType) == false
|| (propertyType.VariesByCulture() && propertyValue.Culture.IsNullOrWhiteSpace())
|| (propertyType.VariesBySegment() && propertyValue.Segment.IsNullOrWhiteSpace()))
|| (propertyType.VariesBySegment() is false && propertyValue.Segment.IsNullOrWhiteSpace() is false))
{
throw new ArgumentException($"Culture or segment variance mismatch for property: {propertyValue.Alias}", nameof(contentEditingModelBase));
}

View File

@@ -6,6 +6,7 @@ public enum ContentEditingOperationStatus
CancelledByNotification,
ContentTypeNotFound,
ContentTypeCultureVarianceMismatch,
ContentTypeSegmentVarianceMismatch,
NotFound,
ParentNotFound,
ParentInvalid,

View File

@@ -71,7 +71,7 @@ public partial class ContentBlueprintEditingServiceTests
[TestCase(false)]
public async Task Can_Create_From_Content_With_Explicit_Key(bool variant)
{
var content = await (variant ? CreateVariantContent() : CreateInvariantContent());
var content = await (variant ? CreateCultureVariantContent() : CreateInvariantContent());
var key = Guid.NewGuid();
const string name = "Test Create From Content Blueprint";
@@ -105,7 +105,7 @@ public partial class ContentBlueprintEditingServiceTests
[TestCase(false)]
public async Task Cannot_Create_From_Content_With_Duplicate_Name(bool variant)
{
var content = await (variant ? CreateVariantContent() : CreateInvariantContent());
var content = await (variant ? CreateCultureVariantContent() : CreateInvariantContent());
const string name = "Test Create From Content Blueprint";

View File

@@ -464,6 +464,179 @@ public partial class ContentEditingServiceTests
}
}
[Test]
public async Task Can_Create_Segment_Variant()
{
var contentType = await CreateVariantContentType(ContentVariation.Segment);
var createModel = new ContentCreateModel
{
ContentTypeKey = contentType.Key,
ParentKey = Constants.System.RootKey,
InvariantProperties =
[
new () { Alias = "invariantTitle", Value = "The Invariant Title" }
],
Variants =
[
new ()
{
Segment = null,
Name = "The Name",
Properties =
[
new () { Alias = "variantTitle", Value = "The Default Title" }
]
},
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.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 Name", createdContent.Name);
Assert.AreEqual("The Invariant Title", createdContent.GetValue<string>("invariantTitle"));
Assert.AreEqual("The Default Title", createdContent.GetValue<string>("variantTitle", segment: null));
Assert.AreEqual("The Seg-1 Title", createdContent.GetValue<string>("variantTitle", segment: "seg-1"));
Assert.AreEqual("The Seg-2 Title", createdContent.GetValue<string>("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<string>("invariantTitle"));
Assert.AreEqual("The Default Title in English", createdContent.GetValue<string>("variantTitle", culture: "en-US", segment: null));
Assert.AreEqual("The Seg-1 Title in English", createdContent.GetValue<string>("variantTitle", culture: "en-US", segment: "seg-1"));
Assert.AreEqual("The Seg-2 Title in English", createdContent.GetValue<string>("variantTitle", culture: "en-US", segment: "seg-2"));
Assert.AreEqual("The Default Title in Danish", createdContent.GetValue<string>("variantTitle", culture: "da-DK", segment: null));
Assert.AreEqual("The Seg-1 Title in Danish", createdContent.GetValue<string>("variantTitle", culture: "da-DK", segment: "seg-1"));
Assert.AreEqual("The Seg-2 Title in Danish", createdContent.GetValue<string>("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<string>("bodyText");

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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<string>("invariantTitle"));
Assert.AreEqual("The updated default title", updatedContent.GetValue<string>("variantTitle", segment: null));
Assert.AreEqual("The updated seg-1 title", updatedContent.GetValue<string>("variantTitle", segment: "seg-1"));
Assert.AreEqual("The updated seg-2 title", updatedContent.GetValue<string>("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);

View File

@@ -69,7 +69,7 @@ public abstract class ContentEditingServiceTestsBase : UmbracoIntegrationTestWit
return contentType;
}
protected async Task<IContentType> CreateVariantContentType()
protected async Task<IContentType> 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<IContent> CreateVariantContent()
protected async Task<IContent> 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<IContent> 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!;
}
}