V14/fix/element switch validation (#16421)

* Added Element <-> Document type switch validation

* Apply HasElementconfigured to block grid and block list

Fix smalle bug + optimization

* Moved some of the logic into warnings trough notifcationhandlers and eventmessages

* Cleanup

* Update openApi spec (merge changes)

* Add IsElement check between parent and child on creation

* Typos

* Transformed HasElementConfigured into HasElementConfigured

* Typo

Co-authored-by: Kenn Jacobsen <kja@umbraco.dk>

* IsElement Validation refactor

Moved validation logic regarding doctype IsElement switch into its own service as it will be consumed by more things down the line

* commit missing services...

* Naming improvements

* Bugfix

* First batch of integration tests for ElementSwitchValidator

* More integration tests!

* Little reformatting

* Changed the default values of block based configuration to match expected values.

---------

Co-authored-by: Kenn Jacobsen <kja@umbraco.dk>
This commit is contained in:
Sven Geusens
2024-08-15 07:11:17 +02:00
committed by GitHub
parent 7a700f3029
commit fd100602c2
25 changed files with 717 additions and 10 deletions

View File

@@ -98,6 +98,18 @@ public abstract class DocumentTypeControllerBase : ManagementApiControllerBase
.WithTitle("Name was too long")
.WithDetail("Name cannot be more than 255 characters in length.")
.Build()),
ContentTypeOperationStatus.InvalidElementFlagDocumentHasContent => new BadRequestObjectResult(problemDetailsBuilder
.WithTitle("Invalid IsElement flag")
.WithDetail("Cannot change to element type because content has already been created with this document type.")
.Build()),
ContentTypeOperationStatus.InvalidElementFlagElementIsUsedInPropertyEditorConfiguration => new BadRequestObjectResult(problemDetailsBuilder
.WithTitle("Invalid IsElement flag")
.WithDetail("Cannot change to document type because this element type is used in the configuration of a data type.")
.Build()),
ContentTypeOperationStatus.InvalidElementFlagComparedToParent => new BadRequestObjectResult(problemDetailsBuilder
.WithTitle("Invalid IsElement flag")
.WithDetail("Can not create a documentType with inheritance composition where the parent and the new type's IsElement flag are different.")
.Build()),
_ => new ObjectResult("Unknown content type operation status") { StatusCode = StatusCodes.Status500InternalServerError },
});

View File

@@ -45275,4 +45275,4 @@
}
}
}
}
}

View File

@@ -398,11 +398,14 @@ namespace Umbraco.Cms.Core.DependencyInjection
// Segments
Services.AddUnique<ISegmentService, NoopSegmentService>();
// definition Import/export
Services.AddUnique<ITemporaryFileToXmlImportService, TemporaryFileToXmlImportService>();
Services.AddUnique<IContentTypeImportService, ContentTypeImportService>();
Services.AddUnique<IMediaTypeImportService, MediaTypeImportService>();
// add validation services
Services.AddUnique<IElementSwitchValidator, ElementSwitchValidator>();
}
}
}

View File

