diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/GetAuditLogDocumentController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/GetAuditLogDocumentController.cs index a22841850e..eedf1a9bf2 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/GetAuditLogDocumentController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/GetAuditLogDocumentController.cs @@ -39,7 +39,7 @@ public class GetAuditLogDocumentController : DocumentControllerBase { AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync( User, - ContentPermissionResource.WithKeys(ActionProtect.ActionLetter, id), + ContentPermissionResource.WithKeys(ActionBrowse.ActionLetter, id), AuthorizationPolicies.ContentPermissionByResource); if (!authorizationResult.Succeeded) diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/UpdateDomainsController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/UpdateDomainsController.cs index a7c7256c6b..a44289b4c6 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/UpdateDomainsController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/UpdateDomainsController.cs @@ -66,6 +66,10 @@ public class UpdateDomainsController : DocumentControllerBase .WithDetail("One or more of the specified domain names were conflicting with domain assignments to other content items.") .WithExtension("conflictingDomainNames", _domainPresentationFactory.CreateDomainAssignmentModels(result.Result.ConflictingDomains.EmptyNull())) .Build()), + DomainOperationStatus.InvalidDomainName => BadRequest(problemDetailsBuilder + .WithTitle("Invalid domain name detected") + .WithDetail("One or more of the specified domain names were invalid.") + .Build()), _ => StatusCode(StatusCodes.Status500InternalServerError, problemDetailsBuilder .WithTitle("Unknown domain update operation status.") .Build()), diff --git a/src/Umbraco.Cms.Api.Management/Controllers/DocumentType/DocumentTypeControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/DocumentType/DocumentTypeControllerBase.cs index c65ed1631d..da1eeddfac 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/DocumentType/DocumentTypeControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/DocumentType/DocumentTypeControllerBase.cs @@ -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 }, }); diff --git a/src/Umbraco.Cms.Api.Management/OpenApi.json b/src/Umbraco.Cms.Api.Management/OpenApi.json index 5d2d532ebc..62bf741f9b 100644 --- a/src/Umbraco.Cms.Api.Management/OpenApi.json +++ b/src/Umbraco.Cms.Api.Management/OpenApi.json @@ -45275,4 +45275,4 @@ } } } -} +} \ No newline at end of file diff --git a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs index 11c9e91099..ffeafe17b7 100644 --- a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs +++ b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs @@ -398,11 +398,14 @@ namespace Umbraco.Cms.Core.DependencyInjection // Segments Services.AddUnique(); - + // definition Import/export Services.AddUnique(); Services.AddUnique(); Services.AddUnique(); + + // add validation services + Services.AddUnique(); } } } diff --git a/src/Umbraco.Core/Extensions/ObjectExtensions.cs b/src/Umbraco.Core/Extensions/ObjectExtensions.cs index f0f10d8cc6..2ef1f9194f 100644 --- a/src/Umbraco.Core/Extensions/ObjectExtensions.cs +++ b/src/Umbraco.Core/Extensions/ObjectExtensions.cs @@ -219,6 +219,24 @@ public static class ObjectExtensions } } + if (target == typeof(DateTime) && input is DateTimeOffset dateTimeOffset) + { + // IMPORTANT: for compatability with various editors, we must discard any Offset information and assume UTC time here + return Attempt.Succeed((object?)new DateTime( + new DateOnly(dateTimeOffset.Year, dateTimeOffset.Month, dateTimeOffset.Day), + new TimeOnly(dateTimeOffset.Hour, dateTimeOffset.Minute, dateTimeOffset.Second, dateTimeOffset.Millisecond, dateTimeOffset.Microsecond), + DateTimeKind.Utc)); + } + + if (target == typeof(DateTimeOffset) && input is DateTime dateTime) + { + // IMPORTANT: for compatability with various editors, we must discard any DateTimeKind information and assume UTC time here + return Attempt.Succeed((object?)new DateTimeOffset( + new DateOnly(dateTime.Year, dateTime.Month, dateTime.Day), + new TimeOnly(dateTime.Hour, dateTime.Minute, dateTime.Second, dateTime.Millisecond, dateTime.Microsecond), + TimeSpan.Zero)); + } + TypeConverter? inputConverter = GetCachedSourceTypeConverter(inputType, target); if (inputConverter != null) { diff --git a/src/Umbraco.Core/Handlers/WarnDocumentTypeElementSwitchNotificationHandler.cs b/src/Umbraco.Core/Handlers/WarnDocumentTypeElementSwitchNotificationHandler.cs new file mode 100644 index 0000000000..b4c13f8da3 --- /dev/null +++ b/src/Umbraco.Core/Handlers/WarnDocumentTypeElementSwitchNotificationHandler.cs @@ -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, + INotificationAsyncHandler +{ + 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 updatedKeys = notification.SavedEntities + .Where(e => e.HasIdentity) + .Select(e => e.Key); + + IEnumerable 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 + 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; } + } +} diff --git a/src/Umbraco.Core/Models/IDataValueEditor.cs b/src/Umbraco.Core/Models/IDataValueEditor.cs index d733488be8..691ee70bf1 100644 --- a/src/Umbraco.Core/Models/IDataValueEditor.cs +++ b/src/Umbraco.Core/Models/IDataValueEditor.cs @@ -27,7 +27,6 @@ public interface IDataValueEditor /// bool SupportsReadOnly => false; - /// /// Gets the validators to use to validate the edited value. /// @@ -75,4 +74,6 @@ public interface IDataValueEditor XNode ConvertDbToXml(IPropertyType propertyType, object value); string ConvertDbToString(IPropertyType propertyType, object? value); + + IEnumerable ConfiguredElementTypeKeys() => Enumerable.Empty(); } diff --git a/src/Umbraco.Core/PropertyEditors/BlockListConfiguration.cs b/src/Umbraco.Core/PropertyEditors/BlockListConfiguration.cs index f398d75eac..7ebf2fd112 100644 --- a/src/Umbraco.Core/PropertyEditors/BlockListConfiguration.cs +++ b/src/Umbraco.Core/PropertyEditors/BlockListConfiguration.cs @@ -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(); [ConfigurationField("validationLimit")] public NumberRange ValidationLimit { get; set; } = new(); diff --git a/src/Umbraco.Core/PropertyEditors/DataEditor.cs b/src/Umbraco.Core/PropertyEditors/DataEditor.cs index f04e24c51b..3048162891 100644 --- a/src/Umbraco.Core/PropertyEditors/DataEditor.cs +++ b/src/Umbraco.Core/PropertyEditors/DataEditor.cs @@ -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; + /// [IgnoreDataMember] public bool IsDeprecated { get; } diff --git a/src/Umbraco.Core/PropertyEditors/DataValueEditor.cs b/src/Umbraco.Core/PropertyEditors/DataValueEditor.cs index 5faa60507a..e9d131c75a 100644 --- a/src/Umbraco.Core/PropertyEditors/DataValueEditor.cs +++ b/src/Umbraco.Core/PropertyEditors/DataValueEditor.cs @@ -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 ConfiguredElementTypeKeys() => Enumerable.Empty(); + /// /// Used to try to convert the string value to the correct CLR type based on the specified for /// this value editor. diff --git a/src/Umbraco.Core/PropertyEditors/IDataEditor.cs b/src/Umbraco.Core/PropertyEditors/IDataEditor.cs index 6db8049d23..5a95445fce 100644 --- a/src/Umbraco.Core/PropertyEditors/IDataEditor.cs +++ b/src/Umbraco.Core/PropertyEditors/IDataEditor.cs @@ -16,6 +16,8 @@ public interface IDataEditor : IDiscoverable bool SupportsReadOnly => false; + bool SupportsConfigurableElements => false; + /// /// Gets a value indicating whether the editor is deprecated. /// diff --git a/src/Umbraco.Core/PropertyEditors/RichTextConfiguration.cs b/src/Umbraco.Core/PropertyEditors/RichTextConfiguration.cs index a0c76b1ffd..5ebcb13b5d 100644 --- a/src/Umbraco.Core/PropertyEditors/RichTextConfiguration.cs +++ b/src/Umbraco.Core/PropertyEditors/RichTextConfiguration.cs @@ -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(); [ConfigurationField("mediaParentId")] public Guid? MediaParentId { get; set; } diff --git a/src/Umbraco.Core/PropertyEditors/ValueConverters/DatePickerValueConverter.cs b/src/Umbraco.Core/PropertyEditors/ValueConverters/DatePickerValueConverter.cs index 740e1b8f8e..6f9238f01f 100644 --- a/src/Umbraco.Core/PropertyEditors/ValueConverters/DatePickerValueConverter.cs +++ b/src/Umbraco.Core/PropertyEditors/ValueConverters/DatePickerValueConverter.cs @@ -20,23 +20,19 @@ public class DatePickerValueConverter : PropertyValueConverterBase internal static DateTime ParseDateTimeValue(object? source) { - if (source == null) + if (source is null) { return DateTime.MinValue; } - // in XML a DateTime is: string - format "yyyy-MM-ddTHH:mm:ss" - // Actually, not always sometimes it is formatted in UTC style with 'Z' suffixed on the end but that is due to this bug: - // http://issues.umbraco.org/issue/U4-4145, http://issues.umbraco.org/issue/U4-3894 - // We should just be using TryConvertTo instead. - if (source is string sourceString) + if (source is DateTime dateTimeValue) { - Attempt attempt = sourceString.TryConvertTo(); - return attempt.Success == false ? DateTime.MinValue : attempt.Result; + return dateTimeValue; } - // in the database a DateTime is: DateTime - // default value is: DateTime.MinValue - return source is DateTime dateTimeValue ? dateTimeValue : DateTime.MinValue; + Attempt attempt = source.TryConvertTo(); + return attempt.Success + ? attempt.Result + : DateTime.MinValue; } } diff --git a/src/Umbraco.Core/Security/Authorization/IAuthorizationHelper.cs b/src/Umbraco.Core/Security/Authorization/IAuthorizationHelper.cs index fb8d1cda35..d197770a00 100644 --- a/src/Umbraco.Core/Security/Authorization/IAuthorizationHelper.cs +++ b/src/Umbraco.Core/Security/Authorization/IAuthorizationHelper.cs @@ -24,5 +24,17 @@ public interface IAuthorizationHelper /// The current user's principal. /// The resulting , if the conversion is successful. /// True if the conversion is successful, false otherwise - bool TryGetUmbracoUser(IPrincipal currentUser, [NotNullWhen(true)] out IUser? user); + bool TryGetUmbracoUser(IPrincipal currentUser, [NotNullWhen(true)] out IUser? user) + { + try + { + user = GetUmbracoUser(currentUser); + return true; + } + catch + { + user = null; + return false; + } + } } diff --git a/src/Umbraco.Core/Services/ContentTypeEditing/ContentTypeEditingService.cs b/src/Umbraco.Core/Services/ContentTypeEditing/ContentTypeEditingService.cs index 9c96fe64a4..e44b66b971 100644 --- a/src/Umbraco.Core/Services/ContentTypeEditing/ContentTypeEditingService.cs +++ b/src/Umbraco.Core/Services/ContentTypeEditing/ContentTypeEditingService.cs @@ -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, 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(); } public async Task> CreateAsync(ContentTypeCreateModel model, Guid userKey) @@ -52,13 +72,20 @@ internal sealed class ContentTypeEditingService : ContentTypeEditingServiceBase< public async Task> UpdateAsync(IContentType contentType, ContentTypeUpdateModel model, Guid userKey) { - Attempt 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.Fail(elementValidationStatus); } - contentType = result.Result ?? throw new InvalidOperationException($"{nameof(ValidateAndMapForUpdateAsync)} succeeded but did not yield any result"); + Attempt 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 AdditionalCreateValidationAsync( + ContentTypeEditingModelBase 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 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; + } + + /// + /// Should be called after it has been established that the composition list is in a valid state and the (composition) parent exists + /// + private async Task ValidateCreateParentElementStatusAsync( + ContentTypeEditingModelBase 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); diff --git a/src/Umbraco.Core/Services/ContentTypeEditing/ContentTypeEditingServiceBase.cs b/src/Umbraco.Core/Services/ContentTypeEditing/ContentTypeEditingServiceBase.cs index 46c4508095..8e602a01f1 100644 --- a/src/Umbraco.Core/Services/ContentTypeEditing/ContentTypeEditingServiceBase.cs +++ b/src/Umbraco.Core/Services/ContentTypeEditing/ContentTypeEditingServiceBase.cs @@ -91,6 +91,8 @@ internal abstract class ContentTypeEditingServiceBase(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(ContentTypeOperationStatus.Success, contentType); } + protected virtual async Task AdditionalCreateValidationAsync( + ContentTypeEditingModelBase model) + => await Task.FromResult(ContentTypeOperationStatus.Success); + #region Sanitization private void SanitizeModelAliases(ContentTypeEditingModelBase model) diff --git a/src/Umbraco.Core/Services/ContentTypeEditing/ElementSwitchValidator.cs b/src/Umbraco.Core/Services/ContentTypeEditing/ElementSwitchValidator.cs new file mode 100644 index 0000000000..cc597b587f --- /dev/null +++ b/src/Umbraco.Core/Services/ContentTypeEditing/ElementSwitchValidator.cs @@ -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 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 DescendantsAreAlignedAsync(IContentType contentType) + { + IEnumerable 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 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 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 DocumentToElementHasNoContentAsync(IContentTypeBase contentType) => + + // if any content for the content type exists, the validation fails. + await Task.FromResult(_contentTypeService.HasContentNodes(contentType.Id) is false); +} diff --git a/src/Umbraco.Core/Services/ContentTypeEditing/IElementSwitchValidator.cs b/src/Umbraco.Core/Services/ContentTypeEditing/IElementSwitchValidator.cs new file mode 100644 index 0000000000..8816f99ec8 --- /dev/null +++ b/src/Umbraco.Core/Services/ContentTypeEditing/IElementSwitchValidator.cs @@ -0,0 +1,14 @@ +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Services.ContentTypeEditing; + +public interface IElementSwitchValidator +{ + Task AncestorsAreAlignedAsync(IContentType contentType); + + Task DescendantsAreAlignedAsync(IContentType contentType); + + Task ElementToDocumentNotUsedInBlockStructuresAsync(IContentTypeBase contentType); + + Task DocumentToElementHasNoContentAsync(IContentTypeBase contentType); +} diff --git a/src/Umbraco.Core/Services/DataTypeService.cs b/src/Umbraco.Core/Services/DataTypeService.cs index 5564802f3c..382238d583 100644 --- a/src/Umbraco.Core/Services/DataTypeService.cs +++ b/src/Umbraco.Core/Services/DataTypeService.cs @@ -330,6 +330,16 @@ namespace Umbraco.Cms.Core.Services.Implement return Task.FromResult(dataTypes); } + + /// + public async Task> GetByEditorAliasAsync(string[] propertyEditorAlias) + { + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + IQuery query = Query().Where(x => propertyEditorAlias.Contains(x.EditorAlias)); + IEnumerable dataTypes = _dataTypeRepository.Get(query).ToArray(); + ConvertMissingEditorsOfDataTypesToLabels(dataTypes); + return await Task.FromResult(dataTypes); + } /// public Task> GetByEditorUiAlias(string editorUiAlias) diff --git a/src/Umbraco.Core/Services/DomainService.cs b/src/Umbraco.Core/Services/DomainService.cs index b4e103f1a4..c527e40b82 100644 --- a/src/Umbraco.Core/Services/DomainService.cs +++ b/src/Umbraco.Core/Services/DomainService.cs @@ -6,6 +6,7 @@ using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Notifications; using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Core.Routing; using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Services.OperationStatus; using Umbraco.Extensions; @@ -201,6 +202,11 @@ public class DomainService : RepositoryService, IDomainService foreach (DomainModel domainModel in updateModel.Domains) { domainModel.DomainName = domainModel.DomainName.ToLowerInvariant(); + + if(Uri.IsWellFormedUriString(domainModel.DomainName, UriKind.RelativeOrAbsolute) is false) + { + return Attempt.FailWithStatus(DomainOperationStatus.InvalidDomainName, new DomainUpdateResult()); + } } // make sure we're not attempting to assign duplicate domains diff --git a/src/Umbraco.Core/Services/IDataTypeService.cs b/src/Umbraco.Core/Services/IDataTypeService.cs index 8aefd3b93f..8536c8a528 100644 --- a/src/Umbraco.Core/Services/IDataTypeService.cs +++ b/src/Umbraco.Core/Services/IDataTypeService.cs @@ -240,4 +240,11 @@ public interface IDataTypeService : IService /// The data type whose configuration to validate. /// One or more if the configuration data is invalid, an empty collection otherwise. IEnumerable ValidateConfigurationData(IDataType dataType); + + /// + /// Gets all for a set of property editors + /// + /// Aliases of the property editors + /// Collection of configured for the property editors + Task> GetByEditorAliasAsync(string[] propertyEditorAlias); } diff --git a/src/Umbraco.Core/Services/MediaService.cs b/src/Umbraco.Core/Services/MediaService.cs index 755b36ae82..c754aa244a 100644 --- a/src/Umbraco.Core/Services/MediaService.cs +++ b/src/Umbraco.Core/Services/MediaService.cs @@ -1002,7 +1002,6 @@ namespace Umbraco.Cms.Core.Services MoveToRecycleBinEventInfo[] moveInfo = moves.Select(x => new MoveToRecycleBinEventInfo(x.Item1, x.Item2)).ToArray(); scope.Notifications.Publish(new MediaMovedToRecycleBinNotification(moveInfo, messages).WithStateFrom(movingToRecycleBinNotification)); Audit(AuditType.Move, userId, media.Id, "Move Media to recycle bin"); - scope.Complete(); } diff --git a/src/Umbraco.Core/Services/OperationStatus/ContentTypeOperationStatus.cs b/src/Umbraco.Core/Services/OperationStatus/ContentTypeOperationStatus.cs index 8b52d921ee..32f28eaf16 100644 --- a/src/Umbraco.Core/Services/OperationStatus/ContentTypeOperationStatus.cs +++ b/src/Umbraco.Core/Services/OperationStatus/ContentTypeOperationStatus.cs @@ -21,4 +21,7 @@ public enum ContentTypeOperationStatus NotFound, NotAllowed, CancelledByNotification, + InvalidElementFlagDocumentHasContent, + InvalidElementFlagElementIsUsedInPropertyEditorConfiguration, + InvalidElementFlagComparedToParent, } diff --git a/src/Umbraco.Core/Services/OperationStatus/DomainOperationStatus.cs b/src/Umbraco.Core/Services/OperationStatus/DomainOperationStatus.cs index a752684b2e..ba19e2bc3f 100644 --- a/src/Umbraco.Core/Services/OperationStatus/DomainOperationStatus.cs +++ b/src/Umbraco.Core/Services/OperationStatus/DomainOperationStatus.cs @@ -7,5 +7,6 @@ public enum DomainOperationStatus ContentNotFound, LanguageNotFound, DuplicateDomainName, - ConflictingDomainName + ConflictingDomainName, + InvalidDomainName } diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs index e12c96557c..6201691d9f 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs @@ -407,6 +407,11 @@ public static partial class UmbracoBuilderExtensions builder .AddNotificationHandler(); + // Handlers for save warnings + builder + .AddNotificationAsyncHandler() + .AddNotificationAsyncHandler(); + return builder; } diff --git a/src/Umbraco.Infrastructure/Events/RelateOnTrashNotificationHandler.cs b/src/Umbraco.Infrastructure/Events/RelateOnTrashNotificationHandler.cs index c2a434dd54..48113586b0 100644 --- a/src/Umbraco.Infrastructure/Events/RelateOnTrashNotificationHandler.cs +++ b/src/Umbraco.Infrastructure/Events/RelateOnTrashNotificationHandler.cs @@ -2,12 +2,14 @@ // See LICENSE for more details. using System.Globalization; +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Notifications; using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; using Umbraco.Extensions; -using IScope = Umbraco.Cms.Infrastructure.Scoping.IScope; namespace Umbraco.Cms.Core.Events; @@ -21,21 +23,35 @@ public sealed class RelateOnTrashNotificationHandler : private readonly IAuditService _auditService; private readonly IEntityService _entityService; private readonly IRelationService _relationService; - private readonly IScopeProvider _scopeProvider; + private readonly ICoreScopeProvider _scopeProvider; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; private readonly ILocalizedTextService _textService; + [Obsolete("Use the new constructor instead, will be removed in V16")] public RelateOnTrashNotificationHandler( IRelationService relationService, IEntityService entityService, ILocalizedTextService textService, IAuditService auditService, IScopeProvider scopeProvider) + : this(relationService, entityService, textService, auditService, scopeProvider, StaticServiceProvider.Instance.GetRequiredService()) + { + } + + public RelateOnTrashNotificationHandler( + IRelationService relationService, + IEntityService entityService, + ILocalizedTextService textService, + IAuditService auditService, + IScopeProvider scopeProvider, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor) { _relationService = relationService; _entityService = entityService; _textService = textService; _auditService = auditService; _scopeProvider = scopeProvider; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; } public void Handle(ContentMovedNotification notification) @@ -56,7 +72,7 @@ public sealed class RelateOnTrashNotificationHandler : public void Handle(ContentMovedToRecycleBinNotification notification) { - using (IScope scope = _scopeProvider.CreateScope()) + using (ICoreScope scope = _scopeProvider.CreateCoreScope()) { const string relationTypeAlias = Constants.Conventions.RelationTypes.RelateParentDocumentOnDeleteAlias; IRelationType? relationType = _relationService.GetRelationTypeByAlias(relationTypeAlias); @@ -90,7 +106,7 @@ public sealed class RelateOnTrashNotificationHandler : _auditService.Add( AuditType.Delete, - item.Entity.WriterId, + _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? item.Entity.WriterId, item.Entity.Id, UmbracoObjectTypes.Document.GetName(), string.Format(_textService.Localize("recycleBin", "contentTrashed"), item.Entity.Id, originalParentId)); @@ -118,7 +134,7 @@ public sealed class RelateOnTrashNotificationHandler : public void Handle(MediaMovedToRecycleBinNotification notification) { - using (IScope scope = _scopeProvider.CreateScope()) + using (ICoreScope scope = _scopeProvider.CreateCoreScope()) { const string relationTypeAlias = Constants.Conventions.RelationTypes.RelateParentMediaFolderOnDeleteAlias; IRelationType? relationType = _relationService.GetRelationTypeByAlias(relationTypeAlias); @@ -150,7 +166,7 @@ public sealed class RelateOnTrashNotificationHandler : _relationService.Save(relation); _auditService.Add( AuditType.Delete, - item.Entity.CreatorId, + _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? item.Entity.WriterId, item.Entity.Id, UmbracoObjectTypes.Media.GetName(), string.Format(_textService.Localize("recycleBin", "mediaTrashed"), item.Entity.Id, originalParentId)); diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs index 8502e4c529..d2bed38ce3 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs @@ -88,5 +88,8 @@ public class UmbracoPlan : MigrationPlan // To 14.1.0 To("{FEF2DAF4-5408-4636-BB0E-B8798DF8F095}"); To("{A385C5DF-48DC-46B4-A742-D5BB846483BC}"); + + // To 14.2.0 + To("{20ED404C-6FF9-4F91-8AC9-2B298E0002EB}"); } } diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_14_2_0/AddMissingDateTimeConfiguration.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_14_2_0/AddMissingDateTimeConfiguration.cs new file mode 100644 index 0000000000..6691ed7e5b --- /dev/null +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_14_2_0/AddMissingDateTimeConfiguration.cs @@ -0,0 +1,50 @@ +using NPoco; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Infrastructure.Persistence; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_14_2_0; + +public class AddMissingDateTimeConfiguration : MigrationBase +{ + private readonly IConfigurationEditorJsonSerializer _configurationEditorJsonSerializer; + + public AddMissingDateTimeConfiguration(IMigrationContext context, IConfigurationEditorJsonSerializer configurationEditorJsonSerializer) + : base(context) + => _configurationEditorJsonSerializer = configurationEditorJsonSerializer; + + protected override void Migrate() + { + Sql sql = Sql() + .Select() + .From() + .Where(dto => + dto.NodeId == Constants.DataTypes.DateTime + && dto.EditorAlias.Equals(Constants.PropertyEditors.Aliases.DateTime)); + + DataTypeDto? dataTypeDto = Database.FirstOrDefault(sql); + if (dataTypeDto is null) + { + return; + } + + Dictionary configurationData = dataTypeDto.Configuration.IsNullOrWhiteSpace() + ? new Dictionary() + : _configurationEditorJsonSerializer + .Deserialize>(dataTypeDto.Configuration)? + .Where(item => item.Value is not null) + .ToDictionary(item => item.Key, item => item.Value!) + ?? new Dictionary(); + + // only proceed with the migration if the data-type has no format assigned + if (configurationData.TryAdd("format", "YYYY-MM-DD HH:mm:ss") is false) + { + return; + } + + dataTypeDto.Configuration = _configurationEditorJsonSerializer.Serialize(configurationData); + Database.Update(dataTypeDto); + } +} diff --git a/src/Umbraco.Infrastructure/PropertyEditors/BlockGridPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/BlockGridPropertyEditor.cs index 8b6ef4862c..6fe0e3882d 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/BlockGridPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/BlockGridPropertyEditor.cs @@ -22,6 +22,7 @@ public class BlockGridPropertyEditor : BlockGridPropertyEditorBase : base(dataValueEditorFactory, blockValuePropertyIndexValueFactory) => _ioHelper = ioHelper; + public override bool SupportsConfigurableElements => true; #region Pre Value Editor diff --git a/src/Umbraco.Infrastructure/PropertyEditors/BlockGridPropertyEditorBase.cs b/src/Umbraco.Infrastructure/PropertyEditors/BlockGridPropertyEditorBase.cs index bfd0e61e0b..254933eb8f 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/BlockGridPropertyEditorBase.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/BlockGridPropertyEditorBase.cs @@ -111,6 +111,12 @@ public abstract class BlockGridPropertyEditorBase : DataEditor return validationResults; } } + + public override IEnumerable ConfiguredElementTypeKeys() + { + var configuration = ConfigurationObject as BlockGridConfiguration; + return configuration?.Blocks.SelectMany(ConfiguredElementTypeKeys) ?? Enumerable.Empty(); + } } #endregion diff --git a/src/Umbraco.Infrastructure/PropertyEditors/BlockListPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/BlockListPropertyEditor.cs index ca10cc8f76..bbb368dec1 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/BlockListPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/BlockListPropertyEditor.cs @@ -36,6 +36,8 @@ public class BlockListPropertyEditor : BlockListPropertyEditorBase { } + public override bool SupportsConfigurableElements => true; + #region Pre Value Editor protected override IConfigurationEditor CreateConfigurationEditor() => diff --git a/src/Umbraco.Infrastructure/PropertyEditors/BlockListPropertyEditorBase.cs b/src/Umbraco.Infrastructure/PropertyEditors/BlockListPropertyEditorBase.cs index 8d84254f16..2e37056cd2 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/BlockListPropertyEditorBase.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/BlockListPropertyEditorBase.cs @@ -93,6 +93,12 @@ public abstract class BlockListPropertyEditorBase : DataEditor return ValidateNumberOfBlocks(blockEditorData, validationLimit.Min, validationLimit.Max); } } + + public override IEnumerable ConfiguredElementTypeKeys() + { + var configuration = ConfigurationObject as BlockListConfiguration; + return configuration?.Blocks.SelectMany(ConfiguredElementTypeKeys) ?? Enumerable.Empty(); + } } #endregion diff --git a/src/Umbraco.Infrastructure/PropertyEditors/BlockValuePropertyValueEditorBase.cs b/src/Umbraco.Infrastructure/PropertyEditors/BlockValuePropertyValueEditorBase.cs index 39806b7a1b..f2178fd71a 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/BlockValuePropertyValueEditorBase.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/BlockValuePropertyValueEditorBase.cs @@ -114,6 +114,15 @@ public abstract class BlockValuePropertyValueEditorBase : DataV MapBlockItemDataToEditor(property, blockValue.SettingsData); } + protected IEnumerable ConfiguredElementTypeKeys(IBlockConfiguration configuration) + { + yield return configuration.ContentElementTypeKey; + if (configuration.SettingsElementTypeKey is not null) + { + yield return configuration.SettingsElementTypeKey.Value; + } + } + private void MapBlockItemDataToEditor(IProperty property, List items) { var valEditors = new Dictionary(); diff --git a/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditor.cs index 619e98743d..025bdd887c 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditor.cs @@ -47,6 +47,8 @@ public class RichTextPropertyEditor : DataEditor public override IPropertyIndexValueFactory PropertyIndexValueFactory => _richTextPropertyIndexValueFactory; + public override bool SupportsConfigurableElements => true; + /// /// Create a custom value editor /// @@ -238,6 +240,12 @@ public class RichTextPropertyEditor : DataEditor return RichTextPropertyEditorHelper.SerializeRichTextEditorValue(cleanedUpRichTextEditorValue, _jsonSerializer); } + public override IEnumerable ConfiguredElementTypeKeys() + { + var configuration = ConfigurationObject as RichTextConfiguration; + return configuration?.Blocks?.SelectMany(ConfiguredElementTypeKeys) ?? Enumerable.Empty(); + } + private bool TryParseEditorValue(object? value, [NotNullWhen(true)] out RichTextEditorValue? richTextEditorValue) => RichTextPropertyEditorHelper.TryParseRichTextEditorValue(value, _jsonSerializer, _logger, out richTextEditorValue); diff --git a/src/Umbraco.Web.UI.Client b/src/Umbraco.Web.UI.Client index 97f256a195..bb6abdc884 160000 --- a/src/Umbraco.Web.UI.Client +++ b/src/Umbraco.Web.UI.Client @@ -1 +1 @@ -Subproject commit 97f256a195d8301bcc68ba21ed879240cd0d663d +Subproject commit bb6abdc88452bbd3a47bf867dcb1332f536ad264 diff --git a/tests/Umbraco.Tests.AcceptanceTest/package-lock.json b/tests/Umbraco.Tests.AcceptanceTest/package-lock.json index 68293e0e27..264945f43c 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/package-lock.json +++ b/tests/Umbraco.Tests.AcceptanceTest/package-lock.json @@ -7,8 +7,8 @@ "name": "acceptancetest", "hasInstallScript": true, "dependencies": { - "@umbraco/json-models-builders": "^2.0.13", - "@umbraco/playwright-testhelpers": "^2.0.0-beta.70", + "@umbraco/json-models-builders": "^2.0.14", + "@umbraco/playwright-testhelpers": "^2.0.0-beta.73", "camelize": "^1.0.0", "dotenv": "^16.3.1", "faker": "^4.1.0", @@ -132,19 +132,19 @@ } }, "node_modules/@umbraco/json-models-builders": { - "version": "2.0.13", - "resolved": "https://registry.npmjs.org/@umbraco/json-models-builders/-/json-models-builders-2.0.13.tgz", - "integrity": "sha512-HeI6I2BO8/3rJyinJTFxhpBSr/TaCc+S1Si+9SXIlze+Erq+yraor706mQDsgIuLfUzAYgmLLoQFxMVof/P7Kw==", + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/@umbraco/json-models-builders/-/json-models-builders-2.0.14.tgz", + "integrity": "sha512-fP6hVSSph1iFQ1c65UH80AM6QK3r1CzuIiYOvZh+QOoVzpVFtH1VCHL3J2k8AwaHWLVAEopcvtvH5kkl7Luqww==", "dependencies": { "camelize": "^1.0.1" } }, "node_modules/@umbraco/playwright-testhelpers": { - "version": "2.0.0-beta.70", - "resolved": "https://registry.npmjs.org/@umbraco/playwright-testhelpers/-/playwright-testhelpers-2.0.0-beta.70.tgz", - "integrity": "sha512-voNem+L8Nct5bsjbS8HLWK2BCOh7gu834OS487Os1Mj4ebZbHk04YkBYNh7X8HWLNZ1NYqTquAWbFb9RxEgnnw==", + "version": "2.0.0-beta.73", + "resolved": "https://registry.npmjs.org/@umbraco/playwright-testhelpers/-/playwright-testhelpers-2.0.0-beta.73.tgz", + "integrity": "sha512-CCURatZa7Ipui9ZTqdZmkpx89Sr5AJLoXogniq6mv84mSVGeCQFYzHvw1op2UE8nkKY5/wyqfrCihjrbW5v8lw==", "dependencies": { - "@umbraco/json-models-builders": "2.0.13", + "@umbraco/json-models-builders": "2.0.14", "node-fetch": "^2.6.7" } }, diff --git a/tests/Umbraco.Tests.AcceptanceTest/package.json b/tests/Umbraco.Tests.AcceptanceTest/package.json index fdb6ab3b7c..9cccd40a36 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/package.json +++ b/tests/Umbraco.Tests.AcceptanceTest/package.json @@ -21,8 +21,8 @@ "wait-on": "^7.2.0" }, "dependencies": { - "@umbraco/json-models-builders": "^2.0.13", - "@umbraco/playwright-testhelpers": "^2.0.0-beta.70", + "@umbraco/json-models-builders": "^2.0.14", + "@umbraco/playwright-testhelpers": "^2.0.0-beta.73", "camelize": "^1.0.0", "dotenv": "^16.3.1", "faker": "^4.1.0", diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ChildrenContent.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ChildrenContent.spec.ts index 20067cd7f4..176f19c2b3 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ChildrenContent.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ChildrenContent.spec.ts @@ -24,7 +24,7 @@ test.afterEach(async ({umbracoApi}) => { test('can create child node', {tag: '@smoke'}, async ({umbracoApi, umbracoUi}) => { // Arrange childDocumentTypeId = await umbracoApi.documentType.createDefaultDocumentType(childDocumentTypeName); - documentTypeId = await umbracoApi.documentType.createDocumentTypeWithAllowedChildNode(documentTypeName, childDocumentTypeId, true); + documentTypeId = await umbracoApi.documentType.createDocumentTypeWithAllowedChildNode(documentTypeName, childDocumentTypeId); contentId = await umbracoApi.document.createDefaultDocument(contentName, documentTypeId); await umbracoUi.goToBackOffice(); await umbracoUi.content.goToSection(ConstantHelper.sections.content); @@ -33,11 +33,11 @@ test('can create child node', {tag: '@smoke'}, async ({umbracoApi, umbracoUi}) = await umbracoUi.content.clickActionsMenuForContent(contentName); await umbracoUi.content.clickCreateButton(); await umbracoUi.content.chooseDocumentType(childDocumentTypeName); - // This wait is needed + // This wait is needed await umbracoUi.waitForTimeout(500); await umbracoUi.content.enterContentName(childContentName); await umbracoUi.content.clickSaveButton(); - + // Assert await umbracoUi.content.isSuccessNotificationVisible(); expect(await umbracoApi.document.doesNameExist(childContentName)).toBeTruthy(); @@ -61,9 +61,9 @@ test('can create child node in child node', async ({umbracoApi, umbracoUi}) => { let childContentId: any; await umbracoApi.documentType.ensureNameNotExists(childOfChildDocumentTypeName); childOfChildDocumentTypeId = await umbracoApi.documentType.createDefaultDocumentType(childOfChildDocumentTypeName); - childDocumentTypeId = await umbracoApi.documentType.createDocumentTypeWithAllowedChildNode(childDocumentTypeName, childOfChildDocumentTypeId, true); - documentTypeId = await umbracoApi.documentType.createDocumentTypeWithAllowedChildNode(documentTypeName, childDocumentTypeId, true); - contentId = await umbracoApi.document.createDefaultDocument(contentName, documentTypeId); + childDocumentTypeId = await umbracoApi.documentType.createDocumentTypeWithAllowedChildNode(childDocumentTypeName, childOfChildDocumentTypeId); + documentTypeId = await umbracoApi.documentType.createDocumentTypeWithAllowedChildNode(documentTypeName, childDocumentTypeId); + contentId = await umbracoApi.document.createDefaultDocument(contentName, documentTypeId); childContentId = await umbracoApi.document.createDefaultDocumentWithParent(childContentName, childDocumentTypeId, contentId); await umbracoUi.goToBackOffice(); await umbracoUi.content.goToSection(ConstantHelper.sections.content); @@ -73,7 +73,7 @@ test('can create child node in child node', async ({umbracoApi, umbracoUi}) => { await umbracoUi.content.clickActionsMenuForContent(childContentName); await umbracoUi.content.clickCreateButton(); await umbracoUi.content.chooseDocumentType(childOfChildDocumentTypeName); - // This wait is needed + // This wait is needed await umbracoUi.waitForTimeout(500); await umbracoUi.content.enterContentName(childOfChildContentName); await umbracoUi.content.clickSaveButton(); @@ -94,7 +94,7 @@ test('can create child node in child node', async ({umbracoApi, umbracoUi}) => { test('cannot publish child if the parent is not published', async ({umbracoApi, umbracoUi}) => { // Arrange childDocumentTypeId = await umbracoApi.documentType.createDefaultDocumentType(childDocumentTypeName); - documentTypeId = await umbracoApi.documentType.createDocumentTypeWithAllowedChildNode(documentTypeName, childDocumentTypeId, true); + documentTypeId = await umbracoApi.documentType.createDocumentTypeWithAllowedChildNode(documentTypeName, childDocumentTypeId); contentId = await umbracoApi.document.createDefaultDocument(contentName, documentTypeId); await umbracoApi.document.createDefaultDocumentWithParent(childContentName, childDocumentTypeId, contentId); await umbracoUi.goToBackOffice(); @@ -114,7 +114,7 @@ test('cannot publish child if the parent is not published', async ({umbracoApi, test('can publish with descendants', {tag: '@smoke'}, async ({umbracoApi, umbracoUi}) => { // Arrange childDocumentTypeId = await umbracoApi.documentType.createDefaultDocumentType(childDocumentTypeName); - documentTypeId = await umbracoApi.documentType.createDocumentTypeWithAllowedChildNode(documentTypeName, childDocumentTypeId, true); + documentTypeId = await umbracoApi.documentType.createDocumentTypeWithAllowedChildNode(documentTypeName, childDocumentTypeId); contentId = await umbracoApi.document.createDefaultDocument(contentName, documentTypeId); await umbracoApi.document.createDefaultDocumentWithParent(childContentName, childDocumentTypeId, contentId); await umbracoUi.goToBackOffice(); diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/Content.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/Content.spec.ts index 98f0a6ec21..0c7a869eb0 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/Content.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/Content.spec.ts @@ -91,7 +91,7 @@ test('can rename content', async ({umbracoApi, umbracoUi}) => { await umbracoUi.content.goToSection(ConstantHelper.sections.content); // Act - await umbracoUi.content.openContent(wrongContentName); + await umbracoUi.content.goToContentWithName(wrongContentName); await umbracoUi.content.enterContentName(contentName); await umbracoUi.content.clickSaveButton(); @@ -111,7 +111,7 @@ test('can update content', {tag: '@smoke'}, async ({umbracoApi, umbracoUi}) => { await umbracoUi.content.goToSection(ConstantHelper.sections.content); // Act - await umbracoUi.content.openContent(contentName); + await umbracoUi.content.goToContentWithName(contentName); await umbracoUi.content.enterTextstring(contentText); await umbracoUi.content.clickSaveButton(); @@ -159,4 +159,4 @@ test('can unpublish content', {tag: '@smoke'}, async ({umbracoApi, umbracoUi}) = await umbracoUi.content.isSuccessNotificationVisible(); const contentData = await umbracoApi.document.getByName(contentName); expect(contentData.variants[0].state).toBe('Draft'); -}); \ No newline at end of file +}); diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentInfoTab.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentInfoTab.spec.ts index 5ec48a6a67..b2cbeb9c6e 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentInfoTab.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentInfoTab.spec.ts @@ -27,11 +27,11 @@ test('can see correct information when published', async ({umbracoApi, umbracoUi await umbracoUi.content.goToSection(ConstantHelper.sections.content); // Act - await umbracoUi.content.openContent(contentName); + await umbracoUi.content.goToContentWithName(contentName); await umbracoUi.content.clickInfoTab(); await umbracoUi.content.doesLinkHaveText(notPublishContentLink); await umbracoUi.content.clickSaveAndPublishButton(); - + // Assert const contentData = await umbracoApi.document.get(contentId); // TODO: Uncomment this when front-end is ready. Currently the link is not updated immediately after publishing @@ -58,7 +58,7 @@ test('can open document type', async ({umbracoApi, umbracoUi}) => { await umbracoUi.content.goToSection(ConstantHelper.sections.content); // Act - await umbracoUi.content.openContent(contentName); + await umbracoUi.content.goToContentWithName(contentName); await umbracoUi.content.clickDocumentTypeByName(documentTypeName); // Assert @@ -74,9 +74,9 @@ test('can open template', async ({umbracoApi, umbracoUi}) => { contentId = await umbracoApi.document.createDocumentWithTemplate(contentName, documentTypeId, templateId); await umbracoUi.goToBackOffice(); await umbracoUi.content.goToSection(ConstantHelper.sections.content); - + // Act - await umbracoUi.content.openContent(contentName); + await umbracoUi.content.goToContentWithName(contentName); await umbracoUi.content.clickTemplateByName(templateName); // Assert @@ -100,7 +100,7 @@ test('can change template', async ({umbracoApi, umbracoUi}) => { await umbracoUi.content.goToSection(ConstantHelper.sections.content); // Act - await umbracoUi.content.openContent(contentName); + await umbracoUi.content.goToContentWithName(contentName); await umbracoUi.content.changeTemplate(firstTemplateName, secondTemplateName); await umbracoUi.content.clickSaveButton(); @@ -127,7 +127,7 @@ test('cannot change to a template that is not allowed in the document type', asy await umbracoUi.content.goToSection(ConstantHelper.sections.content); // Act - await umbracoUi.content.openContent(contentName); + await umbracoUi.content.goToContentWithName(contentName); await umbracoUi.content.clickEditTemplateByName(firstTemplateName); // Assert diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentWithContentPicker.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentWithContentPicker.spec.ts index 478cc7e3b4..ce18c79e9a 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentWithContentPicker.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentWithContentPicker.spec.ts @@ -98,7 +98,7 @@ test('can choose start node for the content picker in the content', async ({umbr await umbracoApi.documentType.ensureNameNotExists(childContentPickerDocumentTypeName); const childContentPickerDocumentTypeId = await umbracoApi.documentType.createDefaultDocumentType(childContentPickerDocumentTypeName); const contentPickerDocumentTypeId = await umbracoApi.documentType.createDocumentTypeWithAllowedChildNode(contentPickerName, childContentPickerDocumentTypeId); - const contentPickerId = await umbracoApi.document.createDefaultDocument(contentPickerName, contentPickerDocumentTypeId); + const contentPickerId = await umbracoApi.document.createDefaultDocument(contentPickerName, contentPickerDocumentTypeId); await umbracoApi.document.createDefaultDocumentWithParent(childContentPickerName, childContentPickerDocumentTypeId, contentPickerId); // Create a custom content picker with start node const customDataTypeId = await umbracoApi.dataType.createContentPickerDataTypeWithStartNode(customDataTypeName, contentPickerId); @@ -129,7 +129,7 @@ test.skip('can ignore user start node for the content picker in the content', as await umbracoApi.documentType.ensureNameNotExists(childContentPickerDocumentTypeName); const childContentPickerDocumentTypeId = await umbracoApi.documentType.createDefaultDocumentType(childContentPickerDocumentTypeName); const contentPickerDocumentTypeId = await umbracoApi.documentType.createDocumentTypeWithAllowedChildNode(contentPickerName, childContentPickerDocumentTypeId); - const contentPickerId = await umbracoApi.document.createDefaultDocument(contentPickerName, contentPickerDocumentTypeId); + const contentPickerId = await umbracoApi.document.createDefaultDocument(contentPickerName, contentPickerDocumentTypeId); await umbracoApi.document.createDefaultDocumentWithParent(childContentPickerName, childContentPickerDocumentTypeId, contentPickerId); // Create a custom content picker with the setting "ignore user start node" is enable const customDataTypeId = await umbracoApi.dataType.createContentPickerDataTypeWithIgnoreUserStartNodes(customDataTypeName, contentPickerId); @@ -161,7 +161,7 @@ test('can remove content picker in the content', async ({umbracoApi, umbracoUi}) await umbracoUi.content.goToSection(ConstantHelper.sections.content); // Act - await umbracoUi.content.openContent(contentName); + await umbracoUi.content.goToContentWithName(contentName); await umbracoUi.content.removeContentPicker(contentPickerName); await umbracoUi.content.clickSaveButton(); diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentWithImageMediaPicker.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentWithImageMediaPicker.spec.ts new file mode 100644 index 0000000000..5dfb9cafa0 --- /dev/null +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentWithImageMediaPicker.spec.ts @@ -0,0 +1,257 @@ +import {AliasHelper, ConstantHelper, test} from '@umbraco/playwright-testhelpers'; +import {expect} from "@playwright/test"; + +const contentName = 'TestContent'; +const documentTypeName = 'TestDocumentTypeForContent'; +const dataTypeName = 'Image Media Picker'; +const customDataTypeName = 'Custom Image Media Picker'; +const groupName = 'TestGroup'; +const mediaName = 'TestImage'; + +test.beforeEach(async ({umbracoApi}) => { + await umbracoApi.document.ensureNameNotExists(contentName); + await umbracoApi.documentType.ensureNameNotExists(documentTypeName); +}); + +test.afterEach(async ({umbracoApi}) => { + await umbracoApi.document.ensureNameNotExists(contentName); + await umbracoApi.documentType.ensureNameNotExists(documentTypeName); +}); + +test('can save content with a image media picker', async ({umbracoApi, umbracoUi}) => { + // Arrange + const expectedState = 'Draft'; + const dataType = await umbracoApi.dataType.getByName(dataTypeName); + await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(documentTypeName, dataTypeName, dataType.id, groupName); + await umbracoUi.goToBackOffice(); + await umbracoUi.content.goToSection(ConstantHelper.sections.content); + + // Act + await umbracoUi.content.clickActionsMenuAtRoot(); + await umbracoUi.content.clickCreateButton(); + await umbracoUi.content.chooseDocumentType(documentTypeName); + await umbracoUi.content.enterContentName(contentName); + await umbracoUi.content.clickSaveButton(); + + // Assert + await umbracoUi.content.isSuccessNotificationVisible(); + expect(await umbracoApi.document.doesNameExist(contentName)).toBeTruthy(); + const contentData = await umbracoApi.document.getByName(contentName); + expect(contentData.variants[0].state).toBe(expectedState); +}); + +test('can publish content with a image media picker', async ({umbracoApi, umbracoUi}) => { + // Arrange + const expectedState = 'Published'; + const dataType = await umbracoApi.dataType.getByName(dataTypeName); + const documentTypeId = await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(documentTypeName, dataTypeName, dataType.id, groupName); + await umbracoApi.document.createDefaultDocument(contentName, documentTypeId); + await umbracoUi.goToBackOffice(); + await umbracoUi.content.goToSection(ConstantHelper.sections.content); + + // Act + await umbracoUi.content.goToContentWithName(contentName); + await umbracoUi.content.clickSaveAndPublishButton(); + + // Assert + await umbracoUi.content.doesSuccessNotificationsHaveCount(2); + expect(await umbracoApi.document.doesNameExist(contentName)).toBeTruthy(); + const contentData = await umbracoApi.document.getByName(contentName); + expect(contentData.variants[0].state).toBe(expectedState); +}); + +test('can add an image to the image media picker', async ({umbracoApi, umbracoUi}) => { + // Arrange + const dataType = await umbracoApi.dataType.getByName(dataTypeName); + await umbracoApi.media.ensureNameNotExists(mediaName); + const imageId = await umbracoApi.media.createDefaultMediaWithImage(mediaName); + const documentTypeId = await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(documentTypeName, dataTypeName, dataType.id, groupName); + await umbracoApi.document.createDefaultDocument(contentName, documentTypeId); + await umbracoUi.goToBackOffice(); + await umbracoUi.content.goToSection(ConstantHelper.sections.content); + + // Act + await umbracoUi.content.goToContentWithName(contentName); + await umbracoUi.content.selectMediaByName(mediaName); + await umbracoUi.content.clickSubmitButton(); + await umbracoUi.content.clickSaveButton(); + + // Assert + await umbracoUi.content.isSuccessNotificationVisible(); + expect(await umbracoApi.document.doesImageMediaPickerContainImage(contentName, AliasHelper.toAlias(dataTypeName), imageId)).toBeTruthy(); + + // Clean + await umbracoApi.media.ensureNameNotExists(mediaName); +}); + +test('can remove an image from the image media picker', async ({umbracoApi, umbracoUi}) => { + // Arrange + const dataType = await umbracoApi.dataType.getByName(dataTypeName); + await umbracoApi.media.ensureNameNotExists(mediaName); + const imageId = await umbracoApi.media.createDefaultMediaWithImage(mediaName); + const documentTypeId = await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(documentTypeName, dataTypeName, dataType.id, groupName); + await umbracoApi.document.createDocumentWithImageMediaPicker(contentName, documentTypeId, AliasHelper.toAlias(dataTypeName), imageId); + await umbracoUi.goToBackOffice(); + await umbracoUi.content.goToSection(ConstantHelper.sections.content); + + // Act + await umbracoUi.content.goToContentWithName(contentName); + await umbracoUi.content.clickRemoveButtonForName(mediaName); + await umbracoUi.content.clickConfirmRemoveButton(); + await umbracoUi.content.clickSaveButton(); + + // Assert + await umbracoUi.content.isSuccessNotificationVisible(); + expect(await umbracoApi.document.doesImageMediaPickerContainImage(contentName, AliasHelper.toAlias(dataTypeName), imageId)).toBeFalsy(); + + // Clean + await umbracoApi.media.ensureNameNotExists(mediaName); +}); + +// TODO: Remove skip when the front-end is ready as there are currently no displayed error notification. +test.skip('image count can not be less that min amount set in image media picker', async ({umbracoApi, umbracoUi}) => { + // Arrange + await umbracoApi.dataType.ensureNameNotExists(customDataTypeName); + const dataTypeId = await umbracoApi.dataType.createImageMediaPickerDataType(customDataTypeName, 1); + const documentTypeId = await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(documentTypeName, customDataTypeName, dataTypeId, groupName); + await umbracoApi.document.createDefaultDocument(contentName, documentTypeId); + await umbracoUi.goToBackOffice(); + await umbracoUi.content.goToSection(ConstantHelper.sections.content); + + // Act + await umbracoUi.content.goToContentWithName(contentName); + await umbracoUi.content.clickSaveButton(); + + // Assert + await umbracoUi.content.isErrorNotificationVisible(); + + // Clean + await umbracoApi.dataType.ensureNameNotExists(customDataTypeName); +}); + +// TODO: Remove skip when the front-end is ready as there are currently no displayed error notification. +test.skip('image count can not be more that max amount set in image media picker', async ({umbracoApi, umbracoUi}) => { + // Arrange + await umbracoApi.dataType.ensureNameNotExists(customDataTypeName); + const dataTypeId = await umbracoApi.dataType.createImageMediaPickerDataType(customDataTypeName, 0, 0); + const documentTypeId = await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(documentTypeName, customDataTypeName, dataTypeId, groupName); + await umbracoApi.document.createDefaultDocument(contentName, documentTypeId); + await umbracoUi.goToBackOffice(); + await umbracoUi.content.goToSection(ConstantHelper.sections.content); + + // Act + await umbracoUi.content.goToContentWithName(contentName); + await umbracoUi.content.selectMediaByName(mediaName); + await umbracoUi.content.clickSubmitButton(); + await umbracoUi.content.clickSaveButton(); + + // Assert + await umbracoUi.content.isErrorNotificationVisible(); + + // Clean + await umbracoApi.dataType.ensureNameNotExists(customDataTypeName); +}); + +test('can add an image from the image media picker with a start node', async ({umbracoApi, umbracoUi}) => { + const mediaFolderName = 'TestFolder'; + await umbracoApi.media.ensureNameNotExists(mediaFolderName); + await umbracoApi.media.ensureNameNotExists(mediaName); + await umbracoApi.dataType.ensureNameNotExists(customDataTypeName); + const imageFolderId = await umbracoApi.media.createDefaultMediaFolder(mediaFolderName); + const imageId = await umbracoApi.media.createDefaultMediaWithImageAndParentId(mediaName, imageFolderId); + const dataTypeId = await umbracoApi.dataType.createImageMediaPickerDataTypeWithStartNodeId(customDataTypeName, imageFolderId); + const documentTypeId = await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(documentTypeName, customDataTypeName, dataTypeId, groupName); + await umbracoApi.document.createDefaultDocument(contentName, documentTypeId); + await umbracoUi.goToBackOffice(); + await umbracoUi.content.goToSection(ConstantHelper.sections.content); + + // Act + await umbracoUi.content.goToContentWithName(contentName); + await umbracoUi.content.selectMediaByName(mediaName); + await umbracoUi.content.clickSubmitButton(); + await umbracoUi.content.clickSaveButton(); + + // Assert + await umbracoUi.content.isSuccessNotificationVisible(); + expect(await umbracoApi.document.doesImageMediaPickerContainImage(contentName, AliasHelper.toAlias(customDataTypeName), imageId)).toBeTruthy(); + + // Clean + await umbracoApi.media.ensureNameNotExists(mediaFolderName); + await umbracoApi.media.ensureNameNotExists(mediaName); + await umbracoApi.dataType.ensureNameNotExists(customDataTypeName); +}); + +test('can add an image from the image media picker with focal point enabled', async ({umbracoApi, umbracoUi}) => { + // Arrange + await umbracoApi.media.ensureNameNotExists(mediaName); + await umbracoApi.dataType.ensureNameNotExists(customDataTypeName); + const imageId = await umbracoApi.media.createDefaultMediaWithImage(mediaName); + const dataTypeId = await umbracoApi.dataType.createImageMediaPickerDataType(customDataTypeName, 0, 1, true); + const documentTypeId = await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(documentTypeName, customDataTypeName, dataTypeId, groupName); + await umbracoApi.document.createDocumentWithImageMediaPicker(contentName, documentTypeId, AliasHelper.toAlias(customDataTypeName), imageId); + const widthPercentage = 40; + const heightPercentage = 20; + await umbracoUi.goToBackOffice(); + await umbracoUi.content.goToSection(ConstantHelper.sections.content); + + // Act + await umbracoUi.content.goToContentWithName(contentName); + await umbracoUi.content.clickExactLinkWithName(mediaName); + await umbracoUi.content.setFocalPoint(widthPercentage, heightPercentage); + await umbracoUi.content.clickSubmitButton(); + await umbracoUi.content.clickSaveButton(); + + // Assert + await umbracoUi.content.isSuccessNotificationVisible(); + expect(await umbracoApi.document.doesImageMediaPickerContainImageWithFocalPoint(contentName, AliasHelper.toAlias(customDataTypeName), imageId, {left: 0.4, top: 0.2})).toBeTruthy(); + + // Clean + await umbracoApi.media.ensureNameNotExists(mediaName); + await umbracoApi.dataType.ensureNameNotExists(customDataTypeName); +}); + +test('can reset focal point in a image from the image media picker', async ({umbracoApi, umbracoUi}) => { + // Arrange + await umbracoApi.media.ensureNameNotExists(mediaName); + await umbracoApi.dataType.ensureNameNotExists(customDataTypeName); + const imageId = await umbracoApi.media.createDefaultMediaWithImage(mediaName); + const dataTypeId = await umbracoApi.dataType.createImageMediaPickerDataType(customDataTypeName, 0, 1, true); + const documentTypeId = await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(documentTypeName, customDataTypeName, dataTypeId, groupName); + await umbracoApi.document.createDocumentWithImageMediaPicker(contentName, documentTypeId, AliasHelper.toAlias(customDataTypeName), imageId, {left: 0.4, top: 0.2}); + await umbracoUi.goToBackOffice(); + await umbracoUi.content.goToSection(ConstantHelper.sections.content); + + // Act + await umbracoUi.content.goToContentWithName(contentName); + await umbracoUi.content.clickExactLinkWithName(mediaName); + await umbracoUi.content.clickResetFocalPointButton(); + await umbracoUi.content.clickSubmitButton(); + await umbracoUi.content.clickSaveButton(); + + // Assert + await umbracoUi.content.isSuccessNotificationVisible(); + expect(await umbracoApi.document.doesImageMediaPickerContainImageWithFocalPoint(contentName, AliasHelper.toAlias(customDataTypeName), imageId, {left: 0, top: 0})).toBeTruthy(); + + // Clean + await umbracoApi.media.ensureNameNotExists(mediaName); + await umbracoApi.dataType.ensureNameNotExists(customDataTypeName); +}); + +// TODO: Remove skip when the front-end is ready as currently the crop is not being selected. +test.skip('can add an image from the image media picker with a image crop', async ({umbracoApi, umbracoUi}) => { + // Arrange + const cropLabel = 'TestCrop'; + const cropWidth = 100; + const cropHeight = 100; + await umbracoApi.media.ensureNameNotExists(mediaName); + await umbracoApi.dataType.ensureNameNotExists(customDataTypeName); + const imageId = await umbracoApi.media.createDefaultMediaWithImage(mediaName); + const dataTypeId = await umbracoApi.dataType.createImageMediaPickerDataTypeWithCrop(customDataTypeName, cropLabel, cropWidth, cropHeight); + const documentTypeId = await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(documentTypeName, customDataTypeName, dataTypeId, groupName); + await umbracoApi.document.createDocumentWithImageMediaPicker(contentName, documentTypeId, AliasHelper.toAlias(customDataTypeName), imageId); + await umbracoUi.goToBackOffice(); + await umbracoUi.content.goToSection(ConstantHelper.sections.content); + + // Act + await umbracoUi.content.goToContentWithName(contentName); +}); diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentWithMediaPicker.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentWithMediaPicker.spec.ts index b263700fd3..b8cb9f4c66 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentWithMediaPicker.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentWithMediaPicker.spec.ts @@ -12,13 +12,13 @@ test.beforeEach(async ({umbracoApi, umbracoUi}) => { await umbracoApi.documentType.ensureNameNotExists(documentTypeName); await umbracoApi.document.ensureNameNotExists(contentName); await umbracoApi.media.ensureNameNotExists(mediaFileName); - mediaFileId = await umbracoApi.media.createDefaultMedia(mediaFileName, mediaTypeName); + mediaFileId = await umbracoApi.media.createDefaultMediaFile(mediaFileName); await umbracoUi.goToBackOffice(); }); test.afterEach(async ({umbracoApi}) => { await umbracoApi.media.ensureNameNotExists(mediaFileName); - await umbracoApi.document.ensureNameNotExists(contentName); + await umbracoApi.document.ensureNameNotExists(contentName); await umbracoApi.documentType.ensureNameNotExists(documentTypeName); }); @@ -33,7 +33,6 @@ test('can create content with the media picker data type', {tag: '@smoke'}, asyn await umbracoUi.content.clickCreateButton(); await umbracoUi.content.chooseDocumentType(documentTypeName); await umbracoUi.content.enterContentName(contentName); - await umbracoUi.content.clickChooseMediaPickerButton(); await umbracoUi.content.selectMediaByName(mediaFileName); await umbracoUi.content.clickSubmitButton(); await umbracoUi.content.clickSaveButton(); @@ -60,7 +59,6 @@ test('can publish content with the media picker data type', async ({umbracoApi, await umbracoUi.content.clickCreateButton(); await umbracoUi.content.chooseDocumentType(documentTypeName); await umbracoUi.content.enterContentName(contentName); - await umbracoUi.content.clickChooseMediaPickerButton(); await umbracoUi.content.selectMediaByName(mediaFileName); await umbracoUi.content.clickSubmitButton(); await umbracoUi.content.clickSaveAndPublishButton(); @@ -84,7 +82,7 @@ test('can remove a media picker in the content', async ({umbracoApi, umbracoUi}) await umbracoUi.content.goToSection(ConstantHelper.sections.content); // Act - await umbracoUi.content.openContent(contentName); + await umbracoUi.content.goToContentWithName(contentName); await umbracoUi.content.removeMediaPickerByName(mediaFileName); await umbracoUi.content.clickSaveButton(); @@ -103,7 +101,7 @@ test('can limit the media picker in the content by setting the start node', asyn await umbracoApi.media.ensureNameNotExists(mediaFolderName); const mediaFolderId = await umbracoApi.media.createDefaultMediaFolder(mediaFolderName); await umbracoApi.media.ensureNameNotExists(childMediaName); - await umbracoApi.media.createDefaultMedia(childMediaName, mediaTypeName, mediaFolderId); + await umbracoApi.media.createDefaultMediaFileAndParentId(childMediaName, mediaFolderId); const customDataTypeId = await umbracoApi.dataType.createMediaPickerDataTypeWithStartNodeId(customDataTypeName, mediaFolderId); await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(documentTypeName, customDataTypeName, customDataTypeId); await umbracoUi.content.goToSection(ConstantHelper.sections.content); diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentWithMultipleMediaPicker.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentWithMultipleMediaPicker.spec.ts index fb3440ad1d..0093866fdd 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentWithMultipleMediaPicker.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentWithMultipleMediaPicker.spec.ts @@ -15,16 +15,16 @@ test.beforeEach(async ({umbracoApi, umbracoUi}) => { await umbracoApi.documentType.ensureNameNotExists(documentTypeName); await umbracoApi.document.ensureNameNotExists(contentName); await umbracoApi.media.ensureNameNotExists(firstMediaFileName); - firstMediaFileId = await umbracoApi.media.createDefaultMedia(firstMediaFileName, firstMediaTypeName); + firstMediaFileId = await umbracoApi.media.createDefaultMediaFile(firstMediaFileName); await umbracoApi.media.ensureNameNotExists(secondMediaFileName); - secondMediaFileId = await umbracoApi.media.createDefaultMedia(secondMediaFileName, secondMediaTypeName); + secondMediaFileId = await umbracoApi.media.createDefaultMediaWithImage(secondMediaFileName); await umbracoUi.goToBackOffice(); }); test.afterEach(async ({umbracoApi}) => { await umbracoApi.media.ensureNameNotExists(firstMediaFileName); await umbracoApi.media.ensureNameNotExists(secondMediaFileName); - await umbracoApi.document.ensureNameNotExists(contentName); + await umbracoApi.document.ensureNameNotExists(contentName); await umbracoApi.documentType.ensureNameNotExists(documentTypeName); }); @@ -39,8 +39,8 @@ test('can create content with multiple media picker data type', async ({umbracoA await umbracoUi.content.clickCreateButton(); await umbracoUi.content.chooseDocumentType(documentTypeName); await umbracoUi.content.enterContentName(contentName); - await umbracoUi.content.clickChooseMediaPickerButton(); await umbracoUi.content.selectMediaByName(firstMediaFileName); + await umbracoUi.content.clickSubmitButton(); await umbracoUi.content.selectMediaByName(secondMediaFileName); await umbracoUi.content.clickSubmitButton(); await umbracoUi.content.clickSaveButton(); @@ -68,8 +68,8 @@ test('can publish content with multiple media picker data type', async ({umbraco await umbracoUi.content.clickCreateButton(); await umbracoUi.content.chooseDocumentType(documentTypeName); await umbracoUi.content.enterContentName(contentName); - await umbracoUi.content.clickChooseMediaPickerButton(); await umbracoUi.content.selectMediaByName(firstMediaFileName); + await umbracoUi.content.clickSubmitButton(); await umbracoUi.content.selectMediaByName(secondMediaFileName); await umbracoUi.content.clickSubmitButton(); await umbracoUi.content.clickSaveAndPublishButton(); @@ -94,7 +94,7 @@ test('can remove a media picker in the content', async ({umbracoApi, umbracoUi}) await umbracoUi.content.goToSection(ConstantHelper.sections.content); // Act - await umbracoUi.content.openContent(contentName); + await umbracoUi.content.goToContentWithName(contentName); await umbracoUi.content.removeMediaPickerByName(firstMediaFileName); await umbracoUi.content.clickSaveButton(); diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/RedirectManagement.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/RedirectManagement.spec.ts index 0cfe5e2c18..62d92f7b55 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/RedirectManagement.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/RedirectManagement.spec.ts @@ -13,7 +13,7 @@ test.beforeEach(async ({umbracoApi, umbracoUi}) => { await umbracoApi.redirectManagement.setStatus(enableStatus); await umbracoUi.goToBackOffice(); // Create a content - await umbracoApi.documentType.ensureNameNotExists(documentTypeName); + await umbracoApi.documentType.ensureNameNotExists(documentTypeName); documentTypeId = await umbracoApi.documentType.createDefaultDocumentTypeWithAllowAsRoot(documentTypeName); contentId = await umbracoApi.document.createDefaultDocument(contentName, documentTypeId); // Publish the content @@ -36,10 +36,10 @@ test('can disable URL tracker', async ({umbracoApi, umbracoUi}) => { await umbracoUi.redirectManagement.clickDisableButton(); // Assert - // Verfiy that if renaming a published page, there are no redirects have been made - // rename the published content + // Verify that if renaming a published page, there are no redirects have been made + // rename the published content await umbracoUi.content.goToSection(ConstantHelper.sections.content); - await umbracoUi.content.openContent(contentName); + await umbracoUi.content.goToContentWithName(contentName); await umbracoUi.content.enterContentName(updatedContentName); await umbracoUi.content.clickSaveAndPublishButton(); // verify that there is no redirects have been made @@ -63,10 +63,10 @@ test.skip('can re-enable URL tracker', async ({umbracoApi, umbracoUi}) => { await umbracoUi.redirectManagement.clickEnableURLTrackerButton(); // Assert - // Verfiy that if renaming a published page, there are one redirects have been made - // rename the published content + // Verify that if renaming a published page, there are one redirects have been made + // rename the published content await umbracoUi.content.goToSection(ConstantHelper.sections.content); - await umbracoUi.content.openContent(contentName); + await umbracoUi.content.goToContentWithName(contentName); await umbracoUi.content.enterContentName(updatedContentName); await umbracoUi.content.clickSaveAndPublishButton(); // verify that there is one redirects have been made @@ -90,7 +90,7 @@ test.skip('can search for original URL', async ({umbracoUi}) => { await umbracoUi.redirectManagement.clickRedirectManagementTab(); await umbracoUi.redirectManagement.enterOriginalUrl(searchKeyword); await umbracoUi.redirectManagement.clickSearchButton(); - + // Assert // TODO: verify the search result }); @@ -98,9 +98,9 @@ test.skip('can search for original URL', async ({umbracoUi}) => { // TODO: Remove skip when the frond-end is ready. Currently there is no redirect have been made after renaming a published page test.skip('can delete a redirect', async ({umbracoApi, umbracoUi}) => { // Arrange - // Rename the published content + // Rename the published content await umbracoUi.content.goToSection(ConstantHelper.sections.content); - await umbracoUi.content.openContent(contentName); + await umbracoUi.content.goToContentWithName(contentName); await umbracoUi.content.enterContentName(updatedContentName); await umbracoUi.content.clickSaveAndPublishButton(); diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Users/User.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Users/User.spec.ts index 789bf96939..87c69bb6a9 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Users/User.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Users/User.spec.ts @@ -224,12 +224,11 @@ test('can remove a content start node from a user', {tag: '@smoke'}, async ({umb test('can add media start nodes for a user', {tag: '@smoke'}, async ({umbracoApi, umbracoUi}) => { // Arrange - const mediaTypeName = 'File'; const mediaName = 'TestMediaFile'; const userGroup = await umbracoApi.userGroup.getByName(defaultUserGroupName); await umbracoApi.user.createDefaultUser(nameOfTheUser, userEmail, [userGroup.id]); await umbracoApi.media.ensureNameNotExists(mediaName); - const mediaId = await umbracoApi.media.createDefaultMedia(mediaName, mediaTypeName); + const mediaId = await umbracoApi.media.createDefaultMediaFile(mediaName); await umbracoUi.user.goToSection(ConstantHelper.sections.users); // Act @@ -249,15 +248,14 @@ test('can add media start nodes for a user', {tag: '@smoke'}, async ({umbracoApi test('can add multiple media start nodes for a user', async ({umbracoApi, umbracoUi}) => { // Arrange - const mediaTypeName = 'File'; const userGroup = await umbracoApi.userGroup.getByName(defaultUserGroupName); const userId = await umbracoApi.user.createDefaultUser(nameOfTheUser, userEmail, [userGroup.id]); const mediaName = 'TestMediaFile'; const secondMediaName = 'SecondMediaFile'; await umbracoApi.media.ensureNameNotExists(mediaName); await umbracoApi.media.ensureNameNotExists(secondMediaName); - const firstMediaId = await umbracoApi.media.createDefaultMedia(mediaName, mediaTypeName); - const secondMediaId = await umbracoApi.media.createDefaultMedia(secondMediaName, mediaTypeName); + const firstMediaId = await umbracoApi.media.createDefaultMediaFile(mediaName); + const secondMediaId = await umbracoApi.media.createDefaultMediaFile(secondMediaName); // Adds the media start node to the user const userData = await umbracoApi.user.getByName(nameOfTheUser); userData.mediaStartNodeIds.push({id: firstMediaId}); @@ -283,12 +281,11 @@ test('can add multiple media start nodes for a user', async ({umbracoApi, umbrac test('can remove a media start node from a user', async ({umbracoApi, umbracoUi}) => { // Arrange - const mediaTypeName = 'File'; const userGroup = await umbracoApi.userGroup.getByName(defaultUserGroupName); const userId = await umbracoApi.user.createDefaultUser(nameOfTheUser, userEmail, [userGroup.id]); const mediaName = 'TestMediaFile'; await umbracoApi.media.ensureNameNotExists(mediaName); - const mediaId = await umbracoApi.media.createDefaultMedia(mediaName, mediaTypeName); + const mediaId = await umbracoApi.media.createDefaultMediaFile(mediaName); // Adds the media start node to the user const userData = await umbracoApi.user.getByName(nameOfTheUser); userData.mediaStartNodeIds.push({id: mediaId}); @@ -374,12 +371,11 @@ test('can see if the user has the correct access based on content start nodes', test('can see if the user has the correct access based on media start nodes', async ({umbracoApi, umbracoUi}) => { // Arrange - const mediaTypeName = 'File'; const userGroup = await umbracoApi.userGroup.getByName(defaultUserGroupName); const userId = await umbracoApi.user.createDefaultUser(nameOfTheUser, userEmail, [userGroup.id]); const mediaName = 'TestMediaFile'; await umbracoApi.media.ensureNameNotExists(mediaName); - const mediaId = await umbracoApi.media.createDefaultMedia(mediaName, mediaTypeName); + const mediaId = await umbracoApi.media.createDefaultMediaFile(mediaName); // Adds the media start node to the user const userData = await umbracoApi.user.getByName(nameOfTheUser); userData.mediaStartNodeIds.push({id: mediaId}); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ElementSwitchValidatorTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ElementSwitchValidatorTests.cs new file mode 100644 index 0000000000..0b7ebd84c9 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ElementSwitchValidatorTests.cs @@ -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(); + + private IContentTypeService ContentTypeService => GetRequiredService(); + + private IContentService ContentService => GetRequiredService(); + + private IDataTypeService DataTypeService => GetRequiredService(); + + [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 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 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, + 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 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.Web.BackOffice/UrlAndDomains/DomainAndUrlsTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/UrlAndDomains/DomainAndUrlsTests.cs index abc4e3894c..462205b231 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/UrlAndDomains/DomainAndUrlsTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/UrlAndDomains/DomainAndUrlsTests.cs @@ -332,6 +332,22 @@ public class DomainAndUrlsTests : UmbracoIntegrationTest Assert.AreEqual(DomainOperationStatus.DuplicateDomainName, result.Status); } + [TestCase("https://*.umbraco.com")] + [TestCase("&#€%#€")] + [TestCase("¢”$¢”¢$≈{")] + public async Task Cannot_Assign_Invalid_Domains(string domainName) + { + var domainService = GetRequiredService(); + var updateModel = new DomainsUpdateModel + { + Domains = new DomainModel { DomainName = domainName, IsoCode = Cultures.First() }.Yield() + }; + + var result = await domainService.UpdateDomainsAsync(Root.Key, updateModel); + Assert.IsFalse(result.Success); + Assert.AreEqual(DomainOperationStatus.InvalidDomainName, result.Status); + } + [Test] public async Task Cannot_Assign_Already_Used_Domains() { diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/CoreThings/ObjectExtensionsTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/CoreThings/ObjectExtensionsTests.cs index bfbe581f2e..7aae5e54ee 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/CoreThings/ObjectExtensionsTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/CoreThings/ObjectExtensionsTests.cs @@ -11,6 +11,7 @@ using NUnit.Framework; using Umbraco.Cms.Core.PropertyEditors; using Umbraco.Cms.Tests.Common.TestHelpers; using Umbraco.Extensions; +using DateTimeOffset = System.DateTimeOffset; namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.CoreThings; @@ -332,6 +333,50 @@ public class ObjectExtensionsTests Assert.AreEqual("This is a string", conv.Result); } + [Test] + public void CanConvertDateTimeOffsetToDateTime() + { + var dateTimeOffset = new DateTimeOffset(new DateOnly(2024, 07, 05), new TimeOnly(12, 30, 01, 02, 03), TimeSpan.Zero); + var result = dateTimeOffset.TryConvertTo(); + Assert.IsTrue(result.Success); + Assert.Multiple(() => + { + Assert.AreEqual(new DateTime(new DateOnly(2024, 07, 05), new TimeOnly(12, 30, 01, 02, 03)), result.Result); + Assert.AreEqual(DateTimeKind.Utc, result.Result.Kind); + }); + } + + [Test] + public void CanConvertDateTimeToDateTimeOffset() + { + var dateTime = new DateTime(new DateOnly(2024, 07, 05), new TimeOnly(12, 30, 01, 02, 03), DateTimeKind.Utc); + var result = dateTime.TryConvertTo(); + Assert.IsTrue(result.Success); + Assert.AreEqual(new DateTimeOffset(new DateOnly(2024, 07, 05), new TimeOnly(12, 30, 01, 02, 03), TimeSpan.Zero), result.Result); + } + + [Test] + public void DiscardsOffsetWhenConvertingDateTimeOffsetToDateTime() + { + var dateTimeOffset = new DateTimeOffset(new DateOnly(2024, 07, 05), new TimeOnly(12, 30, 01, 02, 03), TimeSpan.FromHours(2)); + var result = dateTimeOffset.TryConvertTo(); + Assert.IsTrue(result.Success); + Assert.Multiple(() => + { + Assert.AreEqual(new DateTime(new DateOnly(2024, 07, 05), new TimeOnly(12, 30, 01, 02, 03)), result.Result); + Assert.AreEqual(DateTimeKind.Utc, result.Result.Kind); + }); + } + + [Test] + public void DiscardsDateTimeKindWhenConvertingDateTimeToDateTimeOffset() + { + var dateTime = new DateTime(new DateOnly(2024, 07, 05), new TimeOnly(12, 30, 01, 02, 03), DateTimeKind.Local); + var result = dateTime.TryConvertTo(); + Assert.IsTrue(result.Success); + Assert.AreEqual(new DateTimeOffset(new DateOnly(2024, 07, 05), new TimeOnly(12, 30, 01, 02, 03), TimeSpan.Zero), result.Result); + } + [Test] public void Value_Editor_Can_Convert_Decimal_To_Decimal_Clr_Type() { @@ -342,6 +387,23 @@ public class ObjectExtensionsTests Assert.AreEqual(12.34d, result.Result); } + [Test] + public void Value_Editor_Can_Convert_DateTimeOffset_To_DateTime_Clr_Type() + { + var valueEditor = MockedValueEditors.CreateDataValueEditor(ValueTypes.Date); + + var result = valueEditor.TryConvertValueToCrlType(new DateTimeOffset(new DateOnly(2024, 07, 05), new TimeOnly(12, 30), TimeSpan.Zero)); + Assert.IsTrue(result.Success); + Assert.IsTrue(result.Result is DateTime); + + var dateTime = (DateTime)result.Result; + Assert.Multiple(() => + { + Assert.AreEqual(new DateTime(new DateOnly(2024, 07, 05), new TimeOnly(12, 30)), dateTime); + Assert.AreEqual(DateTimeKind.Utc, dateTime.Kind); + }); + } + private class MyTestObject { public override string ToString() => "Hello world"; diff --git a/version.json b/version.json index cbb26a6556..2fb06038d0 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", - "version": "14.2.0-rc", + "version": "14.3.0-rc", "assemblyVersion": { "precision": "build" },