diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Security/SecurityControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Security/SecurityControllerBase.cs index 09b493c484..1482517a15 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Security/SecurityControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Security/SecurityControllerBase.cs @@ -21,6 +21,10 @@ public abstract class SecurityControllerBase : ManagementApiControllerBase .WithTitle("The password reset token was invalid") .WithDetail("The specified password reset token was either used already or wrong.") .Build()), + UserOperationStatus.CancelledByNotification => BadRequest(problemDetailsBuilder + .WithTitle("Cancelled by notification") + .WithDetail("A notification handler prevented the user operation.") + .Build()), UserOperationStatus.UnknownFailure => BadRequest(problemDetailsBuilder .WithTitle("Unknown failure") .WithDetail(errorMessageResult?.Error?.ErrorMessage ?? "The error was unknown") diff --git a/src/Umbraco.Cms.Api.Management/Factories/UserPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/UserPresentationFactory.cs index 03994e1be7..ae34f41d91 100644 --- a/src/Umbraco.Cms.Api.Management/Factories/UserPresentationFactory.cs +++ b/src/Umbraco.Cms.Api.Management/Factories/UserPresentationFactory.cs @@ -1,13 +1,17 @@ -using Umbraco.Cms.Api.Management.Routing; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; +using Umbraco.Cms.Api.Management.Routing; using Umbraco.Cms.Api.Management.Security; using Umbraco.Cms.Api.Management.ViewModels; using Umbraco.Cms.Api.Management.ViewModels.User; using Umbraco.Cms.Api.Management.ViewModels.User.Current; -using Umbraco.Cms.Core; using Umbraco.Cms.Api.Management.ViewModels.User.Item; +using Umbraco.Cms.Api.Management.ViewModels.UserGroup; +using Umbraco.Cms.Api.Management.ViewModels.UserGroup.Permissions; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Mail; using Umbraco.Cms.Core.Media; @@ -20,7 +24,6 @@ namespace Umbraco.Cms.Api.Management.Factories; public class UserPresentationFactory : IUserPresentationFactory { - private readonly IEntityService _entityService; private readonly AppCaches _appCaches; private readonly MediaFileManager _mediaFileManager; @@ -31,7 +34,10 @@ public class UserPresentationFactory : IUserPresentationFactory private readonly IPasswordConfigurationPresentationFactory _passwordConfigurationPresentationFactory; private readonly IBackOfficeExternalLoginProviders _externalLoginProviders; private readonly SecuritySettings _securitySettings; + private readonly IUserService _userService; + private readonly IContentService _contentService; + [Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 17.")] public UserPresentationFactory( IEntityService entityService, AppCaches appCaches, @@ -43,6 +49,35 @@ public class UserPresentationFactory : IUserPresentationFactory IPasswordConfigurationPresentationFactory passwordConfigurationPresentationFactory, IOptionsSnapshot securitySettings, IBackOfficeExternalLoginProviders externalLoginProviders) + : this( + entityService, + appCaches, + mediaFileManager, + imageUrlGenerator, + userGroupPresentationFactory, + absoluteUrlBuilder, + emailSender, + passwordConfigurationPresentationFactory, + securitySettings, + externalLoginProviders, + StaticServiceProvider.Instance.GetRequiredService(), + StaticServiceProvider.Instance.GetRequiredService()) + { + } + + public UserPresentationFactory( + IEntityService entityService, + AppCaches appCaches, + MediaFileManager mediaFileManager, + IImageUrlGenerator imageUrlGenerator, + IUserGroupPresentationFactory userGroupPresentationFactory, + IAbsoluteUrlBuilder absoluteUrlBuilder, + IEmailSender emailSender, + IPasswordConfigurationPresentationFactory passwordConfigurationPresentationFactory, + IOptionsSnapshot securitySettings, + IBackOfficeExternalLoginProviders externalLoginProviders, + IUserService userService, + IContentService contentService) { _entityService = entityService; _appCaches = appCaches; @@ -54,6 +89,8 @@ public class UserPresentationFactory : IUserPresentationFactory _externalLoginProviders = externalLoginProviders; _securitySettings = securitySettings.Value; _absoluteUrlBuilder = absoluteUrlBuilder; + _userService = userService; + _contentService = contentService; } public UserResponseModel CreateResponseModel(IUser user) @@ -194,7 +231,7 @@ public class UserPresentationFactory : IUserPresentationFactory var contentStartNodeIds = user.CalculateContentStartNodeIds(_entityService, _appCaches); var documentStartNodeKeys = GetKeysFromIds(contentStartNodeIds, UmbracoObjectTypes.Document); - var permissions = presentationGroups.SelectMany(x => x.Permissions).ToHashSet(); + var permissions = GetAggregatedGranularPermissions(user, presentationGroups); var fallbackPermissions = presentationGroups.SelectMany(x => x.FallbackPermissions).ToHashSet(); var hasAccessToAllLanguages = presentationGroups.Any(x => x.HasAccessToAllLanguages); @@ -224,6 +261,42 @@ public class UserPresentationFactory : IUserPresentationFactory }; } + private HashSet GetAggregatedGranularPermissions(IUser user, IEnumerable presentationGroups) + { + var permissions = presentationGroups.SelectMany(x => x.Permissions).ToHashSet(); + + // The raw permission data consists of several permissions for each document. We want to aggregate this server-side so + // we return one set of aggregate permissions per document that the client will use. + + // Get the unique document keys that have granular permissions. + IEnumerable documentKeysWithGranularPermissions = permissions + .Where(x => x is DocumentPermissionPresentationModel) + .Cast() + .Select(x => x.Document.Id) + .Distinct(); + + var aggregatedPermissions = new HashSet(); + foreach (Guid documentKey in documentKeysWithGranularPermissions) + { + // Retrieve the path of the document. + var path = _contentService.GetById(documentKey)?.Path; + if (string.IsNullOrEmpty(path)) + { + continue; + } + + // With the path we can call the same logic as used server-side for authorizing access to resources. + EntityPermissionSet permissionsForPath = _userService.GetPermissionsForPath(user, path); + aggregatedPermissions.Add(new DocumentPermissionPresentationModel + { + Document = new ReferenceByIdModel(documentKey), + Verbs = permissionsForPath.GetAllPermissions() + }); + } + + return aggregatedPermissions; + } + public Task CreateCalculatedUserStartNodesResponseModelAsync(IUser user) { var mediaStartNodeIds = user.CalculateMediaStartNodeIds(_entityService, _appCaches); diff --git a/src/Umbraco.Cms.Api.Management/OpenApi.json b/src/Umbraco.Cms.Api.Management/OpenApi.json index e297bfc9cd..a3a73dbc15 100644 --- a/src/Umbraco.Cms.Api.Management/OpenApi.json +++ b/src/Umbraco.Cms.Api.Management/OpenApi.json @@ -36978,7 +36978,6 @@ "type": "string" }, "translation": { - "minLength": 1, "type": "string" } }, diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Dictionary/DictionaryItemTranslationModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Dictionary/DictionaryItemTranslationModel.cs index e71760fcf9..2763fb2f79 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/Dictionary/DictionaryItemTranslationModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Dictionary/DictionaryItemTranslationModel.cs @@ -1,4 +1,4 @@ -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; namespace Umbraco.Cms.Api.Management.ViewModels.Dictionary; @@ -7,6 +7,5 @@ public class DictionaryItemTranslationModel [Required] public string IsoCode { get; set; } = string.Empty; - [Required] public string Translation { get; set; } = string.Empty; } diff --git a/src/Umbraco.Core/DeliveryApi/ApiContentRouteBuilder.cs b/src/Umbraco.Core/DeliveryApi/ApiContentRouteBuilder.cs index 1868ee0aea..9c3aad1cfc 100644 --- a/src/Umbraco.Core/DeliveryApi/ApiContentRouteBuilder.cs +++ b/src/Umbraco.Core/DeliveryApi/ApiContentRouteBuilder.cs @@ -5,6 +5,7 @@ using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Models.DeliveryApi; using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.Navigation; using Umbraco.Extensions; @@ -19,6 +20,7 @@ public sealed class ApiContentRouteBuilder : IApiContentRouteBuilder private readonly IPublishedContentCache _contentCache; private readonly IDocumentNavigationQueryService _navigationQueryService; private readonly IPublishStatusQueryService _publishStatusQueryService; + private readonly IDocumentUrlService _documentUrlService; private RequestHandlerSettings _requestSettings; public ApiContentRouteBuilder( @@ -29,7 +31,8 @@ public sealed class ApiContentRouteBuilder : IApiContentRouteBuilder IOptionsMonitor requestSettings, IPublishedContentCache contentCache, IDocumentNavigationQueryService navigationQueryService, - IPublishStatusQueryService publishStatusQueryService) + IPublishStatusQueryService publishStatusQueryService, + IDocumentUrlService documentUrlService) { _apiContentPathProvider = apiContentPathProvider; _variationContextAccessor = variationContextAccessor; @@ -37,11 +40,35 @@ public sealed class ApiContentRouteBuilder : IApiContentRouteBuilder _contentCache = contentCache; _navigationQueryService = navigationQueryService; _publishStatusQueryService = publishStatusQueryService; + _documentUrlService = documentUrlService; _globalSettings = globalSettings.Value; _requestSettings = requestSettings.CurrentValue; requestSettings.OnChange(settings => _requestSettings = settings); } + [Obsolete("Use the non-obsolete constructor, scheduled for removal in v17")] + public ApiContentRouteBuilder( + IApiContentPathProvider apiContentPathProvider, + IOptions globalSettings, + IVariationContextAccessor variationContextAccessor, + IRequestPreviewService requestPreviewService, + IOptionsMonitor requestSettings, + IPublishedContentCache contentCache, + IDocumentNavigationQueryService navigationQueryService, + IPublishStatusQueryService publishStatusQueryService) + : this( + apiContentPathProvider, + globalSettings, + variationContextAccessor, + requestPreviewService, + requestSettings, + contentCache, + navigationQueryService, + publishStatusQueryService, + StaticServiceProvider.Instance.GetRequiredService()) + { + } + [Obsolete("Use the non-obsolete constructor, scheduled for removal in v17")] public ApiContentRouteBuilder( IApiContentPathProvider apiContentPathProvider, @@ -59,7 +86,8 @@ public sealed class ApiContentRouteBuilder : IApiContentRouteBuilder requestSettings, contentCache, navigationQueryService, - StaticServiceProvider.Instance.GetRequiredService()) + StaticServiceProvider.Instance.GetRequiredService(), + StaticServiceProvider.Instance.GetRequiredService()) { } @@ -113,7 +141,7 @@ public sealed class ApiContentRouteBuilder : IApiContentRouteBuilder // we can perform fallback to the content route. if (IsInvalidContentPath(contentPath)) { - contentPath = _contentCache.GetRouteById(content.Id, culture) ?? contentPath; + contentPath = _documentUrlService.GetLegacyRouteFormat(content.Key, culture ?? _variationContextAccessor.VariationContext?.Culture, isPreview); } // if the content path has still not been resolved as a valid path, the content is un-routable in this culture @@ -125,7 +153,9 @@ public sealed class ApiContentRouteBuilder : IApiContentRouteBuilder : null; } - return contentPath; + return _requestSettings.AddTrailingSlash + ? contentPath?.EnsureEndsWith('/') + : contentPath?.TrimEnd('/'); } private string ContentPreviewPath(IPublishedContent content) => $"{Constants.DeliveryApi.Routing.PreviewContentPathPrefix}{content.Key:D}{(_requestSettings.AddTrailingSlash ? "/" : string.Empty)}"; diff --git a/src/Umbraco.Core/DeliveryApi/ApiPublishedContentCache.cs b/src/Umbraco.Core/DeliveryApi/ApiPublishedContentCache.cs index 0d6f9ecbf5..5e48fb01c0 100644 --- a/src/Umbraco.Core/DeliveryApi/ApiPublishedContentCache.cs +++ b/src/Umbraco.Core/DeliveryApi/ApiPublishedContentCache.cs @@ -86,10 +86,35 @@ public sealed class ApiPublishedContentCache : IApiPublishedContentCache _variationContextAccessor.VariationContext?.Culture, _requestPreviewService.IsPreview()); + // in multi-root settings, we've historically resolved all but the first root by their ID + URL segment, + // e.g. "1234/second-root-url-segment". in V15+, IDocumentUrlService won't resolve this anymore; it will + // however resolve "1234/" correctly, so to remain backwards compatible, we need to perform this extra step. + var verifyUrlSegment = false; + if (documentKey is null && route.TrimEnd('/').CountOccurrences("/") is 1) + { + documentKey = _apiDocumentUrlService.GetDocumentKeyByRoute( + route[..(route.IndexOf('/') + 1)], + _variationContextAccessor.VariationContext?.Culture, + _requestPreviewService.IsPreview()); + verifyUrlSegment = true; + } + IPublishedContent? content = documentKey.HasValue ? _publishedContentCache.GetById(isPreviewMode, documentKey.Value) : null; + // the additional look-up above can result in false positives; if attempting to request a non-existing child to + // the currently contextualized request root (either by start item or by domain), the root content key might + // get resolved. to counter for this, we compare the requested URL segment with the resolved content URL segment. + if (content is not null && verifyUrlSegment) + { + var expectedUrlSegment = route[(route.IndexOf('/') + 1)..]; + if (content.UrlSegment != expectedUrlSegment) + { + content = null; + } + } + return ContentOrNullIfDisallowed(content); } diff --git a/src/Umbraco.Core/Notifications/UserPasswordResettingNotification.cs b/src/Umbraco.Core/Notifications/UserPasswordResettingNotification.cs new file mode 100644 index 0000000000..9edf9ce265 --- /dev/null +++ b/src/Umbraco.Core/Notifications/UserPasswordResettingNotification.cs @@ -0,0 +1,13 @@ +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models.Membership; + +namespace Umbraco.Cms.Core.Notifications; + +public class UserPasswordResettingNotification : CancelableObjectNotification +{ + public UserPasswordResettingNotification(IUser target, EventMessages messages) : base(target, messages) + { + } + + public IUser User => Target; +} diff --git a/src/Umbraco.Core/Persistence/Repositories/ITrackedReferencesRepository.cs b/src/Umbraco.Core/Persistence/Repositories/ITrackedReferencesRepository.cs index 5a730bb536..dd634213eb 100644 --- a/src/Umbraco.Core/Persistence/Repositories/ITrackedReferencesRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/ITrackedReferencesRepository.cs @@ -41,11 +41,8 @@ public interface ITrackedReferencesRepository long skip, long take, bool filterMustBeIsDependency, - out long totalRecords) - { - totalRecords = 0; - return []; - } + out long totalRecords); + /// /// Gets a page of items used in any kind of relation from selected integer ids. diff --git a/src/Umbraco.Core/Services/ITrackedReferencesService.cs b/src/Umbraco.Core/Services/ITrackedReferencesService.cs index ce09fb4b3d..cc97954846 100644 --- a/src/Umbraco.Core/Services/ITrackedReferencesService.cs +++ b/src/Umbraco.Core/Services/ITrackedReferencesService.cs @@ -29,8 +29,7 @@ public interface ITrackedReferencesService /// dependencies (isDependency field is set to true). /// /// A paged result of objects. - Task> GetPagedRelationsForRecycleBinAsync(UmbracoObjectTypes objectType, long skip, long take, bool filterMustBeIsDependency) - => Task.FromResult(new PagedModel(0, [])); + Task> GetPagedRelationsForRecycleBinAsync(UmbracoObjectTypes objectType, long skip, long take, bool filterMustBeIsDependency); /// /// Gets a paged result of the descending items that have any references, given a parent id. diff --git a/src/Umbraco.Core/Services/TrackedReferencesService.cs b/src/Umbraco.Core/Services/TrackedReferencesService.cs index d1ffe9ea9d..6babae6e05 100644 --- a/src/Umbraco.Core/Services/TrackedReferencesService.cs +++ b/src/Umbraco.Core/Services/TrackedReferencesService.cs @@ -10,7 +10,6 @@ public class TrackedReferencesService : ITrackedReferencesService private readonly IEntityService _entityService; private readonly ITrackedReferencesRepository _trackedReferencesRepository; - public TrackedReferencesService( ITrackedReferencesRepository trackedReferencesRepository, ICoreScopeProvider scopeProvider, @@ -30,7 +29,7 @@ public class TrackedReferencesService : ITrackedReferencesService return Task.FromResult(pagedModel); } - public async Task> GetPagedRelationsForRecycleBinAsync(UmbracoObjectTypes objectType, long skip, long take, bool filterMustBeIsDependency) + public Task> GetPagedRelationsForRecycleBinAsync(UmbracoObjectTypes objectType, long skip, long take, bool filterMustBeIsDependency) { Guid objectTypeKey = objectType switch { @@ -42,7 +41,7 @@ public class TrackedReferencesService : ITrackedReferencesService using ICoreScope scope = _scopeProvider.CreateCoreScope(autoComplete: true); IEnumerable items = _trackedReferencesRepository.GetPagedRelationsForRecycleBin(objectTypeKey, skip, take, filterMustBeIsDependency, out var totalItems); var pagedModel = new PagedModel(totalItems, items); - return await Task.FromResult(pagedModel); + return Task.FromResult(pagedModel); } public Task> GetPagedDescendantsInReferencesAsync(Guid parentKey, long skip, long take, bool filterMustBeIsDependency) diff --git a/src/Umbraco.Core/Services/UserService.cs b/src/Umbraco.Core/Services/UserService.cs index 1d120c6954..718ef1eb69 100644 --- a/src/Umbraco.Core/Services/UserService.cs +++ b/src/Umbraco.Core/Services/UserService.cs @@ -1130,7 +1130,21 @@ internal partial class UserService : RepositoryService, IUserService return keys; } + /// public async Task> ChangePasswordAsync(Guid performingUserKey, ChangeUserPasswordModel model) + { + IServiceScope serviceScope = _serviceScopeFactory.CreateScope(); + IBackOfficeUserStore userStore = serviceScope.ServiceProvider.GetRequiredService(); + IUser? performingUser = await userStore.GetAsync(performingUserKey); + if (performingUser is null) + { + return Attempt.FailWithStatus(UserOperationStatus.MissingUser, new PasswordChangedModel()); + } + + return await ChangePasswordAsync(performingUser, model); + } + + private async Task> ChangePasswordAsync(IUser performingUser, ChangeUserPasswordModel model) { IServiceScope serviceScope = _serviceScopeFactory.CreateScope(); using ICoreScope scope = ScopeProvider.CreateCoreScope(); @@ -1147,12 +1161,6 @@ internal partial class UserService : RepositoryService, IUserService return Attempt.FailWithStatus(UserOperationStatus.InvalidUserType, new PasswordChangedModel()); } - IUser? performingUser = await userStore.GetAsync(performingUserKey); - if (performingUser is null) - { - return Attempt.FailWithStatus(UserOperationStatus.MissingUser, new PasswordChangedModel()); - } - // require old password for self change when outside of invite or resetByToken flows if (performingUser.UserState != UserState.Invited && performingUser.Username == user.Username && string.IsNullOrEmpty(model.OldPassword) && string.IsNullOrEmpty(model.ResetPasswordToken)) { @@ -1176,12 +1184,13 @@ internal partial class UserService : RepositoryService, IUserService IBackOfficePasswordChanger passwordChanger = serviceScope.ServiceProvider.GetRequiredService(); Attempt result = await passwordChanger.ChangeBackOfficePassword( new ChangeBackOfficeUserPasswordModel - { - NewPassword = model.NewPassword, - OldPassword = model.OldPassword, - User = user, - ResetPasswordToken = model.ResetPasswordToken, - }, performingUser); + { + NewPassword = model.NewPassword, + OldPassword = model.OldPassword, + User = user, + ResetPasswordToken = model.ResetPasswordToken, + }, + performingUser); if (result.Success is false) { @@ -1967,9 +1976,26 @@ internal partial class UserService : RepositoryService, IUserService public async Task> ResetPasswordAsync(Guid userKey, string token, string password) { using ICoreScope scope = ScopeProvider.CreateCoreScope(); + IServiceScope serviceScope = _serviceScopeFactory.CreateScope(); + + EventMessages evtMsgs = EventMessagesFactory.Get(); + IBackOfficeUserStore userStore = serviceScope.ServiceProvider.GetRequiredService(); + + IUser? user = await userStore.GetAsync(userKey); + if (user is null) + { + return Attempt.FailWithStatus(UserOperationStatus.UserNotFound, new PasswordChangedModel()); + } + + var savingNotification = new UserPasswordResettingNotification(user, evtMsgs); + if (await scope.Notifications.PublishCancelableAsync(savingNotification)) + { + scope.Complete(); + return Attempt.FailWithStatus(UserOperationStatus.CancelledByNotification, new PasswordChangedModel()); + } Attempt changePasswordAttempt = - await ChangePasswordAsync(userKey, new ChangeUserPasswordModel + await ChangePasswordAsync(user, new ChangeUserPasswordModel { NewPassword = password, UserKey = userKey, diff --git a/src/Umbraco.Infrastructure/Examine/Deferred/DeliveryApiContentIndexHandleContentTypeChanges.cs b/src/Umbraco.Infrastructure/Examine/Deferred/DeliveryApiContentIndexHandleContentTypeChanges.cs index f1f0e25453..6a102ab54e 100644 --- a/src/Umbraco.Infrastructure/Examine/Deferred/DeliveryApiContentIndexHandleContentTypeChanges.cs +++ b/src/Umbraco.Infrastructure/Examine/Deferred/DeliveryApiContentIndexHandleContentTypeChanges.cs @@ -2,6 +2,7 @@ using Examine; using Examine.Search; using Umbraco.Cms.Core.DeliveryApi; using Umbraco.Cms.Core.HostedServices; +using Umbraco.Cms.Core.HostedServices; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.Changes; diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/TrackedReferencesRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/TrackedReferencesRepository.cs index 169cc97a25..8ddf8aae0b 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/TrackedReferencesRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/TrackedReferencesRepository.cs @@ -20,6 +20,72 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement _umbracoMapper = umbracoMapper; } + /// + /// Gets a page of items used in any kind of relation from selected integer ids. + /// + public IEnumerable GetPagedItemsWithRelations(int[] ids, long pageIndex, int pageSize, + bool filterMustBeIsDependency, out long totalRecords) + { + Sql innerUnionSql = GetInnerUnionSql(); + + Sql? sql = _scopeAccessor.AmbientScope?.Database.SqlContext.Sql() + .SelectDistinct( + "[x].[id] as nodeId", + "[n].[uniqueId] as nodeKey", + "[n].[text] as nodeName", + "[n].[nodeObjectType] as nodeObjectType", + "[d].[published] as nodePublished", + "[ctn].[uniqueId] as contentTypeKey", + "[ct].[icon] as contentTypeIcon", + "[ct].[alias] as contentTypeAlias", + "[ctn].[text] as contentTypeName", + "[x].[alias] as relationTypeAlias", + "[x].[name] as relationTypeName", + "[x].[isDependency] as relationTypeIsDependency", + "[x].[dual] as relationTypeIsBidirectional") + .From("n") + .InnerJoinNested(innerUnionSql, "x") + .On((n, x) => n.NodeId == x.Id, "n", "x") + .LeftJoin("c") + .On( + (left, right) => left.NodeId == right.NodeId, + aliasLeft: "n", + aliasRight: "c") + .LeftJoin("ct") + .On( + (left, right) => left.ContentTypeId == right.NodeId, + aliasLeft: "c", + aliasRight: "ct") + .LeftJoin("ctn") + .On( + (left, right) => left.NodeId == right.NodeId, + aliasLeft: "ct", + aliasRight: "ctn") + .LeftJoin("d") + .On( + (left, right) => left.NodeId == right.NodeId, + aliasLeft: "n", + aliasRight: "d"); + + if (ids.Any()) + { + sql = sql?.Where(x => ids.Contains(x.NodeId), "n"); + } + + if (filterMustBeIsDependency) + { + sql = sql?.Where(rt => rt.IsDependency, "x"); + } + + // Ordering is required for paging + sql = sql?.OrderBy(x => x.Alias, "x"); + + Page? pagedResult = _scopeAccessor.AmbientScope?.Database.Page(pageIndex + 1, pageSize, sql); + totalRecords = Convert.ToInt32(pagedResult?.TotalItems); + + return pagedResult?.Items.Select(MapDtoToEntity) ?? Enumerable.Empty(); + } + private Sql GetInnerUnionSql() { if (_scopeAccessor.AmbientScope is null) @@ -91,6 +157,148 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement return innerUnionSql; } + /// + /// Gets a page of the descending items that have any references, given a parent id. + /// + public IEnumerable GetPagedDescendantsInReferences( + int parentId, + long pageIndex, + int pageSize, + bool filterMustBeIsDependency, + out long totalRecords) + { + SqlSyntax.ISqlSyntaxProvider? syntax = _scopeAccessor.AmbientScope?.Database.SqlContext.SqlSyntax; + + // Gets the path of the parent with ",%" added + Sql? subsubQuery = _scopeAccessor.AmbientScope?.Database.SqlContext.Sql() + .Select(syntax?.GetConcat("[node].[path]", "',%'")) + .From("node") + .Where(x => x.NodeId == parentId, "node"); + + // Gets the descendants of the parent node + Sql? subQuery = _scopeAccessor.AmbientScope?.Database.SqlContext.Sql() + .Select(x => x.NodeId) + .From() + .WhereLike(x => x.Path, subsubQuery); + + Sql innerUnionSql = GetInnerUnionSql(); + + Sql? sql = _scopeAccessor.AmbientScope?.Database.SqlContext.Sql().SelectDistinct( + "[x].[id] as nodeId", + "[n].[uniqueId] as nodeKey", + "[n].[text] as nodeName", + "[n].[nodeObjectType] as nodeObjectType", + "[d].[published] as nodePublished", + "[ctn].[uniqueId] as contentTypeKey", + "[ct].[icon] as contentTypeIcon", + "[ct].[alias] as contentTypeAlias", + "[ctn].[text] as contentTypeName", + "[x].[alias] as relationTypeAlias", + "[x].[name] as relationTypeName", + "[x].[isDependency] as relationTypeIsDependency", + "[x].[dual] as relationTypeIsBidirectional") + .From("n") + .InnerJoinNested(innerUnionSql, "x") + .On((n, x) => n.NodeId == x.Id, "n", "x") + .LeftJoin("c") + .On( + (left, right) => left.NodeId == right.NodeId, + aliasLeft: "n", + aliasRight: "c") + .LeftJoin("ct") + .On( + (left, right) => left.ContentTypeId == right.NodeId, + aliasLeft: "c", + aliasRight: "ct") + .LeftJoin("ctn") + .On( + (left, right) => left.NodeId == right.NodeId, + aliasLeft: "ct", + aliasRight: "ctn") + .LeftJoin("d") + .On( + (left, right) => left.NodeId == right.NodeId, + aliasLeft: "n", + aliasRight: "d"); + + sql = sql?.WhereIn((System.Linq.Expressions.Expression>)(x => x.NodeId), subQuery, "n"); + + if (filterMustBeIsDependency) + { + sql = sql?.Where(rt => rt.IsDependency, "x"); + } + + // Ordering is required for paging + sql = sql?.OrderBy(x => x.Alias, "x"); + + Page? pagedResult = _scopeAccessor.AmbientScope?.Database.Page(pageIndex + 1, pageSize, sql); + totalRecords = Convert.ToInt32(pagedResult?.TotalItems); + + return pagedResult?.Items.Select(MapDtoToEntity) ?? Enumerable.Empty(); + } + + + /// + /// Gets a page of items which are in relation with the current item. + /// Basically, shows the items which depend on the current item. + /// + public IEnumerable GetPagedRelationsForItem(int id, long pageIndex, int pageSize, + bool filterMustBeIsDependency, out long totalRecords) + { + Sql innerUnionSql = GetInnerUnionSql(); + Sql? sql = _scopeAccessor.AmbientScope?.Database.SqlContext.Sql().SelectDistinct( + "[x].[otherId] as nodeId", + "[n].[uniqueId] as nodeKey", + "[n].[text] as nodeName", + "[n].[nodeObjectType] as nodeObjectType", + "[d].[published] as nodePublished", + "[ctn].[uniqueId] as contentTypeKey", + "[ct].[icon] as contentTypeIcon", + "[ct].[alias] as contentTypeAlias", + "[ctn].[text] as contentTypeName", + "[x].[alias] as relationTypeAlias", + "[x].[name] as relationTypeName", + "[x].[isDependency] as relationTypeIsDependency", + "[x].[dual] as relationTypeIsBidirectional") + .From("n") + .InnerJoinNested(innerUnionSql, "x") + .On((n, x) => n.NodeId == x.OtherId, "n", "x") + .LeftJoin("c") + .On( + (left, right) => left.NodeId == right.NodeId, + aliasLeft: "n", + aliasRight: "c") + .LeftJoin("ct") + .On( + (left, right) => left.ContentTypeId == right.NodeId, + aliasLeft: "c", + aliasRight: "ct") + .LeftJoin("ctn") + .On( + (left, right) => left.NodeId == right.NodeId, + aliasLeft: "ct", + aliasRight: "ctn") + .LeftJoin("d") + .On( + (left, right) => left.NodeId == right.NodeId, + aliasLeft: "n", + aliasRight: "d") + .Where(x => x.Id == id, "x"); + + if (filterMustBeIsDependency) + { + sql = sql?.Where(rt => rt.IsDependency, "x"); + } + + // Ordering is required for paging + sql = sql?.OrderBy(x => x.Alias, "x"); + + Page? pagedResult = _scopeAccessor.AmbientScope?.Database.Page(pageIndex + 1, pageSize, sql); + totalRecords = Convert.ToInt32(pagedResult?.TotalItems); + + return pagedResult?.Items.Select(MapDtoToEntity) ?? Enumerable.Empty(); + } + public IEnumerable GetPagedRelationsForItem( Guid key, long skip, @@ -388,6 +596,91 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement return _umbracoMapper.MapEnumerable(pagedResult); } + public IEnumerable GetPagedDescendantsInReferences( + int parentId, + long skip, + long take, + bool filterMustBeIsDependency, + out long totalRecords) + { + SqlSyntax.ISqlSyntaxProvider? syntax = _scopeAccessor.AmbientScope?.Database.SqlContext.SqlSyntax; + + // Gets the path of the parent with ",%" added + Sql? subsubQuery = _scopeAccessor.AmbientScope?.Database.SqlContext.Sql() + .Select(syntax?.GetConcat("[node].[path]", "',%'")) + .From("node") + .Where(x => x.NodeId == parentId, "node"); + + // Gets the descendants of the parent node + Sql? subQuery = _scopeAccessor.AmbientScope?.Database.SqlContext.Sql() + .Select(x => x.NodeId) + .From() + .WhereLike(x => x.Path, subsubQuery); + + Sql innerUnionSql = GetInnerUnionSql(); + Sql? sql = _scopeAccessor.AmbientScope?.Database.SqlContext.Sql().SelectDistinct( + "[x].[id] as nodeId", + "[n].[uniqueId] as nodeKey", + "[n].[text] as nodeName", + "[n].[nodeObjectType] as nodeObjectType", + "[ctn].[uniqueId] as contentTypeKey", + "[ct].[icon] as contentTypeIcon", + "[ct].[alias] as contentTypeAlias", + "[ctn].[text] as contentTypeName", + "[x].[alias] as relationTypeAlias", + "[x].[name] as relationTypeName", + "[x].[isDependency] as relationTypeIsDependency", + "[x].[dual] as relationTypeIsBidirectional") + .From("n") + .InnerJoinNested(innerUnionSql, "x") + .On((n, x) => n.NodeId == x.Id, "n", "x") + .LeftJoin("c").On( + (left, right) => left.NodeId == right.NodeId, + aliasLeft: "n", + aliasRight: "c") + .LeftJoin("ct") + .On( + (left, right) => left.ContentTypeId == right.NodeId, + aliasLeft: "c", + aliasRight: "ct") + .LeftJoin("ctn") + .On( + (left, right) => left.NodeId == right.NodeId, + aliasLeft: "ct", + aliasRight: "ctn"); + sql = sql?.WhereIn( + (System.Linq.Expressions.Expression>)(x => x.NodeId), + subQuery, + "n"); + + if (filterMustBeIsDependency) + { + sql = sql?.Where(rt => rt.IsDependency, "x"); + } + + // find the count before ordering + totalRecords = _scopeAccessor.AmbientScope?.Database.Count(sql!) ?? 0; + + RelationItemDto[] pagedResult; + + //Only to all this, if there is items + if (totalRecords > 0) + { + // Ordering is required for paging + sql = sql?.OrderBy(x => x.Alias, "x"); + + pagedResult = + _scopeAccessor.AmbientScope?.Database.SkipTake(skip, take, sql).ToArray() ?? + Array.Empty(); + } + else + { + pagedResult = Array.Empty(); + } + + return _umbracoMapper.MapEnumerable(pagedResult); + } + private class UnionHelperDto { [Column("id")] public int Id { get; set; } diff --git a/src/Umbraco.Web.UI.Client/examples/validation-context/validation-context-dashboard.ts b/src/Umbraco.Web.UI.Client/examples/validation-context/validation-context-dashboard.ts index 8fd8587c34..b288c26ad3 100644 --- a/src/Umbraco.Web.UI.Client/examples/validation-context/validation-context-dashboard.ts +++ b/src/Umbraco.Web.UI.Client/examples/validation-context/validation-context-dashboard.ts @@ -1,10 +1,10 @@ import { html, customElement, css, state, when } from '@umbraco-cms/backoffice/external/lit'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { UMB_VALIDATION_CONTEXT, umbBindToValidation, UmbValidationContext } from '@umbraco-cms/backoffice/validation'; +import type { UmbValidationMessage } from 'src/packages/core/validation/context/validation-messages.manager'; @customElement('umb-example-validation-context-dashboard') -export class UmbExampleValidationContextDashboard extends UmbLitElement { - +export class UmbExampleValidationContextDashboardElement extends UmbLitElement { readonly validation = new UmbValidationContext(this); @state() @@ -20,7 +20,7 @@ export class UmbExampleValidationContextDashboard extends UmbLitElement { country = ''; @state() - messages? : any[] + messages?: UmbValidationMessage[]; @state() totalErrorCount = 0; @@ -32,87 +32,101 @@ export class UmbExampleValidationContextDashboard extends UmbLitElement { tab2ErrorCount = 0; @state() - tab = "1"; + tab = '1'; constructor() { super(); - this.consumeContext(UMB_VALIDATION_CONTEXT,(validationContext)=>{ - - this.observe(validationContext.messages.messages,(messages)=>{ - this.messages = messages; - },'observeValidationMessages') + this.consumeContext(UMB_VALIDATION_CONTEXT, (validationContext) => { + this.observe( + validationContext.messages.messages, + (messages) => { + this.messages = messages; + }, + 'observeValidationMessages', + ); // Observe all errors - this.validation.messages.messagesOfPathAndDescendant('$.form').subscribe((value)=>{ - this.totalErrorCount = [...new Set(value.map(x=>x.path))].length; + this.validation.messages.messagesOfPathAndDescendant('$.form').subscribe((value) => { + this.totalErrorCount = [...new Set(value.map((x) => x.path))].length; }); // Observe errors for tab1, note that we only use part of the full JSONPath - this.validation.messages.messagesOfPathAndDescendant('$.form.tab1').subscribe((value)=>{ - this.tab1ErrorCount = [...new Set(value.map(x=>x.path))].length; + this.validation.messages.messagesOfPathAndDescendant('$.form.tab1').subscribe((value) => { + this.tab1ErrorCount = [...new Set(value.map((x) => x.path))].length; }); // Observe errors for tab2, note that we only use part of the full JSONPath - this.validation.messages.messagesOfPathAndDescendant('$.form.tab2').subscribe((value)=>{ - this.tab2ErrorCount = [...new Set(value.map(x=>x.path))].length; + this.validation.messages.messagesOfPathAndDescendant('$.form.tab2').subscribe((value) => { + this.tab2ErrorCount = [...new Set(value.map((x) => x.path))].length; }); - - }); + }); } - #onTabChange(e:Event) { + #onTabChange(e: Event) { this.tab = (e.target as HTMLElement).getAttribute('data-tab') as string; } #handleSave() { - // fake server validation-errors for all fields - if(this.name == '') - this.validation.messages.addMessage('server','$.form.tab1.name','Name server-error message','4875e113-cd0c-4c57-ac92-53d677ba31ec'); - if(this.email == '') - this.validation.messages.addMessage('server','$.form.tab1.email','Email server-error message','a47e287b-4ce6-4e8b-8e05-614e7cec1a2a'); - if(this.city == '') - this.validation.messages.addMessage('server','$.form.tab2.city','City server-error message','8dfc2f15-fb9a-463b-bcec-2c5d3ba2d07d'); - if(this.country == '') - this.validation.messages.addMessage('server','$.form.tab2.country','Country server-error message','d98624f6-82a2-4e94-822a-776b44b01495'); + if (this.name == '') + this.validation.messages.addMessage( + 'server', + '$.form.tab1.name', + 'Name server-error message', + '4875e113-cd0c-4c57-ac92-53d677ba31ec', + ); + if (this.email == '') + this.validation.messages.addMessage( + 'server', + '$.form.tab1.email', + 'Email server-error message', + 'a47e287b-4ce6-4e8b-8e05-614e7cec1a2a', + ); + if (this.city == '') + this.validation.messages.addMessage( + 'server', + '$.form.tab2.city', + 'City server-error message', + '8dfc2f15-fb9a-463b-bcec-2c5d3ba2d07d', + ); + if (this.country == '') + this.validation.messages.addMessage( + 'server', + '$.form.tab2.country', + 'Country server-error message', + 'd98624f6-82a2-4e94-822a-776b44b01495', + ); } override render() { return html` - This is a demo of how the Validation Context can be used to validate a form with multiple steps. Start typing in the form or press Save to trigger validation. -
+ This is a demo of how the Validation Context can be used to validate a form with multiple steps. Start typing in + the form or press Save to trigger validation. +
Total errors: ${this.totalErrorCount} -
+
- + Tab 1 - ${when(this.tab1ErrorCount,()=>html` - ${this.tab1ErrorCount} - `)} + ${when(this.tab1ErrorCount, () => html` ${this.tab1ErrorCount} `)} - + Tab 2 - ${when(this.tab2ErrorCount,()=>html` - ${this.tab2ErrorCount} - `)} + ${when(this.tab2ErrorCount, () => html` ${this.tab2ErrorCount} `)} - + - ${when(this.tab=='1',()=>html` - ${this.#renderTab1()} - `)} - ${when(this.tab=='2',()=>html` - ${this.#renderTab2()} - `)} + ${when(this.tab == '1', () => html` ${this.#renderTab1()} `)} + ${when(this.tab == '2', () => html` ${this.#renderTab2()} `)} Save -
+

Validation Context Messages

-
${JSON.stringify(this.messages ?? [],null,3)}
+
${JSON.stringify(this.messages ?? [], null, 3)}
- ` + `; } #renderTab1() { @@ -120,28 +134,28 @@ export class UmbExampleValidationContextDashboard extends UmbLitElement {
- - - this.name = (e.target as HTMLInputElement).value} - ${umbBindToValidation(this,'$.form.tab1.name',this.name)} - required> - + + + (this.name = (e.target as HTMLInputElement).value)} + ${umbBindToValidation(this, '$.form.tab1.name', this.name)} + required> +
this.email = (e.target as HTMLInputElement).value} - ${umbBindToValidation(this,'$.form.tab1.email',this.email)} + @input=${(e: InputEvent) => (this.email = (e.target as HTMLInputElement).value)} + ${umbBindToValidation(this, '$.form.tab1.email', this.email)} required>
- ` + `; } #renderTab2() { @@ -149,71 +163,68 @@ export class UmbExampleValidationContextDashboard extends UmbLitElement {
- - - this.city = (e.target as HTMLInputElement).value} - ${umbBindToValidation(this,'$.form.tab2.city',this.city)} - required> - + + + (this.city = (e.target as HTMLInputElement).value)} + ${umbBindToValidation(this, '$.form.tab2.city', this.city)} + required> +
this.country = (e.target as HTMLInputElement).value} - ${umbBindToValidation(this,'$.form.tab2.country',this.country)} + @input=${(e: InputEvent) => (this.country = (e.target as HTMLInputElement).value)} + ${umbBindToValidation(this, '$.form.tab2.country', this.country)} required>
- ` + `; } + static override styles = [ + css` + uui-badge { + top: 0; + right: 0; + font-size: 10px; + min-width: 17px; + min-height: 17px; + } + label { + display: block; + } - static override styles = [css` + uui-box { + margin: 20px; + } - uui-badge { - top:0; - right:0; - font-size:10px; - min-width:17px; - min-height:17px; + uui-button { + margin-top: 1rem; + } - } - - label { - display:block; - } - - uui-box { - margin:20px; - } - - uui-button { - margin-top:1rem; - } - - pre { - text-align:left; - padding:10px; - border:1px dotted #6f6f6f; - background: #f2f2f2; - font-size: 11px; - line-height: 1.3em; - } - - `] + pre { + text-align: left; + padding: 10px; + border: 1px dotted #6f6f6f; + background: #f2f2f2; + font-size: 11px; + line-height: 1.3em; + } + `, + ]; } -export default UmbExampleValidationContextDashboard; +export default UmbExampleValidationContextDashboardElement; declare global { interface HTMLElementTagNameMap { - 'umb-example-validation-context-dashboard': UmbExampleValidationContextDashboard; + 'umb-example-validation-context-dashboard': UmbExampleValidationContextDashboardElement; } } diff --git a/src/Umbraco.Web.UI.Client/package-lock.json b/src/Umbraco.Web.UI.Client/package-lock.json index 6803ab7454..753841429b 100644 --- a/src/Umbraco.Web.UI.Client/package-lock.json +++ b/src/Umbraco.Web.UI.Client/package-lock.json @@ -10872,9 +10872,9 @@ } }, "node_modules/koa": { - "version": "2.15.3", - "resolved": "https://registry.npmjs.org/koa/-/koa-2.15.3.tgz", - "integrity": "sha512-j/8tY9j5t+GVMLeioLaxweJiKUayFhlGqNTzf2ZGwL0ZCQijd2RLHK0SLW5Tsko8YyyqCZC2cojIb0/s62qTAg==", + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/koa/-/koa-2.16.1.tgz", + "integrity": "sha512-umfX9d3iuSxTQP4pnzLOz0HKnPg0FaUUIKcye2lOiz3KPu1Y3M3xlz76dISdFPQs37P9eJz1wUpcTS6KDPn9fA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.controller.test.ts b/src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.controller.test.ts index a01f922f72..fbe5e7e890 100644 --- a/src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.controller.test.ts +++ b/src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.controller.test.ts @@ -175,12 +175,6 @@ describe('UmbLocalizeController', () => { expect((controller.term as any)('logout', 'Hello', 'World')).to.equal('Log out'); }); - it('should encode HTML entities', () => { - expect(controller.term('withInlineToken', 'Hello', ''), 'XSS detected').to.equal( - 'Hello <script>alert("XSS")</script>', - ); - }); - it('only reacts to changes of its own localization-keys', async () => { const element: UmbLocalizationRenderCountElement = await fixture( html``, diff --git a/src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.controller.ts b/src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.controller.ts index cb9e3927a5..c85be3797e 100644 --- a/src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.controller.ts +++ b/src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.controller.ts @@ -20,7 +20,6 @@ import type { import { umbLocalizationManager } from './localization.manager.js'; import type { LitElement } from '@umbraco-cms/backoffice/external/lit'; import type { UmbController, UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; -import { escapeHTML } from '@umbraco-cms/backoffice/utils'; const LocalizationControllerAlias = Symbol(); /** @@ -137,20 +136,16 @@ export class UmbLocalizationController escapeHTML(a)); - if (typeof term === 'function') { - return term(...sanitizedArgs) as string; + return term(...args) as string; } if (typeof term === 'string') { - if (sanitizedArgs.length) { + if (args.length) { // Replace placeholders of format "%index%" and "{index}" with provided values term = term.replace(/(%(\d+)%|\{(\d+)\})/g, (match, _p1, p2, p3): string => { const index = p2 || p3; - return typeof sanitizedArgs[index] !== 'undefined' ? String(sanitizedArgs[index]) : match; + return typeof args[index] !== 'undefined' ? String(args[index]) : match; }); } } diff --git a/src/Umbraco.Web.UI.Client/src/mocks/data/document-blueprint/document-blueprint.data.ts b/src/Umbraco.Web.UI.Client/src/mocks/data/document-blueprint/document-blueprint.data.ts index d2a1eb9fe8..3c4f1445de 100644 --- a/src/Umbraco.Web.UI.Client/src/mocks/data/document-blueprint/document-blueprint.data.ts +++ b/src/Umbraco.Web.UI.Client/src/mocks/data/document-blueprint/document-blueprint.data.ts @@ -1,30 +1,25 @@ -import type { UmbMockDocumentModel } from '../document/document.data.js'; -import { DocumentVariantStateModel } from '@umbraco-cms/backoffice/external/backend-api'; +import { + DocumentVariantStateModel, + type DocumentBlueprintItemResponseModel, + type DocumentBlueprintResponseModel, + type DocumentBlueprintTreeItemResponseModel, +} from '@umbraco-cms/backoffice/external/backend-api'; // eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface UmbMockDocumentBlueprintModel extends UmbMockDocumentModel {} +export type UmbMockDocumentBlueprintModel = DocumentBlueprintResponseModel & + DocumentBlueprintItemResponseModel & + DocumentBlueprintTreeItemResponseModel; export const data: Array = [ { - ancestors: [], - urls: [ - { - culture: 'en-US', - url: '/', - }, - ], - template: null, id: 'the-simplest-document-id', - createDate: '2023-02-06T15:32:05.350038', - parent: null, documentType: { id: 'the-simplest-document-type-id', icon: 'icon-document', }, hasChildren: false, - noAccess: false, - isProtected: false, - isTrashed: false, + isFolder: false, + name: 'The Simplest Document Blueprint', variants: [ { state: DocumentVariantStateModel.DRAFT, diff --git a/src/Umbraco.Web.UI.Client/src/mocks/data/document-blueprint/document-blueprint.db.ts b/src/Umbraco.Web.UI.Client/src/mocks/data/document-blueprint/document-blueprint.db.ts index 6d6eec6720..7e09bcbc50 100644 --- a/src/Umbraco.Web.UI.Client/src/mocks/data/document-blueprint/document-blueprint.db.ts +++ b/src/Umbraco.Web.UI.Client/src/mocks/data/document-blueprint/document-blueprint.db.ts @@ -8,10 +8,10 @@ import type { UmbMockDocumentBlueprintModel } from './document-blueprint.data.js import { DocumentVariantStateModel } from '@umbraco-cms/backoffice/external/backend-api'; import { UmbId } from '@umbraco-cms/backoffice/id'; import type { - CreateDocumentRequestModel, - DocumentItemResponseModel, - DocumentResponseModel, - DocumentTreeItemResponseModel, + CreateDocumentBlueprintRequestModel, + DocumentBlueprintItemResponseModel, + DocumentBlueprintResponseModel, + DocumentBlueprintTreeItemResponseModel, DocumentValueResponseModel, } from '@umbraco-cms/backoffice/external/backend-api'; @@ -23,41 +23,34 @@ export class UmbDocumentBlueprintMockDB extends UmbEntityMockDbBase) { - super(data); - } } -const treeItemMapper = (model: UmbMockDocumentBlueprintModel): Omit => { +const treeItemMapper = (model: UmbMockDocumentBlueprintModel): DocumentBlueprintTreeItemResponseModel => { const documentType = umbDocumentTypeMockDb.read(model.documentType.id); if (!documentType) throw new Error(`Document type with id ${model.documentType.id} not found`); return { - ancestors: model.ancestors, documentType: { icon: documentType.icon, id: documentType.id, }, hasChildren: model.hasChildren, id: model.id, - isProtected: model.isProtected, - isTrashed: model.isTrashed, - noAccess: model.noAccess, + isFolder: model.isFolder, + name: model.name, parent: model.parent, - variants: model.variants, - createDate: model.createDate, }; }; -const createMockDocumentBlueprintMapper = (request: CreateDocumentRequestModel): UmbMockDocumentBlueprintModel => { +const createMockDocumentBlueprintMapper = ( + request: CreateDocumentBlueprintRequestModel, +): UmbMockDocumentBlueprintModel => { const documentType = umbDocumentTypeMockDb.read(request.documentType.id); if (!documentType) throw new Error(`Document type with id ${request.documentType.id} not found`); const now = new Date().toString(); return { - ancestors: [], documentType: { id: documentType.id, icon: documentType.icon, @@ -65,10 +58,8 @@ const createMockDocumentBlueprintMapper = (request: CreateDocumentRequestModel): }, hasChildren: false, id: request.id ? request.id : UmbId.new(), - createDate: now, - isProtected: false, - isTrashed: false, - noAccess: false, + isFolder: false, + name: request.variants[0].name, parent: request.parent, values: request.values as DocumentValueResponseModel[], variants: request.variants.map((variantRequest) => { @@ -82,35 +73,27 @@ const createMockDocumentBlueprintMapper = (request: CreateDocumentRequestModel): publishDate: null, }; }), - urls: [], }; }; -const detailResponseMapper = (model: UmbMockDocumentBlueprintModel): DocumentResponseModel => { +const detailResponseMapper = (model: UmbMockDocumentBlueprintModel): DocumentBlueprintResponseModel => { return { documentType: model.documentType, id: model.id, - isTrashed: model.isTrashed, - template: model.template, - urls: model.urls, values: model.values, variants: model.variants, }; }; -const itemMapper = (model: UmbMockDocumentBlueprintModel): DocumentItemResponseModel => { +const itemMapper = (model: UmbMockDocumentBlueprintModel): DocumentBlueprintItemResponseModel => { return { documentType: { collection: model.documentType.collection, icon: model.documentType.icon, id: model.documentType.id, }, - hasChildren: model.hasChildren, id: model.id, - isProtected: model.isProtected, - isTrashed: model.isTrashed, - parent: model.parent, - variants: model.variants, + name: model.name, }; }; diff --git a/src/Umbraco.Web.UI.Client/src/mocks/data/document/data/permissions-test.data.ts b/src/Umbraco.Web.UI.Client/src/mocks/data/document/data/permissions-test.data.ts new file mode 100644 index 0000000000..4d08835b57 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/mocks/data/document/data/permissions-test.data.ts @@ -0,0 +1,129 @@ +import type { UmbMockDocumentModel } from '../document.data.js'; +import { DocumentVariantStateModel } from '@umbraco-cms/backoffice/external/backend-api'; + +const permissionsTestDocument = { + ancestors: [], + urls: [ + { + culture: null, + url: '/', + }, + ], + template: null, + id: 'permissions-document-id', + createDate: '2023-02-06T15:32:05.350038', + parent: null, + documentType: { + id: 'the-simplest-document-type-id', + icon: 'icon-document', + }, + hasChildren: false, + noAccess: false, + isProtected: false, + isTrashed: false, + values: [], + variants: [ + { + state: DocumentVariantStateModel.PUBLISHED, + publishDate: '2023-02-06T15:32:24.957009', + culture: null, + segment: null, + name: 'Permissions', + createDate: '2023-02-06T15:32:05.350038', + updateDate: '2023-02-06T15:32:24.957009', + }, + ], +}; + +export const data: Array = [ + permissionsTestDocument, + { + ...permissionsTestDocument, + ancestors: [{ id: 'permissions-document-id' }], + hasChildren: false, + id: 'permissions-1-document-id', + parent: { id: 'permissions-document-id' }, + urls: [ + { + culture: null, + url: '/permission-1', + }, + ], + variants: permissionsTestDocument.variants.map((variant) => ({ + ...variant, + name: 'Permissions 1', + })), + }, + { + ...permissionsTestDocument, + ancestors: [{ id: 'permissions-document-id' }], + hasChildren: true, + id: 'permissions-2-document-id', + parent: { id: 'permissions-document-id' }, + urls: [ + { + culture: null, + url: '/permissions-2', + }, + ], + variants: permissionsTestDocument.variants.map((variant) => ({ + ...variant, + name: 'Permissions 2', + })), + }, + { + ...permissionsTestDocument, + ancestors: [{ id: 'permissions-document-id' }, { id: 'permissions-2-document-id' }], + hasChildren: true, + id: 'permission-2-1-document-id', + parent: { id: 'permissions-2-document-id' }, + urls: [ + { + culture: null, + url: '/permissions-1/permissions-2-1', + }, + ], + variants: permissionsTestDocument.variants.map((variant) => ({ + ...variant, + name: 'Permissions 2.1', + })), + }, + { + ...permissionsTestDocument, + ancestors: [{ id: 'permissions-document-id' }, { id: 'permissions-2-document-id' }], + hasChildren: false, + id: 'permissions-2-2-document-id', + parent: { id: 'permissions-2-document-id' }, + urls: [ + { + culture: null, + url: '/permissions-1/permissions-2-2', + }, + ], + variants: permissionsTestDocument.variants.map((variant) => ({ + ...variant, + name: 'Permissions 2.2', + })), + }, + { + ...permissionsTestDocument, + ancestors: [ + { id: 'permissions-document-id' }, + { id: 'permissions-2-document-id' }, + { id: 'permissions-2-2-document-id' }, + ], + hasChildren: false, + id: 'permission-2-2-1-document-id', + parent: { id: 'permissions-2-2-document-id' }, + urls: [ + { + culture: null, + url: '/permissions-1/permissions-2-2/permissions-2-2-1', + }, + ], + variants: permissionsTestDocument.variants.map((variant) => ({ + ...variant, + name: 'Permissions 2.2.1', + })), + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/mocks/data/document/document.data.ts b/src/Umbraco.Web.UI.Client/src/mocks/data/document/document.data.ts index e815b026be..6b9c8a5e9e 100644 --- a/src/Umbraco.Web.UI.Client/src/mocks/data/document/document.data.ts +++ b/src/Umbraco.Web.UI.Client/src/mocks/data/document/document.data.ts @@ -1,3 +1,4 @@ +import { data as permissionsTestData } from './data/permissions-test.data.js'; import type { DocumentItemResponseModel, DocumentResponseModel, @@ -1221,4 +1222,5 @@ export const data: Array = [ }, ], }, + ...permissionsTestData, ]; diff --git a/src/Umbraco.Web.UI.Client/src/mocks/data/document/document.db.ts b/src/Umbraco.Web.UI.Client/src/mocks/data/document/document.db.ts index 8888af52be..0e338e6662 100644 --- a/src/Umbraco.Web.UI.Client/src/mocks/data/document/document.db.ts +++ b/src/Umbraco.Web.UI.Client/src/mocks/data/document/document.db.ts @@ -77,6 +77,20 @@ const createMockDocumentMapper = (request: CreateDocumentRequestModel): UmbMockD const documentType = umbDocumentTypeMockDb.read(request.documentType.id); if (!documentType) throw new Error(`Document type with id ${request.documentType.id} not found`); + const isRoot = request.parent === null || request.parent === undefined; + let ancestors: Array<{ id: string }> = []; + + if (!isRoot) { + const parentId = request.parent!.id; + + const parentAncestors = umbDocumentMockDb.tree.getAncestorsOf({ descendantId: parentId }).map((ancestor) => { + return { + id: ancestor.id, + }; + }); + ancestors = [...parentAncestors, { id: parentId }]; + } + const now = new Date().toString(); return { diff --git a/src/Umbraco.Web.UI.Client/src/mocks/data/user-group/user-group.data.ts b/src/Umbraco.Web.UI.Client/src/mocks/data/user-group/user-group.data.ts index fdbf6cdb99..d2f7f94e8f 100644 --- a/src/Umbraco.Web.UI.Client/src/mocks/data/user-group/user-group.data.ts +++ b/src/Umbraco.Web.UI.Client/src/mocks/data/user-group/user-group.data.ts @@ -26,7 +26,29 @@ export const data: Array = [ 'Umb.Document.PublicAccess', 'Umb.Document.Rollback', ], - permissions: [], + permissions: [ + { + $type: 'DocumentPermissionPresentationModel', + document: { + id: 'permissions-document-id', + }, + verbs: ['Umb.Document.Read'], + }, + { + $type: 'DocumentPermissionPresentationModel', + document: { + id: 'permissions-2-document-id', + }, + verbs: ['Umb.Document.Create', 'Umb.Document.Read'], + }, + { + $type: 'DocumentPermissionPresentationModel', + document: { + id: 'permissions-2-2-document-id', + }, + verbs: ['Umb.Document.Delete', 'Umb.Document.Read'], + }, + ], sections: [ UMB_CONTENT_SECTION_ALIAS, 'Umb.Section.Media', diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-entries/block-grid-entries.element.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-entries/block-grid-entries.element.ts index 290129dc4a..d721602a66 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-entries/block-grid-entries.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-entries/block-grid-entries.element.ts @@ -427,6 +427,7 @@ export class UmbBlockGridEntriesElement extends UmbFormControlMixin(UmbLitElemen return html` @@ -492,13 +493,6 @@ export class UmbBlockGridEntriesElement extends UmbFormControlMixin(UmbLitElemen --umb-block-grid--create-button--is-dragging--variable: var(--umb-block-grid--is-dragging) none; display: var(--umb-block-grid--create-button--is-dragging--variable, grid); } - :host(:not([pristine]):invalid) #createButton { - --uui-button-contrast: var(--uui-color-danger); - --uui-button-contrast-hover: var(--uui-color-danger); - --uui-color-default-emphasis: var(--uui-color-danger); - --uui-button-border-color: var(--uui-color-danger); - --uui-button-border-color-hover: var(--uui-color-danger); - } .umb-block-grid__layout-container[data-area-length='0'] { --umb-block-grid--layout-container--is-dragging--variable: var(--umb-block-grid--is-dragging) 1; diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-entry/block-grid-entry.element.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-entry/block-grid-entry.element.ts index 03a7da86ea..edf7e97a2a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-entry/block-grid-entry.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-entry/block-grid-entry.element.ts @@ -456,7 +456,7 @@ export class UmbBlockGridEntryElement extends UmbLitElement implements UmbProper > ${this.#renderActionBar()} ${!this._showContentEdit && this._contentInvalid - ? html`!` + ? html`!` : nothing} ${this._invalidLocation ? html` ${this._contentInvalid - ? html`!` + ? html`!` : nothing} ` : this._showContentEdit === false && this._exposed === false @@ -581,11 +581,11 @@ export class UmbBlockGridEntryElement extends UmbLitElement implements UmbProper ? html` ${this._settingsInvalid - ? html`!` + ? html`!` : nothing} ` : nothing} @@ -638,7 +638,7 @@ export class UmbBlockGridEntryElement extends UmbLitElement implements UmbProper :host([location-invalid])::after, :host([settings-invalid])::after, :host([content-invalid])::after { - border-color: var(--uui-color-danger); + border-color: var(--uui-color-invalid); } #invalidLocation { diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-list/components/block-list-entry/block-list-entry.element.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/components/block-list-entry/block-list-entry.element.ts index 313ea4e501..b713c8df7e 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-list/components/block-list-entry/block-list-entry.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/components/block-list-entry/block-list-entry.element.ts @@ -416,7 +416,7 @@ export class UmbBlockListEntryElement extends UmbLitElement implements UmbProper ${this.#renderCopyToClipboardAction()} ${this.#renderDeleteAction()} ${!this._showContentEdit && this._contentInvalid - ? html`!` + ? html`!` : nothing} ` @@ -428,11 +428,11 @@ export class UmbBlockListEntryElement extends UmbLitElement implements UmbProper ? html` ${this._contentInvalid - ? html`!` + ? html`!` : nothing} ` : this._showContentEdit === false && this._exposed === false @@ -451,11 +451,11 @@ export class UmbBlockListEntryElement extends UmbLitElement implements UmbProper ? html` ${this._settingsInvalid - ? html`!` + ? html`!` : nothing} ` : nothing} @@ -508,7 +508,7 @@ export class UmbBlockListEntryElement extends UmbLitElement implements UmbProper :host([settings-invalid])::after, :host([content-invalid])::after { - border-color: var(--uui-color-danger); + border-color: var(--uui-color-invalid); } uui-action-bar { diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/components/block-rte-entry/block-rte-entry.element.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/components/block-rte-entry/block-rte-entry.element.ts index 4708b444a3..a056c7045f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/components/block-rte-entry/block-rte-entry.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/components/block-rte-entry/block-rte-entry.element.ts @@ -270,7 +270,7 @@ export class UmbBlockRteEntryElement extends UmbLitElement implements UmbPropert ${this.#renderEditAction()} ${this.#renderEditSettingsAction()} ${!this._showContentEdit && this._contentInvalid - ? html`!` + ? html`!` : nothing} ` @@ -291,11 +291,11 @@ export class UmbBlockRteEntryElement extends UmbLitElement implements UmbPropert ? html` ${this._contentInvalid - ? html`!` + ? html`!` : nothing} ` : this._showContentEdit === false && this._exposed === false @@ -314,11 +314,11 @@ export class UmbBlockRteEntryElement extends UmbLitElement implements UmbPropert ? html` ${this._settingsInvalid - ? html`!` + ? html`!` : nothing} ` : nothing} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/components/input-number-range/input-number-range.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/components/input-number-range/input-number-range.element.ts index 86315f5ce8..d9a961b5aa 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/components/input-number-range/input-number-range.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/components/input-number-range/input-number-range.element.ts @@ -131,10 +131,10 @@ export class UmbInputNumberRangeElement extends UmbFormControlMixin(UmbLitElemen static override styles = css` :host(:invalid:not([pristine])) { - color: var(--uui-color-danger); + color: var(--uui-color-invalid); } :host(:invalid:not([pristine])) uui-input { - border-color: var(--uui-color-danger); + border-color: var(--uui-color-invalid); } `; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/components/input-with-alias/input-with-alias.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/components/input-with-alias/input-with-alias.element.ts index c854fb9dea..fc9ae5ba82 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/components/input-with-alias/input-with-alias.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/components/input-with-alias/input-with-alias.element.ts @@ -147,10 +147,10 @@ export class UmbInputWithAliasElement extends UmbFormControlMixin uui-input { - border-color: var(--uui-color-danger); + border-color: var(--uui-color-invalid); } `; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/components/multiple-color-picker-input/multiple-color-picker-item-input.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/components/multiple-color-picker-input/multiple-color-picker-item-input.element.ts index e16d1f8210..47d05e9cbe 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/components/multiple-color-picker-input/multiple-color-picker-item-input.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/components/multiple-color-picker-input/multiple-color-picker-item-input.element.ts @@ -188,7 +188,6 @@ export class UmbMultipleColorPickerItemInputElement extends UUIFormControlMixin( () => html` html` ; contentValidationRepository?: ClassConstructor>; skipValidationOnSubmit?: boolean; + ignoreValidationResultOnSubmit?: boolean; contentVariantScaffold: VariantModelType; contentTypePropertyName: string; saveModalToken?: UmbModalToken, UmbContentVariantPickerValue>; @@ -151,6 +152,7 @@ export abstract class UmbContentDetailWorkspaceContextBase< } #validateOnSubmit: boolean; + #ignoreValidationResultOnSubmit: boolean; #serverValidation = new UmbServerModelValidatorContext(this); #validationRepositoryClass?: ClassConstructor>; #validationRepository?: UmbContentValidationRepository; @@ -178,6 +180,7 @@ export abstract class UmbContentDetailWorkspaceContextBase< const contentTypeDetailRepository = new args.contentTypeDetailRepository(this); this.#validationRepositoryClass = args.contentValidationRepository; this.#validateOnSubmit = args.skipValidationOnSubmit ? !args.skipValidationOnSubmit : true; + this.#ignoreValidationResultOnSubmit = args.ignoreValidationResultOnSubmit ?? false; this.structure = new UmbContentTypeStructureManager(this, contentTypeDetailRepository); this.variesByCulture = this.structure.ownerContentTypeObservablePart((x) => x?.variesByCulture); this.variesBySegment = this.structure.ownerContentTypeObservablePart((x) => x?.variesBySegment); @@ -726,7 +729,11 @@ export abstract class UmbContentDetailWorkspaceContextBase< return this.performCreateOrUpdate(variantIds, saveData); }, async (reason?: any) => { - return this.invalidSubmit(reason); + if (this.#ignoreValidationResultOnSubmit) { + return this.performCreateOrUpdate(variantIds, saveData); + } else { + return this.invalidSubmit(reason); + } }, ); } else { diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/global-components/entity-actions-table-column-view/entity-actions-table-column-view.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/global-components/entity-actions-table-column-view/entity-actions-table-column-view.element.ts index f625894db3..f1b112ff09 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/global-components/entity-actions-table-column-view/entity-actions-table-column-view.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/global-components/entity-actions-table-column-view/entity-actions-table-column-view.element.ts @@ -1,16 +1,12 @@ import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity'; -import { html, nothing, customElement, property, state } from '@umbraco-cms/backoffice/external/lit'; +import { html, nothing, customElement, property } from '@umbraco-cms/backoffice/external/lit'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; -const elementName = 'umb-entity-actions-table-column-view'; -@customElement(elementName) +@customElement('umb-entity-actions-table-column-view') export class UmbEntityActionsTableColumnViewElement extends UmbLitElement { @property({ attribute: false }) value?: UmbEntityModel; - @state() - _isOpen = false; - override render() { if (!this.value) return nothing; @@ -23,6 +19,6 @@ export class UmbEntityActionsTableColumnViewElement extends UmbLitElement { declare global { interface HTMLElementTagNameMap { - [elementName]: UmbEntityActionsTableColumnViewElement; + 'umb-entity-actions-table-column-view': UmbEntityActionsTableColumnViewElement; } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/entity/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/core/entity/constants.ts new file mode 100644 index 0000000000..3ed4ee95ed --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/entity/constants.ts @@ -0,0 +1 @@ +export * from './contexts/ancestors/constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/entity/contexts/ancestors/ancestors.entity-context-token.ts b/src/Umbraco.Web.UI.Client/src/packages/core/entity/contexts/ancestors/ancestors.entity-context-token.ts new file mode 100644 index 0000000000..bcb07aed03 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/entity/contexts/ancestors/ancestors.entity-context-token.ts @@ -0,0 +1,4 @@ +import type { UmbAncestorsEntityContext } from './ancestors.entity-context.js'; +import { UmbContextToken } from '@umbraco-cms/backoffice/context-api'; + +export const UMB_ANCESTORS_ENTITY_CONTEXT = new UmbContextToken('UmbAncestorsEntityContext'); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/entity/contexts/ancestors/ancestors.entity-context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/entity/contexts/ancestors/ancestors.entity-context.ts new file mode 100644 index 0000000000..eecdffbd06 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/entity/contexts/ancestors/ancestors.entity-context.ts @@ -0,0 +1,38 @@ +import { UMB_ANCESTORS_ENTITY_CONTEXT } from './ancestors.entity-context-token.js'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; +import { UmbArrayState } from '@umbraco-cms/backoffice/observable-api'; +import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity'; + +/** + * A entity context for the ancestors + * @class UmbAncestorsEntityContext + * @augments {UmbContextBase} + * @implements {UmbAncestorsEntityContext} + */ +export class UmbAncestorsEntityContext extends UmbContextBase { + #ancestors = new UmbArrayState([], (x) => x.unique); + ancestors = this.#ancestors.asObservable(); + + constructor(host: UmbControllerHost) { + super(host, UMB_ANCESTORS_ENTITY_CONTEXT); + } + + /** + * Gets the ancestors state + * @returns {Array} - The ancestors state + * @memberof UmbAncestorsEntityContext + */ + getAncestors(): Array { + return this.#ancestors.getValue(); + } + + /** + * Sets the ancestors state + * @param {Array} ancestors - The ancestors state + * @memberof UmbAncestorsEntityContext + */ + setAncestors(ancestors: Array) { + this.#ancestors.setValue(ancestors); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/entity/contexts/ancestors/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/core/entity/contexts/ancestors/constants.ts new file mode 100644 index 0000000000..f9f9c304ab --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/entity/contexts/ancestors/constants.ts @@ -0,0 +1 @@ +export { UMB_ANCESTORS_ENTITY_CONTEXT } from './ancestors.entity-context-token.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/entity/contexts/ancestors/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/entity/contexts/ancestors/index.ts new file mode 100644 index 0000000000..b38afa73e8 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/entity/contexts/ancestors/index.ts @@ -0,0 +1 @@ +export { UmbAncestorsEntityContext } from './ancestors.entity-context.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/entity/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/entity/index.ts index 737d7bf482..d064b7c97a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/entity/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/entity/index.ts @@ -1,3 +1,5 @@ export { UMB_ENTITY_CONTEXT } from './entity.context-token.js'; export { UmbEntityContext } from './entity.context.js'; +export * from './constants.js'; +export * from './contexts/ancestors/index.js'; export type * from './types.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/localization/localize.element.test.ts b/src/Umbraco.Web.UI.Client/src/packages/core/localization/localize.element.test.ts index ae69b81fd1..66f5487a1d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/localization/localize.element.test.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/localization/localize.element.test.ts @@ -95,6 +95,14 @@ describe('umb-localize', () => { expect(element.shadowRoot?.innerHTML).to.contain('Hello World'); }); + it('should localize a key with multiple arguments as encoded HTML', async () => { + element.key = 'general_moreThanOneArgument'; + element.args = ['Hello', 'World']; + await elementUpdated(element); + + expect(element.shadowRoot?.innerHTML).to.contain('<strong>Hello</strong> <em>World</em>'); + }); + it('should localize a key with args as an attribute', async () => { element.key = 'general_moreThanOneArgument'; element.setAttribute('args', '["Hello","World"]'); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/localization/localize.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/localization/localize.element.ts index 945209f072..11aa824808 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/localization/localize.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/localization/localize.element.ts @@ -1,4 +1,5 @@ import { css, customElement, html, property, state, unsafeHTML } from '@umbraco-cms/backoffice/external/lit'; +import { escapeHTML } from '@umbraco-cms/backoffice/utils'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; /** @@ -34,7 +35,11 @@ export class UmbLocalizeElement extends UmbLitElement { @state() protected get text(): string { - const localizedValue = this.localize.term(this.key, ...(this.args ?? [])); + // As translated texts can contain HTML, we will need to render with unsafeHTML. + // But arguments can come from user input, so they should be escaped. + const escapedArgs = (this.args ?? []).map((a) => escapeHTML(a)); + + const localizedValue = this.localize.term(this.key, ...escapedArgs); // If the value is the same as the key, it means the key was not found. if (localizedValue === this.key) { @@ -44,12 +49,13 @@ export class UmbLocalizeElement extends UmbLitElement { (this.getHostElement() as HTMLElement).removeAttribute('data-localize-missing'); - return localizedValue; + return localizedValue.trim(); } override render() { - return this.text.trim() - ? html`${unsafeHTML(this.text)}` + const text = this.text; + return text + ? unsafeHTML(text) : this.debug ? html`${this.key}` : html``; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/menu/menu-variant-tree-structure-workspace-context-base.ts b/src/Umbraco.Web.UI.Client/src/packages/core/menu/menu-variant-tree-structure-workspace-context-base.ts index 8d89588a1d..4233b340f5 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/menu/menu-variant-tree-structure-workspace-context-base.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/menu/menu-variant-tree-structure-workspace-context-base.ts @@ -5,6 +5,7 @@ import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; import { UMB_VARIANT_WORKSPACE_CONTEXT } from '@umbraco-cms/backoffice/workspace'; import { UmbArrayState, UmbObjectState } from '@umbraco-cms/backoffice/observable-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { UmbAncestorsEntityContext } from '@umbraco-cms/backoffice/entity'; interface UmbMenuVariantTreeStructureWorkspaceContextBaseArgs { treeRepositoryAlias: string; @@ -21,6 +22,8 @@ export abstract class UmbMenuVariantTreeStructureWorkspaceContextBase extends Um #parent = new UmbObjectState(undefined); public readonly parent = this.#parent.asObservable(); + #ancestorContext = new UmbAncestorsEntityContext(this); + constructor(host: UmbControllerHost, args: UmbMenuVariantTreeStructureWorkspaceContextBaseArgs) { // TODO: set up context token super(host, 'UmbMenuStructureWorkspaceContext'); @@ -85,6 +88,15 @@ export abstract class UmbMenuVariantTreeStructureWorkspaceContextBase extends Um }; }); + const ancestorEntities = data.map((treeItem) => { + return { + unique: treeItem.unique, + entityType: treeItem.entityType, + }; + }); + + this.#ancestorContext.setAncestors(ancestorEntities); + structureItems.push(...ancestorItems); const parent = structureItems[structureItems.length - 2]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property/components/property-layout/property-layout.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property/components/property-layout/property-layout.element.ts index e7e1b41dc5..fac3aa45bc 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/property/components/property-layout/property-layout.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/property/components/property-layout/property-layout.element.ts @@ -74,7 +74,10 @@ export class UmbPropertyLayoutElement extends UmbLitElement {
${this.localize.string(this.label)} - ${when(this.invalid, () => html`!`)} + ${when( + this.invalid, + () => html`
!
`, + )}
${this.#renderDescription()} @@ -129,15 +132,28 @@ export class UmbPropertyLayoutElement extends UmbLitElement { } /*}*/ + :host { + /* TODO: Temp solution to not get a yellow asterisk when invalid. */ + --umb-temp-uui-color-invalid: var(--uui-color-invalid); + } + #label { position: relative; word-break: break-word; + /* TODO: Temp solution to not get a yellow asterisk when invalid. */ + --uui-color-invalid: var(--uui-color-danger); } - :host([invalid]) #label { - color: var(--uui-color-danger); + #invalid-badge { + display: inline-block; + position: relative; + width: 18px; + height: 1em; + margin-right: 6px; } uui-badge { - right: -30px; + //height: var(--uui-color-invalid); + background-color: var(--umb-temp-uui-color-invalid); + color: var(--uui-color-invalid-contrast); } #description { diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/router/route/not-found/route-not-found.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/router/route/not-found/route-not-found.element.ts index 522228a482..e5537de497 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/router/route/not-found/route-not-found.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/router/route/not-found/route-not-found.element.ts @@ -33,7 +33,7 @@ export class UmbRouteNotFoundElement extends UmbLitElement { align-items: center; height: 100%; opacity: 0; - animation: fadeIn 6s 0.2s forwards; + animation: fadeIn 5s 5s forwards; } @keyframes fadeIn { diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/router/route/route.interface.ts b/src/Umbraco.Web.UI.Client/src/packages/core/router/route/route.interface.ts index b46571cdee..08bceefe62 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/router/route/route.interface.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/router/route/route.interface.ts @@ -1 +1 @@ -export type { IRoute as UmbRoute } from '../router-slot/model.js'; +export type { IRoute as UmbRoute } from '../router-slot/model.js'; \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/utils/sanitize/escape-html.function.ts b/src/Umbraco.Web.UI.Client/src/packages/core/utils/sanitize/escape-html.function.ts index ee84b1ee86..e183b0dac2 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/utils/sanitize/escape-html.function.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/utils/sanitize/escape-html.function.ts @@ -4,7 +4,7 @@ const NON_ALPHANUMERIC_REGEXP = /([^#-~| |!])/g; /** * Escapes HTML entities in a string. - * @example escapeHTML(''), // "<script>alert("XSS")</script>" + * @example escapeHTML(''), // "<script>alert("XSS")</script>" * @param html The HTML string to escape. * @returns The sanitized HTML string. */ diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/validation/components/form-validation-message.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/validation/components/form-validation-message.element.ts index 38a4fb1181..e8b1806b50 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/validation/components/form-validation-message.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/validation/components/form-validation-message.element.ts @@ -85,7 +85,7 @@ export class UmbFormValidationMessageElement extends UmbLitElement { static override styles = [ css` #messages { - color: var(--uui-color-danger-standalone); + color: var(--uui-color-invalid-standalone); } `, ]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/entity-detail/entity-detail-workspace-base.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/entity-detail/entity-detail-workspace-base.ts index 5775788ca4..6ff40b9e37 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/entity-detail/entity-detail-workspace-base.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/entity-detail/entity-detail-workspace-base.ts @@ -171,6 +171,7 @@ export abstract class UmbEntityDetailWorkspaceContextBase< return (await this._getDataPromise) as GetDataType; } this.resetState(); + this.setIsNew(false); this.#entityContext.setUnique(unique); this.loading.addState({ unique: LOADING_STATE_UNIQUE, message: `Loading ${this.getEntityType()} Details` }); await this.#init; @@ -182,7 +183,6 @@ export abstract class UmbEntityDetailWorkspaceContextBase< if (data) { this._data.setPersisted(data); this._data.setCurrent(data); - this.setIsNew(false); this.observe( response.asObservable(), @@ -246,8 +246,8 @@ export abstract class UmbEntityDetailWorkspaceContextBase< data = { ...data, ...this.modalContext.data.preset }; } - this.#entityContext.setUnique(data.unique); this.setIsNew(true); + this.#entityContext.setUnique(data.unique); this._data.setPersisted(data); this._data.setCurrent(data); } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/modals/workspace-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/modals/workspace-modal.element.ts index 40fd96f0ef..873e5c3087 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/modals/workspace-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/modals/workspace-modal.element.ts @@ -7,7 +7,22 @@ import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; @customElement('umb-workspace-modal') export class UmbWorkspaceModalElement extends UmbLitElement { @property({ attribute: false }) - data?: UmbWorkspaceModalData; + public get data(): UmbWorkspaceModalData | undefined { + return this._data; + } + public set data(value: UmbWorkspaceModalData | undefined) { + this._data = value; + if (value?.inheritValidationLook) { + // Do nothing. + } else { + const elementStyle = this.style; + elementStyle.setProperty('--uui-color-invalid', 'var(--uui-color-danger)'); + elementStyle.setProperty('--uui-color-invalid-emphasis', 'var(--uui-color-danger-emphasis)'); + elementStyle.setProperty('--uui-color-invalid-standalone', 'var(--uui-color-danger-standalone)'); + elementStyle.setProperty('--uui-color-invalid-contrast', 'var(--uui-color-danger-contrast)'); + } + } + private _data?: UmbWorkspaceModalData | undefined; /** * TODO: Consider if this binding and events integration is the right for communicating back the modal handler. Or if we should go with some Context API. like a Modal Context API. diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/modals/workspace-modal.token.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/modals/workspace-modal.token.ts index b78617a648..c1ee724432 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/modals/workspace-modal.token.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/modals/workspace-modal.token.ts @@ -3,6 +3,7 @@ export interface UmbWorkspaceModalData { entityType: string; preset: Partial; baseDataPath?: string; + inheritValidationLook?: boolean; } export type UmbWorkspaceModalValue = diff --git a/src/Umbraco.Web.UI.Client/src/packages/data-type/components/data-type-flow-input/data-type-flow-input.element.ts b/src/Umbraco.Web.UI.Client/src/packages/data-type/components/data-type-flow-input/data-type-flow-input.element.ts index 5f98391fa7..2f2b69fb85 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/data-type/components/data-type-flow-input/data-type-flow-input.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/data-type/components/data-type-flow-input/data-type-flow-input.element.ts @@ -115,9 +115,9 @@ export class UmbInputDataTypeElement extends UmbFormControlMixin(UmbLitElement, --uui-button-padding-bottom-factor: 4; } :host(:invalid:not([pristine])) #empty-state-button { - --uui-button-border-color: var(--uui-color-danger); + --uui-button-border-color: var(--uui-color-invalid); --uui-button-border-width: 2px; - --uui-button-contrast: var(--uui-color-danger); + --uui-button-contrast: var(--uui-color-invalid); } `, ]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/repository/document-collection.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/repository/document-collection.server.data-source.ts index 3085ee363b..ecca618550 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/repository/document-collection.server.data-source.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/repository/document-collection.server.data-source.ts @@ -37,6 +37,12 @@ export class UmbDocumentCollectionServerDataSource implements UmbCollectionDataS const variant = item.variants[0]; const model: UmbDocumentCollectionItemModel = { + ancestors: item.ancestors.map((ancestor) => { + return { + unique: ancestor.id, + entityType: UMB_DOCUMENT_ENTITY_TYPE, + }; + }), unique: item.id, entityType: UMB_DOCUMENT_ENTITY_TYPE, contentTypeAlias: item.documentType.alias, diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/types.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/types.ts index 55107999fc..a1ec0ecc44 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/types.ts @@ -1,5 +1,6 @@ import type { UmbDocumentEntityType } from '../entity.js'; import type { UmbDocumentItemVariantModel } from '../item/repository/types.js'; +import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity'; import type { UmbCollectionFilterModel } from '@umbraco-cms/backoffice/collection'; export interface UmbDocumentCollectionFilterModel extends UmbCollectionFilterModel { @@ -12,6 +13,7 @@ export interface UmbDocumentCollectionFilterModel extends UmbCollectionFilterMod } export interface UmbDocumentCollectionItemModel { + ancestors: Array; unique: string; entityType: UmbDocumentEntityType; creator?: string | null; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/views/table/column-layouts/document-entity-actions-table-column-view.element.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/views/table/column-layouts/document-entity-actions-table-column-view.element.ts new file mode 100644 index 0000000000..3a67955958 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/views/table/column-layouts/document-entity-actions-table-column-view.element.ts @@ -0,0 +1,36 @@ +import type { UmbDocumentCollectionItemModel } from '../../../types.js'; +import { UmbAncestorsEntityContext } from '@umbraco-cms/backoffice/entity'; +import { html, nothing, customElement, property } from '@umbraco-cms/backoffice/external/lit'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; + +@customElement('umb-document-entity-actions-table-column-view') +export class UmbDocumentEntityActionsTableColumnViewElement extends UmbLitElement { + @property({ attribute: false }) + public get value(): UmbDocumentCollectionItemModel | undefined { + return this._value; + } + public set value(value: UmbDocumentCollectionItemModel | undefined) { + this._value = value; + this.#ancestorContext.setAncestors(this._value?.ancestors ?? []); + } + + private _value?: UmbDocumentCollectionItemModel | undefined; + + #ancestorContext = new UmbAncestorsEntityContext(this); + + override render() { + if (!this._value) return nothing; + + return html` + + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + ['umb-document-entity-actions-table-column-view']: UmbDocumentEntityActionsTableColumnViewElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/views/table/document-table-collection-view.element.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/views/table/document-table-collection-view.element.ts index 3ce823efc3..d06973e3a7 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/views/table/document-table-collection-view.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/views/table/document-table-collection-view.element.ts @@ -19,6 +19,7 @@ import type { UmbTableSelectedEvent, } from '@umbraco-cms/backoffice/components'; +import './column-layouts/document-entity-actions-table-column-view.element.js'; import './column-layouts/document-table-column-name.element.js'; import './column-layouts/document-table-column-state.element.js'; @@ -143,11 +144,8 @@ export class UmbDocumentTableCollectionViewElement extends UmbLitElement { if (column.alias === 'entityActions') { return { columnAlias: 'entityActions', - value: html``, + value: html``, }; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/publish/workspace-action/save-and-publish.action.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/publish/workspace-action/save-and-publish.action.ts index ae617ef378..d57aa9361a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/publish/workspace-action/save-and-publish.action.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/publish/workspace-action/save-and-publish.action.ts @@ -16,14 +16,14 @@ export class UmbDocumentSaveAndPublishWorkspaceAction extends UmbWorkspaceAction will first be triggered when the condition is changed to permitted */ this.disable(); - const condition = new UmbDocumentUserPermissionCondition(host, { + new UmbDocumentUserPermissionCondition(host, { host, config: { alias: 'Umb.Condition.UserPermission.Document', allOf: [UMB_USER_PERMISSION_DOCUMENT_UPDATE, UMB_USER_PERMISSION_DOCUMENT_PUBLISH], }, - onChange: () => { - if (condition.permitted) { + onChange: (permitted) => { + if (permitted) { this.enable(); } else { this.disable(); diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/workspace-context/document-publishing.workspace-context.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/workspace-context/document-publishing.workspace-context.ts index 437a87d61f..b090e3f46a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/workspace-context/document-publishing.workspace-context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/workspace-context/document-publishing.workspace-context.ts @@ -72,6 +72,11 @@ export class UmbDocumentPublishingWorkspaceContext extends UmbContextBase { + const elementStyle = (this.getHostElement() as HTMLElement).style; + elementStyle.removeProperty('--uui-color-invalid'); + elementStyle.removeProperty('--uui-color-invalid-emphasis'); + elementStyle.removeProperty('--uui-color-invalid-standalone'); + elementStyle.removeProperty('--uui-color-invalid-contrast'); return this.#handleSaveAndPublish(); } diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/types.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/types.ts index 3db26df828..7aac1d4011 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/types.ts @@ -2,7 +2,7 @@ import type { UmbDocumentTreeItemModel } from '../../tree/index.js'; import type { UmbTreeRootModel } from '@umbraco-cms/backoffice/tree'; // eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface UmbDocumentRecycleBinTreeItemModel extends UmbDocumentTreeItemModel {} +export interface UmbDocumentRecycleBinTreeItemModel extends Omit {} // eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface UmbDocumentRecycleBinTreeRootModel extends UmbTreeRootModel {} diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/rollback/entity-action/rollback.action.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/rollback/entity-action/rollback.action.ts index 3b9c94c806..a486644add 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/rollback/entity-action/rollback.action.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/rollback/entity-action/rollback.action.ts @@ -1,6 +1,6 @@ import { UMB_ROLLBACK_MODAL } from '../constants.js'; import { UmbEntityActionBase } from '@umbraco-cms/backoffice/entity-action'; -import { umbOpenModal } from '@umbraco-cms/backoffice/modal'; +import { UMB_MODAL_MANAGER_CONTEXT } from '@umbraco-cms/backoffice/modal'; import { UMB_NOTIFICATION_CONTEXT } from '@umbraco-cms/backoffice/notification'; import { UmbLocalizationController } from '@umbraco-cms/backoffice/localization-api'; @@ -8,7 +8,14 @@ export class UmbRollbackDocumentEntityAction extends UmbEntityActionBase #localize = new UmbLocalizationController(this); override async execute() { - await umbOpenModal(this, UMB_ROLLBACK_MODAL, {}); + const modalManagerContext = await this.getContext(UMB_MODAL_MANAGER_CONTEXT); + if (!modalManagerContext) return; + + const modalContext = modalManagerContext.open(this, UMB_ROLLBACK_MODAL, {}); + + const data = await modalContext.onSubmit().catch(() => undefined); + if (!data) return; + const notificationContext = await this.getContext(UMB_NOTIFICATION_CONTEXT); if (!notificationContext) { throw new Error('Notification context not found'); diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/rollback/modal/rollback-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/rollback/modal/rollback-modal.element.ts index 5da19c0cca..82fab6b2c2 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/rollback/modal/rollback-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/rollback/modal/rollback-modal.element.ts @@ -226,6 +226,8 @@ export class UmbRollbackModalElement extends UmbModalBaseElement const mapper = (item: DocumentTreeItemResponseModel): UmbDocumentTreeItemModel => { return { + ancestors: item.ancestors.map((ancestor) => { + return { + unique: ancestor.id, + entityType: UMB_DOCUMENT_ENTITY_TYPE, + }; + }), unique: item.id, parent: { unique: item.parent ? item.parent.id : null, diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/tree/tree-item/document-tree-item.context.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/tree/tree-item/document-tree-item.context.ts index ee3b089159..cf556635f9 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/tree/tree-item/document-tree-item.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/tree/tree-item/document-tree-item.context.ts @@ -3,6 +3,7 @@ import { UmbDocumentItemDataResolver } from '../../item/index.js'; import { UmbDefaultTreeItemContext } from '@umbraco-cms/backoffice/tree'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { UmbIsTrashedEntityContext } from '@umbraco-cms/backoffice/recycle-bin'; +import { UmbAncestorsEntityContext } from '@umbraco-cms/backoffice/entity'; export class UmbDocumentTreeItemContext extends UmbDefaultTreeItemContext< UmbDocumentTreeItemModel, @@ -10,12 +11,14 @@ export class UmbDocumentTreeItemContext extends UmbDefaultTreeItemContext< > { // TODO: Provide this together with the EntityContext, ideally this takes part via a extension-type [NL] #isTrashedContext = new UmbIsTrashedEntityContext(this); + #ancestorsContext = new UmbAncestorsEntityContext(this); #item = new UmbDocumentItemDataResolver(this); readonly name = this.#item.name; readonly icon = this.#item.icon; readonly isDraft = this.#item.isDraft; + readonly ancestors = this._treeItem.asObservablePart((item) => item?.ancestors ?? []); readonly isTrashed = this._treeItem.asObservablePart((item) => item?.isTrashed ?? false); constructor(host: UmbControllerHost) { @@ -24,6 +27,10 @@ export class UmbDocumentTreeItemContext extends UmbDefaultTreeItemContext< this.observe(this.isTrashed, (isTrashed) => { this.#isTrashedContext.setIsTrashed(isTrashed); }); + + this.observe(this.ancestors, (ancestors) => { + this.#ancestorsContext.setAncestors(ancestors); + }); } public override setTreeItem(treeItem: UmbDocumentTreeItemModel | undefined) { diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/tree/types.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/tree/types.ts index fb7fb4ee5b..7afd0ae918 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/tree/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/tree/types.ts @@ -7,8 +7,10 @@ import type { } from '@umbraco-cms/backoffice/tree'; import type { DocumentVariantStateModel } from '@umbraco-cms/backoffice/external/backend-api'; import type { UmbReferenceByUnique } from '@umbraco-cms/backoffice/models'; +import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity'; export interface UmbDocumentTreeItemModel extends UmbTreeItemModel { + ancestors: Array; entityType: UmbDocumentEntityType; noAccess: boolean; isTrashed: boolean; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/user-permissions/conditions/document-user-permission.condition.test.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/user-permissions/conditions/document-user-permission.condition.test.ts new file mode 100644 index 0000000000..f69fdb8375 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/user-permissions/conditions/document-user-permission.condition.test.ts @@ -0,0 +1,134 @@ +import { expect } from '@open-wc/testing'; +import { customElement } from '@umbraco-cms/backoffice/external/lit'; +import { UmbControllerHostElementMixin } from '@umbraco-cms/backoffice/controller-api'; +import { UmbCurrentUserContext, UmbCurrentUserStore } from '@umbraco-cms/backoffice/current-user'; +import { UmbNotificationContext } from '@umbraco-cms/backoffice/notification'; +import { UmbDocumentUserPermissionCondition } from './document-user-permission.condition'; +import { UmbAncestorsEntityContext, UmbEntityContext, type UmbEntityModel } from '@umbraco-cms/backoffice/entity'; +import { + UMB_DOCUMENT_ENTITY_TYPE, + UMB_DOCUMENT_USER_PERMISSION_CONDITION_ALIAS, + UMB_USER_PERMISSION_DOCUMENT_READ, +} from '@umbraco-cms/backoffice/document'; + +@customElement('test-controller-host') +class UmbTestControllerHostElement extends UmbControllerHostElementMixin(HTMLElement) { + currentUserContext = new UmbCurrentUserContext(this); + entityContext = new UmbEntityContext(this); + ancestorsContext = new UmbAncestorsEntityContext(this); + + constructor() { + super(); + new UmbNotificationContext(this); + new UmbCurrentUserStore(this); + } + + async init() { + await this.currentUserContext.load(); + } + + setEntity(entity: UmbEntityModel) { + this.entityContext.setUnique(entity.unique); + this.entityContext.setEntityType(entity.entityType); + } + + setEntityAncestors(ancestors: Array) { + this.ancestorsContext.setAncestors(ancestors); + } +} + +describe('UmbDocumentUserPermissionCondition', () => { + let hostElement: UmbTestControllerHostElement; + let condition: UmbDocumentUserPermissionCondition; + + beforeEach(async () => { + hostElement = new UmbTestControllerHostElement(); + document.body.appendChild(hostElement); + await hostElement.init(); + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + describe('Specific permissions', () => { + it('should return true if a user has permissions', (done) => { + // Sets the current entity data + hostElement.setEntity({ + unique: 'permissions-document-id', + entityType: UMB_DOCUMENT_ENTITY_TYPE, + }); + + // This entity does not have any ancestors. + hostElement.setEntityAncestors([]); + + // We expect to find the read permission on the current entity + condition = new UmbDocumentUserPermissionCondition(hostElement, { + host: hostElement, + config: { + alias: UMB_DOCUMENT_USER_PERMISSION_CONDITION_ALIAS, + allOf: [UMB_USER_PERMISSION_DOCUMENT_READ], + }, + onChange: (permitted) => { + expect(permitted).to.be.true; + done(); + }, + }); + }); + }); + + describe('Inherited permissions', () => { + it('should inherit permissions from closest ancestor with specific permissions set', (done) => { + // Sets the current entity data + hostElement.setEntity({ + unique: 'permissions-document-1-id', + entityType: UMB_DOCUMENT_ENTITY_TYPE, + }); + + // Sets the ancestors of the current entity. These are the ancestors that will be checked for permissions. + hostElement.setEntityAncestors([{ unique: 'permissions-document-id', entityType: UMB_DOCUMENT_ENTITY_TYPE }]); + + // We expect to find the read permission on the ancestor + condition = new UmbDocumentUserPermissionCondition(hostElement, { + host: hostElement, + config: { + alias: UMB_DOCUMENT_USER_PERMISSION_CONDITION_ALIAS, + allOf: [UMB_USER_PERMISSION_DOCUMENT_READ], + }, + onChange: (permitted) => { + expect(permitted).to.be.true; + done(); + }, + }); + }); + }); + + describe('Fallback Permissions', () => { + it('should use the fallback permissions if no specific permissions are set for the entity or ancestors', (done) => { + // Sets the current entity to a document without permissions + hostElement.setEntity({ + unique: 'no-permissions-document-id', + entityType: UMB_DOCUMENT_ENTITY_TYPE, + }); + + // Sets the ancestors of the current entity. These are the ancestors that will be checked for permissions. + // This ancestor does not have any permissions either. + hostElement.setEntityAncestors([ + { unique: 'no-permissions-parent-document-id', entityType: UMB_DOCUMENT_ENTITY_TYPE }, + ]); + + // We expect to find the read permission in the fallback permissions + condition = new UmbDocumentUserPermissionCondition(hostElement, { + host: hostElement, + config: { + alias: UMB_DOCUMENT_USER_PERMISSION_CONDITION_ALIAS, + allOf: [UMB_USER_PERMISSION_DOCUMENT_READ], + }, + onChange: (permitted) => { + expect(permitted).to.be.true; + done(); + }, + }); + }); + }); +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/user-permissions/conditions/document-user-permission.condition.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/user-permissions/conditions/document-user-permission.condition.ts index 060e758808..a91b99689a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/user-permissions/conditions/document-user-permission.condition.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/user-permissions/conditions/document-user-permission.condition.ts @@ -1,7 +1,7 @@ import { isDocumentUserPermission } from '../utils.js'; import type { UmbDocumentUserPermissionConditionConfig } from './types.js'; import { UMB_CURRENT_USER_CONTEXT } from '@umbraco-cms/backoffice/current-user'; -import { UMB_ENTITY_CONTEXT } from '@umbraco-cms/backoffice/entity'; +import { UMB_ANCESTORS_ENTITY_CONTEXT, UMB_ENTITY_CONTEXT, type UmbEntityUnique } from '@umbraco-cms/backoffice/entity'; import { observeMultiple } from '@umbraco-cms/backoffice/observable-api'; import type { UmbConditionControllerArguments, UmbExtensionCondition } from '@umbraco-cms/backoffice/extension-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; @@ -20,6 +20,7 @@ export class UmbDocumentUserPermissionCondition extends UmbControllerBase implem #documentPermissions: Array = []; #fallbackPermissions: string[] = []; #onChange: UmbOnChangeCallbackType; + #ancestors: Array = []; constructor( host: UmbControllerHost, @@ -54,6 +55,17 @@ export class UmbDocumentUserPermissionCondition extends UmbControllerBase implem 'umbUserPermissionEntityContextObserver', ); }); + + this.consumeContext(UMB_ANCESTORS_ENTITY_CONTEXT, (instance) => { + this.observe( + instance?.ancestors, + (ancestors) => { + this.#ancestors = ancestors.map((item) => item.unique); + this.#checkPermissions(); + }, + 'observeAncestors', + ); + }); } #checkPermissions() { @@ -68,21 +80,29 @@ export class UmbDocumentUserPermissionCondition extends UmbControllerBase implem return; } - /* If there are document permission we check if there are permissions for the current document - If there aren't we use the fallback permissions */ + // If there are document permissions, we need to check the full path to see if any permissions are defined for the current document + // If we find multiple permissions in the same path, we will apply the closest one if (hasDocumentPermissions) { - const permissionsForCurrentDocument = this.#documentPermissions.find( - (permission) => permission.document.id === this.#unique, - ); + // Path including the current document and all ancestors + const path = [...this.#ancestors, this.#unique].filter((unique) => unique !== null); + // Reverse the path to find the closest document permission quickly + const reversedPath = [...path].reverse(); + const documentPermissionsMap = new Map(this.#documentPermissions.map((p) => [p.document.id, p])); + + // Find the closest document permission in the path + const closestDocumentPermission = reversedPath.find((id) => documentPermissionsMap.has(id)); + + // Retrieve the corresponding permission data + const match = closestDocumentPermission ? documentPermissionsMap.get(closestDocumentPermission) : undefined; // no permissions for the current document - use the fallback permissions - if (!permissionsForCurrentDocument) { + if (!match) { this.#check(this.#fallbackPermissions); return; } - // we found permissions for the current document - check them - this.#check(permissionsForCurrentDocument.verbs); + // we found permissions - check them + this.#check(match.verbs); } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/document-workspace.context.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/document-workspace.context.ts index b80962e3f5..4fe42be4e9 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/document-workspace.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/document-workspace.context.ts @@ -80,7 +80,8 @@ export class UmbDocumentWorkspaceContext detailRepositoryAlias: UMB_DOCUMENT_DETAIL_REPOSITORY_ALIAS, contentTypeDetailRepository: UmbDocumentTypeDetailRepository, contentValidationRepository: UmbDocumentValidationRepository, - skipValidationOnSubmit: true, + skipValidationOnSubmit: false, + ignoreValidationResultOnSubmit: true, contentVariantScaffold: UMB_DOCUMENT_DETAIL_MODEL_VARIANT_SCAFFOLD, contentTypePropertyName: 'documentType', saveModalToken: UMB_DOCUMENT_SAVE_MODAL, @@ -99,7 +100,13 @@ export class UmbDocumentWorkspaceContext allOf: [UMB_USER_PERMISSION_DOCUMENT_CREATE], }, onChange: (permitted: boolean) => { + if (permitted === this.#userCanCreate) return; this.#userCanCreate = permitted; + this.#setReadOnlyStateForUserPermission( + UMB_USER_PERMISSION_DOCUMENT_CREATE, + this.#userCanCreate, + 'You do not have permission to create documents.', + ); }, }, ]); @@ -110,11 +117,31 @@ export class UmbDocumentWorkspaceContext allOf: [UMB_USER_PERMISSION_DOCUMENT_UPDATE], }, onChange: (permitted: boolean) => { + if (permitted === this.#userCanUpdate) return; this.#userCanUpdate = permitted; + this.#setReadOnlyStateForUserPermission( + UMB_USER_PERMISSION_DOCUMENT_UPDATE, + this.#userCanUpdate, + 'You do not have permission to update documents.', + ); }, }, ]); + this.observe(this.variants, () => { + this.#setReadOnlyStateForUserPermission( + UMB_USER_PERMISSION_DOCUMENT_CREATE, + this.#userCanCreate, + 'You do not have permission to create documents.', + ); + + this.#setReadOnlyStateForUserPermission( + UMB_USER_PERMISSION_DOCUMENT_UPDATE, + this.#userCanUpdate, + 'You do not have permission to update documents.', + ); + }); + this.routes.setRoutes([ { path: UMB_CREATE_FROM_BLUEPRINT_DOCUMENT_WORKSPACE_PATH_PATTERN.toString(), @@ -147,13 +174,6 @@ export class UmbDocumentWorkspaceContext const parentUnique = info.match.params.parentUnique === 'null' ? null : info.match.params.parentUnique; const documentTypeUnique = info.match.params.documentTypeUnique; await this.create({ entityType: parentEntityType, unique: parentUnique }, documentTypeUnique); - - this.#setReadOnlyStateForUserPermission( - UMB_USER_PERMISSION_DOCUMENT_CREATE, - this.#userCanCreate, - 'You do not have permission to create documents.', - ); - new UmbWorkspaceIsNewRedirectController( this, this, @@ -168,11 +188,6 @@ export class UmbDocumentWorkspaceContext this.removeUmbControllerByAlias(UmbWorkspaceIsNewRedirectControllerAlias); const unique = info.match.params.unique; await this.load(unique); - this.#setReadOnlyStateForUserPermission( - UMB_USER_PERMISSION_DOCUMENT_UPDATE, - this.#userCanUpdate, - 'You do not have permission to update documents.', - ); }, }, ]); @@ -256,11 +271,11 @@ export class UmbDocumentWorkspaceContext * @returns {Promise} a promise which resolves once it has been completed. */ public override requestSubmit() { - return this._handleSubmit(); - } - - // Because we do not make validation prevent submission this also submits the workspace. [NL] - public override invalidSubmit() { + const elementStyle = (this.getHostElement() as HTMLElement).style; + elementStyle.setProperty('--uui-color-invalid', 'var(--uui-color-warning)'); + elementStyle.setProperty('--uui-color-invalid-emphasis', 'var(--uui-color-warning-emphasis)'); + elementStyle.setProperty('--uui-color-invalid-standalone', 'var(--uui-color-warning-standalone)'); + elementStyle.setProperty('--uui-color-invalid-contrast', 'var(--uui-color-warning-contrast)'); return this._handleSubmit(); } diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/dynamic-root/components/input-content-picker-document-root.element.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/dynamic-root/components/input-content-picker-document-root.element.ts index 901b185141..21df4ede50 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/dynamic-root/components/input-content-picker-document-root.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/dynamic-root/components/input-content-picker-document-root.element.ts @@ -4,15 +4,20 @@ import { UMB_CONTENT_PICKER_DOCUMENT_ROOT_QUERY_STEP_PICKER_MODAL, } from '../modals/index.js'; import type { ManifestDynamicRootOrigin, ManifestDynamicRootQueryStep } from '../dynamic-root.extension.js'; -import { html, css, customElement, property, ifDefined, state, repeat } from '@umbraco-cms/backoffice/external/lit'; +import { css, customElement, html, ifDefined, property, repeat, state } from '@umbraco-cms/backoffice/external/lit'; import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; +import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; +import { UmbFormControlMixin } from '@umbraco-cms/backoffice/validation'; import { UmbId } from '@umbraco-cms/backoffice/id'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import { UmbRepositoryItemsManager } from '@umbraco-cms/backoffice/repository'; import { UmbSorterController } from '@umbraco-cms/backoffice/sorter'; -import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; +import { UMB_DOCUMENT_ITEM_REPOSITORY_ALIAS } from '@umbraco-cms/backoffice/document'; +import { UMB_DOCUMENT_TYPE_ITEM_REPOSITORY_ALIAS } from '@umbraco-cms/backoffice/document-type'; import { UMB_MODAL_MANAGER_CONTEXT } from '@umbraco-cms/backoffice/modal'; +import type { UmbDocumentItemModel } from '@umbraco-cms/backoffice/document'; +import type { UmbDocumentTypeItemModel } from '@umbraco-cms/backoffice/document-type'; import type { UmbModalContext } from '@umbraco-cms/backoffice/modal'; -import { UmbFormControlMixin } from '@umbraco-cms/backoffice/validation'; const elementName = 'umb-input-content-picker-document-root'; @customElement(elementName) @@ -20,6 +25,16 @@ export class UmbInputContentPickerDocumentRootElement extends UmbFormControlMixi string | undefined, typeof UmbLitElement >(UmbLitElement) { + readonly #documentItemManager = new UmbRepositoryItemsManager( + this, + UMB_DOCUMENT_ITEM_REPOSITORY_ALIAS, + ); + + readonly #documentTypeItemManager = new UmbRepositoryItemsManager( + this, + UMB_DOCUMENT_TYPE_ITEM_REPOSITORY_ALIAS, + ); + protected override getFormElement() { return undefined; } @@ -35,6 +50,10 @@ export class UmbInputContentPickerDocumentRootElement extends UmbFormControlMixi #dynamicRootOrigin?: { label: string; icon: string; description?: string }; + #documentLookup: Record = {}; + + #documentTypeLookup: Record = {}; + #modalContext?: typeof UMB_MODAL_MANAGER_CONTEXT.TYPE; #openModal?: UmbModalContext; @@ -59,9 +78,29 @@ export class UmbInputContentPickerDocumentRootElement extends UmbFormControlMixi this._queryStepManifests = queryStepManifests; }, ); + + this.observe(this.#documentItemManager.items, (documents) => { + if (!documents?.length) return; + + documents.forEach((document) => { + this.#documentLookup[document.unique] = document.name; + }); + + this.requestUpdate(); + }); + + this.observe(this.#documentTypeItemManager.items, (documentTypes) => { + if (!documentTypes?.length) return; + + documentTypes.forEach((documentType) => { + this.#documentTypeLookup[documentType.unique] = documentType.name; + }); + + this.requestUpdate(); + }); } - override connectedCallback(): void { + override connectedCallback() { super.connectedCallback(); this.#updateDynamicRootOrigin(this.data); @@ -116,6 +155,11 @@ export class UmbInputContentPickerDocumentRootElement extends UmbFormControlMixi #updateDynamicRootOrigin(data?: UmbContentPickerDynamicRoot) { if (!data) return; const origin = this._originManifests.find((item) => item.meta.originAlias === data.originAlias)?.meta; + + if (data.originKey) { + this.#documentItemManager.setUniques([data.originKey]); + } + this.#dynamicRootOrigin = { label: origin?.label ?? data.originAlias, icon: origin?.icon ?? 'icon-wand', @@ -131,7 +175,10 @@ export class UmbInputContentPickerDocumentRootElement extends UmbFormControlMixi querySteps = querySteps.map((item) => (item.unique ? item : { ...item, unique: UmbId.new() })); } + this.#documentTypeItemManager.setUniques((querySteps ?? []).map((x) => x.anyOfDocTypeKeys ?? []).flat()); + this.#sorter?.setModel(querySteps ?? []); + this.data = { ...this.data, ...{ querySteps } }; } @@ -142,8 +189,16 @@ export class UmbInputContentPickerDocumentRootElement extends UmbFormControlMixi description?: string; } { const step = this._queryStepManifests.find((step) => step.meta.queryStepAlias === item.alias)?.meta; - const docTypes = item.anyOfDocTypeKeys?.join(', '); - const description = docTypes ? this.localize.term('dynamicRoot_queryStepTypes') + docTypes : undefined; + + const docTypeNames = + item.anyOfDocTypeKeys + ?.map((docTypeKey) => this.#documentTypeLookup[docTypeKey] ?? docTypeKey) + .sort() + .join(', ') ?? ''; + + const description = item.anyOfDocTypeKeys + ? this.localize.term('dynamicRoot_queryStepTypes') + docTypeNames + : undefined; return { unique: item.unique, @@ -193,11 +248,11 @@ export class UmbInputContentPickerDocumentRootElement extends UmbFormControlMixi #renderOrigin() { if (!this.#dynamicRootOrigin) return; + const description = this.#dynamicRootOrigin.description + ? this.#documentLookup[this.#dynamicRootOrigin.description] + : ''; return html` - + (UmbracoAppAuthenticator.Name); +// } +//} diff --git a/tests/Umbraco.Tests.AcceptanceTest/package-lock.json b/tests/Umbraco.Tests.AcceptanceTest/package-lock.json index f524f9a62e..7e3d117ad3 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/package-lock.json +++ b/tests/Umbraco.Tests.AcceptanceTest/package-lock.json @@ -58,9 +58,9 @@ } }, "node_modules/@umbraco/json-models-builders": { - "version": "2.0.31", - "resolved": "https://registry.npmjs.org/@umbraco/json-models-builders/-/json-models-builders-2.0.31.tgz", - "integrity": "sha512-RGulJazaSjp6ZfHYACXUKDQuF055oXx+Kk3Q7/+PyfGZWRngT7V799Mal1vnrG7W9EDEX9Up+AP22O/alYj3Gg==", + "version": "2.0.32", + "resolved": "https://registry.npmjs.org/@umbraco/json-models-builders/-/json-models-builders-2.0.32.tgz", + "integrity": "sha512-Aw7yBu8ePNxdjS7Q61j5KPFsiOS+IGCYxBX0H4KWbjXTdvL/PsB98KiqbDHHKFnp0fF1b2ffwJAI6jmvnxPBzg==", "license": "MIT", "dependencies": { "camelize": "^1.0.1" @@ -72,7 +72,7 @@ "integrity": "sha512-c3+Z0l8p2nBgrtkaiXdYJONT16h0N/jSd9pC/E6IhXVloUH0qlUFVUVffxzofHVSfIsyrPKQC3T4iLzjRb/HIw==", "license": "MIT", "dependencies": { - "@umbraco/json-models-builders": "2.0.31", + "@umbraco/json-models-builders": "2.0.32", "node-fetch": "^2.6.7" } }, diff --git a/tests/Umbraco.Tests.Common/Builders/UserGroupBuilder.cs b/tests/Umbraco.Tests.Common/Builders/UserGroupBuilder.cs index 2d78198649..b06c97b419 100644 --- a/tests/Umbraco.Tests.Common/Builders/UserGroupBuilder.cs +++ b/tests/Umbraco.Tests.Common/Builders/UserGroupBuilder.cs @@ -159,7 +159,7 @@ public class UserGroupBuilder Key = key, StartContentId = startContentId, StartMediaId = startMediaId, - Permissions = _permissions + Permissions = _permissions, }; BuildAllowedSections(userGroup); @@ -204,6 +204,6 @@ public class UserGroupBuilder .WithAlias(alias + suffix) .WithName(name + suffix) .WithPermissions(permissions ?? new[] { "A", "B", "C" }.ToHashSet()) - .WithAllowedSections(allowedSections ?? new[] { "content", "media" }) + .WithAllowedSections(allowedSections ?? ["content", "media"]) .Build(); } diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Factories/UserGroupPresentationFactoryTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Factories/UserGroupPresentationFactoryTests.cs index c31b9eb3f3..fd669604e7 100644 --- a/tests/Umbraco.Tests.Integration/ManagementApi/Factories/UserGroupPresentationFactoryTests.cs +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Factories/UserGroupPresentationFactoryTests.cs @@ -34,15 +34,15 @@ internal sealed class UserGroupPresentationFactoryTests : UmbracoIntegrationTest services.AddTransient(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(x=>x.GetRequiredService()); - services.AddSingleton(x=>x.GetRequiredService()); + services.AddSingleton(x => x.GetRequiredService()); + services.AddSingleton(x => x.GetRequiredService()); } [Test] public async Task Can_Map_Create_Model_And_Create() { - var updateModel = new CreateUserGroupRequestModel() + var createModel = new CreateUserGroupRequestModel() { Alias = "testAlias", FallbackPermissions = new HashSet(), @@ -53,7 +53,7 @@ internal sealed class UserGroupPresentationFactoryTests : UmbracoIntegrationTest Permissions = new HashSet() }; - var attempt = await UserGroupPresentationFactory.CreateAsync(updateModel); + var attempt = await UserGroupPresentationFactory.CreateAsync(createModel); Assert.IsTrue(attempt.Success); var userGroupCreateAttempt = await UserGroupService.CreateAsync(attempt.Result, Constants.Security.SuperUserKey); @@ -71,7 +71,7 @@ internal sealed class UserGroupPresentationFactoryTests : UmbracoIntegrationTest [Test] public async Task Cannot_Create_UserGroup_With_Unexisting_Document_Reference() { - var updateModel = new CreateUserGroupRequestModel() + var createModel = new CreateUserGroupRequestModel() { Alias = "testAlias", FallbackPermissions = new HashSet(), @@ -89,7 +89,7 @@ internal sealed class UserGroupPresentationFactoryTests : UmbracoIntegrationTest } }; - var attempt = await UserGroupPresentationFactory.CreateAsync(updateModel); + var attempt = await UserGroupPresentationFactory.CreateAsync(createModel); Assert.IsTrue(attempt.Success); var userGroupCreateAttempt = await UserGroupService.CreateAsync(attempt.Result, Constants.Security.SuperUserKey); @@ -102,11 +102,11 @@ internal sealed class UserGroupPresentationFactoryTests : UmbracoIntegrationTest } [Test] - public async Task Can_Create_Usergroup_With_Empty_Granluar_Permissions_For_Document() + public async Task Can_Create_Usergroup_With_Empty_Granular_Permissions_For_Document() { var contentKey = await CreateContent(); - var updateModel = new CreateUserGroupRequestModel() + var createModel = new CreateUserGroupRequestModel() { Alias = "testAlias", FallbackPermissions = new HashSet(), @@ -124,7 +124,7 @@ internal sealed class UserGroupPresentationFactoryTests : UmbracoIntegrationTest } }; - var attempt = await UserGroupPresentationFactory.CreateAsync(updateModel); + var attempt = await UserGroupPresentationFactory.CreateAsync(createModel); Assert.IsTrue(attempt.Success); var userGroupCreateAttempt = await UserGroupService.CreateAsync(attempt.Result, Constants.Security.SuperUserKey); diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Factories/UserPresentationFactoryTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Factories/UserPresentationFactoryTests.cs new file mode 100644 index 0000000000..f5f0c93fba --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Factories/UserPresentationFactoryTests.cs @@ -0,0 +1,191 @@ +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.Mapping.Permissions; +using Umbraco.Cms.Api.Management.Routing; +using Umbraco.Cms.Api.Management.ViewModels.UserGroup.Permissions; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Models.Membership.Permissions; +using Umbraco.Cms.Core.Routing; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Infrastructure.Persistence.Mappers; +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.ManagementApi.Factories; + +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)] +public class UserPresentationFactoryTests : UmbracoIntegrationTestWithContent +{ + public IUserPresentationFactory UserPresentationFactory => GetRequiredService(); + + public IUserGroupService UserGroupService => GetRequiredService(); + + public IUserService UserService => GetRequiredService(); + + public ILanguageService LanguageService => GetRequiredService(); + + public IMediaService MediaService => GetRequiredService(); + + protected override void ConfigureTestServices(IServiceCollection services) + { + services.AddTransient(); + services.AddTransient(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(x => x.GetRequiredService()); + services.AddSingleton(x => x.GetRequiredService()); + } + + [Test] + public async Task Can_Create_Current_User_Response_Model() + { + var daLanguage = new LanguageBuilder() + .WithCultureInfo("da-DK") + .Build(); + await LanguageService.CreateAsync(daLanguage, Constants.Security.SuperUserKey); + var enUsLanguage = await LanguageService.GetAsync("en-US"); + var daDkLanguage = await LanguageService.GetAsync("da-DK"); + + var rootContentKey = Guid.Parse(TextpageKey); + var subPageContentKey = Guid.Parse(SubPageKey); + var subPage2ContentKey = Guid.Parse(SubPage2Key); + + var rootMediaFolder = MediaService.CreateMedia("Pictures Folder", Constants.System.Root, "Folder"); + MediaService.Save(rootMediaFolder); + + var groupOne = await CreateUserGroup( + "Group One", + "groupOne", + [enUsLanguage.Id], + ["A", "B", "C"], + [ + new DocumentGranularPermission + { + Key = rootContentKey, + Permission = "A", + }, + new DocumentGranularPermission + { + Key = rootContentKey, + Permission = "E", + }, + new DocumentGranularPermission + { + Key = subPageContentKey, + Permission = "F", + }, + new DocumentGranularPermission + { + Key = subPage2ContentKey, + Permission = "F", + } + ], + rootMediaFolder.Id); + var groupTwo = await CreateUserGroup( + "Group Two", + "groupTwo", + [daDkLanguage.Id], + ["A", "B", "D"], + [ + new DocumentGranularPermission + { + Key = subPage2ContentKey, + Permission = "G", + }, + new DocumentGranularPermission + { + Key = subPage2ContentKey, + Permission = "H", + } + ], + rootMediaFolder.Id); + + var user = await CreateUser([groupOne.Key, groupTwo.Key]); + + var model = await UserPresentationFactory.CreateCurrentUserResponseModelAsync(user); + + Assert.AreEqual(user.Key, model.Id); + Assert.AreEqual("test@test.com", model.Email); + Assert.AreEqual("Test User", model.Name); + Assert.AreEqual("test@test.com", model.UserName); + Assert.AreEqual(2, model.UserGroupIds.Count); + Assert.IsTrue(model.UserGroupIds.Select(x => x.Id).ContainsAll([groupOne.Key, groupTwo.Key])); + Assert.IsFalse(model.HasAccessToAllLanguages); + Assert.AreEqual(2, model.Languages.Count()); + Assert.IsTrue(model.Languages.ContainsAll(["en-US", "da-DK"])); + Assert.IsTrue(model.HasDocumentRootAccess); + Assert.AreEqual(0, model.DocumentStartNodeIds.Count); + Assert.IsFalse(model.HasMediaRootAccess); + Assert.AreEqual(1, model.MediaStartNodeIds.Count); + Assert.AreEqual(rootMediaFolder.Key, model.MediaStartNodeIds.First().Id); + Assert.IsFalse(model.HasAccessToSensitiveData); + Assert.AreEqual(4, model.FallbackPermissions.Count); + Assert.IsTrue(model.FallbackPermissions.ContainsAll(["A", "B", "C", "D"])); + + // When aggregated, we expect one permission per document (we have several granular permissions assigned, for three unique documents). + Assert.AreEqual(3, model.Permissions.Count); + + // User has two user groups, one of which provides granular permissions for the root content item. + // As such we expect the aggregated permissions to be the union of the specific permissions coming from the user group with them assigned to the document, + // and the fallback permissions from the other. + var rootContentPermissions = model.Permissions.Cast().Single(x => x.Document.Id == rootContentKey); + Assert.AreEqual(4, rootContentPermissions.Verbs.Count); + Assert.IsTrue(rootContentPermissions.Verbs.ContainsAll(["A", "B", "D", "E"])); + + // The sub-page and it's parent have specific granular permissions from one user group. + // So we expect the aggregated permissions to include those from the sub-page and the other user's groups fallback permissions. + var subPageContentPermissions = model.Permissions.Cast().Single(x => x.Document.Id == subPageContentKey); + Assert.AreEqual(4, subPageContentPermissions.Verbs.Count); + Assert.IsTrue(subPageContentPermissions.Verbs.ContainsAll(["A", "B", "D", "F"])); + + // Both user groups provide granular permissions for the second sub-page content item. + // Here we expect the aggregated permissions to be the union of the granular permissions on the document from both user groups. + var subPage2ContentPermissions = model.Permissions.Cast().Single(x => x.Document.Id == subPage2ContentKey); + Assert.AreEqual(3, subPage2ContentPermissions.Verbs.Count); + Assert.IsTrue(subPage2ContentPermissions.Verbs.ContainsAll(["F", "G", "H"])); + } + + private async Task CreateUserGroup( + string name, + string alias, + int[] allowedLanguages, + string[] permissions, + DocumentGranularPermission[] granularPermissions, + int startMediaId) + { + var userGroup = new UserGroupBuilder() + .WithName(name) + .WithAlias(alias) + .WithAllowedLanguages(allowedLanguages) + .WithStartMediaId(startMediaId) + .WithPermissions(permissions.ToHashSet()) + .WithGranularPermissions(granularPermissions) + .Build(); + var createUserGroupResult = await UserGroupService.CreateAsync(userGroup, Constants.Security.SuperUserKey); + Assert.IsTrue(createUserGroupResult.Success); + return userGroup; + } + + private async Task CreateUser(Guid[] userGroupKeys) + { + var createUserAttempt = await UserService.CreateAsync(Constants.Security.SuperUserKey, new UserCreateModel + { + Email = "test@test.com", + Name = "Test User", + UserName = "test@test.com", + UserGroupKeys = userGroupKeys.ToHashSet(), + }); + Assert.IsTrue(createUserAttempt.Success); + + return await UserService.GetAsync(createUserAttempt.Result.CreatedUser.Key); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/DeliveryApi/Request/ApiContentPathResolverInvariantTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/DeliveryApi/Request/ApiContentPathResolverInvariantTests.cs new file mode 100644 index 0000000000..371f0d70b2 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/DeliveryApi/Request/ApiContentPathResolverInvariantTests.cs @@ -0,0 +1,258 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services.Changes; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Builders.Extensions; +using Umbraco.Cms.Tests.Integration.Attributes; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.DeliveryApi.Request; + +public class ApiContentPathResolverInvariantTests : ApiContentPathResolverTestBase +{ + private Dictionary _contentByName = new (); + + public static void ConfigureIncludeTopLevelNodeInPath(IUmbracoBuilder builder) + => builder.Services.Configure(config => config.HideTopLevelNodeFromPath = false); + + [SetUp] + public async Task SetUpTest() + { + UmbracoContextFactory.EnsureUmbracoContext(); + SetRequestHost("localhost"); + + if (_contentByName.Any()) + { + // these tests all run on the same DB to make them run faster, so we need to get the cache in a + // predictable state with each test run. + RefreshContentCache(); + return; + } + + await DocumentUrlService.InitAsync(true, CancellationToken.None); + + var contentType = new ContentTypeBuilder() + .WithAlias("theContentType") + .Build(); + contentType.AllowedAsRoot = true; + await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey); + contentType.AllowedContentTypes = [new() { Alias = contentType.Alias, Key = contentType.Key }]; + await ContentTypeService.UpdateAsync(contentType, Constants.Security.SuperUserKey); + foreach (var rootNumber in Enumerable.Range(1, 3)) + { + var root = new ContentBuilder() + .WithContentType(contentType) + .WithName($"Root {rootNumber}") + .Build(); + ContentService.Save(root); + ContentService.Publish(root, ["*"]); + _contentByName[root.Name!] = root; + + foreach (var childNumber in Enumerable.Range(1, 3)) + { + var child = new ContentBuilder() + .WithContentType(contentType) + .WithParent(root) + .WithName($"Child {childNumber}") + .Build(); + ContentService.Save(child); + ContentService.Publish(child, ["*"]); + _contentByName[$"{root.Name!}/{child.Name!}"] = child; + + foreach (var grandchildNumber in Enumerable.Range(1, 3)) + { + var grandchild = new ContentBuilder() + .WithContentType(contentType) + .WithParent(child) + .WithName($"Grandchild {grandchildNumber}") + .Build(); + ContentService.Save(grandchild); + ContentService.Publish(grandchild, ["*"]); + _contentByName[$"{root.Name!}/{child.Name!}/{grandchild.Name!}"] = grandchild; + } + } + } + } + + [Test] + public void First_Root_Without_StartItem() + { + Assert.IsEmpty(GetRequiredService().HttpContext!.Request.Headers["Start-Item"].ToString()); + + var content = ApiContentPathResolver.ResolveContentPath("/"); + Assert.IsNotNull(content); + Assert.AreEqual(_contentByName["Root 1"].Key, content.Key); + } + + [Test] + [ConfigureBuilder(ActionName = nameof(ConfigureIncludeTopLevelNodeInPath))] + public void First_Root_Without_StartItem_With_Top_Level_Node_Included() + { + Assert.IsEmpty(GetRequiredService().HttpContext!.Request.Headers["Start-Item"].ToString()); + + var content = ApiContentPathResolver.ResolveContentPath("/"); + Assert.IsNotNull(content); + Assert.AreEqual(_contentByName["Root 1"].Key, content.Key); + } + + [TestCase(1)] + [TestCase(2)] + [TestCase(3)] + public void First_Root_Child_Without_StartItem(int child) + { + Assert.IsEmpty(GetRequiredService().HttpContext!.Request.Headers["Start-Item"].ToString()); + + var content = ApiContentPathResolver.ResolveContentPath($"/child-{child}"); + Assert.IsNotNull(content); + Assert.AreEqual(_contentByName[$"Root 1/Child {child}"].Key, content.Key); + } + + [TestCase(1, 1)] + [TestCase(2, 2)] + [TestCase(3, 3)] + [TestCase(1, 2)] + [TestCase(2, 3)] + [TestCase(3, 1)] + public void First_Root_Grandchild_Without_StartItem(int child, int grandchild) + { + Assert.IsEmpty(GetRequiredService().HttpContext!.Request.Headers["Start-Item"].ToString()); + + var content = ApiContentPathResolver.ResolveContentPath($"/child-{child}/grandchild-{grandchild}"); + Assert.IsNotNull(content); + Assert.AreEqual(_contentByName[$"Root 1/Child {child}/Grandchild {grandchild}"].Key, content.Key); + } + + [TestCase(1)] + [TestCase(2)] + [TestCase(3)] + public void Root_With_StartItem(int root) + { + SetRequestStartItem($"root-{root}"); + + var content = ApiContentPathResolver.ResolveContentPath("/"); + Assert.IsNotNull(content); + Assert.AreEqual(_contentByName[$"Root {root}"].Key, content.Key); + } + + [TestCase(1)] + [TestCase(2)] + [TestCase(3)] + [ConfigureBuilder(ActionName = nameof(ConfigureIncludeTopLevelNodeInPath))] + public void Root_With_StartItem_With_Top_Level_Node_Included(int root) + { + SetRequestStartItem($"root-{root}"); + + var content = ApiContentPathResolver.ResolveContentPath("/"); + Assert.IsNotNull(content); + Assert.AreEqual(_contentByName[$"Root {root}"].Key, content.Key); + } + + [TestCase(1, 1)] + [TestCase(2, 2)] + [TestCase(3, 3)] + [TestCase(1, 2)] + [TestCase(2, 3)] + [TestCase(3, 1)] + public void Child_With_StartItem(int root, int child) + { + SetRequestStartItem($"root-{root}"); + + var content = ApiContentPathResolver.ResolveContentPath($"/child-{child}"); + Assert.IsNotNull(content); + Assert.AreEqual(_contentByName[$"Root {root}/Child {child}"].Key, content.Key); + } + + [TestCase(1, 1)] + [TestCase(2, 2)] + [TestCase(3, 3)] + [TestCase(1, 2)] + [TestCase(2, 3)] + [TestCase(3, 1)] + [ConfigureBuilder(ActionName = nameof(ConfigureIncludeTopLevelNodeInPath))] + public void Child_With_StartItem_With_Top_Level_Node_Included(int root, int child) + { + SetRequestStartItem($"root-{root}"); + + var content = ApiContentPathResolver.ResolveContentPath($"/child-{child}"); + Assert.IsNotNull(content); + Assert.AreEqual(_contentByName[$"Root {root}/Child {child}"].Key, content.Key); + } + + [TestCase(1, 1, 1)] + [TestCase(2, 2, 2)] + [TestCase(3, 3, 3)] + [TestCase(1, 2, 3)] + [TestCase(2, 3, 1)] + [TestCase(3, 1, 2)] + public void Grandchild_With_StartItem(int root, int child, int grandchild) + { + SetRequestStartItem($"root-{root}"); + + var content = ApiContentPathResolver.ResolveContentPath($"/child-{child}/grandchild-{grandchild}"); + Assert.IsNotNull(content); + Assert.AreEqual(_contentByName[$"Root {root}/Child {child}/Grandchild {grandchild}"].Key, content.Key); + } + + [TestCase("/", 1)] + [TestCase("/root-2", 2)] + [TestCase("/root-3", 3)] + public void Root_By_Path_With_StartItem(string path, int root) + { + SetRequestStartItem($"root-{root}"); + + var content = ApiContentPathResolver.ResolveContentPath(path); + Assert.IsNotNull(content); + Assert.AreEqual(_contentByName[$"Root {root}"].Key, content.Key); + } + + [TestCase("/", 1)] + [TestCase("/root-2", 2)] + [TestCase("/root-3", 3)] + public void Root_By_Path_Without_StartItem(string path, int root) + { + var content = ApiContentPathResolver.ResolveContentPath(path); + Assert.IsNotNull(content); + Assert.AreEqual(_contentByName[$"Root {root}"].Key, content.Key); + } + + [TestCase(1)] + [TestCase(2)] + [TestCase(3)] + public async Task Root_With_Domain_Bindings(int root) + { + await SetContentHost(_contentByName[$"Root {root}"], "some.host", "en-US"); + SetRequestHost("some.host"); + + var content = ApiContentPathResolver.ResolveContentPath("/"); + Assert.IsNotNull(content); + Assert.AreEqual(_contentByName[$"Root {root}"].Key, content.Key); + } + + [TestCase("/a", 1)] + [TestCase("/123", 2)] + [TestCase("/no-such-child", 3)] + [TestCase("/a/b", 1)] + [TestCase("/123/456", 2)] + [TestCase("/no-such-child/no-such-grandchild", 3)] + public void Non_Existant_Descendant_By_Path_With_StartItem(string path, int root) + { + SetRequestStartItem($"root-{root}"); + + var content = ApiContentPathResolver.ResolveContentPath(path); + Assert.IsNull(content); + } + + [TestCase("/a")] + [TestCase("/123")] + [TestCase("/a/b")] + [TestCase("/123/456")] + public void Non_Existant_Descendant_By_Path_Without_StartItem(string path) + { + var content = ApiContentPathResolver.ResolveContentPath(path); + Assert.IsNull(content); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/DeliveryApi/Request/ApiContentPathResolverTestBase.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/DeliveryApi/Request/ApiContentPathResolverTestBase.cs new file mode 100644 index 0000000000..05b650de58 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/DeliveryApi/Request/ApiContentPathResolverTestBase.cs @@ -0,0 +1,15 @@ +using NUnit.Framework; +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Tests.Common.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.DeliveryApi.Request; + +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerFixture)] +public abstract class ApiContentPathResolverTestBase : ApiContentRequestTestBase +{ + protected IApiContentPathResolver ApiContentPathResolver => GetRequiredService(); + + protected IDocumentUrlService DocumentUrlService => GetRequiredService(); +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/DeliveryApi/Request/ApiContentPathResolverVariantTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/DeliveryApi/Request/ApiContentPathResolverVariantTests.cs new file mode 100644 index 0000000000..628b1839ec --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/DeliveryApi/Request/ApiContentPathResolverVariantTests.cs @@ -0,0 +1,326 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Builders.Extensions; +using Umbraco.Cms.Tests.Integration.Attributes; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.DeliveryApi.Request; + +public class ApiContentPathResolverVariantTests : ApiContentPathResolverTestBase +{ + private readonly Dictionary _contentByName = new (); + + public static void ConfigureIncludeTopLevelNodeInPath(IUmbracoBuilder builder) + => builder.Services.Configure(config => config.HideTopLevelNodeFromPath = false); + + [SetUp] + public async Task SetUpTest() + { + UmbracoContextFactory.EnsureUmbracoContext(); + SetRequestHost("localhost"); + + if (_contentByName.Any()) + { + // these tests all run on the same DB to make them run faster, so we need to get the cache in a + // predictable state with each test run. + RefreshContentCache(); + return; + } + + await DocumentUrlService.InitAsync(true, CancellationToken.None); + + await GetRequiredService().CreateAsync(new Language("da-DK", "Danish"), Constants.Security.SuperUserKey); + + var contentType = new ContentTypeBuilder() + .WithAlias("theContentType") + .WithContentVariation(ContentVariation.Culture) + .Build(); + contentType.AllowedAsRoot = true; + await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey); + contentType.AllowedContentTypes = [new() { Alias = contentType.Alias, Key = contentType.Key }]; + await ContentTypeService.UpdateAsync(contentType, Constants.Security.SuperUserKey); + foreach (var rootNumber in Enumerable.Range(1, 3)) + { + var root = new ContentBuilder() + .WithContentType(contentType) + .WithCultureName("en-US", $"Root {rootNumber} en-US") + .WithCultureName("da-DK", $"Root {rootNumber} da-DK") + .Build(); + ContentService.Save(root); + ContentService.Publish(root, ["*"]); + _contentByName[$"Root {rootNumber}"] = root; + + foreach (var childNumber in Enumerable.Range(1, 3)) + { + var child = new ContentBuilder() + .WithContentType(contentType) + .WithParent(root) + .WithCultureName("en-US", $"Child {childNumber} en-US") + .WithCultureName("da-DK", $"Child {childNumber} da-DK") + .Build(); + ContentService.Save(child); + ContentService.Publish(child, ["*"]); + _contentByName[$"Root {rootNumber}/Child {childNumber}"] = child; + + foreach (var grandchildNumber in Enumerable.Range(1, 3)) + { + var grandchild = new ContentBuilder() + .WithContentType(contentType) + .WithParent(child) + .WithCultureName("en-US", $"Grandchild {grandchildNumber} en-US") + .WithCultureName("da-DK", $"Grandchild {grandchildNumber} da-DK") + .Build(); + ContentService.Save(grandchild); + ContentService.Publish(grandchild, ["*"]); + _contentByName[$"Root {rootNumber}/Child {childNumber}/Grandchild {grandchildNumber}"] = grandchild; + } + } + } + } + + [TestCase("en-US")] + [TestCase("da-DK")] + public void First_Root_Without_StartItem(string culture) + { + Assert.IsEmpty(GetRequiredService().HttpContext!.Request.Headers["Start-Item"].ToString()); + SetVariationContext(culture); + + var content = ApiContentPathResolver.ResolveContentPath("/"); + Assert.IsNotNull(content); + Assert.AreEqual(_contentByName["Root 1"].Key, content.Key); + } + + [TestCase("en-US")] + [TestCase("da-DK")] + [ConfigureBuilder(ActionName = nameof(ConfigureIncludeTopLevelNodeInPath))] + public void First_Root_Without_StartItem_With_Top_Level_Node_Included(string culture) + { + Assert.IsEmpty(GetRequiredService().HttpContext!.Request.Headers["Start-Item"].ToString()); + SetVariationContext(culture); + + var content = ApiContentPathResolver.ResolveContentPath("/"); + Assert.IsNotNull(content); + Assert.AreEqual(_contentByName["Root 1"].Key, content.Key); + } + + [TestCase(1, "en-US")] + [TestCase(2, "en-US")] + [TestCase(3, "en-US")] + [TestCase(1, "da-DK")] + [TestCase(2, "da-DK")] + [TestCase(3, "da-DK")] + public void First_Root_Child_Without_StartItem(int child, string culture) + { + Assert.IsEmpty(GetRequiredService().HttpContext!.Request.Headers["Start-Item"].ToString()); + SetVariationContext(culture); + + var content = ApiContentPathResolver.ResolveContentPath($"/child-{child}-{culture.ToLowerInvariant()}"); + Assert.IsNotNull(content); + Assert.AreEqual(_contentByName[$"Root 1/Child {child}"].Key, content.Key); + } + + [TestCase(1, 1, "en-US")] + [TestCase(2, 2, "en-US")] + [TestCase(3, 3, "en-US")] + [TestCase(1, 2, "en-US")] + [TestCase(2, 3, "en-US")] + [TestCase(3, 1, "en-US")] + [TestCase(1, 1, "da-DK")] + [TestCase(2, 2, "da-DK")] + [TestCase(3, 3, "da-DK")] + [TestCase(1, 2, "da-DK")] + [TestCase(2, 3, "da-DK")] + [TestCase(3, 1, "da-DK")] + public void First_Root_Grandchild_Without_StartItem(int child, int grandchild, string culture) + { + Assert.IsEmpty(GetRequiredService().HttpContext!.Request.Headers["Start-Item"].ToString()); + SetVariationContext(culture); + + var content = ApiContentPathResolver.ResolveContentPath($"/child-{child}-{culture.ToLowerInvariant()}/grandchild-{grandchild}-{culture.ToLowerInvariant()}"); + Assert.IsNotNull(content); + Assert.AreEqual(_contentByName[$"Root 1/Child {child}/Grandchild {grandchild}"].Key, content.Key); + } + + [TestCase(1, "en-US")] + [TestCase(2, "en-US")] + [TestCase(3, "en-US")] + [TestCase(1, "da-DK")] + [TestCase(2, "da-DK")] + [TestCase(3, "da-DK")] + public void Root_With_StartItem(int root, string culture) + { + SetRequestStartItem($"root-{root}-{culture.ToLowerInvariant()}"); + SetVariationContext(culture); + + var content = ApiContentPathResolver.ResolveContentPath("/"); + Assert.IsNotNull(content); + Assert.AreEqual(_contentByName[$"Root {root}"].Key, content.Key); + } + + [TestCase(1, "en-US")] + [TestCase(2, "en-US")] + [TestCase(3, "en-US")] + [TestCase(1, "da-DK")] + [TestCase(2, "da-DK")] + [TestCase(3, "da-DK")] + [ConfigureBuilder(ActionName = nameof(ConfigureIncludeTopLevelNodeInPath))] + public void Root_With_StartItem_With_Top_Level_Node_Included(int root, string culture) + { + SetRequestStartItem($"root-{root}-{culture.ToLowerInvariant()}"); + SetVariationContext(culture); + + var content = ApiContentPathResolver.ResolveContentPath("/"); + Assert.IsNotNull(content); + Assert.AreEqual(_contentByName[$"Root {root}"].Key, content.Key); + } + + [TestCase(1, 1, "en-US")] + [TestCase(2, 2, "en-US")] + [TestCase(3, 3, "en-US")] + [TestCase(1, 2, "en-US")] + [TestCase(2, 3, "en-US")] + [TestCase(3, 1, "en-US")] + [TestCase(1, 1, "da-DK")] + [TestCase(2, 2, "da-DK")] + [TestCase(3, 3, "da-DK")] + [TestCase(1, 2, "da-DK")] + [TestCase(2, 3, "da-DK")] + [TestCase(3, 1, "da-DK")] + public void Child_With_StartItem(int root, int child, string culture) + { + SetRequestStartItem($"root-{root}-{culture.ToLowerInvariant()}"); + SetVariationContext(culture); + + var content = ApiContentPathResolver.ResolveContentPath($"/child-{child}-{culture.ToLowerInvariant()}"); + Assert.IsNotNull(content); + Assert.AreEqual(_contentByName[$"Root {root}/Child {child}"].Key, content.Key); + } + + [TestCase(1, 1, "en-US")] + [TestCase(2, 2, "en-US")] + [TestCase(3, 3, "en-US")] + [TestCase(1, 2, "en-US")] + [TestCase(2, 3, "en-US")] + [TestCase(3, 1, "en-US")] + [TestCase(1, 1, "da-DK")] + [TestCase(2, 2, "da-DK")] + [TestCase(3, 3, "da-DK")] + [TestCase(1, 2, "da-DK")] + [TestCase(2, 3, "da-DK")] + [TestCase(3, 1, "da-DK")] + [ConfigureBuilder(ActionName = nameof(ConfigureIncludeTopLevelNodeInPath))] + public void Child_With_StartItem_With_Top_Level_Node_Included(int root, int child, string culture) + { + SetRequestStartItem($"root-{root}-{culture.ToLowerInvariant()}"); + SetVariationContext(culture); + + var content = ApiContentPathResolver.ResolveContentPath($"/child-{child}-{culture.ToLowerInvariant()}"); + Assert.IsNotNull(content); + Assert.AreEqual(_contentByName[$"Root {root}/Child {child}"].Key, content.Key); + } + + [TestCase(1, 1, 1, "en-US")] + [TestCase(2, 2, 2, "en-US")] + [TestCase(3, 3, 3, "en-US")] + [TestCase(1, 2, 3, "en-US")] + [TestCase(2, 3, 1, "en-US")] + [TestCase(3, 1, 2, "en-US")] + [TestCase(1, 1, 1, "da-DK")] + [TestCase(2, 2, 2, "da-DK")] + [TestCase(3, 3, 3, "da-DK")] + [TestCase(1, 2, 3, "da-DK")] + [TestCase(2, 3, 1, "da-DK")] + [TestCase(3, 1, 2, "da-DK")] + public void Grandchild_With_StartItem(int root, int child, int grandchild, string culture) + { + SetRequestStartItem($"root-{root}-{culture.ToLowerInvariant()}"); + SetVariationContext(culture); + + var content = ApiContentPathResolver.ResolveContentPath($"/child-{child}-{culture.ToLowerInvariant()}/grandchild-{grandchild}-{culture.ToLowerInvariant()}"); + Assert.IsNotNull(content); + Assert.AreEqual(_contentByName[$"Root {root}/Child {child}/Grandchild {grandchild}"].Key, content.Key); + } + + [TestCase("/", 1, "en-US")] + [TestCase("/root-2-en-us", 2, "en-US")] + [TestCase("/root-3-en-us", 3, "en-US")] + [TestCase("/", 1, "da-DK")] + [TestCase("/root-2-da-dk", 2, "da-DK")] + [TestCase("/root-3-da-dk", 3, "da-DK")] + public void Root_By_Path_With_StartItem(string path, int root, string culture) + { + SetRequestStartItem($"root-{root}-{culture.ToLowerInvariant()}"); + SetVariationContext(culture); + + var content = ApiContentPathResolver.ResolveContentPath(path); + Assert.IsNotNull(content); + Assert.AreEqual(_contentByName[$"Root {root}"].Key, content.Key); + } + + [TestCase("/", 1, "en-US")] + [TestCase("/root-2-en-us", 2, "en-US")] + [TestCase("/root-3-en-us", 3, "en-US")] + [TestCase("/", 1, "da-DK")] + [TestCase("/root-2-da-dk", 2, "da-DK")] + [TestCase("/root-3-da-dk", 3, "da-DK")] + public void Root_By_Path_Without_StartItem(string path, int root, string culture) + { + SetVariationContext(culture); + + var content = ApiContentPathResolver.ResolveContentPath(path); + Assert.IsNotNull(content); + Assert.AreEqual(_contentByName[$"Root {root}"].Key, content.Key); + } + + [TestCase(1, "en-US")] + [TestCase(2, "en-US")] + [TestCase(3, "en-US")] + [TestCase(1, "da-DK")] + [TestCase(2, "da-DK")] + [TestCase(3, "da-DK")] + public async Task Root_With_Domain_Bindings(int root, string culture) + { + await SetContentHost(_contentByName[$"Root {root}"], "some.host", "en-US"); + SetRequestHost("some.host"); + SetVariationContext(culture); + + var content = ApiContentPathResolver.ResolveContentPath("/"); + Assert.IsNotNull(content); + Assert.AreEqual(_contentByName[$"Root {root}"].Key, content.Key); + } + + [TestCase("/a", 1, "en-US")] + [TestCase("/b", 1, "da-DK")] + [TestCase("/123", 2, "en-US")] + [TestCase("/456", 2, "da-DK")] + [TestCase("/no-such-child", 3, "en-US")] + [TestCase("/not-at-all", 3, "da-DK")] + [TestCase("/a/b", 1, "en-US")] + [TestCase("/c/d", 1, "da-DK")] + [TestCase("/123/456", 2, "en-US")] + [TestCase("/789/012", 2, "da-DK")] + [TestCase("/no-such-child/no-such-grandchild", 3, "en-US")] + [TestCase("/not-at-all/aint-no-way", 3, "da-DK")] + public void Non_Existant_Descendant_By_Path_With_StartItem(string path, int root, string culture) + { + SetRequestStartItem($"root-{root}-{culture.ToLowerInvariant()}"); + + var content = ApiContentPathResolver.ResolveContentPath(path); + Assert.IsNull(content); + } + + [TestCase("/a")] + [TestCase("/123")] + [TestCase("/a/b")] + [TestCase("/123/456")] + public void Non_Existant_Descendant_By_Path_Without_StartItem(string path) + { + var content = ApiContentPathResolver.ResolveContentPath(path); + Assert.IsNull(content); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/DeliveryApi/Request/ApiContentRequestTestBase.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/DeliveryApi/Request/ApiContentRequestTestBase.cs new file mode 100644 index 0000000000..8ec8e92bba --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/DeliveryApi/Request/ApiContentRequestTestBase.cs @@ -0,0 +1,93 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.Changes; +using Umbraco.Cms.Core.Sync; +using Umbraco.Cms.Core.Web; +using Umbraco.Cms.Tests.Integration.Testing; +using Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.DeliveryApi.Request; + +public abstract class ApiContentRequestTestBase : UmbracoIntegrationTest +{ + protected IContentService ContentService => GetRequiredService(); + + protected IContentTypeService ContentTypeService => GetRequiredService(); + + protected IApiContentRouteBuilder ApiContentRouteBuilder => GetRequiredService(); + + protected IVariationContextAccessor VariationContextAccessor => GetRequiredService(); + + protected IUmbracoContextAccessor UmbracoContextAccessor => GetRequiredService(); + + protected IUmbracoContextFactory UmbracoContextFactory => GetRequiredService(); + + protected override void CustomTestSetup(IUmbracoBuilder builder) + { + builder.AddUmbracoHybridCache(); + builder.AddNotificationHandler(); + builder.Services.AddUnique(); + + builder.AddDeliveryApi(); + } + + [TearDown] + public async Task CleanUpAfterTest() + { + var domainService = GetRequiredService(); + foreach (var content in ContentService.GetRootContent()) + { + await domainService.UpdateDomainsAsync(content.Key, new DomainsUpdateModel { Domains = [] }); + } + + var httpContextAccessor = GetRequiredService(); + httpContextAccessor.HttpContext?.Request.Headers.Clear(); + } + + protected void SetVariationContext(string? culture) + => VariationContextAccessor.VariationContext = new VariationContext(culture: culture); + + protected async Task SetContentHost(IContent content, string host, string culture) + => await GetRequiredService().UpdateDomainsAsync( + content.Key, + new DomainsUpdateModel { Domains = [new DomainModel { DomainName = host, IsoCode = culture }] }); + + protected void SetRequestHost(string host) + { + var httpContextAccessor = GetRequiredService(); + + httpContextAccessor.HttpContext = new DefaultHttpContext + { + Request = + { + Scheme = "https", + Host = new HostString(host), + Path = "/", + QueryString = new QueryString(string.Empty) + }, + RequestServices = Services + }; + } + + protected void SetRequestStartItem(string startItem) + { + var httpContextAccessor = GetRequiredService(); + if (httpContextAccessor.HttpContext is null) + { + throw new InvalidOperationException("HTTP context is null"); + } + + httpContextAccessor.HttpContext.Request.Headers["Start-Item"] = startItem; + } + + protected void RefreshContentCache() + => Services.GetRequiredService().Refresh([new ContentCacheRefresher.JsonPayload { ChangeTypes = TreeChangeTypes.RefreshAll }]); +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/DeliveryApi/Request/ApiContentRouteBuilderInvariantTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/DeliveryApi/Request/ApiContentRouteBuilderInvariantTests.cs new file mode 100644 index 0000000000..dd077a7f16 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/DeliveryApi/Request/ApiContentRouteBuilderInvariantTests.cs @@ -0,0 +1,242 @@ +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Builders.Extensions; +using Umbraco.Cms.Tests.Integration.Attributes; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.DeliveryApi.Request; + +public class ApiContentRouteBuilderInvariantTests : ApiContentRouteBuilderTestBase +{ + private readonly Dictionary _contentByName = new (); + + public static void ConfigureIncludeTopLevelNodeInPath(IUmbracoBuilder builder) + => builder.Services.Configure(config => config.HideTopLevelNodeFromPath = false); + + public static void ConfigureOmitTrailingSlash(IUmbracoBuilder builder) + => builder.Services.Configure(config => config.AddTrailingSlash = false); + + [SetUp] + public async Task SetUpTest() + { + SetRequestHost("localhost"); + + if (_contentByName.Any()) + { + // these tests all run on the same DB to make them run faster, so we need to get the cache in a + // predictable state with each test run. + RefreshContentCache(); + return; + } + + var contentType = new ContentTypeBuilder() + .WithAlias("theContentType") + .Build(); + contentType.AllowedAsRoot = true; + await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey); + contentType.AllowedContentTypes = [new() { Alias = contentType.Alias, Key = contentType.Key }]; + await ContentTypeService.UpdateAsync(contentType, Constants.Security.SuperUserKey); + foreach (var rootNumber in Enumerable.Range(1, 3)) + { + var root = new ContentBuilder() + .WithContentType(contentType) + .WithName($"Root {rootNumber}") + .Build(); + ContentService.Save(root); + ContentService.Publish(root, ["*"]); + _contentByName[root.Name!] = root; + + foreach (var childNumber in Enumerable.Range(1, 3)) + { + var child = new ContentBuilder() + .WithContentType(contentType) + .WithParent(root) + .WithName($"Child {childNumber}") + .Build(); + ContentService.Save(child); + ContentService.Publish(child, ["*"]); + _contentByName[$"{root.Name!}/{child.Name!}"] = child; + + foreach (var grandchildNumber in Enumerable.Range(1, 3)) + { + var grandchild = new ContentBuilder() + .WithContentType(contentType) + .WithParent(child) + .WithName($"Grandchild {grandchildNumber}") + .Build(); + ContentService.Save(grandchild); + ContentService.Publish(grandchild, ["*"]); + _contentByName[$"{root.Name!}/{child.Name!}/{grandchild.Name!}"] = grandchild; + } + } + } + } + + [Test] + public void First_Root() + { + var publishedContent = GetPublishedContent(_contentByName["Root 1"].Key); + var route = ApiContentRouteBuilder.Build(publishedContent); + Assert.IsNotNull(route); + Assert.Multiple(() => + { + Assert.AreEqual("/", route.Path); + Assert.AreEqual("root-1", route.StartItem.Path); + Assert.AreEqual(_contentByName["Root 1"].Key, route.StartItem.Id); + }); + } + + [Test] + public void Last_Root() + { + var publishedContent = GetPublishedContent(_contentByName["Root 3"].Key); + var route = ApiContentRouteBuilder.Build(publishedContent); + Assert.IsNotNull(route); + Assert.Multiple(() => + { + Assert.AreEqual("/root-3/", route.Path); + Assert.AreEqual("root-3", route.StartItem.Path); + Assert.AreEqual(_contentByName["Root 3"].Key, route.StartItem.Id); + }); + } + + [TestCase(1)] + [TestCase(2)] + [TestCase(3)] + public void First_Child(int root) + { + var publishedContent = GetPublishedContent(_contentByName[$"Root {root}/Child 1"].Key); + var route = ApiContentRouteBuilder.Build(publishedContent); + Assert.IsNotNull(route); + Assert.Multiple(() => + { + Assert.AreEqual("/child-1/", route.Path); + Assert.AreEqual($"root-{root}", route.StartItem.Path); + Assert.AreEqual(_contentByName[$"Root {root}"].Key, route.StartItem.Id); + }); + } + + [TestCase(1)] + [TestCase(2)] + [TestCase(3)] + public void Last_Child(int root) + { + var publishedContent = GetPublishedContent(_contentByName[$"Root {root}/Child 3"].Key); + var route = ApiContentRouteBuilder.Build(publishedContent); + Assert.IsNotNull(route); + Assert.Multiple(() => + { + Assert.AreEqual("/child-3/", route.Path); + Assert.AreEqual($"root-{root}", route.StartItem.Path); + Assert.AreEqual(_contentByName[$"Root {root}"].Key, route.StartItem.Id); + }); + } + + [TestCase(1)] + [TestCase(2)] + [TestCase(3)] + public void First_Grandchild(int root) + { + var publishedContent = GetPublishedContent(_contentByName[$"Root {root}/Child 1/Grandchild 1"].Key); + var route = ApiContentRouteBuilder.Build(publishedContent); + Assert.IsNotNull(route); + Assert.Multiple(() => + { + Assert.AreEqual("/child-1/grandchild-1/", route.Path); + Assert.AreEqual($"root-{root}", route.StartItem.Path); + Assert.AreEqual(_contentByName[$"Root {root}"].Key, route.StartItem.Id); + }); + } + + [TestCase(1)] + [TestCase(2)] + [TestCase(3)] + public void Last_Grandchild(int root) + { + var publishedContent = GetPublishedContent(_contentByName[$"Root {root}/Child 3/Grandchild 3"].Key); + var route = ApiContentRouteBuilder.Build(publishedContent); + Assert.IsNotNull(route); + Assert.Multiple(() => + { + Assert.AreEqual("/child-3/grandchild-3/", route.Path); + Assert.AreEqual($"root-{root}", route.StartItem.Path); + Assert.AreEqual(_contentByName[$"Root {root}"].Key, route.StartItem.Id); + }); + } + + [TestCase(1)] + [TestCase(2)] + [TestCase(3)] + [ConfigureBuilder(ActionName = nameof(ConfigureIncludeTopLevelNodeInPath))] + public void Root_With_Top_Level_Node_Included(int root) + { + var publishedContent = GetPublishedContent(_contentByName[$"Root {root}"].Key); + var route = ApiContentRouteBuilder.Build(publishedContent); + Assert.IsNotNull(route); + Assert.Multiple(() => + { + Assert.AreEqual("/", route.Path); + Assert.AreEqual($"root-{root}", route.StartItem.Path); + Assert.AreEqual(_contentByName[$"Root {root}"].Key, route.StartItem.Id); + }); + } + + [TestCase(1)] + [TestCase(2)] + [TestCase(3)] + public async Task Root_With_Domain_Bindings(int root) + { + await SetContentHost(_contentByName[$"Root {root}"], "some.host", "en-US"); + SetRequestHost("some.host"); + + var publishedContent = GetPublishedContent(_contentByName[$"Root {root}"].Key); + var route = ApiContentRouteBuilder.Build(publishedContent); + Assert.IsNotNull(route); + Assert.Multiple(() => + { + Assert.AreEqual("/", route.Path); + Assert.AreEqual($"root-{root}", route.StartItem.Path); + Assert.AreEqual(_contentByName[$"Root {root}"].Key, route.StartItem.Id); + }); + } + + [TestCase(1, "/")] + [TestCase(2, "/root-2")] + [TestCase(3, "/root-3")] + [ConfigureBuilder(ActionName = nameof(ConfigureOmitTrailingSlash))] + public void Root_Without_Trailing_Slash(int root, string expectedPath) + { + var publishedContent = GetPublishedContent(_contentByName[$"Root {root}"].Key); + var route = ApiContentRouteBuilder.Build(publishedContent); + Assert.IsNotNull(route); + Assert.Multiple(() => + { + Assert.AreEqual(expectedPath, route.Path); + Assert.AreEqual($"root-{root}", route.StartItem.Path); + Assert.AreEqual(_contentByName[$"Root {root}"].Key, route.StartItem.Id); + }); + } + + [TestCase(1, 1)] + [TestCase(2, 2)] + [TestCase(3, 3)] + [TestCase(1, 2)] + [TestCase(2, 3)] + [TestCase(3, 1)] + [ConfigureBuilder(ActionName = nameof(ConfigureOmitTrailingSlash))] + public void Child_Without_Trailing_Slash(int root, int child) + { + var publishedContent = GetPublishedContent(_contentByName[$"Root {root}/Child {child}"].Key); + var route = ApiContentRouteBuilder.Build(publishedContent); + Assert.IsNotNull(route); + Assert.Multiple(() => + { + Assert.AreEqual($"/child-{child}", route.Path); + Assert.AreEqual($"root-{root}", route.StartItem.Path); + Assert.AreEqual(_contentByName[$"Root {root}"].Key, route.StartItem.Id); + }); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/DeliveryApi/Request/ApiContentRouteBuilderTestBase.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/DeliveryApi/Request/ApiContentRouteBuilderTestBase.cs new file mode 100644 index 0000000000..52a1c3d35d --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/DeliveryApi/Request/ApiContentRouteBuilderTestBase.cs @@ -0,0 +1,20 @@ +using NUnit.Framework; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Tests.Common.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.DeliveryApi.Request; + +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerFixture)] +public abstract class ApiContentRouteBuilderTestBase : ApiContentRequestTestBase +{ + protected IPublishedContent GetPublishedContent(Guid key) + { + UmbracoContextAccessor.Clear(); + var umbracoContext = UmbracoContextFactory.EnsureUmbracoContext().UmbracoContext; + var publishedContent = umbracoContext.Content?.GetById(key); + Assert.IsNotNull(publishedContent); + + return publishedContent; + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/DeliveryApi/Request/ApiContentRouteBuilderVariantTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/DeliveryApi/Request/ApiContentRouteBuilderVariantTests.cs new file mode 100644 index 0000000000..d268d3f196 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/DeliveryApi/Request/ApiContentRouteBuilderVariantTests.cs @@ -0,0 +1,287 @@ +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Builders.Extensions; +using Umbraco.Cms.Tests.Integration.Attributes; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.DeliveryApi.Request; + +public class ApiContentRouteBuilderVariantTests : ApiContentRouteBuilderTestBase +{ + private readonly Dictionary _contentByName = new (); + + public static void ConfigureIncludeTopLevelNodeInPath(IUmbracoBuilder builder) + => builder.Services.Configure(config => config.HideTopLevelNodeFromPath = false); + + public static void ConfigureOmitTrailingSlash(IUmbracoBuilder builder) + => builder.Services.Configure(config => config.AddTrailingSlash = false); + + [SetUp] + public async Task SetUpTest() + { + SetRequestHost("localhost"); + + if (_contentByName.Any()) + { + // these tests all run on the same DB to make them run faster, so we need to get the cache in a + // predictable state with each test run. + RefreshContentCache(); + return; + } + + await GetRequiredService().CreateAsync(new Language("da-DK", "Danish"), Constants.Security.SuperUserKey); + + var contentType = new ContentTypeBuilder() + .WithAlias("theContentType") + .WithContentVariation(ContentVariation.Culture) + .Build(); + contentType.AllowedAsRoot = true; + await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey); + contentType.AllowedContentTypes = [new() { Alias = contentType.Alias, Key = contentType.Key }]; + await ContentTypeService.UpdateAsync(contentType, Constants.Security.SuperUserKey); + foreach (var rootNumber in Enumerable.Range(1, 3)) + { + var root = new ContentBuilder() + .WithContentType(contentType) + .WithCultureName("en-US", $"Root {rootNumber} en-US") + .WithCultureName("da-DK", $"Root {rootNumber} da-DK") + .Build(); + ContentService.Save(root); + ContentService.Publish(root, ["*"]); + _contentByName[$"Root {rootNumber}"] = root; + + foreach (var childNumber in Enumerable.Range(1, 3)) + { + var child = new ContentBuilder() + .WithContentType(contentType) + .WithParent(root) + .WithCultureName("en-US", $"Child {childNumber} en-US") + .WithCultureName("da-DK", $"Child {childNumber} da-DK") + .Build(); + ContentService.Save(child); + ContentService.Publish(child, ["*"]); + _contentByName[$"Root {rootNumber}/Child {childNumber}"] = child; + + foreach (var grandchildNumber in Enumerable.Range(1, 3)) + { + var grandchild = new ContentBuilder() + .WithContentType(contentType) + .WithParent(child) + .WithCultureName("en-US", $"Grandchild {grandchildNumber} en-US") + .WithCultureName("da-DK", $"Grandchild {grandchildNumber} da-DK") + .Build(); + ContentService.Save(grandchild); + ContentService.Publish(grandchild, ["*"]); + _contentByName[$"Root {rootNumber}/Child {childNumber}/Grandchild {grandchildNumber}"] = grandchild; + } + } + } + } + + [TestCase("da-DK")] + [TestCase("en-US")] + public void First_Root(string culture) + { + SetVariationContext(culture); + var publishedContent = GetPublishedContent(_contentByName[$"Root 1"].Key); + var route = ApiContentRouteBuilder.Build(publishedContent); + Assert.IsNotNull(route); + Assert.Multiple(() => + { + Assert.AreEqual("/", route.Path); + Assert.AreEqual($"root-1-{culture.ToLowerInvariant()}", route.StartItem.Path); + Assert.AreEqual(_contentByName[$"Root 1"].Key, route.StartItem.Id); + }); + } + + [TestCase("da-DK")] + [TestCase("en-US")] + public void Last_Root(string culture) + { + SetVariationContext(culture); + var publishedContent = GetPublishedContent(_contentByName["Root 3"].Key); + var route = ApiContentRouteBuilder.Build(publishedContent); + Assert.IsNotNull(route); + Assert.Multiple(() => + { + Assert.AreEqual($"/root-3-{culture.ToLowerInvariant()}/", route.Path); + Assert.AreEqual($"root-3-{culture.ToLowerInvariant()}", route.StartItem.Path); + Assert.AreEqual(_contentByName["Root 3"].Key, route.StartItem.Id); + }); + } + + [TestCase(1, "en-US")] + [TestCase(1, "da-DK")] + [TestCase(2, "en-US")] + [TestCase(2, "da-DK")] + [TestCase(3, "en-US")] + [TestCase(3, "da-DK")] + public void First_Child(int root, string culture) + { + SetVariationContext(culture); + var publishedContent = GetPublishedContent(_contentByName[$"Root {root}/Child 1"].Key); + var route = ApiContentRouteBuilder.Build(publishedContent); + Assert.IsNotNull(route); + Assert.Multiple(() => + { + Assert.AreEqual($"/child-1-{culture.ToLowerInvariant()}/", route.Path); + Assert.AreEqual($"root-{root}-{culture.ToLowerInvariant()}", route.StartItem.Path); + Assert.AreEqual(_contentByName[$"Root {root}"].Key, route.StartItem.Id); + }); + } + + [TestCase(1, "en-US")] + [TestCase(1, "da-DK")] + [TestCase(2, "en-US")] + [TestCase(2, "da-DK")] + [TestCase(3, "en-US")] + [TestCase(3, "da-DK")] + public void Last_Child(int root, string culture) + { + SetVariationContext(culture); + var publishedContent = GetPublishedContent(_contentByName[$"Root {root}/Child 3"].Key); + var route = ApiContentRouteBuilder.Build(publishedContent); + Assert.IsNotNull(route); + Assert.Multiple(() => + { + Assert.AreEqual($"/child-3-{culture.ToLowerInvariant()}/", route.Path); + Assert.AreEqual($"root-{root}-{culture.ToLowerInvariant()}", route.StartItem.Path); + Assert.AreEqual(_contentByName[$"Root {root}"].Key, route.StartItem.Id); + }); + } + + [TestCase(1, "en-US")] + [TestCase(1, "da-DK")] + [TestCase(2, "en-US")] + [TestCase(2, "da-DK")] + [TestCase(3, "en-US")] + [TestCase(3, "da-DK")] + public void First_Grandchild(int root, string culture) + { + SetVariationContext(culture); + var publishedContent = GetPublishedContent(_contentByName[$"Root {root}/Child 1/Grandchild 1"].Key); + var route = ApiContentRouteBuilder.Build(publishedContent); + Assert.IsNotNull(route); + Assert.Multiple(() => + { + Assert.AreEqual($"/child-1-{culture.ToLowerInvariant()}/grandchild-1-{culture.ToLowerInvariant()}/", route.Path); + Assert.AreEqual($"root-{root}-{culture.ToLowerInvariant()}", route.StartItem.Path); + Assert.AreEqual(_contentByName[$"Root {root}"].Key, route.StartItem.Id); + }); + } + + [TestCase(1, "en-US")] + [TestCase(1, "da-DK")] + [TestCase(2, "en-US")] + [TestCase(2, "da-DK")] + [TestCase(3, "en-US")] + [TestCase(3, "da-DK")] + public void Last_Grandchild(int root, string culture) + { + SetVariationContext(culture); + var publishedContent = GetPublishedContent(_contentByName[$"Root {root}/Child 3/Grandchild 3"].Key); + var route = ApiContentRouteBuilder.Build(publishedContent); + Assert.IsNotNull(route); + Assert.Multiple(() => + { + Assert.AreEqual($"/child-3-{culture.ToLowerInvariant()}/grandchild-3-{culture.ToLowerInvariant()}/", route.Path); + Assert.AreEqual($"root-{root}-{culture.ToLowerInvariant()}", route.StartItem.Path); + Assert.AreEqual(_contentByName[$"Root {root}"].Key, route.StartItem.Id); + }); + } + + [TestCase(1, "en-US")] + [TestCase(1, "da-DK")] + [TestCase(2, "en-US")] + [TestCase(2, "da-DK")] + [TestCase(3, "en-US")] + [TestCase(3, "da-DK")] + [ConfigureBuilder(ActionName = nameof(ConfigureIncludeTopLevelNodeInPath))] + public void Root_With_Top_Level_Node_Included(int root, string culture) + { + SetVariationContext(culture); + var publishedContent = GetPublishedContent(_contentByName[$"Root {root}"].Key); + var route = ApiContentRouteBuilder.Build(publishedContent); + Assert.IsNotNull(route); + Assert.Multiple(() => + { + Assert.AreEqual("/", route.Path); + Assert.AreEqual($"root-{root}-{culture.ToLowerInvariant()}", route.StartItem.Path); + Assert.AreEqual(_contentByName[$"Root {root}"].Key, route.StartItem.Id); + }); + } + + [TestCase(1, "en-US")] + [TestCase(1, "da-DK")] + [TestCase(2, "en-US")] + [TestCase(2, "da-DK")] + [TestCase(3, "en-US")] + [TestCase(3, "da-DK")] + public async Task Root_With_Domain_Bindings(int root, string culture) + { + await SetContentHost(_contentByName[$"Root {root}"], "some.host", culture); + SetRequestHost("some.host"); + SetVariationContext(culture); + var publishedContent = GetPublishedContent(_contentByName[$"Root {root}"].Key); + var route = ApiContentRouteBuilder.Build(publishedContent); + Assert.IsNotNull(route); + Assert.Multiple(() => + { + Assert.AreEqual("/", route.Path); + Assert.AreEqual($"root-{root}-{culture.ToLowerInvariant()}", route.StartItem.Path); + Assert.AreEqual(_contentByName[$"Root {root}"].Key, route.StartItem.Id); + }); + } + + [TestCase(1, "en-US", "/")] + [TestCase(1, "da-DK", "/")] + [TestCase(2, "en-US", "/root-2-en-us")] + [TestCase(2, "da-DK", "/root-2-da-dk")] + [TestCase(3, "en-US", "/root-3-en-us")] + [TestCase(3, "da-DK", "/root-3-da-dk")] + [ConfigureBuilder(ActionName = nameof(ConfigureOmitTrailingSlash))] + public void Root_Without_Trailing_Slash(int root, string culture, string expectedPath) + { + SetVariationContext(culture); + var publishedContent = GetPublishedContent(_contentByName[$"Root {root}"].Key); + var route = ApiContentRouteBuilder.Build(publishedContent); + Assert.IsNotNull(route); + Assert.Multiple(() => + { + Assert.AreEqual(expectedPath, route.Path); + Assert.AreEqual($"root-{root}-{culture.ToLowerInvariant()}", route.StartItem.Path); + Assert.AreEqual(_contentByName[$"Root {root}"].Key, route.StartItem.Id); + }); + } + + [TestCase(1, 1, "en-US")] + [TestCase(1, 1, "da-DK")] + [TestCase(2, 2, "en-US")] + [TestCase(2, 2, "da-DK")] + [TestCase(3, 3, "en-US")] + [TestCase(3, 3, "da-DK")] + [TestCase(1, 2, "en-US")] + [TestCase(1, 2, "da-DK")] + [TestCase(2, 3, "en-US")] + [TestCase(2, 3, "da-DK")] + [TestCase(3, 1, "en-US")] + [TestCase(3, 1, "da-DK")] + [ConfigureBuilder(ActionName = nameof(ConfigureOmitTrailingSlash))] + public void Child_Without_Trailing_Slash(int root, int child, string culture) + { + SetVariationContext(culture); + var publishedContent = GetPublishedContent(_contentByName[$"Root {root}/Child {child}"].Key); + var route = ApiContentRouteBuilder.Build(publishedContent); + Assert.IsNotNull(route); + Assert.Multiple(() => + { + Assert.AreEqual($"/child-{child}-{culture.ToLowerInvariant()}", route.Path); + Assert.AreEqual($"root-{root}-{culture.ToLowerInvariant()}", route.StartItem.Path); + Assert.AreEqual(_contentByName[$"Root {root}"].Key, route.StartItem.Id); + }); + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/ContentBuilderTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/ContentBuilderTests.cs index 99ccbd7a61..91b67673d9 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/ContentBuilderTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/ContentBuilderTests.cs @@ -48,7 +48,7 @@ public class ContentBuilderTests : DeliveryApiTests Assert.NotNull(result); Assert.AreEqual("The page", result.Name); Assert.AreEqual("thePageType", result.ContentType); - Assert.AreEqual("/url:url-segment", result.Route.Path); + Assert.AreEqual("/url:url-segment/", result.Route.Path); Assert.AreEqual(key, result.Id); Assert.AreEqual(2, result.Properties.Count); Assert.AreEqual("Delivery API value", result.Properties["deliveryApi"]); diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/ContentPickerValueConverterTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/ContentPickerValueConverterTests.cs index c876278eec..1aca987862 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/ContentPickerValueConverterTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/ContentPickerValueConverterTests.cs @@ -40,7 +40,7 @@ public class ContentPickerValueConverterTests : PropertyValueConverterTests Assert.NotNull(result); Assert.AreEqual("The page", result.Name); Assert.AreEqual(PublishedContent.Key, result.Id); - Assert.AreEqual("/the-page-url", result.Route.Path); + Assert.AreEqual("/the-page-url/", result.Route.Path); Assert.AreEqual("TheContentType", result.ContentType); Assert.IsEmpty(result.Properties); } @@ -103,7 +103,7 @@ public class ContentPickerValueConverterTests : PropertyValueConverterTests Assert.NotNull(result); Assert.AreEqual("The page", result.Name); Assert.AreEqual(content.Object.Key, result.Id); - Assert.AreEqual("/page-url-segment", result.Route.Path); + Assert.AreEqual("/page-url-segment/", result.Route.Path); Assert.AreEqual("TheContentType", result.ContentType); Assert.AreEqual(2, result.Properties.Count); Assert.AreEqual("Delivery API value", result.Properties[DeliveryApiPropertyType.Alias]); diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/ContentRouteBuilderTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/ContentRouteBuilderTests.cs index 9da66ba10c..03da8c6989 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/ContentRouteBuilderTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/ContentRouteBuilderTests.cs @@ -50,7 +50,7 @@ public class ContentRouteBuilderTests : DeliveryApiTests IEnumerable ancestorsKeys = [rootKey]; navigationQueryServiceMock.Setup(x => x.TryGetAncestorsKeys(childKey, out ancestorsKeys)).Returns(true); - var contentCache = CreatePublishedContentCache("#"); + var contentCache = CreatePublishedContentCache(); Mock.Get(contentCache).Setup(x => x.GetById(It.IsAny(), root.Key)).Returns(root); Mock.Get(contentCache).Setup(x => x.GetById(It.IsAny(), child.Key)).Returns(child); @@ -77,7 +77,7 @@ public class ContentRouteBuilderTests : DeliveryApiTests var grandchildKey = Guid.NewGuid(); var grandchild = SetupInvariantPublishedContent("The Grandchild", grandchildKey, navigationQueryServiceMock, child); - var contentCache = CreatePublishedContentCache("#"); + var contentCache = CreatePublishedContentCache(); Mock.Get(contentCache).Setup(x => x.GetById(It.IsAny(), root.Key)).Returns(root); Mock.Get(contentCache).Setup(x => x.GetById(It.IsAny(), child.Key)).Returns(child); Mock.Get(contentCache).Setup(x => x.GetById(It.IsAny(), grandchild.Key)).Returns(grandchild); @@ -104,7 +104,7 @@ public class ContentRouteBuilderTests : DeliveryApiTests var childKey = Guid.NewGuid(); var child = SetupVariantPublishedContent("The Child", childKey, navigationQueryServiceMock, root); - var contentCache = CreatePublishedContentCache("#"); + var contentCache = CreatePublishedContentCache(); Mock.Get(contentCache).Setup(x => x.GetById(It.IsAny(), root.Key)).Returns(root); Mock.Get(contentCache).Setup(x => x.GetById(It.IsAny(), child.Key)).Returns(child); @@ -136,7 +136,7 @@ public class ContentRouteBuilderTests : DeliveryApiTests var childKey = Guid.NewGuid(); var child = SetupInvariantPublishedContent("The Child", childKey, navigationQueryServiceMock, root); - var contentCache = CreatePublishedContentCache("#"); + var contentCache = CreatePublishedContentCache(); Mock.Get(contentCache).Setup(x => x.GetById(It.IsAny(), root.Key)).Returns(root); Mock.Get(contentCache).Setup(x => x.GetById(It.IsAny(), child.Key)).Returns(child); @@ -168,7 +168,7 @@ public class ContentRouteBuilderTests : DeliveryApiTests var childKey = Guid.NewGuid(); var child = SetupVariantPublishedContent("The Child", childKey, navigationQueryServiceMock, root); - var contentCache = CreatePublishedContentCache("#"); + var contentCache = CreatePublishedContentCache(); Mock.Get(contentCache).Setup(x => x.GetById(It.IsAny(), root.Key)).Returns(root); Mock.Get(contentCache).Setup(x => x.GetById(It.IsAny(), child.Key)).Returns(child); @@ -209,7 +209,7 @@ public class ContentRouteBuilderTests : DeliveryApiTests { var result = GetUnRoutableRoute(resolvedUrl, "/the/content/route"); Assert.IsNotNull(result); - Assert.AreEqual("/the/content/route", result.Path); + Assert.AreEqual("/the/content/route/", result.Path); } [TestCase("")] @@ -266,7 +266,7 @@ public class ContentRouteBuilderTests : DeliveryApiTests var childKey = Guid.NewGuid(); var child = SetupInvariantPublishedContent("The Child", childKey, navigationQueryServiceMock, root, false); - var contentCache = CreatePublishedContentCache("#"); + var contentCache = CreatePublishedContentCache(); Mock.Get(contentCache).Setup(x => x.GetById(It.IsAny(), root.Key)).Returns(root); Mock.Get(contentCache).Setup(x => x.GetById(It.IsAny(), child.Key)).Returns(child); @@ -293,7 +293,7 @@ public class ContentRouteBuilderTests : DeliveryApiTests var childKey = Guid.NewGuid(); var child = SetupInvariantPublishedContent("The Child", childKey, navigationQueryServiceMock, root, false); - var contentCache = CreatePublishedContentCache("#"); + var contentCache = CreatePublishedContentCache(); Mock.Get(contentCache).Setup(x => x.GetById(It.IsAny(), root.Key)).Returns(root); Mock.Get(contentCache).Setup(x => x.GetById(It.IsAny(), child.Key)).Returns(child); @@ -321,7 +321,7 @@ public class ContentRouteBuilderTests : DeliveryApiTests var requestPreviewServiceMock = new Mock(); requestPreviewServiceMock.Setup(m => m.IsPreview()).Returns(isPreview); - var contentCache = CreatePublishedContentCache("#"); + var contentCache = CreatePublishedContentCache(); Mock.Get(contentCache).Setup(x => x.GetById(It.IsAny(), root.Key)).Returns(root); Mock.Get(contentCache).Setup(x => x.GetById(It.IsAny(), child.Key)).Returns(child); @@ -355,7 +355,7 @@ public class ContentRouteBuilderTests : DeliveryApiTests var childKey = Guid.NewGuid(); var child = SetupInvariantPublishedContent("The Child", childKey, navigationQueryServiceMock, root); - var contentCache = CreatePublishedContentCache("#"); + var contentCache = CreatePublishedContentCache(); Mock.Get(contentCache).Setup(x => x.GetById(It.IsAny(), root.Key)).Returns(root); Mock.Get(contentCache).Setup(x => x.GetById(It.IsAny(), child.Key)).Returns(child); @@ -460,7 +460,7 @@ public class ContentRouteBuilderTests : DeliveryApiTests var requestPreviewServiceMock = new Mock(); requestPreviewServiceMock.Setup(m => m.IsPreview()).Returns(isPreview); - contentCache ??= CreatePublishedContentCache("#"); + contentCache ??= CreatePublishedContentCache(); apiContentPathProvider ??= SetupApiContentPathProvider(hideTopLevelNodeFromPath, contentCache, navigationQueryService); return CreateContentRouteBuilder( @@ -480,25 +480,24 @@ public class ContentRouteBuilderTests : DeliveryApiTests .Returns(publishedUrl); var contentPathProvider = new ApiContentPathProvider(publishedUrlProviderMock.Object); - var contentCache = CreatePublishedContentCache(routeById); + var contentCache = CreatePublishedContentCache(); var navigationQueryServiceMock = new Mock(); var content = SetupVariantPublishedContent("The Content", Guid.NewGuid(), navigationQueryServiceMock); + var documentUrlServiceMock = new Mock(); + documentUrlServiceMock + .Setup(m => m.GetLegacyRouteFormat(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(routeById); + var builder = CreateContentRouteBuilder( contentPathProvider, CreateGlobalSettings(), - contentCache: contentCache); + contentCache: contentCache, + documentUrlService: documentUrlServiceMock.Object); return builder.Build(content); } - private IPublishedContentCache CreatePublishedContentCache(string routeById) - { - var publishedContentCacheMock = new Mock(); - publishedContentCacheMock - .Setup(c => c.GetRouteById(It.IsAny(), It.IsAny())) - .Returns(routeById); - - return publishedContentCacheMock.Object; - } + private IPublishedContentCache CreatePublishedContentCache() + => Mock.Of(); } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/DeliveryApiTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/DeliveryApiTests.cs index 0e785b1134..8d48d3016d 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/DeliveryApiTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/DeliveryApiTests.cs @@ -9,6 +9,7 @@ using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.PropertyEditors; using Umbraco.Cms.Core.PropertyEditors.DeliveryApi; using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.Navigation; using Umbraco.Extensions; @@ -128,7 +129,8 @@ public class DeliveryApiTests IOptionsMonitor? requestHandlerSettingsMonitor = null, IPublishedContentCache? contentCache = null, IDocumentNavigationQueryService? navigationQueryService = null, - IPublishStatusQueryService? publishStatusQueryService = null) + IPublishStatusQueryService? publishStatusQueryService = null, + IDocumentUrlService? documentUrlService = null) { if (requestHandlerSettingsMonitor == null) { @@ -145,6 +147,7 @@ public class DeliveryApiTests requestHandlerSettingsMonitor, contentCache ?? Mock.Of(), navigationQueryService ?? Mock.Of(), - publishStatusQueryService ?? PublishStatusQueryService); + publishStatusQueryService ?? PublishStatusQueryService, + documentUrlService ?? Mock.Of()); } } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/MultiNodeTreePickerValueConverterTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/MultiNodeTreePickerValueConverterTests.cs index 57f3b5178f..e83bd28514 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/MultiNodeTreePickerValueConverterTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/MultiNodeTreePickerValueConverterTests.cs @@ -59,7 +59,7 @@ public class MultiNodeTreePickerValueConverterTests : PropertyValueConverterTest Assert.AreEqual(1, result.Count()); Assert.AreEqual(PublishedContent.Name, result.First().Name); Assert.AreEqual(PublishedContent.Key, result.First().Id); - Assert.AreEqual("/the-page-url", result.First().Route.Path); + Assert.AreEqual("/the-page-url/", result.First().Route.Path); Assert.AreEqual("TheContentType", result.First().ContentType); Assert.IsEmpty(result.First().Properties); } @@ -86,7 +86,7 @@ public class MultiNodeTreePickerValueConverterTests : PropertyValueConverterTest Assert.AreEqual(PublishedContent.Name, result.First().Name); Assert.AreEqual(PublishedContent.Key, result.First().Id); - Assert.AreEqual("/the-page-url", result.First().Route.Path); + Assert.AreEqual("/the-page-url/", result.First().Route.Path); Assert.AreEqual("TheContentType", result.First().ContentType); Assert.AreEqual("The other page", result.Last().Name); @@ -130,7 +130,7 @@ public class MultiNodeTreePickerValueConverterTests : PropertyValueConverterTest Assert.AreEqual(1, result.Count()); Assert.AreEqual("The page", result.First().Name); Assert.AreEqual(key, result.First().Id); - Assert.AreEqual("/page-url-segment", result.First().Route.Path); + Assert.AreEqual("/page-url-segment/", result.First().Route.Path); Assert.AreEqual("TheContentType", result.First().ContentType); Assert.AreEqual(2, result.First().Properties.Count); Assert.AreEqual("Delivery API value", result.First().Properties[DeliveryApiPropertyType.Alias]); @@ -204,7 +204,7 @@ public class MultiNodeTreePickerValueConverterTests : PropertyValueConverterTest Assert.AreEqual(1, result.Count()); Assert.AreEqual(PublishedContent.Name, result.First().Name); Assert.AreEqual(PublishedContent.Key, result.First().Id); - Assert.AreEqual("/the-page-url", result.First().Route.Path); + Assert.AreEqual("/the-page-url/", result.First().Route.Path); Assert.AreEqual("TheContentType", result.First().ContentType); } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/MultiUrlPickerValueConverterTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/MultiUrlPickerValueConverterTests.cs index 2f7df14f2f..454aa358b4 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/MultiUrlPickerValueConverterTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/MultiUrlPickerValueConverterTests.cs @@ -46,7 +46,7 @@ public class MultiUrlPickerValueConverterTests : PropertyValueConverterTests Assert.Null(link.Target); var route = link.Route; Assert.NotNull(route); - Assert.AreEqual("/the-page-url", route.Path); + Assert.AreEqual("/the-page-url/", route.Path); } [Test] @@ -221,7 +221,7 @@ public class MultiUrlPickerValueConverterTests : PropertyValueConverterTests var link = result.First(); Assert.AreEqual("Custom link name", link.Title); Assert.AreEqual(PublishedContent.Key, link.DestinationId); - Assert.AreEqual("/the-page-url", link.Route!.Path); + Assert.AreEqual("/the-page-url/", link.Route!.Path); Assert.AreEqual(LinkType.Content, link.LinkType); Assert.AreEqual("_blank", link.Target); Assert.AreEqual("?something=true", link.QueryString); @@ -252,7 +252,7 @@ public class MultiUrlPickerValueConverterTests : PropertyValueConverterTests var link = result.First(); Assert.AreEqual(PublishedContent.Name, link.Title); Assert.AreEqual(PublishedContent.Key, link.DestinationId); - Assert.AreEqual("/the-page-url", link.Route!.Path); + Assert.AreEqual("/the-page-url/", link.Route!.Path); Assert.AreEqual(LinkType.Content, link.LinkType); Assert.Null(link.Target); Assert.Null(link.Url); diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/PropertyValueConverterTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/PropertyValueConverterTests.cs index 2751cf6f13..59238902f7 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/PropertyValueConverterTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/PropertyValueConverterTests.cs @@ -6,6 +6,7 @@ using Umbraco.Cms.Core.DeliveryApi; using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.PublishedCache; using Umbraco.Cms.Core.Routing; +using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.Navigation; namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.DeliveryApi; @@ -127,7 +128,8 @@ public class PropertyValueConverterTests : DeliveryApiTests IOptionsMonitor? requestHandlerSettingsMonitor = null, IPublishedContentCache? contentCache = null, IDocumentNavigationQueryService? navigationQueryService = null, - IPublishStatusQueryService? publishStatusQueryService = null) + IPublishStatusQueryService? publishStatusQueryService = null, + IDocumentUrlService? documentUrlService = null) { contentCache ??= PublishedContentCacheMock.Object; navigationQueryService ??= DocumentNavigationQueryServiceMock.Object; @@ -140,6 +142,7 @@ public class PropertyValueConverterTests : DeliveryApiTests requestHandlerSettingsMonitor, contentCache, navigationQueryService, - publishStatusQueryService); + publishStatusQueryService, + documentUrlService); } }