@@ -0,0 +1,104 @@
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Extensions;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.ContentTypeEditing;
namespace Umbraco.Cms.Core.Handlers;
public class WarnDocumentTypeElementSwitchNotificationHandler :
INotificationAsyncHandler<ContentTypeSavingNotification>,
INotificationAsyncHandler<ContentTypeSavedNotification>
{
private const string NotificationStateKey =
"Umbraco.Cms.Core.Handlers.WarnDocumentTypeElementSwitchNotificationHandler";
private readonly IEventMessagesFactory _eventMessagesFactory;
private readonly IContentTypeService _contentTypeService;
private readonly IElementSwitchValidator _elementSwitchValidator;
public WarnDocumentTypeElementSwitchNotificationHandler(
IEventMessagesFactory eventMessagesFactory,
IContentTypeService contentTypeService,
IElementSwitchValidator elementSwitchValidator)
{
_eventMessagesFactory = eventMessagesFactory;
_contentTypeService = contentTypeService;
_elementSwitchValidator = elementSwitchValidator;
}
// To figure out whether a warning should be generated, we need both the state before and after saving
public async Task HandleAsync(ContentTypeSavingNotification notification, CancellationToken cancellationToken)
{
IEnumerable<Guid> updatedKeys = notification.SavedEntities
.Where(e => e.HasIdentity)
.Select(e => e.Key);
IEnumerable<IContentType> persistedItems = _contentTypeService.GetAll(updatedKeys);
var stateInformation = persistedItems
.ToDictionary(
contentType => contentType.Key,
contentType => new DocumentTypeElementSwitchInformation { WasElement = contentType.IsElement });
notification.State[NotificationStateKey] = stateInformation;
}
public async Task HandleAsync(ContentTypeSavedNotification notification, CancellationToken cancellationToken)
{
if (notification.State[NotificationStateKey] is not Dictionary<Guid, DocumentTypeElementSwitchInformation>
stateInformation)
{
return;
}
EventMessages eventMessages = _eventMessagesFactory.Get();
foreach (IContentType savedDocumentType in notification.SavedEntities)
{
if (stateInformation.ContainsKey(savedDocumentType.Key) is false)
{
continue;
}
DocumentTypeElementSwitchInformation state = stateInformation[savedDocumentType.Key];
if (state.WasElement == savedDocumentType.IsElement)
{
// no change
continue;
}
await WarnIfAncestorsAreMisaligned(savedDocumentType, eventMessages);
await WarnIfDescendantsAreMisaligned(savedDocumentType, eventMessages);
}
}
private async Task WarnIfAncestorsAreMisaligned(IContentType contentType, EventMessages eventMessages)
{
if (await _elementSwitchValidator.AncestorsAreAlignedAsync(contentType) == false)
{
// todo update this message when the format has been agreed upon on with the client
eventMessages.Add(new EventMessage(
"DocumentType saved",
"One or more ancestors have a mismatching element flag",
EventMessageType.Warning));
}
}
private async Task WarnIfDescendantsAreMisaligned(IContentType contentType, EventMessages eventMessages)
{
if (await _elementSwitchValidator.DescendantsAreAlignedAsync(contentType) == false)
{
// todo update this message when the format has been agreed upon on with the client
eventMessages.Add(new EventMessage(
"DocumentType saved",
"One or more descendants have a mismatching element flag",
EventMessageType.Warning));
}
}
private class DocumentTypeElementSwitchInformation
{
public bool WasElement { get; set; }
}
}

View File

@@ -27,7 +27,6 @@ public interface IDataValueEditor
/// </summary>
bool SupportsReadOnly => false;
/// <summary>
/// Gets the validators to use to validate the edited value.
/// </summary>
@@ -75,4 +74,6 @@ public interface IDataValueEditor
XNode ConvertDbToXml(IPropertyType propertyType, object value);
string ConvertDbToString(IPropertyType propertyType, object? value);
IEnumerable<Guid> ConfiguredElementTypeKeys() => Enumerable.Empty<Guid>();
}

View File

@@ -9,7 +9,7 @@ namespace Umbraco.Cms.Core.PropertyEditors;
public class BlockListConfiguration
{
[ConfigurationField("blocks")]
public BlockConfiguration[] Blocks { get; set; } = null!;
public BlockConfiguration[] Blocks { get; set; } = Array.Empty<BlockConfiguration>();
[ConfigurationField("validationLimit")]
public NumberRange ValidationLimit { get; set; } = new();

View File

@@ -75,6 +75,10 @@ public class DataEditor : IDataEditor
[DataMember(Name = "supportsReadOnly", IsRequired = true)]
public bool SupportsReadOnly { get; set; }
// Adding a virtual method that wraps the default implementation allows derived classes
// to override the default implementation without having to explicitly inherit the interface.
public virtual bool SupportsConfigurableElements => false;
/// <inheritdoc />
[IgnoreDataMember]
public bool IsDeprecated { get; }

View File

@@ -356,6 +356,10 @@ public class DataValueEditor : IDataValueEditor
}
}
// Adding a virtual method that wraps the default implementation allows derived classes
// to override the default implementation without having to explicitly inherit the interface.
public virtual IEnumerable<Guid> ConfiguredElementTypeKeys() => Enumerable.Empty<Guid>();
/// <summary>
/// Used to try to convert the string value to the correct CLR type based on the <see cref="ValueType" /> specified for
/// this value editor.

View File

@@ -16,6 +16,8 @@ public interface IDataEditor : IDiscoverable
bool SupportsReadOnly => false;
bool SupportsConfigurableElements => false;
/// <summary>
/// Gets a value indicating whether the editor is deprecated.
/// </summary>

View File

@@ -6,7 +6,7 @@ namespace Umbraco.Cms.Core.PropertyEditors;
public class RichTextConfiguration : IIgnoreUserStartNodesConfig
{
[ConfigurationField("blocks")]
public RichTextBlockConfiguration[]? Blocks { get; set; } = null!;
public RichTextBlockConfiguration[]? Blocks { get; set; } = Array.Empty<RichTextBlockConfiguration>();
[ConfigurationField("mediaParentId")]
public Guid? MediaParentId { get; set; }

View File

@@ -1,6 +1,9 @@
using Umbraco.Cms.Core.Models;
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.ContentEditing;
using Umbraco.Cms.Core.Models.ContentTypeEditing;
using Umbraco.Cms.Core.PropertyEditors;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Core.Strings;
using Umbraco.Extensions;
@@ -12,8 +15,24 @@ namespace Umbraco.Cms.Core.Services.ContentTypeEditing;
internal sealed class ContentTypeEditingService : ContentTypeEditingServiceBase<IContentType, IContentTypeService, ContentTypePropertyTypeModel, ContentTypePropertyContainerModel>, IContentTypeEditingService
{
private readonly ITemplateService _templateService;
private readonly IElementSwitchValidator _elementSwitchValidator;
private readonly IContentTypeService _contentTypeService;
public ContentTypeEditingService(
IContentTypeService contentTypeService,
ITemplateService templateService,
IDataTypeService dataTypeService,
IEntityService entityService,
IShortStringHelper shortStringHelper,
IElementSwitchValidator elementSwitchValidator)
: base(contentTypeService, contentTypeService, dataTypeService, entityService, shortStringHelper)
{
_contentTypeService = contentTypeService;
_templateService = templateService;
_elementSwitchValidator = elementSwitchValidator;
}
[Obsolete("Use the constructor that is not marked obsolete, will be removed in v16")]
public ContentTypeEditingService(
IContentTypeService contentTypeService,
ITemplateService templateService,
@@ -24,6 +43,7 @@ internal sealed class ContentTypeEditingService : ContentTypeEditingServiceBase<
{
_contentTypeService = contentTypeService;
_templateService = templateService;
_elementSwitchValidator = StaticServiceProvider.Instance.GetRequiredService<IElementSwitchValidator>();
}
public async Task<Attempt<IContentType?, ContentTypeOperationStatus>> CreateAsync(ContentTypeCreateModel model, Guid userKey)
@@ -52,13 +72,20 @@ internal sealed class ContentTypeEditingService : ContentTypeEditingServiceBase<
public async Task<Attempt<IContentType?, ContentTypeOperationStatus>> UpdateAsync(IContentType contentType, ContentTypeUpdateModel model, Guid userKey)
{
Attempt<IContentType?, ContentTypeOperationStatus> result = await ValidateAndMapForUpdateAsync(contentType, model);
if (result.Success is false)
// this needs to happen before the base call as that one is not a pure function
ContentTypeOperationStatus elementValidationStatus = await ValidateElementStatusForUpdateAsync(contentType, model);
if (elementValidationStatus is not ContentTypeOperationStatus.Success)
{
return result;
return Attempt<IContentType?, ContentTypeOperationStatus>.Fail(elementValidationStatus);
}
contentType = result.Result ?? throw new InvalidOperationException($"{nameof(ValidateAndMapForUpdateAsync)} succeeded but did not yield any result");
Attempt<IContentType?, ContentTypeOperationStatus> baseValidationAttempt = await ValidateAndMapForUpdateAsync(contentType, model);
if (baseValidationAttempt.Success is false)
{
return baseValidationAttempt;
}
contentType = baseValidationAttempt.Result ?? throw new InvalidOperationException($"{nameof(ValidateAndMapForUpdateAsync)} succeeded but did not yield any result");
UpdateHistoryCleanup(contentType, model);
UpdateTemplates(contentType, model);
@@ -77,6 +104,13 @@ internal sealed class ContentTypeEditingService : ContentTypeEditingServiceBase<
bool isElement) =>
await FindAvailableCompositionsAsync(key, currentCompositeKeys, currentPropertyAliases, isElement);
protected override async Task<ContentTypeOperationStatus> AdditionalCreateValidationAsync(
ContentTypeEditingModelBase<ContentTypePropertyTypeModel, ContentTypePropertyContainerModel> model)
{
// validate if the parent documentType (if set) has the same element status as the documentType being created
return await ValidateCreateParentElementStatusAsync(model);
}
// update content type history clean-up
private void UpdateHistoryCleanup(IContentType contentType, ContentTypeModelBase model)
{
@@ -100,6 +134,48 @@ internal sealed class ContentTypeEditingService : ContentTypeEditingServiceBase<
contentType.SetDefaultTemplate(allowedTemplates.FirstOrDefault(t => t.Key == model.DefaultTemplateKey));
}
private async Task<ContentTypeOperationStatus> ValidateElementStatusForUpdateAsync(IContentTypeBase contentType, ContentTypeModelBase model)
{
// no change, ignore rest of validation
if (contentType.IsElement == model.IsElement)
{
return ContentTypeOperationStatus.Success;
}
// this method should only contain blocking validation, warnings are handled by WarnDocumentTypeElementSwitchNotificationHandler
// => check whether the element was used in a block structure prior to updating
if (model.IsElement is false)
{
return await _elementSwitchValidator.ElementToDocumentNotUsedInBlockStructuresAsync(contentType)
? ContentTypeOperationStatus.Success
: ContentTypeOperationStatus.InvalidElementFlagElementIsUsedInPropertyEditorConfiguration;
}
return await _elementSwitchValidator.DocumentToElementHasNoContentAsync(contentType)
? ContentTypeOperationStatus.Success
: ContentTypeOperationStatus.InvalidElementFlagDocumentHasContent;
}
/// <summary>
/// Should be called after it has been established that the composition list is in a valid state and the (composition) parent exists
/// </summary>
private async Task<ContentTypeOperationStatus> ValidateCreateParentElementStatusAsync(
ContentTypeEditingModelBase<ContentTypePropertyTypeModel, ContentTypePropertyContainerModel> model)
{
Guid? parentId = model.Compositions
.SingleOrDefault(composition => composition.CompositionType == CompositionType.Inheritance)?.Key;
if (parentId is null)
{
return ContentTypeOperationStatus.Success;
}
IContentType? parent = await _contentTypeService.GetAsync(parentId.Value);
return parent!.IsElement == model.IsElement
? ContentTypeOperationStatus.Success
: ContentTypeOperationStatus.InvalidElementFlagComparedToParent;
}
protected override IContentType CreateContentType(IShortStringHelper shortStringHelper, int parentId)
=> new ContentType(shortStringHelper, parentId);

View File

@@ -91,6 +91,8 @@ internal abstract class ContentTypeEditingServiceBase<TContentType, TContentType
return Attempt.FailWithStatus<TContentType?, ContentTypeOperationStatus>(operationStatus, null);
}
await AdditionalCreateValidationAsync(model);
// get the ID of the parent to create the content type under (we already validated that it exists)
var parentId = GetParentId(model, containerKey) ?? throw new ArgumentException("Parent ID could not be found", nameof(model));
TContentType contentType = CreateContentType(_shortStringHelper, parentId);
@@ -137,6 +139,10 @@ internal abstract class ContentTypeEditingServiceBase<TContentType, TContentType
return Attempt.SucceedWithStatus<TContentType?, ContentTypeOperationStatus>(ContentTypeOperationStatus.Success, contentType);
}
protected virtual async Task<ContentTypeOperationStatus> AdditionalCreateValidationAsync(
ContentTypeEditingModelBase<TPropertyTypeModel, TPropertyTypeContainer> model)
=> await Task.FromResult(ContentTypeOperationStatus.Success);
#region Sanitization
private void SanitizeModelAliases(ContentTypeEditingModelBase<TPropertyTypeModel, TPropertyTypeContainer> model)

View File

@@ -0,0 +1,66 @@
using Umbraco.Cms.Core.Extensions;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.PropertyEditors;
namespace Umbraco.Cms.Core.Services.ContentTypeEditing;
public class ElementSwitchValidator : IElementSwitchValidator
{
private readonly IContentTypeService _contentTypeService;
private readonly PropertyEditorCollection _propertyEditorCollection;
private readonly IDataTypeService _dataTypeService;
public ElementSwitchValidator(
IContentTypeService contentTypeService,
PropertyEditorCollection propertyEditorCollection,
IDataTypeService dataTypeService)
{
_contentTypeService = contentTypeService;
_propertyEditorCollection = propertyEditorCollection;
_dataTypeService = dataTypeService;
}
public async Task<bool> AncestorsAreAlignedAsync(IContentType contentType)
{
// this call does not return the system roots
var ancestorIds = contentType.AncestorIds();
if (ancestorIds.Length == 0)
{
// if there are no ancestors, validation passes
return true;
}
// if there are any ancestors where IsElement is different from the contentType, the validation fails
return await Task.FromResult(_contentTypeService.GetAll(ancestorIds)
.Any(ancestor => ancestor.IsElement != contentType.IsElement) is false);
}
public async Task<bool> DescendantsAreAlignedAsync(IContentType contentType)
{
IEnumerable<IContentType> descendants = _contentTypeService.GetDescendants(contentType.Id, false);
// if there are any descendants where IsElement is different from the contentType, the validation fails
return await Task.FromResult(descendants.Any(descendant => descendant.IsElement != contentType.IsElement) is false);
}
public async Task<bool> ElementToDocumentNotUsedInBlockStructuresAsync(IContentTypeBase contentType)
{
// get all propertyEditors that support block usage
IDataEditor[] editors = _propertyEditorCollection.Where(pe => pe.SupportsConfigurableElements).ToArray();
var blockEditorAliases = editors.Select(pe => pe.Alias).ToArray();
// get all dataTypes that are based on those propertyEditors
IEnumerable<IDataType> dataTypes = await _dataTypeService.GetByEditorAliasAsync(blockEditorAliases);
// if any dataType has a configuration where this element is selected as a possible block, the validation fails.
return dataTypes.Any(dataType =>
editors.First(editor => editor.Alias == dataType.EditorAlias)
.GetValueEditor(dataType.ConfigurationObject)
.ConfiguredElementTypeKeys().Contains(contentType.Key)) is false;
}
public async Task<bool> DocumentToElementHasNoContentAsync(IContentTypeBase contentType) =>
// if any content for the content type exists, the validation fails.
await Task.FromResult(_contentTypeService.HasContentNodes(contentType.Id) is false);
}

View File

@@ -0,0 +1,14 @@
using Umbraco.Cms.Core.Models;
namespace Umbraco.Cms.Core.Services.ContentTypeEditing;
public interface IElementSwitchValidator
{
Task<bool> AncestorsAreAlignedAsync(IContentType contentType);
Task<bool> DescendantsAreAlignedAsync(IContentType contentType);
Task<bool> ElementToDocumentNotUsedInBlockStructuresAsync(IContentTypeBase contentType);
Task<bool> DocumentToElementHasNoContentAsync(IContentTypeBase contentType);
}

View File

@@ -330,6 +330,16 @@ namespace Umbraco.Cms.Core.Services.Implement
return Task.FromResult(dataTypes);
}
/// <inheritdoc />
public async Task<IEnumerable<IDataType>> GetByEditorAliasAsync(string[] propertyEditorAlias)
{
using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true);
IQuery<IDataType> query = Query<IDataType>().Where(x => propertyEditorAlias.Contains(x.EditorAlias));
IEnumerable<IDataType> dataTypes = _dataTypeRepository.Get(query).ToArray();
ConvertMissingEditorsOfDataTypesToLabels(dataTypes);
return await Task.FromResult(dataTypes);
}
/// <inheritdoc />
public Task<IEnumerable<IDataType>> GetByEditorUiAlias(string editorUiAlias)

View File

@@ -240,4 +240,11 @@ public interface IDataTypeService : IService
/// <param name="dataType">The data type whose configuration to validate.</param>
/// <returns>One or more <see cref="ValidationResult"/> if the configuration data is invalid, an empty collection otherwise.</returns>
IEnumerable<ValidationResult> ValidateConfigurationData(IDataType dataType);
/// <summary>
/// Gets all <see cref="IDataType" /> for a set of property editors
/// </summary>
/// <param name="propertyEditorAlias">Aliases of the property editors</param>
/// <returns>Collection of <see cref="IDataType" /> configured for the property editors</returns>
Task<IEnumerable<IDataType>> GetByEditorAliasAsync(string[] propertyEditorAlias);
}

View File

@@ -21,4 +21,7 @@ public enum ContentTypeOperationStatus
NotFound,
NotAllowed,
CancelledByNotification,
InvalidElementFlagDocumentHasContent,
InvalidElementFlagElementIsUsedInPropertyEditorConfiguration,
InvalidElementFlagComparedToParent,
}

View File

@@ -407,6 +407,11 @@ public static partial class UmbracoBuilderExtensions
builder
.AddNotificationHandler<ContentPublishedNotification, AddDomainWarningsWhenPublishingNotificationHandler>();
// Handlers for save warnings
builder
.AddNotificationAsyncHandler<ContentTypeSavingNotification, WarnDocumentTypeElementSwitchNotificationHandler>()
.AddNotificationAsyncHandler<ContentTypeSavedNotification, WarnDocumentTypeElementSwitchNotificationHandler>();
return builder;
}

View File

@@ -22,6 +22,7 @@ public class BlockGridPropertyEditor : BlockGridPropertyEditorBase
: base(dataValueEditorFactory, blockValuePropertyIndexValueFactory)
=> _ioHelper = ioHelper;
public override bool SupportsConfigurableElements => true;
#region Pre Value Editor

View File

@@ -111,6 +111,12 @@ public abstract class BlockGridPropertyEditorBase : DataEditor
return validationResults;
}
}
public override IEnumerable<Guid> ConfiguredElementTypeKeys()
{
var configuration = ConfigurationObject as BlockGridConfiguration;
return configuration?.Blocks.SelectMany(ConfiguredElementTypeKeys) ?? Enumerable.Empty<Guid>();
}
}
#endregion

View File

@@ -36,6 +36,8 @@ public class BlockListPropertyEditor : BlockListPropertyEditorBase
{
}
public override bool SupportsConfigurableElements => true;
#region Pre Value Editor
protected override IConfigurationEditor CreateConfigurationEditor() =>

View File

@@ -93,6 +93,12 @@ public abstract class BlockListPropertyEditorBase : DataEditor
return ValidateNumberOfBlocks(blockEditorData, validationLimit.Min, validationLimit.Max);
}
}
public override IEnumerable<Guid> ConfiguredElementTypeKeys()
{
var configuration = ConfigurationObject as BlockListConfiguration;
return configuration?.Blocks.SelectMany(ConfiguredElementTypeKeys) ?? Enumerable.Empty<Guid>();
}
}
#endregion

View File

@@ -114,6 +114,15 @@ public abstract class BlockValuePropertyValueEditorBase<TValue, TLayout> : DataV
MapBlockItemDataToEditor(property, blockValue.SettingsData);
}
protected IEnumerable<Guid> ConfiguredElementTypeKeys(IBlockConfiguration configuration)
{
yield return configuration.ContentElementTypeKey;
if (configuration.SettingsElementTypeKey is not null)
{
yield return configuration.SettingsElementTypeKey.Value;
}
}
private void MapBlockItemDataToEditor(IProperty property, List<BlockItemData> items)
{
var valEditors = new Dictionary<Guid, IDataValueEditor>();

View File

@@ -47,6 +47,8 @@ public class RichTextPropertyEditor : DataEditor
public override IPropertyIndexValueFactory PropertyIndexValueFactory => _richTextPropertyIndexValueFactory;
public override bool SupportsConfigurableElements => true;
/// <summary>
/// Create a custom value editor
/// </summary>
@@ -238,6 +240,12 @@ public class RichTextPropertyEditor : DataEditor
return RichTextPropertyEditorHelper.SerializeRichTextEditorValue(cleanedUpRichTextEditorValue, _jsonSerializer);
}
public override IEnumerable<Guid> ConfiguredElementTypeKeys()
{
var configuration = ConfigurationObject as RichTextConfiguration;
return configuration?.Blocks?.SelectMany(ConfiguredElementTypeKeys) ?? Enumerable.Empty<Guid>();
}
private bool TryParseEditorValue(object? value, [NotNullWhen(true)] out RichTextEditorValue? richTextEditorValue)
=> RichTextPropertyEditorHelper.TryParseRichTextEditorValue(value, _jsonSerializer, _logger, out richTextEditorValue);

View File

@@ -0,0 +1,358 @@
using NUnit.Framework;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Extensions;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.PropertyEditors;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.ContentTypeEditing;
using Umbraco.Cms.Tests.Common.Attributes;
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.Core.Services;
[TestFixture]
[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)]
public class ElementSwitchValidatorTests : UmbracoIntegrationTest
{
private IElementSwitchValidator ElementSwitchValidator => GetRequiredService<IElementSwitchValidator>();
private IContentTypeService ContentTypeService => GetRequiredService<IContentTypeService>();
private IContentService ContentService => GetRequiredService<IContentService>();
private IDataTypeService DataTypeService => GetRequiredService<IDataTypeService>();
[TestCase(new[] { true }, 0, true, true, TestName = "E=>E No Ancestor or children")]
[TestCase(new[] { false }, 0, false, true, TestName = "D=>D No Ancestor or children")]
[TestCase(new[] { true }, 0, false, true, TestName = "E=>D No Ancestor or children")]
[TestCase(new[] { false }, 0, true, true, TestName = "D=>E No Ancestor or children")]
[TestCase(new[] { true, true }, 1, true, true, TestName = "E Valid Parent")]
[TestCase(new[] { true, true }, 0, true, true, TestName = "E Valid Child")]
[TestCase(new[] { false, false }, 1, false, true, TestName = "D Valid Parent")]
[TestCase(new[] { false, false }, 0, false, true, TestName = "D Valid Child")]
[TestCase(new[] { false, false }, 1, true, false, TestName = "E InValid Parent")]
[TestCase(new[] { false, false }, 0, true, true, TestName = "E InValid Child")]
[TestCase(new[] { true, true }, 1, false, false, TestName = "D InValid Parent")]
[TestCase(new[] { true, true }, 0, false, true, TestName = "D InValid Child")]
[TestCase(new[] { true, false, false, true, false }, 2, true, false,
TestName = "D=>E InValid Child, Invalid Parent")]
[TestCase(new[] { false, true, false, true, false }, 2, true, false,
TestName = "D=>E InValid Child, Invalid Ancestor")]
[TestCase(new[] { true, false, false, true, true }, 2, true, false,
TestName = "D=>E Valid Children, Invalid Parent")]
[TestCase(new[] { false, true, false, true, true }, 2, true, false,
TestName = "D=>E Valid Children, Invalid Ancestor")]
[TestCase(new[] { false, false, false, false, false }, 2, true, false, TestName = "D=>E mismatch")]
[TestCase(new[] { false, false, true, false, false }, 2, false, true, TestName = "D=>E correction")]
[TestCase(new[] { true, true, true, true, true }, 2, false, false, TestName = "E=>D mismatch")]
[TestCase(new[] { true, true, false, true, true }, 2, true, true, TestName = "E=>D correction")]
[LongRunning]
public async Task AncestorsAreAligned(
bool[] isElementDoctypeChain,
int itemToTestIndex,
bool itemToTestNewIsElementValue,
bool validationShouldPass)
{
// Arrange
IContentType? parentItem = null;
IContentType? itemToTest = null;
for (var index = 0; index < isElementDoctypeChain.Length; index++)
{
var itemIsElement = isElementDoctypeChain[index];
var builder = new ContentTypeBuilder()
.WithIsElement(itemIsElement);
if (parentItem is not null)
{
builder.WithParentContentType(parentItem);
}
var contentType = builder.Build();
await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey);
parentItem = contentType;
if (index == itemToTestIndex)
{
itemToTest = contentType;
}
}
// Act
itemToTest!.IsElement = itemToTestNewIsElementValue;
var result = await ElementSwitchValidator.AncestorsAreAlignedAsync(itemToTest);
// Assert
Assert.AreEqual(result, validationShouldPass);
}
[TestCase(new[] { true }, 0, true, true, TestName = "E=>E No Ancestor or children")]
[TestCase(new[] { false }, 0, false, true, TestName = "D=>D No Ancestor or children")]
[TestCase(new[] { true }, 0, false, true, TestName = "E=>D No Ancestor or children")]
[TestCase(new[] { false }, 0, true, true, TestName = "D=>E No Ancestor or children")]
[TestCase(new[] { true, true }, 1, true, true, TestName = "E Valid Parent")]
[TestCase(new[] { true, true }, 0, true, true, TestName = "E Valid Child")]
[TestCase(new[] { false, false }, 1, false, true, TestName = "D Valid Parent")]
[TestCase(new[] { false, false }, 0, false, true, TestName = "D Valid Child")]
[TestCase(new[] { false, false }, 1, true, true, TestName = "E InValid Parent")]
[TestCase(new[] { false, false }, 0, true, false, TestName = "E InValid Child")]
[TestCase(new[] { true, true }, 1, false, true, TestName = "D InValid Parent")]
[TestCase(new[] { true, true }, 0, false, false, TestName = "D InValid Child")]
[TestCase(new[] { true, false, false, true, false }, 2, true, false,
TestName = "D=>E InValid Child, Invalid Parent")]
[TestCase(new[] { false, true, false, true, false }, 2, true, false,
TestName = "D=>E InValid Child, Invalid Ancestor")]
[TestCase(new[] { true, false, false, true, true }, 2, true, true,
TestName = "D=>E Valid Children, Invalid Parent")]
[TestCase(new[] { false, true, false, true, true }, 2, true, true,
TestName = "D=>E Valid Children, Invalid Ancestor")]
[TestCase(new[] { false, false, false, false, false }, 2, true, false, TestName = "D=>E mismatch")]
[TestCase(new[] { false, false, true, false, false }, 2, false, true, TestName = "D=>E correction")]
[TestCase(new[] { true, true, true, true, true }, 2, false, false, TestName = "E=>D mismatch")]
[TestCase(new[] { true, true, false, true, true }, 2, true, true, TestName = "E=>D correction")]
[LongRunning]
public async Task DescendantsAreAligned(
bool[] isElementDoctypeChain,
int itemToTestIndex,
bool itemToTestNewIsElementValue,
bool validationShouldPass)
{
// Arrange
IContentType? parentItem = null;
IContentType? itemToTest = null;
for (var index = 0; index < isElementDoctypeChain.Length; index++)
{
var itemIsElement = isElementDoctypeChain[index];
var builder = new ContentTypeBuilder()
.WithIsElement(itemIsElement);
if (parentItem is not null)
{
builder.WithParentContentType(parentItem);
}
var contentType = builder.Build();
await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey);
parentItem = contentType;
if (index == itemToTestIndex)
{
itemToTest = contentType;
}
}
// Act
itemToTest!.IsElement = itemToTestNewIsElementValue;
var result = await ElementSwitchValidator.DescendantsAreAlignedAsync(itemToTest);
// Assert
Assert.AreEqual(result, validationShouldPass);
}
[TestCase(0, true, TestName = "No Content")]
[TestCase(1, false, TestName = "One Content Item")]
[TestCase(5, false, TestName = "Many Content Items")]
public async Task DocumentToElementHasNoContent(int amountOfDocumentsCreated, bool validationShouldPass)
{
// Arrange
var contentType = await SetupContentType(false);
for (int i = 0; i < amountOfDocumentsCreated; i++)
{
var contentBuilder = new ContentBuilder().WithContentType(contentType);
var content = contentBuilder.Build();
ContentService.Save(content);
}
// Act
contentType.IsElement = true;
var result = await ElementSwitchValidator.DocumentToElementHasNoContentAsync(contentType);
// Assert
Assert.AreEqual(result, validationShouldPass);
}
// Since the full permutation table would result in 64 tests and more block editors might be added later,
// we will at least test each single failure and a few combinations
// used in none
[TestCase(false, false, false, false, false, false, true)]
// used in one
[TestCase(true, false, false, false, false, false, false)]
[TestCase(false, true, false, false, false, false, false)]
[TestCase(false, false, true, false, false, false, false)]
[TestCase(false, false, false, true, false, false, false)]
[TestCase(false, false, false, false, true, false, false)]
[TestCase(false, false, false, false, false, true, false)]
// used in selection and setting
[TestCase(true, true, false, false, false, false, false)]
// used in 2 selections
[TestCase(true, false, true, false, false, false, false)]
// used in 2 settings
[TestCase(false, true, false, false, false, true, false)]
// used in all
[TestCase(true, true, true, true, true, true, false)]
public async Task ElementToDocumentNotUsedInBlockStructures(
bool isUsedInBlockList,
bool isUsedInBlockListBlockSetting,
bool isUsedInBlockGrid,
bool isUsedInBlockGridBlockSetting,
bool isUsedInRte,
bool isUsedInRteBlockSetting,
bool validationShouldPass)
{
// Arrange
var elementType = await SetupContentType(true);
var otherElementType = await SetupContentType(true);
if (isUsedInBlockList)
{
await SetupDataType(Constants.PropertyEditors.Aliases.BlockList, elementType.Key, null);
}
if (isUsedInBlockListBlockSetting)
{
await SetupDataType(Constants.PropertyEditors.Aliases.BlockList, otherElementType.Key, elementType.Key);
}
if (isUsedInBlockGrid)
{
await SetupDataType(Constants.PropertyEditors.Aliases.BlockGrid, elementType.Key, null);
}
if (isUsedInBlockGridBlockSetting)
{
await SetupDataType(Constants.PropertyEditors.Aliases.BlockGrid, otherElementType.Key, elementType.Key);
}
if (isUsedInRte)
{
await SetupDataType(Constants.PropertyEditors.Aliases.RichText, elementType.Key, null);
}
if (isUsedInRteBlockSetting)
{
await SetupDataType(Constants.PropertyEditors.Aliases.RichText, otherElementType.Key, elementType.Key);
}
// Act
var result = await ElementSwitchValidator.ElementToDocumentNotUsedInBlockStructuresAsync(elementType);
// Assert
Assert.AreEqual(result, validationShouldPass);
}
private async Task<IContentType> SetupContentType(bool isElement)
{
var typeBuilder = new ContentTypeBuilder()
.WithIsElement(isElement);
var contentType = typeBuilder.Build();
await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey);
return contentType;
}
// Constants.PropertyEditors.Aliases.BlockGrid
private async Task SetupDataType(
string editorAlias,
Guid elementKey,
Guid? elementSettingKey)
{
Dictionary<string, object> configuration;
switch (editorAlias)
{
case Constants.PropertyEditors.Aliases.BlockGrid:
configuration = GetBlockGridBaseConfiguration();
break;
case Constants.PropertyEditors.Aliases.RichText:
configuration = GetRteBaseConfiguration();
break;
default:
configuration = new Dictionary<string, object>();
break;
}
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;
}
var dataType = dataTypeBuilder.Done()
.Build();
await DataTypeService.CreateAsync(dataType, Constants.Security.SuperUserKey);
}
private void SetBlockConfiguration(
Dictionary<string, object> dictionary,
Guid? elementKey,
Guid? elementSettingKey,
bool? allowAtRoot)
{
if (elementKey is null)
{
return;
}
dictionary["blocks"] = new[] { BuildBlockConfiguration(elementKey.Value, elementSettingKey, allowAtRoot) };
}
private Dictionary<string, object> GetBlockGridBaseConfiguration()
=> new Dictionary<string, object> { ["gridColumns"] = 12 };
private Dictionary<string, object> GetRteBaseConfiguration()
{
var dictionary = new Dictionary<string, object>
{
["maxImageSize"] = 500,
["mode"] = "Classic",
["toolbar"] = new[]
{
"styles", "bold", "italic", "alignleft", "aligncenter", "alignright", "bullist", "numlist",
"outdent", "indent", "sourcecode", "link", "umbmediapicker", "umbembeddialog"
},
};
return dictionary;
}
private Dictionary<string, object> BuildBlockConfiguration(
Guid? elementKey,
Guid? elementSettingKey,
bool? allowAtRoot)
{
var dictionary = new Dictionary<string, object>();
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;
}
}